Skip to content

Commit 5d4de37

Browse files
os-zhuangclaude
andauthored
fix(objectql,driver-sql)!: a group key is the column's value, in the shape find() presents it (#3849) (#3864)
Three code paths produce a `groupBy` key and no two agreed: qty (number) won (boolean) find() 3 number true boolean aggregate() pushed down 3 number 0/1 number in-memory fallback '3' string 'false'/'true' string Two independent causes. `applyInMemoryAggregation` ran every key through `String()`, which the pushed-down path never did. And the pushed-down path returns raw builder output: #3797 taught it to present temporal columns the way `formatOutput` does on a `find()` row, but not the boolean and numeric repairs, so a SQLite boolean — no native type, stored 0/1 — surfaced as an integer from `aggregate()` and a real boolean from `find()`. `engine.aggregate` picks between the two aggregate paths per query, so the same column changed shape with no change to the data or the query. The measures were always right, which is why nobody noticed. What broke was downstream code probing a raw `Map` keyed by the value's own type — `Map` lookup is SameValueZero, so '1' never finds 1: select-option labels fell back to raw values, lookup labels missed whenever the referenced object had a numeric id (routine for external objects), and cross-object rebucketing filed every row under RESTRICTED_BUCKET — one bar, correct grand total, no error. A boolean dimension drilled from the in-memory path also sent { won: 'true' } to SQLite, which an INTEGER column can never match. - `applyInMemoryAggregation` emits the value verbatim; its rows are find() output, so passing through is what makes the key equal the column's read shape. - The internal bucket id is type-preserving, so 1 and '1', true and 'true' stay distinct groups. BigInt is encoded explicitly — JSON.stringify throws on it, and a value that bucketed fine under String() must not start crashing. - `SqlDriver.aggregate`/`.distinct` present group keys and min/max with the same rules `formatOutput` applies, generalizing #3797 to boolean and numeric columns. The protected helpers are renamed to match (temporalFieldKind → readPresentationKind, presentTemporalValue → presentReadValue, presentTemporalColumns → presentReadColumns) and the kind union is exported. Date-bucketed groupBy is unaffected: both sides already produce canonical string labels, and #3839 pinned their empty bucket. Gate: group-key-read-shape-parity.test.ts measures both aggregate paths against find() for number, boolean and text columns on driver-sql and driver-sqlite-wasm, asserting the runtime TYPE — folding through String() is the reflex that hid this and would pass against the bug. Each half was confirmed to redden the gate on its own. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent e47b342 commit 5d4de37

5 files changed

Lines changed: 435 additions & 49 deletions

File tree

