Joins¶
Match_Datasets brings a second dataset into reach, so a check can compare across the two.
Match_Datasets:
- Name: "DM"
Keys: ["STUDYID", "USUBJID"]
Check:
expression: 'AESTDTC < DM.RFSTDTC'
Columns of the joined dataset are referenced dot-qualified — DM.RFSTDTC. Unqualified names
always mean the primary dataset.
The ordinary join¶
Match_Datasets:
- Name: "DM"
Keys: ["STUDYID", "USUBJID"]
Join_Type: "left"
Name is the dataset to join.
Keys are the columns matched on. The usual form is a list of names shared by both sides:
Keys: ["STUDYID", "USUBJID"]
When the column is named differently on each side, declare both:
Keys:
- "STUDYID"
- { left: "AEREFID", right: "EGREFID" }
left is the primary dataset, right is the joined one. The two forms mix freely in one list.
Join_Type is inner or left, lowercase.
leftkeeps every primary row, whether or not it found a partner. Unmatched rows see missing values on the joined side.innerkeeps only primary rows that matched.
The default is not a shrug. With no
Join_Typethe join behaves as the rule's shape implies, and for a rule about "every AE must have a matching DM record" that is the wrong question to leave to a default — aninnerjoin silently removes exactly the rows the rule exists to find. Say which you mean whenever the absence of a partner is part of the question.
Key types must agree — and what to do when they do not¶
A join key is matched by type. A character AESEQ and a numeric AESEQ are not the same key,
and a rule that meets such a pair stops with an error naming the column and both types:
Match_Datasets AE: join key AESEQ is Character in the primary dataset and Numeric in AE.
That is deliberate. The alternative — matching them anyway — only works for the canonical
spelling: "5" would join 5, while "05", "5.0" and " 5" would not. Whether a subject's
records joined would then depend on the spelling of a value whose type is already wrong, which
is not something a rule should decide by accident.
This is a data defect, not an authoring one. In CDISC a variable of a given name has one type — measured across the whole metadata library, every join key used anywhere in this corpus is single-typed,
AESEQincluded. A divergence means the delivered study disagrees with the standard, andCDISC-AD0199is the rule that reports exactly that. Your rule erroring is the correct outcome: it says "I cannot answer this question on this data" instead of inventing an answer.
You have two ways to say what should happen instead.
Declare the types you need, and the rule skips cleanly. This is the usual choice — the rule states its precondition and steps aside when the study does not meet it:
Requirements:
Variables:
All:
- "AESEQ:N" # the primary dataset
- "AE.AESEQ:N" # the joined dataset
⚠⚠ Declare BOTH sides. A qualified entry like
AE.AESEQ:Nis checked against the joined dataset only, and an unqualified one against the primary only. Declaring just the joined side is the common mistake: on a study where the primary is the wrong type, that entry is satisfied, the rule does not skip, and it errors anyway.
Or compare the keys as text, on purpose:
Match_Datasets:
- Name: "AE"
Keys: ["STUDYID", "USUBJID", "AESEQ"]
Join_Type: "left"
Join_As_String: true
Join_As_String applies to every key of that entry, not only the mismatched one, so an entry's
behaviour cannot change from study to study. It does not make "05" join 5 — it makes the text
comparison explicit rather than accidental.
⚠ Two things to know before reaching for it. Numeric keys under
Join_As_Stringcompare at 12 significant digits rather than exactly, because that is the precision of the rendered form. And it must be the unquoted booleantrue—"true"in quotes is rejected as a load error rather than silently accepted.
There is no numeric counterpart, and none is planned: parsing text into a number is partial
("ABC" has no value) and it invents identity, collapsing "05", "5.0" and " 5" onto the
same key. A join may fail to find a match; it must never manufacture one.
Three cases are exempt, because they match a text-carried key against a typed column by design
and have their own rules: Child: true, Name: "RELREC", and SUPP-- / SQ* entries. A
SUPPAE.IDVARVAL of "1" is meant to find AE.AESEQ of 1. Join_As_String has no effect on
these, and writing it on one is a load error rather than a silent no-op — as it is on an entry with
no Keys at all, which builds no key comparison to apply it to.
Sided keys must declare both sides¶
If you use the { left, right } form, every such element needs both halves as strings:
Keys:
- "STUDYID"
- { left: "AEREFID", right: "EGREFID" } # ✅
- { left: "VISITNUM" } # ⛔ load error
The two sides are read as parallel lists and paired by position, so an element only one side can read does not make the pair "partly declared" — it shifts everything after it. A half-declared element, an empty object, or a non-string name is rejected at load.
⛔ Sided keys and the Child / RELREC / SUPP-- family do not combine. That family pivots on
the standard IDVAR / IDVARVAL names, which are the same on both sides by construction, and its
merge reads the left names only — so a sided declaration there would be silently ignored. It is a
load error instead.
Did it match? — _matched_¶
A rule that asks "is there a related record at all?" does not need a column from the other side. It needs to know whether the join found one:
Match_Datasets:
- Name: "AE"
Keys: ["STUDYID", "USUBJID"]
Join_Type: "left"
Filter: 'AEOUT == "FATAL"'
Check:
expression: 'not AE._matched_'
<DATASET>._matched_ is true on a primary row that found a partner. With a left join and a
Filter, it is the natural way to ask "does this subject have a fatal AE?"
⭐ The second use: saying what you conclude when the join found nothing¶
_matched_ is not only for rules whose whole question is "does a partner exist". It is also how a
rule that reads a joined column says what it concludes when there is no partner — and without
it, that case is decided by accident.
The trap is that var_exists(SV.SVPRESP) looks like it answers "can I read this", but it is a
schema fact, broadcast to every row: does SV have this column at all. It says nothing about
whether this row matched. So a cascade written as
var_exists(SV.SVPRESP) and SV.SVPRESP == "Y" -- "SV says planned"
or not var_exists(SV.SVPRESP) and <fallback> -- "SV cannot say"
silently drops every unmatched row on a study whose SV does carry the column: the first arm is
selected because the column exists, SV.SVPRESP on an unmatched row is the type default ("", not
"Y"), and the second arm can never be reached. No finding, no SKIPPED, nothing in the report.
Adding an explicit arm fixes it, and states the intent:
SV._matched_ and SV.SVPRESP == "Y"
or SV._matched_ and not var_exists(SV.SVPRESP) and <fallback>
or not SV._matched_ and <what to conclude with no SV record>
⛔ Two things this obliges you to do:
- Declare the dataset.
_matched_is unresolvable without it —Requirements.Datasets: ["SV"]— and the rule is a hard ERROR otherwise, not a skip (D89b: declare ⇒ skip, don't declare ⇒ error, with no third answer). - Then cover the population that declaration excludes. The rule now skips when the dataset is
absent. If that absence is legitimate, the study needs a sibling rule gated
not ds_exists("SV").
⚠ Join on the other dataset's own key. A key component present on one side and absent on the
other makes the row unequal unconditionally, so joining on a Perm column makes _matched_ false
for the whole study and routes every row to the unmatched arm — the failure looks exactly like "no
partner ever existed". For SV that key is USUBJID + VISITNUM, never VISIT.
⭐ The worked instance is the canonical unplanned(visit) predicate; the full doctrine, both
polarities and the four-case table are house rules rather than guide material.
Filter — narrow the other side before matching¶
Filter: 'AEOUT == "FATAL"'
A boolean expression evaluated against the joined dataset's own rows, before the key index is built. Rows that fail it can never become a partner.
That ordering is the point. Filtering after the join would depend on which partner row survived; filtering before it makes the question — does a fatal AE exist for this subject? — independent of the collapse.
Two constraints:
- Right-side columns only. A reference to the primary dataset, or to a
$-binding, is a load error. A filter that correlates the two sides is a different feature and is not this one. - An unresolvable filter column is a bind error — unless it is declared in
Requirements.Variables, in which case the rule skips cleanly instead.
Child: true — resolving a parent row¶
For a primary dataset that points at another — SUPP--, CO, RELREC — whose rows carry
RDOMAIN, IDVAR and IDVARVAL naming the parent row:
Match_Datasets:
- Name: "AE"
Keys: ["STUDYID", "USUBJID", "IDVAR", "IDVARVAL"]
Child: true
The engine resolves the parent domain (from RDOMAIN, or by stripping the SUPP prefix), finds
the parent row where parent[IDVAR] == IDVARVAL, and augments the primary row with the
parent's columns. The check can then read those columns directly.
The remaining keys — Keys minus IDVAR and IDVARVAL — narrow which parent rows are
considered.
⚠
STUDYIDparticipates only if you declare it. It is not added implicitly. Omitting it fromKeysmeans rows are matched across studies, which matters for a pooled submission.⚠ On a column-name collision the child wins. If the primary already has a column of that name, the parent's value does not overwrite it.
Wildcard — forward RELREC expansion¶
Match_Datasets:
- Name: "RELREC"
Wildcard: "PC"
Builds one evaluation row per (primary record, related record) pair via RELREC, with the
related columns reachable as RELREC.*.
Two behaviours worth knowing before you use it:
- It is inner — a primary record with no related record produces no row, so a rule written this way cannot report "nothing is related".
- A declared forward join with no matching pairs yields zero rows, and therefore no findings.
Which shape do you need?¶
| the question | the shape |
|---|---|
| compare a value here with a value there | ordinary join, Keys |
| does a related record exist? | left join + _matched_, usually with Filter |
| does a related record of a particular kind exist? | Filter on the joined side |
| my primary dataset points at a parent row | Child: true |
| the two sides type the key differently | fix the data, or Requirements.Variables on both sides, or Join_As_String: true |
| walk RELREC relationships | Name: "RELREC" + Wildcard |
⚠ A join changes what an unqualified name means in only one respect: nothing. Unqualified names keep referring to the primary dataset even after a join. If you mean the joined side, qualify it — see Values and missing values for what an unmatched row looks like.
Next: Grouping — evaluating per group instead of per row.