Skip to content

Commit 6ac13a9

Browse files
committed
feat(spec)!: retire CubeJoin.sql and CubeJoin.relationship — the ON clause is derived
`CubeJoin.sql` was REQUIRED and described itself as the `ON` clause, and nothing ever read it. Both analytics strategies synthesise the join — `NativeSQLStrategy` emits `LEFT JOIN <name> <alias> ON "<parent>"."<segment>" = "<alias>"."id"` from the dotted member path alone, and `ObjectQLStrategy` resolves the join through `cube.joins?.[alias]?.name` and lowers it to a relationship traversal with no `ON` clause at all. So an authored join condition was not ignored, it was REPLACED under a 200. `relationship` is the same shape one key over: it carried a `.default('many_to_one')` and nothing dispatched on the cardinality. `CubeJoinSchema` is a `strictObject`, so the route is strict deletion plus a `guidance` prescription rather than a `retiredKey()` tombstone — the same route `MetricSchema.filters` took in this file. The refusal names the key and states that the `ON` clause is derived from the declared relationship between the two cubes' objects. The `on` alias, which pointed at `sql`, becomes a `guidance` entry of its own so an author is never sent to a key the shape cannot accept. ADR-0087 registration: the two exact keys in `RETIRED_KEYS_BY_MAJOR[18]` plus the D3 semantic entry `cube-join-sql-and-relationship-retired`. Not a D2 conversion — there is no consumer source to rewrite, and an author who wrote a non-FK condition wanted a join the runtime does not perform, which is a judgement rather than a strip. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18 (director batch #154 item 4, letter 2). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
1 parent 54145cc commit 6ac13a9

21 files changed

Lines changed: 400 additions & 87 deletions
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
**BREAKING** — retire `CubeJoin.sql` and `CubeJoin.relationship`. A cube join declares
6+
WHICH object it reaches; the ON clause is derived from the declared relationship between
7+
the two cubes' objects and is never authored.
8+
9+
`CubeJoin.sql` was **required** and described itself as the `ON` clause, and nothing ever
10+
read it. Both analytics strategies synthesise the join: `NativeSQLStrategy` emits
11+
`LEFT JOIN <name> <alias> ON "<parent>"."<segment>" = "<alias>"."id"` from the dotted member
12+
path alone, and `ObjectQLStrategy` resolves the join through `cube.joins?.[alias]?.name` and
13+
lowers it to a relationship traversal with no `ON` clause at all. So an authored join
14+
condition was not ignored — it was **replaced**, under a `200`, by an equality the author had
15+
not asked for, with a plausible number attached. `relationship` is the same shape one key
16+
over: it carried a `.default('many_to_one')`, nothing dispatched on the cardinality, and
17+
`one_to_many` parsed, changed no SQL and kept the many-to-one arithmetic.
18+
19+
ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18 (director batch #154 item 4,
20+
letter 2). The ruling declined the other remedy — executing the author's SQL — as a new
21+
capability whose first design question is an injection boundary, for zero authors today. A
22+
custom join condition, if a customer needs one, is a capability card with that boundary
23+
decided first.
24+
25+
## FROM → TO
26+
27+
| you wrote (17.4 and earlier) | write instead |
28+
| --- | --- |
29+
| `joins: { account: { name: 'crm_account', relationship: 'many_to_one', sql: '${orders}.account = ${crm_account}.id' } }` | `joins: { account: { name: 'crm_account' } }` — delete both keys |
30+
| `joins: { a: { name: 'b', relationship: 'one_to_many' } }` | `joins: { a: { name: 'b' } }` — the cardinality was never read; declare it on the object's own relationship field |
31+
| `joins: { a: { name: 'b', on: '…' } }` | `joins: { a: { name: 'b' } }` — `on` was the curated alias for `sql` and is retired with it |
32+
33+
**The one-line fix:** delete `sql` and `relationship` from every `joins` entry; keep `name`.
34+
35+
Nothing regresses by deleting them: neither key ever reached a query. What decides the join
36+
is `name` (the joined object, which is also what the per-object RLS/tenant read scope is
37+
computed for) and the declared relationship the runtime derives the equality from.
38+
39+
## The retirement kit
40+
41+
- **Strict deletion plus a `guidance` prescription, not a `retiredKey()` tombstone.** Every
42+
cube shape is a `strictObject`, so the key leaves the walked shape entirely and the
43+
refusal carries the upgrade: writing `sql`, `relationship` or `on` on a join is an
44+
`unrecognized_keys` rejection whose message names the key and states that the `ON` clause
45+
is DERIVED from the declared relationship between the two cubes' objects. Same route
46+
`MetricSchema.filters` took one shape over in this same file.
47+
- **`on` is no longer an alias.** It pointed at `sql`; an alias naming a key the shape
48+
cannot accept answers an author with a second rejection, so it became a `guidance` entry
49+
of its own and the rename suggestion is gone. Pinned in both directions.
50+
- **ADR-0087: a D3 SEMANTIC entry**, `cube-join-sql-and-relationship-retired`, plus the two
51+
exact-key registrations `data/CubeJoin:sql` and `data/CubeJoin:relationship` in
52+
`RETIRED_KEYS_BY_MAJOR[18]`. Not a D2 conversion: the ruling's census found zero authored
53+
cube joins outside this repository, so there is no consumer source to rewrite, and a
54+
mechanical strip would delete the key without recording which cube lost it — an author who
55+
wrote a non-FK `sql` wanted a join the runtime does not perform, and that want needs a
56+
decision rather than a rewrite. The `list-view-navigation-view-retired` entry took the same
57+
route for the same reason.
58+
- **No `os migrate meta` sentence** in either prescription: that sentence is owed only where
59+
an ADR-0087 conversion covers the surface, and none does.
60+
- **The liveness ledger rows went WITH the keys** (`liveness/analytics_cube.json`), which is
61+
the strict-deletion route's disposition — the opposite of the tombstone route, which keeps
62+
the row because `retiredKey()` keeps the key in the walked shape. `analytics_cube` drops
63+
from 12 `dead` to 10.
64+
- **The one in-repo producer is fixed in the same diff.** `examples/app-showcase`'s
65+
`DeliveryCube` authored both keys, including an `ON` clause the runtime was replacing;
66+
`dataset-compiler.ts` minted them as two constants no reader consulted.
67+
68+
Clause-②: yes (narrowing)
69+
70+
<!-- adr-0087: registered cube-join-sql-and-relationship-retired -->

