ADR 0014 - Source joins via @join
- Status: accepted
- Date: 2026-05-07
- Spec target: XTL 0.1 draft
- Affects: evaluation.md, language.md
1.0 inclusion review (2026-05-08): Internal review flagged
@joinas a candidate to defer to XTL 1.1 on the grounds that multi-source via@source+XLOOKUP(ADR-0012/0013) covers most reporting cases. Decision: keep in 1.0. The block-level join is a distinct shape (one rendered row per pair, joined-row columns in row context); rebuilding it as a chain ofXLOOKUPcalls is noticeably more verbose for templates that iterate over a fact table. The surface is small (single inner join, deterministic first-match by joined-source row order) and conformance fixtures 079–082 are stable. Post-1.0 reviews can revisit based on adoption data — the deferred shapes (multi-join, left semantics, multi-row matches) remain off-limits until then.
Context
ADR-0012 introduced multi-source data and ADR-0013 added per-cell
XLOOKUP. Those cover the common "for each primary row, fetch a
column from elsewhere" case. They do not cover the block-level
join: rendering one row per pair where the iteration shape itself
depends on the join (e.g. one rendered row per renewal, with the
matched customer's columns available alongside in row context).
The natural Excel-adjacent shape is a join directive that pairs the primary block source with a secondary source on a key. The result is that cells inside the block see both sources' columns at row level.
Considered Options
A. Stick with XLOOKUP only. Cost: every cell that wants joined
data calls XLOOKUP, which is verbose for templates with many
columns from the joined source. Performance is also worse without
indexing (ADR-0013 leaves that as an impl detail).
B. SQL-style multi-table FROM clause. Cost: too much spec surface for a templating system; alien to spreadsheet authors.
C. @join directive on the block (chosen). A single @join SourceName on SourceName[k] = ActiveSource[k] directive pairs the
primary source's row with one row of the joined source. Inside the
block, [Column] resolves to the active source's row and
SourceName[Column] resolves to the paired joined row.
Decision
A data block MAY add one @join directive after @source:
{{ @source Renewals }}
{{ @join Customers on Customers[Account] = Renewals[Account] }}
{{ @repeat }}
{{ Renewals[Account] }} | {{ Customers[Name] }} | {{ Renewals[Amount] }}
@join MUST appear after @source for the same block. Without
@source, the active source is default; @join joins against that.
Pairing semantics — inner, first-match
For each row of the primary (active) source, the engine finds the first row in the joined source whose join-column value equals the primary row's join-column value (per ADR-0009 comparison). If no match is found, the primary row is dropped from rendering (inner semantics).
"First" is defined by the joined source's natural row order — the
order rows appear in the joined source's source_table range (per
ADR-0016 ordering, top-to-bottom). This is normative, not
implementation-defined: two implementations given the same
template+data MUST pick the same paired row when multiple joined
rows have an equal join key. Templates that depend on a specific
match SHOULD ensure join keys are unique on the joined side or sort
the joined source upstream.
@join operates on the joined source's full row set; it ignores any
@filter/@sort/@top directives on the current block (which apply
to the primary).
Cell references inside the block
| Form | Meaning |
|---|---|
[Column] | Active source's current row's column. |
Renewals[Column] | Active source's row column (when active source is Renewals). |
Customers[Column] | Paired joined row's column. |
OtherSource[Column] | Error — only the active source and the joined source are available at row level. |
SUM(Customers[Column]), COUNT(...), etc. continue to operate on
that source's full row set (per ADR-0012), independent of the join.
Multiple joins, left semantics, multi-row matches
Out of scope for ADR-0014:
- Multiple
@joindirectives in the same block. @join ... left(preserve unmatched primary rows).- Multi-row matches → cross-product.
A future ADR can extend if real demand emerges.
Error diagnostics (stable substrings)
@join requires "<JoinedSource>[col] = <PrimarySource>[col]"@join source "X" must be declared in __sources__@join key columns must reference the joined and primary sourcesCannot reference X[Column] outside an active @source X or @join X block
Consequences
- The "render one row per primary, with paired joined columns available everywhere in the block" idiom has a clean expression. Templates that previously needed XLOOKUP per cell can now declare the join once and reference both sources naturally.
- Inner semantics are deliberate: no-match rows disappear, which
matches the strict tone of
XLOOKUPwithout a fallback. Authors who need left-join behavior can useXLOOKUPper cell for the optional-column pattern. - Performance: the engine indexes the joined source by its join column on first use of the block (O(M)), then matches each primary row in O(1). XLOOKUP indexing follows the same approach (impl-only optimization, not normative).
@joinis intentionally limited to one directive per block in 0.1. Templates that need multi-source row pairing chain via XLOOKUP inside cells.
References
- ADR-0007 (empty value): empty join keys never match.
- ADR-0009 (comparison and string coercion): drives equality.
- ADR-0012 (multi-source data model): provides
__sources__andSource[Column]row-level refs. - ADR-0013 (XLOOKUP cross-source lookup): the per-cell sibling.