Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .changeset/18612-cubejoin-retire-sql-relationship.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
'@objectstack/spec': minor
---

**BREAKING** — retire `CubeJoin.sql` and `CubeJoin.relationship`. A cube join declares
WHICH object it reaches; the ON clause is derived from the declared relationship between
the two cubes' objects and is never authored.

`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`, by an equality the author had
not asked for, with a plausible number attached. `relationship` is the same shape one key
over: it carried a `.default('many_to_one')`, nothing dispatched on the cardinality, and
`one_to_many` parsed, changed no SQL and kept the many-to-one arithmetic.

ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18 (director batch #154 item 4,
letter 2). The ruling declined the other remedy — executing the author's SQL — as a new
capability whose first design question is an injection boundary, for zero authors today. A
custom join condition, if a customer needs one, is a capability card with that boundary
decided first.

## FROM → TO

| you wrote (17.4 and earlier) | write instead |
| --- | --- |
| `joins: { account: { name: 'crm_account', relationship: 'many_to_one', sql: '${orders}.account = ${crm_account}.id' } }` | `joins: { account: { name: 'crm_account' } }` — delete both keys |
| `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 |
| `joins: { a: { name: 'b', on: '…' } }` | `joins: { a: { name: 'b' } }` — `on` was the curated alias for `sql` and is retired with it |

**The one-line fix:** delete `sql` and `relationship` from every `joins` entry; keep `name`.

Nothing regresses by deleting them: neither key ever reached a query. What decides the join
is `name` (the joined object, which is also what the per-object RLS/tenant read scope is
computed for) and the declared relationship the runtime derives the equality from.

## The retirement kit

- **Strict deletion plus a `guidance` prescription, not a `retiredKey()` tombstone.** Every
cube shape is a `strictObject`, so the key leaves the walked shape entirely and the
refusal carries the upgrade: writing `sql`, `relationship` or `on` on a join is an
`unrecognized_keys` rejection whose message names the key and states that the `ON` clause
is DERIVED from the declared relationship between the two cubes' objects. Same route
`MetricSchema.filters` took one shape over in this same file.
- **`on` is no longer an alias.** It pointed at `sql`; an alias naming a key the shape
cannot accept answers an author with a second rejection, so it became a `guidance` entry
of its own and the rename suggestion is gone. Pinned in both directions.
- **ADR-0087: a D2 conversion AND a D3 semantic entry**, plus the two exact-key
registrations `data/CubeJoin:sql` and `data/CubeJoin:relationship` in
`RETIRED_KEYS_BY_MAJOR[18]`. The conversion is
`cube-join-sql-and-relationship-removed` (`toMajor: 18`,
`retiredFromLoadPath: true`), chained into step 18: it strips both keys from every
`analyticsCubes[].joins.*` wherever the chain is replayed, one notice per stripped site,
each naming the cube that lost the key. It is owed because the removal is measured
against **metadata at rest**, not only against sources: `sql` was required and
`relationship` was defaulted, so every cube artifact ever written from the old schema's
own parse output carries both keys, and the boot door
(`ObjectStackDefinitionSchema` → `analyticsCubes: z.array(CubeSchema)`) would otherwise
refuse it with no remedy short of hand-editing JSON. The strip is lossless in the only
sense that applies: a key that never had an effect has none to lose. The D3 entry
`cube-join-sql-and-relationship-retired` stays as the human-facing record — the strip
removes the key, the entry says why an author who wrote a non-FK `sql` should re-read the
numbers that join produced.
- **The `os migrate meta --from 17` sentence** closes all three prescriptions, which is what
a covered surface owes.
- **The `joins` record KEY is documented.** `name`'s describe now states that the key a join
is declared under is the FOREIGN-KEY FIELD on the cube's own base object — the column the
derived `ON` reads — not a second spelling of the object the join reaches.
- **The liveness ledger rows went WITH the keys** (`liveness/analytics_cube.json`), which is
the strict-deletion route's disposition — the opposite of the tombstone route, which keeps
the row because `retiredKey()` keeps the key in the walked shape. `analytics_cube` drops
from 12 `dead` to 10.
- **The one in-repo producer is fixed in the same diff.** `examples/app-showcase`'s
`DeliveryCube` authored both keys, including an `ON` clause the runtime was replacing;
`dataset-compiler.ts` minted them as two constants no reader consulted. Its join was also
keyed `showcase_project` — the object it reaches — while `showcase_task`'s foreign key is
`project`, so the derived `ON` named a column the base object does not have and the join
never resolved. It is re-keyed `project` here and pinned against the object's own field
map.

Clause-②: yes (narrowing)

<!-- adr-0087: registered cube-join-sql-and-relationship-retired -->
10 changes: 3 additions & 7 deletions content/docs/references/data/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Type: `[string, string]`
| **sql** | `string` | ✅ | Base SQL statement or Table Name |
| **measures** | `Record<string, { name: string; label: string; description?: string; type: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>; … }>` | ✅ | Quantitative metrics |
| **dimensions** | `Record<string, { name: string; label: string; description?: string; type: Enum<'string' \| 'number' \| 'boolean' \| 'time' \| 'geo'>; … }>` | ✅ | Qualitative attributes |
| **joins** | `Record<string, { name: string; relationship: Enum<'one_to_one' \| 'one_to_many' \| 'many_to_one'>; sql: string }>` | optional | |
| **joins** | `Record<string, { name: string }>` | optional | |
| **refreshKey** | `{ every?: string; sql?: string }` | optional | |
| **public** | `boolean` | optional (default: `false`) | |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
Expand Down Expand Up @@ -164,9 +164,7 @@ Type: `[string, string]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Target cube name |
| **relationship** | `Enum<'one_to_one' \| 'one_to_many' \| 'many_to_one'>` | optional (default: `"many_to_one"`) | |
| **sql** | `string` | ✅ | Join condition (ON clause) |
| **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. The KEY this join is declared under in the `joins` record is the FOREIGN-KEY FIELD on this cube's own object, not a second spelling of the object it reaches: the runtime emits `LEFT JOIN <name> <key> ON <base>.<key> = <key>.id` and resolves a member written `<key>.<field>` through that alias. A join keyed after the TARGET object joins on a column the base object does not have, so nothing resolves. |