.changeset/group-key-read-shape.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/driver-sql": minor
4+
---
5+
6+
fix(objectql,driver-sql)!: a group key is the column's value, in the shape `find()` presents it (#3849)
7+
8+
`groupBy: ['qty']` now returns `3`, not `'3'`. `groupBy: ['won']` returns `true` /
9+
`false`, not `'true'` / `'false'` on one path and `1` / `0` on the other. A bucket
10+
key is a column value, so there is one right answer for what it looks like —
11+
whatever that column looks like on a `find()` row — and all three paths that
12+
produce one now give it.
13+
14+
### What was wrong
15+
16+
Three code paths produce a group key, and no two of them agreed:
17+
18+
| | `qty` (number) | `won` (boolean) |
19+
|---|---|---|
20+
| `find()` | `3` number | `true` boolean |
21+
| `aggregate()` pushed down | `3` number | `0` / `1` **number** |
22+
| in-memory fallback | `'3'` **string** | `'false'` / `'true'` **string** |
23+
24+
Two independent causes:
25+
26+
- `applyInMemoryAggregation` ran every key through `String()`. The pushed-down
27+
path never did.
28+
- The pushed-down path returns raw builder output. #3797 taught it to present
29+
temporal columns the way `formatOutput` does on a `find()` row, but not the
30+
boolean and numeric repairs — so a SQLite boolean, which has no native type and
31+
is stored as `0`/`1`, surfaced as an integer from `aggregate()` and as a real
32+
boolean from `find()`.
33+
34+
`engine.aggregate` chooses between the two aggregate paths per query — by whether
35+
the driver aggregates natively, whether it advertises the requested granularity,
36+
and whether the reference timezone is UTC — so the same column changed shape with
37+
no change to the data or the query.
38+
39+
### Why it mattered
40+
41+
The measures were always right, which is why this went unnoticed. What broke was
42+
downstream code that probes a raw `Map` keyed by the value's own type. `Map`
43+
lookup is SameValueZero, so `'1'` never finds `1`:
44+
45+
- **Select-option labels** (`dimension-labels.ts`) — the label table is keyed by
46+
the option's own `value`. A numeric option value never matched a stringified
47+
key, so the chart rendered the raw stored value instead of its label.
48+
- **Lookup / master-detail labels** — the id → record-name table is built by an
49+
inner query that always pushes down (raw ids), then probed with the outer
50+
query's keys, which may be in-memory (stringified). With a numeric primary key
51+
— routine for external/federated objects — every label missed.
52+
- **Cross-object rebucketing** (`cross-object-rebucket.ts`) — the FK → attribute
53+
map is built and probed the same way, and a miss is not a fallback but
54+
`RESTRICTED_BUCKET`. A numeric FK filed **every row** under `'(restricted)'`:
55+
one bar, correct grand total, no error.
56+
- **Drill-through** — the raw dimension value goes into the drill filter
57+
verbatim, so a boolean dimension drilled from the in-memory path sent
58+
`{ won: 'true' }` to SQLite, whose INTEGER column cannot equal the text
59+
`'true'`. Zero rows.
60+
61+
### What changed
62+
63+
- `applyInMemoryAggregation` (`@objectstack/objectql`) emits the value verbatim.
64+
Its rows come straight from `driver.find()`, so passing the value through is
65+
what makes the key equal the column's own read shape.
66+
- The internal composite bucket id is now type-preserving, so `1` and `'1'`,
67+
`true` and `'true'` stay distinct groups rather than merging on the way in.
68+
BigInt is encoded explicitly — `JSON.stringify` throws on it, and a value that
69+
used to bucket under `String()` must not start crashing the aggregate.
70+
- `SqlDriver.aggregate` / `.distinct` (`@objectstack/driver-sql`) present group
71+
keys and `min`/`max` results with the same rules `formatOutput` applies on a
72+
`find()` row, generalizing the #3797 temporal fix to boolean and numeric
73+
columns. The `protected` helpers behind it are renamed accordingly
74+
(`temporalFieldKind``readPresentationKind`, `presentTemporalValue`
75+
`presentReadValue`, `presentTemporalColumns``presentReadColumns`) and the
76+
kind union is exported as `ReadPresentationKind`.
77+
78+
Date-bucketed `groupBy` items are unaffected: `bucketDateValue` and the dialect
79+
bucket expressions both produce canonical string labels, and #3839 already pinned
80+
their empty bucket.
81+
82+
### Gate
83+
84+
`packages/qa/dogfood/test/group-key-read-shape-parity.test.ts` measures both
85+
aggregate paths against `find()` for a number, boolean and text column, on
86+
`driver-sql` and `driver-sqlite-wasm`. It asserts the runtime TYPE, not just the
87+
value — folding both sides through `String()` is the reflex that hid this in the
88+
first place and would make the check pass against the bug it exists to catch.
89+
90+
Each half was confirmed to fail the gate on its own: reverting only the
91+
in-memory change reddens the number and boolean cases, reverting only the driver
92+
change reddens the boolean cases with `0<number>` against `false<boolean>`.

