|
| 1 | +--- |
| 2 | +"@objectstack/driver-sql": minor |
| 3 | +"@objectstack/driver-turso": minor |
| 4 | +"@objectstack/driver-sqlite-wasm": minor |
| 5 | +"@objectstack/spec": minor |
| 6 | +"@objectstack/objectql": patch |
| 7 | +--- |
| 8 | + |
| 9 | +feat(drivers,spec)!: `GroupByNode.alias` is honoured by the SQL faces — one aggregate, one column key (#6401) |
| 10 | + |
| 11 | +`GroupByNodeSchema` has declared `alias` ("Alias for the projected group |
| 12 | +value", defaulting to `field`) for as long as the structured `groupBy` entry has |
| 13 | +existed. Exactly one execution path read it. The result: the SAME query came |
| 14 | +back with a different result-column key depending on which path the engine |
| 15 | +happened to take. |
| 16 | + |
| 17 | +```ts |
| 18 | +groupBy: [{ field: 'closed_at', dateGranularity: 'month', alias: 'qtr' }] |
| 19 | +``` |
| 20 | + |
| 21 | +- pushed down to a driver ⇒ rows keyed **`closed_at`** |
| 22 | +- run through the in-memory fallback ⇒ rows keyed **`qtr`** |
| 23 | + |
| 24 | +And the choice between them is `engine.ts`'s |
| 25 | +`allStructuredSupported && !tzRequiresInMemory` — a driver capability bit and a |
| 26 | +`timezone`, neither of which the caller can see. That is the multi-face |
| 27 | +consistency invariant broken in its quietest form: both answers are valid rows, |
| 28 | +so nothing throws and nothing looks wrong. |
| 29 | + |
| 30 | +**Resolved to ENFORCE**, and the leg was chosen by measurement rather than |
| 31 | +taste. ADR-0049 splits on whether the feature already exists: a *dangling* |
| 32 | +promise is removed, a *live* one with a missing gate is enforced. `alias` is |
| 33 | +live — three consumers read it and change behaviour |
| 34 | +(`in-memory-aggregation.ts`, `MemoryDriver.performAggregation`, and |
| 35 | +`chartAggregateCategoryKey`), and the publish gate *compels* it: |
| 36 | +`validate-react-page-props.ts` errors `REACT_CHART_AXIS_UNKNOWN` unless a |
| 37 | +chart's category axis is bound to `alias ?? field`, telling the author in so |
| 38 | +many words to "bind it to" the alias. A key the build gate makes you write is |
| 39 | +not a dangling promise. The count of real non-test producers is **zero**, which |
| 40 | +is what makes enforcing safe rather than what argues against it: no shipped |
| 41 | +payload changes its result keys. |
| 42 | + |
| 43 | +**What changed, on every SQL face at once** — a fix landing on one and not its |
| 44 | +twin is the #6203 shape, and `TursoDriver` picks its face from `url`: |
| 45 | + |
| 46 | +- **`driver-sql`** — both limbs of the structured `groupBy` branch project |
| 47 | + `alias ?? field`: the date-bucket limb aliases the bucket expression to it, |
| 48 | + and the plain limb emits `?? as ??` (only when the name actually moves — an |
| 49 | + alias equal to the field emits no self-rename). `presentedOutput` is now keyed |
| 50 | + by the OUTPUT column, matching how the aggregation branch beside it has always |
| 51 | + worked; an aliased group value went unpresented before. |
| 52 | +- **`driver-turso` REMOTE** — the same projection, `"field" AS "alias"`. The |
| 53 | + alias reaches the statement as a quoted identifier and is therefore held to |
| 54 | + `assertSafeIdentifier`, exactly like `field`. |
| 55 | +- **`driver-sqlite-wasm`** — inherits `SqlDriver`'s compiler; covered by its own |
| 56 | + conformance suite rather than by assumption. |
| 57 | + |
| 58 | +**GROUP BY still keys on the FIELD** on every face. Only the projection is |
| 59 | +renamed, so the buckets are unchanged. This is deliberate and pinned: SQLite |
| 60 | +resolves output names in `GROUP BY`, so a face that grouped by the alias would |
| 61 | +look correct here and diverge on a dialect that does not. |
| 62 | + |
| 63 | +`having` needed no change and now means one thing: it is applied over the |
| 64 | +aggregated row's own columns, so a filter on a group projection references the |
| 65 | +alias on every path — previously the alias on one path and the field on the |
| 66 | +other. |
| 67 | + |
| 68 | +**Conformance.** `AGGREGATION_CASES` (#6409) gains a `groupByAlias` axis and two |
| 69 | +cases. Their VALUES are an existing case verbatim — only the key moves — so they |
| 70 | +can fail only on the key, which is the point: every wrong answer in this area is |
| 71 | +a valid query returning plausible rows. `objectql`'s in-memory fallback is now |
| 72 | +**enrolled** as a fourth face, answering #6409's open question ②: it is the face |
| 73 | +the SQL three were converged onto, so the new behaviour would otherwise be |
| 74 | +pinned against nothing, and reaching it needs no engine at all — |
| 75 | +`applyInMemoryAggregation` is a pure function of rows and an AST. |
| 76 | + |
| 77 | +**Reverse verification**, predicted before running. Reverting the in-memory face |
| 78 | +to `g.field`: only the two alias cases move and only ONE fails — the degenerate |
| 79 | +`alias === field` case stays green, which is why both are in the table. |
| 80 | +Reverting the harness to read `c.groupBy` instead of `c.groupByAlias ?? c.groupBy` |
| 81 | +— the copied-neighbour mistake: everything passes on an unmodified face, a false |
| 82 | +GREEN, which is the failure mode that would have made the axis vacuous. |
| 83 | + |
| 84 | +**Frozen drivers (#5499), measured from source, not flipped.** `driver-memory` |
| 85 | +already returned `{ field, alias: node.alias ?? node.field }` and projects under |
| 86 | +the alias — it had independently reached the enforce answer, so it needed no |
| 87 | +alignment. `driver-mongodb` is a recorded DEBT row and the defect is wider than |
| 88 | +`alias`: `buildAggregationPipeline` types `groupBy` as `string[]` and builds |
| 89 | +`groupId[field] = '$' + field`, so a structured node — aliased or not — becomes |
| 90 | +the literal key `"[object Object]"`. It cannot take a structured `GroupByNode` |
| 91 | +at all; `mongodb-driver.ts` passes `(query as any).groupBy`, which is why `tsc` |
| 92 | +never saw it. Tracked on #6814. |
| 93 | + |
| 94 | +**Compatibility.** A caller who writes `alias` and reads the result under |
| 95 | +`field` on a pushdown path will now find the value under `alias` — which is what |
| 96 | +the key has always meant on the fallback path, and what the chart gate already |
| 97 | +required. Callers who never write `alias` are unaffected: the emitted SQL is |
| 98 | +byte-identical. |
| 99 | + |
| 100 | +<!-- adr-0087: not-required (no-migration-prescription) Nothing is retired: `GroupByNodeSchema.alias` keeps its declaration, its spelling and its type — it starts being HONOURED by three faces that parsed and ignored it. There is no tombstone to write and no authored metadata to rewrite, so there is no mechanical transform a migration could prescribe: every stack that validated before validates after, unchanged. The behaviour change is in the RESULT of a runtime query (a result-column key moves from `field` to `alias` on the pushdown path, converging on what the in-memory path and the chart publish gate already required), which the ledger has no channel for and no upgrader could apply a codemod to. The bang is on the changeset because callers who read that column by the field name must move, and the measured non-test producer count for the key is zero. --> |
| 101 | + |
0 commit comments