### Nested Shape: `Cube.refreshKey`

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

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Target cube name |
| **relationship** | `Enum<'one_to_one' \| 'one_to_many' \| 'many_to_one'>` | optional (default: `"many_to_one"`) | |
| **sql** | `string` | ✅ | Join condition (ON clause) |
| **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. The KEY this join is declared under in the `joins` record is the FOREIGN-KEY FIELD on this cube's own object, not a second spelling of the object it reaches: the runtime emits `LEFT JOIN <name> <key> ON <base>.<key> = <key>.id` and resolves a member written `<key>.<field>` through that alias. A join keyed after the TARGET object joins on a column the base object does not have, so nothing resolves. |


---
Expand Down
19 changes: 16 additions & 3 deletions examples/app-showcase/src/data/analytics/showcase.cube.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,24 @@ export const DeliveryCube = defineCube({
sql: 'assignee',
},
},
// The ON clause is DERIVED, never authored: the runtime builds a foreign-key
// equality from the declared relationship between the two cubes' objects. A
// join declares only WHICH object it reaches (#18612 removed `sql` and
// `relationship`; before that, the ON clause written here was silently
// replaced by exactly this derivation).
//
// The record KEY is the FOREIGN-KEY FIELD on the base object —
// `showcase_task.project`, declared as `Field.masterDetail('showcase_project')`
// — never a second spelling of the object it reaches. Both strategies read it
// that way: NativeSQLStrategy emits
// `LEFT JOIN "showcase_project" "project" ON "showcase_task"."project" = "project"."id"`
// and ObjectQLStrategy lowers `fkField: 'project'`. Keyed `showcase_project`
// (as it was until #18612) the derivation asked for a
// `showcase_task.showcase_project` column that does not exist, so the join
// never resolved and the example demonstrated nothing.
joins: {
showcase_project: {
project: {
name: 'showcase_project',
relationship: 'many_to_one',
sql: '${showcase_delivery}.project = ${showcase_project}.id',
},
},
refreshKey: {
Expand Down
21 changes: 20 additions & 1 deletion examples/app-showcase/test/gap-fill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,26 @@ describe('showcase gap fill — analytics cube', () => {
expect(Object.keys(DeliveryCube.dimensions ?? {})).toEqual(
expect.arrayContaining(['status', 'priority', 'due_date']),
);
expect(DeliveryCube.joins?.showcase_project?.relationship).toBe('many_to_one');
// A join declares the object it reaches and nothing else: the ON clause is
// derived from the declared relationship (#18612 removed `sql`/`relationship`).
expect(DeliveryCube.joins?.project?.name).toBe('showcase_project');
});

it('keys every join by a FOREIGN-KEY FIELD of its own base object, not by the target', () => {
// #18612: the `joins` record KEY is what both strategies join ON — native
// emits `ON "<base>"."<key>" = "<key>"."id"`, ObjectQL lowers `fkField: key`
// — so a key that is not a field of the base object joins on a column that
// does not exist. Pinned against the object's REAL field map rather than a
// literal, so this fails the moment either side moves.
const objects = ((stack as { objects?: Array<{ name: string; fields?: Record<string, unknown> }> }).objects ?? []);
const base = objects.find((o) => o.name === DeliveryCube.sql);
expect(base, `cube base object '${DeliveryCube.sql}' is not in the stack`).toBeDefined();
const fields = new Set(Object.keys(base?.fields ?? {}));
const joinKeys = Object.keys(DeliveryCube.joins ?? {});
expect(joinKeys.length).toBeGreaterThan(0);
for (const key of joinKeys) {
expect(fields.has(key), `join key '${key}' is not a field of '${DeliveryCube.sql}'`).toBe(true);
}
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ const CUBE: Cube = {
dimensions: {
status: { name: 'status', label: 'Status', type: 'string', sql: 'status' },
},
joins: { account: { name: 'crm_account', relationship: 'belongsTo', sql: '' } },
joins: { account: { name: 'crm_account' } },
} as never;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ describe('custom-expression measures emit their expression', () => {
describe('an expression containing a dot is not mistaken for a join path', () => {
const dotted: Cube = {
...cube,
joins: { account: { name: 'account', relationship: 'belongsTo', sql: '' } },
joins: { account: { name: 'account' } },
measures: {
...cube.measures,
// A dot inside a function call — an expression, not `relation.column`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ describe('NativeSQLStrategy — D-C RLS hardening', () => {

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

Expand Down Expand Up @@ -168,8 +168,8 @@ describe('NativeSQLStrategy — multi-hop joins (ADR-0071)', () => {
owner_region: { name: 'owner_region', label: 'Owner Region', type: 'string', sql: 'account.owner.region' },
},
joins: {
account: { name: 'crm_account', relationship: 'many_to_one', sql: 'opportunity.account = account.id' },
'account__owner': { name: 'core_user', relationship: 'many_to_one', sql: 'account.owner = account__owner.id' },
account: { name: 'crm_account' },
'account__owner': { name: 'core_user' },
},
public: false,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ const joinedCube: Cube = {
opened: { name: 'opened', label: 'Opened', type: 'time', sql: 'account.created_at' },
},
joins: {
account: { name: 'account', relationship: 'many_to_one', sql: 'opportunity.account = account.id' },
account: { name: 'account' },
},
public: false,
};
Expand Down
13 changes: 6 additions & 7 deletions packages/services/service-analytics/src/dataset-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,6 @@ export function compileDataset(
);
}
let fromObject = dataset.object;
let parentAlias = dataset.object;
let prefix = '';
for (const seg of segments) {
prefix = prefix ? `${prefix}.${seg}` : seg;
Expand All @@ -596,14 +595,14 @@ export function compileDataset(
if (!joins[alias]) {
// KEY is the SQL-safe alias; `name` carries the join TABLE; the strategy
// rebuilds the ON clause from the alias convention (`<parent>.<seg> = <alias>.id`).
joins[alias] = {
name: target.table,
relationship: 'many_to_one',
sql: `${parentAlias}.${seg} = ${prefix}.id`,
};
// That derivation is now the whole contract: #18612 removed `CubeJoin.sql`
// and `CubeJoin.relationship` (ADR-0049 enforce-or-remove), so the two
// constants this literal used to carry are gone rather than re-synthesised
// here. Nothing ever read them — both strategies resolve a join through
// `cube.joins?.[alias]?.name` alone.
joins[alias] = { name: target.table };
}
fromObject = target.object;
parentAlias = prefix;
}
}

Expand Down
12 changes: 0 additions & 12 deletions packages/spec/api-surface-declarations/data.txt
Original file line number Diff line number Diff line change
Expand Up @@ -977,12 +977,6 @@ type CubeJoinParsed = z.infer<typeof CubeJoinSchema>;
// ── CubeJoinSchema (const) ──
declare const CubeJoinSchema: z.ZodObject<{
name: z.ZodString;
relationship: z.ZodDefault<z.ZodEnum<{
one_to_one: "one_to_one";
one_to_many: "one_to_many";
many_to_one: "many_to_one";
}>>;
sql: z.ZodString;
}, z.core.$strict>;

// ── CubeParsed (type) ──
Expand Down Expand Up @@ -1054,12 +1048,6 @@ declare const CubeSchema: z.ZodObject<{
}, z.core.$strict>>;
joins: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
name: z.ZodString;
relationship: z.ZodDefault<z.ZodEnum<{
one_to_one: "one_to_one";
one_to_many: "one_to_many";
many_to_one: "many_to_one";
}>>;
sql: z.ZodString;
}, z.core.$strict>>>;
refreshKey: z.ZodOptional<z.ZodObject<{
every: z.ZodOptional<z.ZodString>;
Expand Down
12 changes: 0 additions & 12 deletions packages/spec/api-surface-declarations/root.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22346,12 +22346,6 @@ declare const ObjectStackDefinitionSchema: z.ZodObject<{
}, z.core.$strict>>;
joins: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
name: z.ZodString;
relationship: z.ZodDefault<z.ZodEnum<{
one_to_one: "one_to_one";
one_to_many: "one_to_many";
many_to_one: "many_to_one";
}>>;
sql: z.ZodString;
}, z.core.$strict>>>;
refreshKey: z.ZodOptional<z.ZodObject<{
every: z.ZodOptional<z.ZodString>;
Expand Down Expand Up @@ -44199,12 +44193,6 @@ declare const ObjectStackSchema: z.ZodObject<{
}, z.core.$strict>>;
joins: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
name: z.ZodString;
relationship: z.ZodDefault<z.ZodEnum<{
one_to_one: "one_to_one";
one_to_many: "one_to_many";
many_to_one: "many_to_one";
}>>;
sql: z.ZodString;
}, z.core.$strict>>>;
refreshKey: z.ZodOptional<z.ZodObject<{
every: z.ZodOptional<z.ZodString>;
Expand Down
6 changes: 0 additions & 6 deletions packages/spec/api-surface-declarations/system.txt
Original file line number Diff line number Diff line change
Expand Up @@ -54359,12 +54359,6 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{
}, z.core.$strict>>;
joins: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
name: z.ZodString;
relationship: z.ZodDefault<z.ZodEnum<{
one_to_one: "one_to_one";
one_to_many: "one_to_many";
many_to_one: "many_to_one";
}>>;
sql: z.ZodString;
}, z.core.$strict>>>;
refreshKey: z.ZodOptional<z.ZodObject<{
every: z.ZodOptional<z.ZodString>;
Expand Down
1 change: 0 additions & 1 deletion packages/spec/authorable-defaults/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
"data/CrossFieldValidation:priority = 100",
"data/CrossFieldValidation:severity = \"error\"",
"data/Cube:public = false",
"data/CubeJoin:relationship = \"many_to_one\"",
"data/CurrencyConfig:currencyMode = \"dynamic\"",
"data/CurrencyConfig:defaultCurrency = \"CNY\"",
"data/CurrencyConfig:precision = 2",
Expand Down
2 changes: 0 additions & 2 deletions packages/spec/authorable-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,6 @@
"data/Cube:sql",
"data/Cube:title",
"data/CubeJoin:name",
"data/CubeJoin:relationship",
"data/CubeJoin:sql",
"data/CurrencyConfig:currencyMode",
"data/CurrencyConfig:defaultCurrency",
"data/CurrencyConfig:precision",
Expand Down
Loading
Loading