packages/objectql/src/in-memory-aggregation.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,69 @@ describe('applyInMemoryAggregation', () => {
115115
expect(out.find((r) => r.stage === null)!.total).toBe(10);
116116
expect(out.find((r) => r.stage === 'null')!.total).toBe(5);
117117
});
118+
119+
// #3849 — these rows ARE `driver.find()` output, so a key that is not the
120+
// value verbatim is a key that disagrees with every other read of the column.
121+
// This used to `String()` everything, which the pushed-down path never did.
122+
it('keys a non-empty bucket with the value verbatim, not a string of it', () => {
123+
const dataset = [
124+
{ qty: 3, won: true, amount: 1 },
125+
{ qty: 3, won: false, amount: 2 },
126+
{ qty: 7, won: false, amount: 4 },
127+
];
128+
const agg = [{ function: 'sum' as const, field: 'amount', alias: 'total' }];
129+
130+
const byQty = applyInMemoryAggregation(dataset, { groupBy: ['qty'], aggregations: agg });
131+
expect(byQty.map((r) => r.qty).sort()).toEqual([3, 7]);
132+
expect(byQty.find((r) => r.qty === 3)!.total).toBe(3);
133+
134+
const byWon = applyInMemoryAggregation(dataset, { groupBy: ['won'], aggregations: agg });
135+
expect(byWon.map((r) => r.won).sort()).toEqual([false, true]);
136+
expect(byWon.find((r) => r.won === false)!.total).toBe(6);
137+
});
138+
139+
// The bucket id is built from the key, so preserving the key's type is only
140+
// half of it — the id has to preserve it too, or `1` and `'1'` merge on the
141+
// way in and the surviving key is whichever row happened to arrive first.
142+
it('keeps values of different types in different buckets', () => {
143+
const dataset = [
144+
{ v: 1, amount: 1 },
145+
{ v: '1', amount: 2 },
146+
{ v: true, amount: 4 },
147+
{ v: 'true', amount: 8 },
148+
{ v: null, amount: 16 },
149+
{ v: 'null', amount: 32 },
150+
];
151+
const out = applyInMemoryAggregation(dataset, {
152+
groupBy: ['v'],
153+
aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }],
154+
});
155+
expect(out).toHaveLength(6);
156+
const total = (v: unknown) => out.find((r) => Object.is(r.v, v))!.total;
157+
expect(total(1)).toBe(1);
158+
expect(total('1')).toBe(2);
159+
expect(total(true)).toBe(4);
160+
expect(total('true')).toBe(8);
161+
expect(total(null)).toBe(16);
162+
expect(total('null')).toBe(32);
163+
});
164+
165+
// `JSON.stringify` throws on a BigInt, and the id builder runs on every row of
166+
// every grouped query — a shape that used to bucket fine under `String()` must
167+
// not start crashing the aggregate.
168+
it('buckets a BigInt key without throwing', () => {
169+
const dataset = [
170+
{ v: 9007199254740993n, amount: 1 },
171+
{ v: 9007199254740993n, amount: 2 },
172+
{ v: 9007199254740994n, amount: 4 },
173+
];
174+
const out = applyInMemoryAggregation(dataset, {
175+
groupBy: ['v'],
176+
aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }],
177+
});
178+
expect(out).toHaveLength(2);
179+
expect(out.find((r) => r.v === 9007199254740993n)!.total).toBe(3);
180+
});
118181
});
119182