‎content/docs/references/data/analytics.mdx‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ Type: `[string, string]`
127127
| **sql** | `string` | ✅ | Base SQL statement or Table Name |
128128
| **measures** | `Record<string, { name: string; label: string; description?: string; type: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>; … }>` | ✅ | Quantitative metrics |
129129
| **dimensions** | `Record<string, { name: string; label: string; description?: string; type: Enum<'string' \| 'number' \| 'boolean' \| 'time' \| 'geo'>; … }>` | ✅ | Qualitative attributes |
130-
| **joins** | `Record<string, { name: string; relationship: Enum<'one_to_one' \| 'one_to_many' \| 'many_to_one'>; sql: string }>` | optional | |
130+
| **joins** | `Record<string, { name: string }>` | optional | |
131131
| **refreshKey** | `{ every?: string; sql?: string }` | optional | |
132132
| **public** | `boolean` | optional (default: `false`) | |
133133
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
@@ -164,9 +164,7 @@ Type: `[string, string]`
164164

165165
| Property | Type | Required | Description |
166166
| :--- | :--- | :--- | :--- |
167-
| **name** | `string` | ✅ | Target cube name |
168-
| **relationship** | `Enum<'one_to_one' \| 'one_to_many' \| 'many_to_one'>` | optional (default: `"many_to_one"`) | |
169-
| **sql** | `string` | ✅ | Join condition (ON clause) |
167+
| **name** | `string` | ✅ | Target cube name — the object this join reaches. The ON clause is DERIVED from the declared relationship between the two cubes' objects (a foreign-key equality) and is never authored. |
170168

171169
### Nested Shape: `Cube.refreshKey`
172170

@@ -184,9 +182,7 @@ Type: `[string, string]`
184182

185183
| Property | Type | Required | Description |
186184
| :--- | :--- | :--- | :--- |
187-
| **name** | `string` | ✅ | Target cube name |
188-
| **relationship** | `Enum<'one_to_one' \| 'one_to_many' \| 'many_to_one'>` | optional (default: `"many_to_one"`) | |
189-
| **sql** | `string` | ✅ | Join condition (ON clause) |
185+
| **name** | `string` | ✅ | Target cube name — the object this join reaches. The ON clause is DERIVED from the declared relationship between the two cubes' objects (a foreign-key equality) and is never authored. |
190186

191187

192188
---

‎examples/app-showcase/src/data/analytics/showcase.cube.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,14 @@ export const DeliveryCube = defineCube({
7272
sql: 'assignee',
7373
},
7474
},
75+
// The ON clause is DERIVED, never authored: the runtime builds a foreign-key
76+
// equality from the declared relationship between the two cubes' objects. A
77+
// join declares only WHICH object it reaches (#18612 removed `sql` and
78+
// `relationship`; before that, the ON clause written here was silently
79+
// replaced by exactly this derivation).
7580
joins: {
7681
showcase_project: {
7782
name: 'showcase_project',
78-
relationship: 'many_to_one',
79-
sql: '${showcase_delivery}.project = ${showcase_project}.id',
8083
},
8184
},
8285
refreshKey: {

‎examples/app-showcase/test/gap-fill.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ describe('showcase gap fill — analytics cube', () => {
3131
expect(Object.keys(DeliveryCube.dimensions ?? {})).toEqual(
3232
expect.arrayContaining(['status', 'priority', 'due_date']),
3333
);
34-
expect(DeliveryCube.joins?.showcase_project?.relationship).toBe('many_to_one');
34+
// A join declares the object it reaches and nothing else: the ON clause is
35+
// derived from the declared relationship (#18612 removed `sql`/`relationship`).
36+
expect(DeliveryCube.joins?.showcase_project?.name).toBe('showcase_project');
3537
});
3638
});
3739

