Skip to content

Commit e8246e3

Browse files
os-zhuangclaude
andauthored
docs(adr): ADR-0071 correction — matrix already implemented, refocus on multi-hop (#2293)
A code check before implementing the proposed matrix "P1" found matrix/across pivoting already shipped end-to-end (DatasetMatrixTable in plugin-report + ReportDefaultInspector type=matrix columns-across + report.form.ts), so the ADR-0021 D2 matrix follow-up is satisfied — the first draft's "flattened" premise was stale. Refocus ADR-0071 on the one remaining gap, multi-hop joins, which native-sql-strategy.ts still rejects ("Only the first dotted hop is supported"). Adds a Correction section citing the matrix implementation. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 82509b3 commit e8246e3

1 file changed

Lines changed: 68 additions & 83 deletions

File tree

Lines changed: 68 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,29 @@
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`
1132
clauses), and named `dimensions` / `measures` that presentations bind by name.
1233
RLS/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

Comments
 (0)