1- # ADR-0071: Dataset semantic-layer depth — multi-hop joins and matrix pivoting
1+ # ADR-0071: Dataset semantic-layer depth — multi-hop joins
22
33## Status
44
5- Proposed
5+ Proposed. (Refocused — see ** Correction** below. The first merged draft also
6+ proposed matrix/across pivoting; a code check found that already implemented, so
7+ this ADR now covers only the remaining gap: multi-hop joins.)
8+
9+ ## Correction (2026-06-24)
10+
11+ The first merged draft listed ** matrix / across pivoting** as an open gap (a
12+ proposed "P1"). That was wrong — a check of the current code found matrix
13+ pivoting ** already implemented end-to-end** :
14+
15+ - ** Render** — ` packages/plugin-report/src/DatasetReportRenderer.tsx ` →
16+ ` DatasetMatrixTable ` does a true cross-tab: one dataset query over
17+ ` [...rows, ...columns] ` , pivoted client-side into row × column × measure cells,
18+ with server-supplied row/column subtotals + grand total and drill-down. Covered
19+ by ` DatasetReportRenderer.test.tsx ` .
20+ - ** Author** — ` packages/app-shell/src/views/metadata-admin/inspectors/ReportDefaultInspector.tsx `
21+ offers ` type: 'matrix' ` and a dedicated "columns across" dimension editor;
22+ ` packages/spec/src/ui/report.form.ts ` reveals ` columns ` when ` type == 'matrix' ` .
23+
24+ The ADR-0021 D2 matrix follow-up is therefore ** satisfied** ; the stale "flattened"
25+ note came from the ADR-0021 era and predates that renderer. This ADR is refocused
26+ on the one follow-up that remains open: ** multi-hop joins** .
627
728## Context
829
@@ -11,74 +32,59 @@ to `include` (joins **derived** from the object graph — no hand-written `ON`
1132clauses), and named ` dimensions ` / ` measures ` that presentations bind by name.
1233RLS/tenant scoping is enforced per joined object at runtime (D-C, fail-closed).
1334
14- Two capability gaps remain, both flagged as follow-ups in ADR-0021:
15-
16- 1 . ** Single-hop joins only.** ` Dataset.include ` is ` string[] ` of relationship
17- ** names** (lookup / master_detail fields on the * base* object), and a
18- dimension/measure ` field ` uses a one-dot ` relationship.field ` path.
19- ` native-sql-strategy.qualifyAndRegisterJoin ` splits on the ** first** dot:
20- ` account.region ` → ` LEFT JOIN account ` + column ` "account"."region" ` . A
21- two-hop path like ` account.owner.region ` is parsed as alias=` account ` ,
22- column=` owner.region ` → invalid SQL. You cannot group/aggregate by a field
23- two relationships away (e.g. ` opportunity → account → owner → region ` ).
24-
25- 2 . ** Matrix / "across" is flattened, not pivoted.** ` Report ` already declares
26- ` rows ` (down) and ` columns ` (across) for ` type: 'matrix' ` , and the dataset
27- query can group by both. But the dataset report renderer shows a ** flat**
28- table (rows ∪ columns as combined groupings) — it does not pivot the
29- ` columns ` dimension into a 2-D grid (ADR-0021 D2 follow-up). A true matrix
30- (rows × distinct-column-values × measure cells) isn't rendered.
35+ ** Single-hop joins only.** ` Dataset.include ` is ` string[] ` of relationship
36+ ** names** (lookup / master_detail fields on the * base* object), and a
37+ dimension/measure ` field ` uses a one-dot ` relationship.field ` path.
38+ ` native-sql-strategy.ts qualifyAndRegisterJoin ` splits on the ** first** dot — the
39+ code comment is explicit: * "Only the first dotted hop is supported (single-level
40+ relation)"* (` const [alias, ...rest] = rawSql.split('.') ` ). So ` account.region `
41+ works, but a two-hop path like ` account.owner.region ` is parsed as
42+ alias=` account ` , column=` owner.region ` → invalid SQL. You cannot group/aggregate
43+ by a field two relationships away (e.g. ` opportunity → account → owner → region ` ).
3144
3245## Prior art / industry alignment
3346
3447- ** Curated join graph, not arbitrary SQL.** Salesforce * report types* pre-wire
35- a primary object + up to 3 related objects; Looker * Explores* declare the
36- joins a developer blesses; Power BI / Tableau model ** relationships** and the
37- engine resolves the path; Cube (this platform's runtime) resolves ** transitive
38- joins** from pairwise relationship declarations. All keep joins governed and
39- derived from the object graph — exactly ADR-0021's D-C stance. This ADR stays
40- in that lane (no ` ON ` clauses).
48+ a primary object + up to 3 related objects; Looker * Explores* declare blessed
49+ joins; Power BI / Tableau model ** relationships** and the engine resolves the
50+ path; Cube (this platform's runtime) resolves ** transitive joins** from
51+ pairwise relationship declarations. All keep joins governed and derived from
52+ the object graph — ADR-0021's D-C stance. This ADR stays in that lane (no ` ON ` ).
4153- ** Spanning depth.** Salesforce report types reach 4 objects (≈3 to-one hops);
42- SOQL allows 5 levels of parent traversal. A small, fixed depth covers the vast
54+ SOQL allows 5 levels of parent traversal. A small fixed depth covers the vast
4355 majority of real reports.
44- - ** The fan-out lesson.** Looker (* symmetric aggregates* ) and Tableau (* relationships
45- vs. joins* ) both exist because joining across a ** to-many** edge and then
46- aggregating ** double-counts** . The robust answer for a governed, AI-authored,
47- RLS-scoped layer is to ** not** traverse to-many in the join chain at all (see
48- Decision A) rather than to build symmetric-aggregate machinery now.
49- - ** Matrix.** Salesforce matrix reports allow ≤2 row groupings + ≤2 column
50- groupings; Power BI's matrix visual pivots with overflow handling. P1 targets
51- the single-column-grouping MVP with a display-side overflow cap.
52-
53- ## Decision
56+ - ** The fan-out lesson.** Looker (* symmetric aggregates* ) and Tableau
57+ (* relationships vs. joins* ) exist because joining across a ** to-many** edge and
58+ then aggregating ** double-counts** . The robust answer for a governed,
59+ AI-authored, RLS-scoped layer is to ** not** traverse to-many in the join chain
60+ (see Decision) rather than build symmetric-aggregate machinery now.
5461
55- ### A. Multi -hop joins (framework: spec + compiler + strategy + RLS)
62+ ## Decision — multi -hop joins (framework: spec + compiler + strategy + RLS)
5663
5764- ** To-one only (the correctness boundary).** Multi-hop traversal is restricted
5865 to ** to-one** relationships — ` lookup ` / ` master_detail ` (child→parent), which
5966 is exactly what ` include ` holds today. To-one chains ** never fan out** , so
6067 existing ` SUM ` /` COUNT ` /etc. stay correct with ** zero** symmetric-aggregate
6168 machinery. Traversing a ** to-many** (child) relationship inside a dataset's
6269 join chain is explicitly ** out of scope** (it needs symmetric aggregates /
63- sub-query rollups — a separate feature). Consequently the depth cap below is a
70+ sub-query rollups — a separate feature). The depth cap below is therefore a
6471 ** performance/complexity** guard, not a correctness one.
6572- ** Spec.** ` Dataset.include ` accepts relationship ** paths** — dotted chains of
6673 to-one relationship field names from the base object (` 'account' ` ,
6774 ` 'account.owner' ` ). A dimension/measure ` field ` may reference any field
6875 reachable along a ** declared** path (` account.owner.region ` ). The dotted path
6976 ** is** the join alias, so paths are ** self-disambiguating** (no named-join
70- disambiguation like Power BI active/inactive or Looker aliased joins). A depth
77+ disambiguation as in Power BI active/inactive or Looker aliased joins). A depth
7178 cap (default ** 3 hops** → 4 objects, Salesforce-report-type parity) bounds join
7279 count/perf; undeclared paths are still rejected (D-C unchanged).
7380- ** Compiler** (` dataset-compiler ` ). Expand each ` include ` path into the ordered
74- join chain; ` cube.joins ` is keyed by the full path alias (` account.owner ` ) and
75- carries ` { parentAlias, fkField, targetObject } ` so the chain is reconstructable.
81+ join chain; ` cube.joins ` keyed by the full path alias (` account.owner ` ),
82+ carrying ` { parentAlias, fkField, targetObject } ` so the chain is reconstructable.
7683- ** Strategy** (` native-sql-strategy.qualifyAndRegisterJoin ` ). For ` a.b.c ` ,
77- register the chain (` base → a ` on alias ` a ` ; ` a → b ` on alias ` a.b ` ) and
78- qualify the column as ` "a.b"."c" ` . Emit ` LEFT JOIN ` s (outer — base rows
79- without a related record still appear, the report-friendly default) in
80- dependency order. Base columns stay qualified with the base table so shared
81- column names remain unambiguous across all hops.
84+ register the chain (` base → a ` on alias ` a ` ; ` a → b ` on alias ` a.b ` ) and qualify
85+ the column as ` "a.b"."c" ` . Emit ` LEFT JOIN ` s (outer — base rows without a
86+ related record still appear, the report-friendly default) in dependency order.
87+ Base columns stay qualified with the base table so shared names stay unambiguous.
8288- ** RLS (D-C, fail-closed).** Apply the tenant read-scope to ** every** object in
8389 the chain, not just the first hop. The strategy already scopes per joined
8490 alias; generalize the loop to each hop's target object.
@@ -87,57 +93,36 @@ Two capability gaps remain, both flagged as follow-ups in ADR-0021:
8793 ` account.owner.region ` ; the include editor shows/edits paths; the existing
8894 ` missingRelationship ` author-time validation generalizes to paths.
8995
90- ### B. Matrix pivot (mostly objectui renderer)
91-
92- - ** Query unchanged.** Group by ` rows ∪ columns ` — the across dimension is just
93- another ` groupBy ` .
94- - ** Renderer** (` DatasetReportRenderer ` ). When ` type === 'matrix' ` and ` columns `
95- is non-empty, ** pivot** the flat result into a 2-D grid: distinct ` columns `
96- values become column headers, ` rows ` go down the side, measure(s) fill the
97- cells. Cap distinct column values at a ** display** ceiling (default ** 50** ,
98- with a "+N more — refine with a filter" notice) — a render-side guard, not a
99- query limit. MVP = a single column grouping; ≤2 row/col groupings (Salesforce
100- parity) is a fast-follow.
101- - ** Spec unchanged** (` rows ` / ` columns ` already exist on ` Report ` ).
102-
10396## Consequences
10497
105- - New capability: analytics two-plus to-one hops deep, and true matrix reports.
106- - Cost/risk: multi-hop joins multiply ` LEFT JOIN ` s (perf) and widen the RLS
107- surface (each hop scoped) — bounded by the depth cap + declared-path allowlist.
108- No fan-out risk by construction (to-one only). Matrix column cardinality is
109- bounded by the display cap.
110- - ** Backward compatible.** Single-hop ` include ` and flat rows keep working
111- unchanged; the depth cap and "flat unless ` matrix ` + ` columns ` " defaults
112- preserve current behaviour.
98+ - New capability: analytics two-plus to-one hops deep.
99+ - Cost/risk: multi-hop multiplies ` LEFT JOIN ` s (perf) and widens the RLS surface
100+ (each hop scoped) — bounded by the depth cap + declared-path allowlist. No
101+ fan-out risk by construction (to-one only).
102+ - ** Backward compatible.** Single-hop ` include ` keeps working unchanged; the
103+ depth cap default preserves current behaviour.
113104
114105## Phasing
115106
116- - ** P1 — matrix pivot (objectui-only, lowest risk).** Render the matrix grid from
117- the existing ` rows ` /` columns ` . No spec/query change. Ships independently.
118- - ** P2 — multi-hop (framework).** Spec path support + compiler chain + strategy
107+ - ** P1 — multi-hop (framework).** Spec path support + compiler chain + strategy
119108 chained joins + per-hop RLS, behind the depth cap, ** to-one only** . Gated by
120109 the ADR-0021 reconciliation harness (old-vs-new numbers).
121- - ** P3 — objectui catalog/UI.** Multi-hop field picker + include-path editor +
110+ - ** P2 — objectui catalog/UI.** Multi-hop field picker + include-path editor +
122111 path-aware author-time validation.
123112
124113## Alternatives considered
125114
126- - ** Arbitrary join predicates / raw SQL** — rejected (ADR-0021 D-C: joins are
127- derived from the object graph, no ` ON ` clauses; keeps datasets reviewable and
128- RLS-safe). Matches Salesforce/Cube/Power BI, not the developer-only Looker
129- ` sql_on ` .
115+ - ** Arbitrary join predicates / raw SQL** — rejected (ADR-0021 D-C: joins derived
116+ from the object graph, no ` ON ` clauses; reviewable and RLS-safe). Matches
117+ Salesforce/Cube/Power BI, not the developer-only Looker ` sql_on ` .
130118- ** To-many traversal with symmetric aggregates** — deferred. Correct
131119 cross-to-many aggregation (Looker-style symmetric aggregates) is a separate,
132120 larger feature; the to-one boundary delivers the common case safely first.
133- - ** Edge-graph + automatic path resolution** (declare relationship edges, let the
134- engine find the path — Cube/Power BI style) — rejected for v1 in favour of
135- explicit dotted paths: smaller delta, self-disambiguating, and per-path
136- RLS-auditable.
137- - ** Materialized / precomputed pivots** — out of scope; query-time pivot is
138- sufficient at expected cardinalities (bounded by the display cap).
121+ - ** Edge-graph + automatic path resolution** (Cube/Power BI style) — rejected for
122+ v1 in favour of explicit dotted paths: smaller delta, self-disambiguating, and
123+ per-path RLS-auditable.
139124
140125## Related
141126
142- - ADR-0021 (analytics dataset semantic layer) — the foundation; D2 / join
143- follow-ups this ADR addresses.
127+ - ADR-0021 (analytics dataset semantic layer) — the foundation; the matrix D2
128+ follow-up (now confirmed implemented) and the join follow-up this ADR addresses.
0 commit comments