120183
describe('bucketDateValue', () => {

packages/objectql/src/in-memory-aggregation.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@
4040
// '(empty)', a localized "Uncategorized") and build drill filters as
4141
// `field = null`, so a sentinel leaked a raw English debug string into the UI
4242
// and turned the empty bucket's drill-through into a zero-row query.
43+
//
44+
// A NON-EMPTY KEY IS THE VALUE AS `find()` PRESENTS IT — no `String()` (#3849).
45+
// The rows this function groups come straight from `driver.find()`, so passing
46+
// the value through is what makes a bucket key equal the column's own read
47+
// shape: a `number` stays a number, a `boolean` stays a boolean. This used to
48+
// `String()` every key, which the pushed-down path never did, so `1` became
49+
// `'1'` and `true` became `'true'` depending only on which path ran.
50+
//
51+
// That was not cosmetic. Several consumers probe a raw `Map` keyed by the
52+
// value's own type — the select-option label table, the lookup FK → record-name
53+
// table, the cross-object FK → attribute table — and `Map` lookup is
54+
// SameValueZero, so `'1'` never finds `1`. A stringified key silently missed
55+
// every entry: labels fell back to raw ids, and cross-object rebucketing filed
56+
// every row under `'(restricted)'` while the grand total still reconciled.
4357

4458
import { calendarPartsInTzOrUtc } from '@objectstack/core';
4559
import type { QueryAST, GroupByNode, AggregationNode, DateGranularityValue } from '@objectstack/spec/data';
@@ -75,11 +89,11 @@ export function applyInMemoryAggregation(
7589
const fieldName = typeof g === 'string' ? g : (g.alias ?? g.field);
7690
const value = projectGroupValue(row, g, timezone);
7791
key[fieldName] = value;
78-
// JSON-encoded so the empty bucket's `null` key cannot collide with a row
79-
// whose value is the literal STRING `"null"` — plain interpolation renders
80-
// both as `null` and would merge two distinct groups. This id is internal
81-
// to the bucketing loop; only `key` is emitted.
82-
parts.push(`${fieldName}=${JSON.stringify(value)}`);
92+
// Type-preserving, because the key no longer is: `1` and `'1'`, `true` and
93+
// `'true'`, `null` and `'null'` are all distinct groups and must not merge
94+
// just because they interpolate the same. This id is internal to the
95+
// bucketing loop; only `key` is emitted.
96+
parts.push(`${fieldName}=${bucketIdPart(value)}`);
8397
}
8498
const id = parts.join('\u0001');
8599
let bucket = buckets.get(id);
@@ -98,15 +112,29 @@ export function applyInMemoryAggregation(
98112
return out;
99113
}
100114

101-
function projectGroupValue(row: any, g: GroupByNode, timezone?: string): string | null {
115+
/**
116+
* Stable, TYPE-PRESERVING encoding of one group value, for the internal bucket
117+
* id only. `JSON.stringify` gives that for every shape a column can hold —
118+
* `1` → `1`, `'1'` → `"1"`, `true` → `true`, `null` → `null` — except the two
119+
* it refuses, which are handled explicitly so a value that used to bucket fine
120+
* under `String()` cannot start throwing mid-aggregate.
121+
*/
122+
function bucketIdPart(v: unknown): string {
123+
if (typeof v === 'bigint') return `bigint:${v}`; // JSON.stringify throws on these
124+
return JSON.stringify(v) ?? `${typeof v}:${String(v)}`; // undefined / symbol / function
125+
}
126+
127+
function projectGroupValue(row: any, g: GroupByNode, timezone?: string): unknown {
102128
const field = typeof g === 'string' ? g : g.field;
103129
const v = row?.[field];
104130
if (typeof g !== 'string' && g.dateGranularity) {
105131
return bucketDateValue(v, g.dateGranularity, timezone);
106132
}
107-
// `null`, not a sentinel string — same key the pushed-down SQL gives a NULL
108-
// group column (#3839). See the empty-bucket note at the top of this file.
109-
return v == null ? null : String(v);
133+
// The value as `driver.find()` presented it — these rows ARE find() output, so
134+
// passing it through is what makes the bucket key equal the column's own read
135+
// shape (#3849). `undefined` (absent field) folds into the empty bucket's
136+
// `null` (#3839). See both notes at the top of this file.
137+
return v ?? null;
110138
}
111139

112140
function aggregateBucket(rows: any[], aggregations: AggregationNode[]): Record<string, any> {

0 commit comments

Comments
 (0)