getting-started.RmdA Define-XML document is a promise about the data: these datasets
exist, these variables are in them, this variable is at most 20
characters, this one only ever holds F or M.
The document is usually produced from a spreadsheet by a tool that never
opens the datasets, so the promise quietly stops being true.
define21 builds the document and checks the
promise.
A specification is eight plain data frames. Only
datasets and variables are required. Here is
the example shipped with the package, read from CSV - a spreadsheet
export works exactly the same way.
p <- system.file("extdata", package = "define21")
meta <- function(f) utils::read.csv(file.path(p, f), colClasses = "character", na.strings = "")
spec <- define_spec(
datasets = meta("datasets.csv"),
variables = meta("variables.csv"),
codelists = meta("codelists.csv"),
methods = meta("methods.csv"),
comments = meta("comments.csv"),
value_levels = meta("value_levels.csv"),
where_clauses = meta("where_clauses.csv"),
external_codelists = meta("external_codelists.csv"),
study = "STUDY01",
standard = c(name = "SDTMIG", type = "IG", version = "3.4")
)
spec
#>
#> ── Define-XML 2.1 specification ────────────────────────────────────────────────
#> ℹ Study: STUDY01 | standard: SDTMIG 3.4 (IG)
#> ℹ 3 dataset(s), 28 variable(s), 9 codelist(s), 2 method(s), 1 comment(s)
#> ℹ 6 value(s) across 2 value list(s), 3 where clause(s), 2 external codelist(s)
#> ✔ [DM] Demographics - 10 variable(s), One record per subject
#> ✔ [AE] Adverse Events - 10 variable(s), One record per adverse event per subject
#> ✔ [VS] Vital Signs - 8 variable(s), One record per vital sign measurement per subjectThe required columns are documented in ?define_spec.
Optional columns are filled with defaults: purpose becomes
Tabulation, mandatory becomes No,
order follows the row order within each dataset,
archive_location becomes
<dataset>.xpt.
spec$variables[1:5, c("dataset", "variable", "type", "length", "order", "key_sequence")]
#> # A tibble: 5 × 6
#> dataset variable type length order key_sequence
#> <chr> <chr> <chr> <int> <int> <int>
#> 1 DM STUDYID text 10 1 1
#> 2 DM DOMAIN text 2 2 NA
#> 3 DM USUBJID text 20 3 2
#> 4 DM SUBJID text 10 4 NA
#> 5 DM AGE integer 3 5 NAValidation happens at the boundary, and reports every problem in one error rather than one per run:
define_spec(
datasets = data.frame(
dataset = "DM", label = "Demographics",
class = "SPECIAL PURPOSE", structure = "One record per subject"
),
variables = data.frame(
dataset = c("DM", "EX"),
variable = c("AGE", "EXDOSE"),
label = c("Age", "Dose"),
type = c("int", "float"),
length = c(3, 8),
codelist = c("NOT_DEFINED", NA)
)
)
#> Error in `.abort_problems()`:
#> ! Define-XML specification failed validation:
#> ✖ `variables` refers to dataset(s) absent from `datasets`: EX.
#> ✖ `variables$type`: 'int' not in {text, integer, float, date, time, datetime, partialDate, partialTime, partialDatetime, incompleteDatetime, durationDatetime, intervalDatetime, URI, boolean}.
#> ✖ `variables$codelist` refers to unknown codelists key(s): NOT_DEFINED.
f <- file.path(tempdir(), "define.xml")
write_define(spec, f)
cat(paste(readLines(f)[1:12], collapse = "\n"))
#> <?xml version="1.0" encoding="UTF-8"?>
#> <ODM xmlns="http://www.cdisc.org/ns/odm/v1.3" xmlns:def="http://www.cdisc.org/ns/def/v2.1" xmlns:xlink="http://www.w3.org/1999/xlink" ODMVersion="1.3.2" FileType="Snapshot" FileOID="DEF.STUDY01.20260803022927" CreationDateTime="2026-08-03T02:29:27Z" def:Context="Submission">
#> <Study OID="SDY.STUDY01">
#> <GlobalVariables>
#> <StudyName>STUDY01</StudyName>
#> <StudyDescription>STUDY01</StudyDescription>
#> <ProtocolName>STUDY01</ProtocolName>
#> </GlobalVariables>
#> <MetaDataVersion OID="MDV.STUDY01" Name="STUDY01" Description="SDTMIG 3.4 STUDY01" def:DefineVersion="2.1.0">
#> <def:Standards>
#> <def:Standard OID="STD.1" Name="SDTMIG" Type="IG" Version="3.4" Status="Final"/>
#> </def:Standards>Each dataset becomes an ItemGroupDef with its
def:Class, def:Structure and the
def:leaf that points at the shipped transport file:
doc <- xml2::read_xml(f)
ns <- xml2::xml_ns(doc)
cat(as.character(xml2::xml_find_first(doc, "//d1:ItemGroupDef[@Name='AE']", ns)))
#> <ItemGroupDef OID="IG.AE" Name="AE" Repeating="Yes" IsReferenceData="No" SASDatasetName="AE" Domain="AE" Purpose="Tabulation" def:Structure="One record per adverse event per subject" def:ArchiveLocationID="LF.AE" def:StandardOID="STD.1" def:CommentOID="COM.AESRC">
#> <Description>
#> <TranslatedText xml:lang="en">Adverse Events</TranslatedText>
#> </Description>
#> <ItemRef ItemOID="IT.AE.STUDYID" OrderNumber="1" Mandatory="Yes" KeySequence="1" Role="Identifier"/>
#> <ItemRef ItemOID="IT.AE.DOMAIN" OrderNumber="2" Mandatory="Yes" Role="Identifier"/>
#> <ItemRef ItemOID="IT.AE.USUBJID" OrderNumber="3" Mandatory="Yes" KeySequence="2" Role="Identifier"/>
#> <ItemRef ItemOID="IT.AE.AESEQ" OrderNumber="4" Mandatory="Yes" KeySequence="3" Role="Identifier"/>
#> <ItemRef ItemOID="IT.AE.AETERM" OrderNumber="5" Mandatory="Yes" Role="Topic"/>
#> <ItemRef ItemOID="IT.AE.AEDECOD" OrderNumber="6" Mandatory="Yes" Role="Synonym Qualifier"/>
#> <ItemRef ItemOID="IT.AE.AEBODSYS" OrderNumber="7" Mandatory="Yes" Role="Record Qualifier"/>
#> <ItemRef ItemOID="IT.AE.AESEV" OrderNumber="8" Mandatory="No" Role="Record Qualifier"/>
#> <ItemRef ItemOID="IT.AE.AESER" OrderNumber="9" Mandatory="Yes" Role="Record Qualifier"/>
#> <ItemRef ItemOID="IT.AE.AESTDTC" OrderNumber="10" Mandatory="No" Role="Timing"/>
#> <def:Class Name="EVENTS"/>
#> <def:leaf ID="LF.AE" xlink:href="ae.xpt">
#> <def:title>ae.xpt</def:title>
#> </def:leaf>
#> </ItemGroupDef>Codelists with a decode are written as CodeListItem,
those without as EnumeratedItem, and a codelist whose terms
live in a dictionary as one ExternalCodeList:
cat(as.character(xml2::xml_find_first(doc, "//d1:CodeList[@OID='CL.SEX']", ns)))
#> <CodeList OID="CL.SEX" Name="Sex" DataType="text">
#> <CodeListItem CodedValue="F" OrderNumber="1">
#> <Decode>
#> <TranslatedText xml:lang="en">Female</TranslatedText>
#> </Decode>
#> </CodeListItem>
#> <CodeListItem CodedValue="M" OrderNumber="2">
#> <Decode>
#> <TranslatedText xml:lang="en">Male</TranslatedText>
#> </Decode>
#> </CodeListItem>
#> </CodeList>
cat(as.character(xml2::xml_find_first(doc, "//d1:CodeList[@OID='CL.AESEV']", ns)))
#> <CodeList OID="CL.AESEV" Name="Severity/Intensity Scale for Adverse Events" DataType="text">
#> <EnumeratedItem CodedValue="MILD" OrderNumber="1"/>
#> <EnumeratedItem CodedValue="MODERATE" OrderNumber="2"/>
#> <EnumeratedItem CodedValue="SEVERE" OrderNumber="3"/>
#> </CodeList>
cat(as.character(xml2::xml_find_first(doc, "//d1:CodeList[@OID='CL.MEDDRAPT']", ns)))
#> <CodeList OID="CL.MEDDRAPT" Name="MedDRA Preferred Term" DataType="text">
#> <ExternalCodeList Dictionary="MedDRA" Version="26.1"/>
#> </CodeList>VS.VSSTRESN means something different for each
VSTESTCD, so it carries a def:ValueListDef
rather than one set of attributes. The variable’s ItemDef
points at the list, each entry of the list has its own
ItemDef, and each is selected by a
def:WhereClauseDef:
spec$value_levels[, c("variable", "value", "where_clause", "type", "length", "codelist")]
#> # A tibble: 6 × 6
#> variable value where_clause type length codelist
#> <chr> <chr> <chr> <chr> <int> <chr>
#> 1 VSSTRESN SYSBP VS_SYSBP float 5 NA
#> 2 VSSTRESN DIABP VS_DIABP float 5 NA
#> 3 VSSTRESN PULSE VS_PULSE float 5 NA
#> 4 VSSTRESU SYSBP VS_SYSBP text 4 VSRESU_BP
#> 5 VSSTRESU DIABP VS_DIABP text 4 VSRESU_BP
#> 6 VSSTRESU PULSE VS_PULSE text 9 VSRESU_HR
cat(as.character(xml2::xml_find_first(doc, "//def:ValueListDef[@OID='VL.VS.VSSTRESU']", ns)))
#> <def:ValueListDef OID="VL.VS.VSSTRESU">
#> <ItemRef ItemOID="IT.VS.VSSTRESU.SYSBP" OrderNumber="1" Mandatory="No" Role="Variable Qualifier">
#> <def:WhereClauseRef WhereClauseOID="WC.VS_SYSBP"/>
#> </ItemRef>
#> <ItemRef ItemOID="IT.VS.VSSTRESU.DIABP" OrderNumber="2" Mandatory="No" Role="Variable Qualifier">
#> <def:WhereClauseRef WhereClauseOID="WC.VS_DIABP"/>
#> </ItemRef>
#> <ItemRef ItemOID="IT.VS.VSSTRESU.PULSE" OrderNumber="3" Mandatory="No" Role="Variable Qualifier">
#> <def:WhereClauseRef WhereClauseOID="WC.VS_PULSE"/>
#> </ItemRef>
#> </def:ValueListDef>
cat(as.character(xml2::xml_find_first(doc, "//def:WhereClauseDef[@OID='WC.VS_PULSE']", ns)))
#> <def:WhereClauseDef OID="WC.VS_PULSE">
#> <RangeCheck Comparator="EQ" SoftHard="Soft" def:ItemOID="IT.VS.VSTESTCD">
#> <CheckValue>PULSE</CheckValue>
#> </RangeCheck>
#> </def:WhereClauseDef>
cat(as.character(xml2::xml_find_first(doc, "//d1:ItemDef[@OID='IT.VS.VSSTRESU.PULSE']", ns)))
#> <ItemDef OID="IT.VS.VSSTRESU.PULSE" Name="PULSE" DataType="text" Length="9" SASFieldName="VSSTRESU">
#> <Description>
#> <TranslatedText xml:lang="en">Pulse Rate Units</TranslatedText>
#> </Description>
#> <CodeListRef CodeListOID="CL.VSRESU_HR"/>
#> <def:Origin Type="Assigned"/>
#> </ItemDef>IN ('A', 'B') is written as several rows of
where_clauses sharing one comparator; they become one
RangeCheck with several CheckValue
children.
read_define() returns a specification identical to the
one that was written, so the XML is a storage format rather than a dead
end:
back <- read_define(f)
identical(back$variables, spec$variables)
#> [1] TRUE
identical(back$codelists, spec$codelists)
#> [1] TRUE
identical(back$value_levels, spec$value_levels)
#> [1] TRUE
identical(back$where_clauses, spec$where_clauses)
#> [1] TRUE
identical(back$external_codelists, spec$external_codelists)
#> [1] TRUEThis is the part a spreadsheet cannot do. Supply the real datasets as a named list; names are matched case-insensitively.
data <- define21_example()$data
check_define(spec, data)
#>
#> ── Define-XML consistency check ────────────────────────────────────────────────
#> ✔ 0 errors
#> ! 2 warning(s)
#> ℹ codelist_external: 2
#> # A tibble: 2 × 6
#> dataset variable value check severity message
#> <chr> <chr> <chr> <chr> <chr> <chr>
#> 1 AE AEDECOD NA codelist_external warning Variable 'AE.AEDECOD' uses …
#> 2 AE AEBODSYS NA codelist_external warning Variable 'AE.AEBODSYS' uses…The two warnings are the MedDRA codelists: their terms are in a
dictionary that is not part of the document, so
check_define() reports that it could not compare them
rather than passing them silently.
Now introduce the three kinds of drift a live study produces - a value longer than the declared length, a codelist term that was never added to the metadata, and a variable that was dropped from the data:
drifted <- data
drifted$DM$USUBJID[1] <- "STUDY01-A-VERY-LONG-SUBJECT-IDENTIFIER"
drifted$DM$SEX[2] <- "U"
drifted$DM$ARM <- NULL
res <- check_define(spec, drifted)
res[, c("dataset", "variable", "check", "severity")]
#>
#> ── Define-XML consistency check ────────────────────────────────────────────────
#> ✖ 3 error(s)
#> ! 2 warning(s)
#> ℹ variable_missing: 1
#> ℹ length_too_short: 1
#> ℹ codelist_value_missing: 1
#> ℹ codelist_external: 2
#> # A tibble: 5 × 4
#> dataset variable check severity
#> <chr> <chr> <chr> <chr>
#> 1 DM ARM variable_missing error
#> 2 DM USUBJID length_too_short error
#> 3 DM SEX codelist_value_missing error
#> 4 AE AEDECOD codelist_external warning
#> 5 AE AEBODSYS codelist_external warningEvery message names the fix:
writeLines(strwrap(res$message, width = 78, exdent = 2))
#> Variable 'ARM' is declared for dataset 'DM' but is not a column of it.
#> Variable 'DM.USUBJID' declares Length 20 but the longest value is 38
#> characters ('STUDY01-A-VERY-LONG-SUBJECT-IDENTIFIER'). Set Length to at
#> least 38.
#> Variable 'DM.SEX' holds 1 value(s) absent from codelist 'SEX': 'U'.
#> Variable 'AE.AEDECOD' uses external codelist 'MEDDRAPT' (MedDRA 26.1); its
#> terms are not in the document, so the values were not checked.
#> Variable 'AE.AEBODSYS' uses external codelist 'MEDDRASOC' (MedDRA 26.1); its
#> terms are not in the document, so the values were not checked.Value level metadata is checked the same way, on the rows its where
clause selects. mmHg is a legal VSSTRESU for
the variable, and illegal for PULSE - a disagreement only
value level metadata can express and only check_define()
will find:
vlm <- data
vlm$VS$VSSTRESU[vlm$VS$VSTESTCD == "PULSE"] <- "mmHg"
vres <- check_define(spec, vlm)
vres[vres$check == "codelist_value_missing", c("variable", "value", "message")]$message
#> [1] "Value 'PULSE' of variable 'VS.VSSTRESU' holds 1 value(s) absent from codelist 'VSRESU_HR': 'mmHg'."A sensible submission-time gate is simply:
res <- check_define(spec, data)
if (any(res$severity == "error")) stop("define.xml disagrees with the data")
write_define(spec, "define.xml")Analysis results metadata (ARM) is not written or read. It is a
separate model with its own namespace and its own conformance rules, and
half of it is worse than none. Nor are annotated-CRF and supplemental
document leaves, Alias, SignificantDigits,
more than one def:Standard, or more than one
FormalExpression per method.
If your submission needs any of those, this package will not produce a complete document - it will produce a smaller, valid one, and say so, rather than emit constructs a validator would reject. The README lists every limit and what happens to each construct on a round trip.