|
| 1 | +# ADR-0071: Dataset semantic-layer depth — multi-hop joins and matrix pivoting |
| 2 | + |
| 3 | +## Status |
| 4 | + |
| 5 | +Proposed |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +ADR-0021 established the `dataset` semantic layer: a base `object`, relationships |
| 10 | +to `include` (joins **derived** from the object graph — no hand-written `ON` |
| 11 | +clauses), and named `dimensions` / `measures` that presentations bind by name. |
| 12 | +RLS/tenant scoping is enforced per joined object at runtime (D-C, fail-closed). |
| 13 | + |
| 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. |
| 31 | + |
| 32 | +## Decision |
| 33 | + |
| 34 | +### A. Multi-hop joins (framework: spec + compiler + strategy + RLS) |
| 35 | + |
| 36 | +- **Spec.** `Dataset.include` accepts relationship **paths** — dotted chains of |
| 37 | + lookup/master_detail field names from the base object (`'account'`, |
| 38 | + `'account.owner'`). A dimension/measure `field` may reference any field |
| 39 | + reachable along a **declared** path (`account.owner.region`). A depth cap |
| 40 | + (default **3 hops**) bounds join count/perf; undeclared paths are still |
| 41 | + rejected (D-C unchanged — only declared relationships are joinable). |
| 42 | +- **Compiler** (`dataset-compiler`). Expand each `include` path into the ordered |
| 43 | + join chain; `cube.joins` is keyed by the full path alias (`account.owner`) and |
| 44 | + carries `{ parentAlias, fkField, targetObject }` so the chain is reconstructable. |
| 45 | +- **Strategy** (`native-sql-strategy.qualifyAndRegisterJoin`). For `a.b.c`, |
| 46 | + register the chain (`base → a` on alias `a`; `a → b` on alias `a.b`) and |
| 47 | + qualify the column as `"a.b"."c"`. Emit `LEFT JOIN`s in dependency order. Base |
| 48 | + columns stay qualified with the base table (the fix from objectui#…/framework |
| 49 | + joined-column work) so shared column names remain unambiguous across all hops. |
| 50 | +- **RLS (D-C, fail-closed).** Apply the tenant read-scope to **every** object in |
| 51 | + the chain, not just the first hop. The strategy already scopes per joined |
| 52 | + alias; generalize the loop to each hop's target object. |
| 53 | +- **UI** (objectui). `useDatasetFieldCatalog` lazily expands a relationship's own |
| 54 | + relationships (one more level on demand) so the field picker offers |
| 55 | + `account.owner.region`; the include editor shows/edits paths; the existing |
| 56 | + `missingRelationship` author-time validation generalizes to paths. |
| 57 | + |
| 58 | +### B. Matrix pivot (mostly objectui renderer) |
| 59 | + |
| 60 | +- **Query unchanged.** Group by `rows ∪ columns` — the across dimension is just |
| 61 | + another `groupBy`. |
| 62 | +- **Renderer** (`DatasetReportRenderer`). When `type === 'matrix'` and `columns` |
| 63 | + is non-empty, **pivot** the flat result into a 2-D grid: distinct `columns` |
| 64 | + values become column headers, `rows` go down the side, measure(s) fill the |
| 65 | + cells. Cap distinct column values (default **50**) with a "+N more — refine |
| 66 | + with a filter" notice to avoid column explosion. |
| 67 | +- **Spec unchanged** (`rows` / `columns` already exist on `Report`). |
| 68 | + |
| 69 | +## Consequences |
| 70 | + |
| 71 | +- New capability: analytics two-plus relationships deep, and true matrix reports. |
| 72 | +- Cost/risk: multi-hop joins multiply `LEFT JOIN`s (perf) and widen the RLS |
| 73 | + surface (each hop scoped) — bounded by the depth cap + declared-path allowlist. |
| 74 | + Matrix column cardinality is bounded by the column cap. |
| 75 | +- **Backward compatible.** Single-hop `include` and flat rows keep working |
| 76 | + unchanged; the depth cap and "flat unless `matrix` + `columns`" defaults |
| 77 | + preserve current behaviour. |
| 78 | + |
| 79 | +## Phasing |
| 80 | + |
| 81 | +- **P1 — matrix pivot (objectui-only, lowest risk).** Render the matrix grid from |
| 82 | + the existing `rows`/`columns`. No spec/query change. Ships independently. |
| 83 | +- **P2 — multi-hop (framework).** Spec path support + compiler chain + strategy |
| 84 | + chained joins + per-hop RLS, behind the depth cap. Gated by the ADR-0021 |
| 85 | + reconciliation harness (old-vs-new numbers). |
| 86 | +- **P3 — objectui catalog/UI.** Multi-hop field picker + include-path editor + |
| 87 | + path-aware author-time validation. |
| 88 | + |
| 89 | +## Alternatives considered |
| 90 | + |
| 91 | +- **Arbitrary join predicates / raw SQL** — rejected (ADR-0021 D-C: joins are |
| 92 | + derived from the object graph, no `ON` clauses; keeps datasets reviewable and |
| 93 | + RLS-safe). |
| 94 | +- **Materialized / precomputed pivots** — out of scope; query-time pivot is |
| 95 | + sufficient at expected cardinalities (bounded by the column cap). |
| 96 | + |
| 97 | +## Related |
| 98 | + |
| 99 | +- ADR-0021 (analytics dataset semantic layer) — the foundation; D2 / join |
| 100 | + follow-ups this ADR addresses. |
0 commit comments