‎packages/services/service-analytics/src/__tests__/measure-expression-both-strategies.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ const CUBE: Cube = {
100100
dimensions: {
101101
status: { name: 'status', label: 'Status', type: 'string', sql: 'status' },
102102
},
103-
joins: { account: { name: 'crm_account', relationship: 'belongsTo', sql: '' } },
103+
joins: { account: { name: 'crm_account' } },
104104
} as never;
105105

106106
/**

‎packages/services/service-analytics/src/__tests__/measure-expression-sql.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ describe('custom-expression measures emit their expression', () => {
8787
describe('an expression containing a dot is not mistaken for a join path', () => {
8888
const dotted: Cube = {
8989
...cube,
90-
joins: { account: { name: 'account', relationship: 'belongsTo', sql: '' } },
90+
joins: { account: { name: 'account' } },
9191
measures: {
9292
...cube.measures,
9393
// A dot inside a function call — an expression, not `relation.column`.

‎packages/services/service-analytics/src/__tests__/native-sql-rls.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ describe('NativeSQLStrategy — D-C RLS hardening', () => {
7171

7272
it('joins the resolved TARGET TABLE when cube.joins maps alias→table (namespaced)', async () => {
7373
// alias `account` → table `crm_account` (what the dataset compiler emits).
74-
const nsCube: Cube = { ...cube, joins: { account: { name: 'crm_account', relationship: 'many_to_one', sql: '' } } };
74+
const nsCube: Cube = { ...cube, joins: { account: { name: 'crm_account' } } };
7575
const strategy = new NativeSQLStrategy();
7676
const ctx = ctxWith({
7777
getCube: (n) => (n === 'sales' ? nsCube : undefined),
@@ -123,7 +123,7 @@ describe('NativeSQLStrategy — base-column qualification under joins', () => {
123123
status: { name: 'status', label: 'Status', type: 'string', sql: 'status' },
124124
region: { name: 'region', label: 'Region', type: 'string', sql: 'account.region' },
125125
},
126-
joins: { account: { name: 'account', relationship: 'many_to_one', sql: '' } },
126+
joins: { account: { name: 'account' } },
127127
public: false,
128128
};
129129

@@ -168,8 +168,8 @@ describe('NativeSQLStrategy — multi-hop joins (ADR-0071)', () => {
168168
owner_region: { name: 'owner_region', label: 'Owner Region', type: 'string', sql: 'account.owner.region' },
169169
},
170170
joins: {
171-
account: { name: 'crm_account', relationship: 'many_to_one', sql: 'opportunity.account = account.id' },
172-
'account__owner': { name: 'core_user', relationship: 'many_to_one', sql: 'account.owner = account__owner.id' },
171+
account: { name: 'crm_account' },
172+
'account__owner': { name: 'core_user' },
173173
},
174174
public: false,
175175
};

‎packages/services/service-analytics/src/__tests__/unlisted-refusal-envelope.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ const joinedCube: Cube = {
169169
opened: { name: 'opened', label: 'Opened', type: 'time', sql: 'account.created_at' },
170170
},
171171
joins: {
172-
account: { name: 'account', relationship: 'many_to_one', sql: 'opportunity.account = account.id' },
172+
account: { name: 'account' },
173173
},
174174
public: false,
175175
};

‎packages/services/service-analytics/src/dataset-compiler.ts‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,6 @@ export function compileDataset(
584584
);
585585
}
586586
let fromObject = dataset.object;
587-
let parentAlias = dataset.object;
588587
let prefix = '';
589588
for (const seg of segments) {
590589
prefix = prefix ? `${prefix}.${seg}` : seg;
@@ -596,14 +595,14 @@ export function compileDataset(
596595
if (!joins[alias]) {
597596
// KEY is the SQL-safe alias; `name` carries the join TABLE; the strategy
598597
// rebuilds the ON clause from the alias convention (`<parent>.<seg> = <alias>.id`).
599-
joins[alias] = {
600-
name: target.table,
601-
relationship: 'many_to_one',
602-
sql: `${parentAlias}.${seg} = ${prefix}.id`,
603-
};
598+
// That derivation is now the whole contract: #18612 removed `CubeJoin.sql`
599+
// and `CubeJoin.relationship` (ADR-0049 enforce-or-remove), so the two
600+
// constants this literal used to carry are gone rather than re-synthesised
601+
// here. Nothing ever read them — both strategies resolve a join through
602+
// `cube.joins?.[alias]?.name` alone.
603+
joins[alias] = { name: target.table };
604604
}
605605
fromObject = target.object;
606-
parentAlias = prefix;
607606
}
608607
}
609608

‎packages/spec/authorable-defaults/data.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
"data/CrossFieldValidation:priority = 100",
1212
"data/CrossFieldValidation:severity = \"error\"",
1313
"data/Cube:public = false",
14-
"data/CubeJoin:relationship = \"many_to_one\"",
1514
"data/CurrencyConfig:currencyMode = \"dynamic\"",
1615
"data/CurrencyConfig:defaultCurrency = \"CNY\"",
1716
"data/CurrencyConfig:precision = 2",

0 commit comments

Comments
 (0)