Skip to content

Commit e120a5a

Browse files
os-zhuangclaude
andauthored
feat(drivers): lower count_distinct on the SQL family — the enforce half of the #6188 ruling (#6409) (#6816)
* feat(drivers): lower count_distinct on both SQL faces (#6409) Implementation half of the #6188 split ruling: count_distinct stays declared and now compiles to COUNT(DISTINCT column) on driver-sql's SqlDriver.aggregate and driver-turso's RemoteTransport.aggregate. Adds the shared AGGREGATION_CASES conformance table so the two faces are pinned against each other on values rather than on a side-by-side read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWMbS2ctjZSADPJiSdUtMA * test(drivers): run AGGREGATION_CASES on both SQL faces (#6409) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWMbS2ctjZSADPJiSdUtMA * test(drivers): flip the count_distinct refusal pins to the new substance, add changeset (#6409) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWMbS2ctjZSADPJiSdUtMA * chore(gates): classify AGGREGATION_CASES in the driver conformance matrix (#6409) Enrols driver-sql, driver-turso and driver-sqlite-wasm; records honest DEBT rows for the two #5499-frozen packages (tracked as #6814). Regenerates the spec API surface for the new shared exports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWMbS2ctjZSADPJiSdUtMA * fix(tests): the conformance fixture read uses `orderBy`, the key DriverQuery declares (#6409) `sort` is not a member of QueryAST; the fixture read-back compiled locally only because the package typecheck had not been re-run since the suite was written. Caught by CI's workspace typecheck, which the local gate enumeration had missed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWMbS2ctjZSADPJiSdUtMA --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c32944d commit e120a5a

13 files changed

Lines changed: 1394 additions & 120 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
"@objectstack/driver-turso": patch
4+
"@objectstack/spec": patch
5+
---
6+
7+
feat(drivers): lower `count_distinct` on the SQL family (#6409)
8+
9+
`count_distinct` has been declared by `AggregationFunction` since the enum was
10+
written, and until now no SQL backend compiled it: both faces of the SQL family
11+
refused it with `NOT_IMPLEMENTED` / 501. A dashboard measure asking for a
12+
deduplicated count against a SQL datasource got a capability-gap refusal for a
13+
query that was already correct.
14+
15+
This is the ENFORCE half of #6188's split ruling (maintainer, 2026-08-07).
16+
`array_agg` and `string_agg` took ADR-0049's remove leg and left the enum in
17+
protocol 17 — no SQL backend compiled them and `string_agg` had no single shape
18+
to lower to. `count_distinct` was deliberately kept on the other side of that
19+
split, on the strength of having exactly one portable lowering. That lowering
20+
now exists:
21+
22+
- **`driver-sql`**`SqlDriver.aggregate` emits `count(distinct "column")`, on
23+
every dialect the driver targets.
24+
- **`driver-turso`**`RemoteTransport.aggregate` emits the same, on the remote
25+
path. Both faces in one change, deliberately: `TursoDriver` picks between them
26+
from `url`, so a lowering that landed on one alone would mean one query
27+
answering two ways depending on a connection string.
28+
29+
**Semantics: distinct NON-NULL values of the target column** — the standard
30+
`COUNT(DISTINCT col)` answer, and the same one `objectql`'s in-memory fallback
31+
and `service-analytics`'s SQL strategy already give.
32+
33+
**`field` is now required for `count_distinct`.** `AggregationNodeSchema` makes
34+
`field` optional because `COUNT(*)` is a real spelling, but `COUNT(DISTINCT *)`
35+
is a syntax error in every dialect. A `count_distinct` aggregation with no
36+
`field` is refused up front with `INVALID_QUERY` / 400 and a message naming the
37+
fix, rather than being sent to the database and coming back as an opaque 500.
38+
Plain `count` with no `field` still means `COUNT(*)`, unchanged.
39+
40+
**The refusal message no longer names `count_distinct` as unsupported.** Both
41+
faces build their "Compiled here:" list from their lowering table, so the
42+
message now lists it among the functions that work. With this entry the declared
43+
aggregate vocabulary and the SQL family's compiled vocabulary are the same set.
44+
45+
**New shared conformance table.** `AGGREGATION_CASES` / `AGGREGATION_ROWS`
46+
(`@objectstack/spec/data`) is the standard both SQL faces are now run against —
47+
values over one fixture carrying duplicates and nulls, so a lowering that lost
48+
the dedup or counted NULL as a value fails on a number rather than passing a
49+
SQL-string assertion. `driver-memory` and `driver-mongodb` are inside the #5499
50+
freeze and are not enrolled; the table records what each would answer and why,
51+
rather than omitting them.
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#6409] Aggregate-vocabulary conformance for the LOCAL SQL face — the shared
5+
* `@objectstack/spec/data` cases, on rows, through a real database.
6+
*
7+
* The twin runs the SAME table through `driver-turso`'s `RemoteTransport`
8+
* (`turso-remote-aggregation-conformance.test.ts`). That pairing is the point of
9+
* putting the cases in the spec package rather than writing a standalone test
10+
* here: `TursoDriver` picks between the two compilers from `url`, so an
11+
* aggregate that answers differently on one of them is one driver giving one
12+
* query two numbers, and only a shared table run on both can see it.
13+
*
14+
* ## Why a real better-sqlite3 database and not a SQL-string assertion
15+
*
16+
* `count_distinct` is the first entry in the vocabulary whose lowering is not a
17+
* function name — `COUNT(DISTINCT x)` puts a keyword inside the argument list.
18+
* Every way of getting that wrong still produces valid SQL: `count("stage")`
19+
* loses the dedup, `count(*)` loses the NULL exclusion too, and both run
20+
* happily and return a number. A string assertion pins the text this driver
21+
* emits TODAY; only executing it says whether the number is right. Same
22+
* instrument choice `turso-remote-filter-logic-conformance.test.ts` makes, and
23+
* for the same reason.
24+
*
25+
* ## Reverse verification — direction predicted BEFORE it was run
26+
*
27+
* Two reverts, because the two mistakes this file guards against fail in
28+
* different ways and only one of them needs a database to see.
29+
*
30+
* **(A) `SQL_AGGREGATE_FUNCTIONS`'s `count_distinct` entry deleted** — the
31+
* pre-#6409 state. Predicted: the `count_distinct` cases fail by THROWING
32+
* `NOT_IMPLEMENTED`/501 out of `refuseAggregateFunction`, never by returning a
33+
* wrong number, and nothing else moves.
34+
*
35+
* **(B) the entry present but `distinct` flipped to `false`** — the "lowering
36+
* copied from its neighbour" mistake, and the one a review that only checked
37+
* the two faces' tables have the same KEYS would pass. Predicted: failures on
38+
* VALUES — 4 instead of 2 ungrouped, `west` 3 instead of 2 grouped — while
39+
* `count_distinct(score)` stays GREEN at 6, because that column has nothing to
40+
* dedup. (B) is the direction this file exists for; (A) is reachable by any
41+
* test that merely calls the function.
42+
*
43+
* Measured after writing the above, of 16:
44+
*
45+
* - **(A) 5 failed / 11 passed.** The three `count_distinct` value cases and
46+
* the emitted-SQL case all died on the thrown 501 — not one on a wrong
47+
* number, as predicted. The fifth is the field-less refusal case, red on
48+
* `expected 'NOT_IMPLEMENTED' to be 'INVALID_QUERY'`: with no entry in the
49+
* table the name never reaches the distinct-without-field check, so it is
50+
* answered as a capability gap. That case was NOT named in the prediction and
51+
* is recorded rather than tidied — it is the one that proves the two refusals
52+
* are distinguishable rather than interchangeable.
53+
* - **(B) 4 failed / 12 passed**, on
54+
* `expected [{ group: null, value: 4 }] to deeply equal [{ group: null, value: 2 }]`,
55+
* on the grouped case (`west` 3 vs 2), on the emitted SQL
56+
* (`select count(\`stage\`) …` missing the keyword), and on the field-less
57+
* case now RESOLVING instead of refusing — `count(*)` is valid, so a
58+
* non-distinct lowering has nothing to refuse. `count_distinct(score)` stayed
59+
* green throughout, exactly as predicted, which is why the table carries both
60+
* columns.
61+
*/
62+
63+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
64+
import { AGGREGATION_CASES, AGGREGATION_ROWS } from '@objectstack/spec/data';
65+
import type { AggregationCase, QueryAST } from '@objectstack/spec/data';
66+
import { SqlDriver } from './index.js';
67+
68+
const CONFORMANCE_OBJECT = {
69+
name: 'conformance_agg',
70+
fields: {
71+
id: { type: 'text', name: 'id' },
72+
region: { type: 'text', name: 'region' },
73+
// Nullable, and it must stay that way — see `AggregationRow.stage`.
74+
stage: { type: 'text', name: 'stage' },
75+
score: { type: 'number', name: 'score' },
76+
},
77+
};
78+
79+
/** The case as a `QueryAST`. The alias is the harness's, not the table's. */
80+
const astFor = (c: AggregationCase): QueryAST => ({
81+
object: CONFORMANCE_OBJECT.name,
82+
aggregations: [{ function: c.function, ...(c.field ? { field: c.field } : {}), alias: 'n' }],
83+
...(c.groupBy ? { groupBy: [c.groupBy] } : {}),
84+
});
85+
86+
/**
87+
* The rows a case must produce, in the table's own order: `group` ascending for
88+
* a grouped case, one `null`-grouped row otherwise. Numbers are compared as
89+
* numbers — SQLite hands `avg` back as a float and `count` as an integer, and
90+
* neither is the property under test.
91+
*/
92+
const actualFor = (c: AggregationCase, rows: Array<Record<string, unknown>>) =>
93+
rows
94+
.map((r) => ({ group: c.groupBy ? String(r[c.groupBy]) : null, value: Number(r.n) }))
95+
.sort((x, y) => String(x.group).localeCompare(String(y.group)));
96+
97+
describe('[#6409] SqlDriver — aggregate vocabulary conformance', () => {
98+
let driver: SqlDriver;
99+
100+
beforeAll(async () => {
101+
driver = new SqlDriver({
102+
client: 'better-sqlite3',
103+
connection: { filename: ':memory:' },
104+
useNullAsDefault: true,
105+
});
106+
await driver.initObjects([CONFORMANCE_OBJECT as any]);
107+
for (const row of AGGREGATION_ROWS) await driver.create(CONFORMANCE_OBJECT.name, { ...row });
108+
});
109+
110+
afterAll(async () => {
111+
await driver.disconnect();
112+
});
113+
114+
/**
115+
* The fixture first, and read back through `find()` rather than trusted —
116+
* a case that answers 2 because only two rows landed is not a case that
117+
* deduplicated correctly, and the null-bearing column is exactly the one a
118+
* seed is most likely to mangle.
119+
*/
120+
it('the fixture is all six rows, with the nulls stored AS nulls', async () => {
121+
const rows = await driver.find(CONFORMANCE_OBJECT.name, { orderBy: [{ field: 'id', order: 'asc' }] });
122+
expect(rows.map((r: any) => String(r.id))).toEqual(['1', '2', '3', '4', '5', '6']);
123+
for (const r of rows as any[]) {
124+
const seeded = AGGREGATION_ROWS.find((s) => s.id === String(r.id))!;
125+
expect([r.region, r.stage ?? null, Number(r.score)], r.id)
126+
.toEqual([seeded.region, seeded.stage, seeded.score]);
127+
}
128+
// The property the null cases hang off, asserted directly: an empty string
129+
// in place of a null would keep every `count_distinct` case green at the
130+
// wrong number.
131+
expect((rows as any[]).filter((r) => r.stage === null)).toHaveLength(2);
132+
});
133+
134+
for (const c of AGGREGATION_CASES) {
135+
it(c.name, async () => {
136+
const rows = await driver.aggregate(CONFORMANCE_OBJECT.name, astFor(c));
137+
expect(actualFor(c, rows as any[]), c.note).toEqual([...c.expected]);
138+
});
139+
}
140+
141+
/**
142+
* The statement this driver ACTUALLY emits, captured off knex's `query`
143+
* event. Not the standard — the values above are that — but the shape a
144+
* reviewer of a future change to the lowering table needs to see, and the one
145+
* property values cannot show: that the column arrives as a bound IDENTIFIER
146+
* (`??`) rather than interpolated into the statement text, which is what
147+
* keeps a caller's field name out of the SQL when `distinct` is in play.
148+
*/
149+
it('count_distinct compiles to count(distinct "column"), the column bound as an identifier', async () => {
150+
const knex = (driver as any).knex;
151+
const statements: string[] = [];
152+
const capture = (q: { sql: string }) => statements.push(q.sql);
153+
knex.on('query', capture);
154+
try {
155+
await driver.aggregate(CONFORMANCE_OBJECT.name, astFor({
156+
name: 'probe', function: 'count_distinct', field: 'stage', expected: [],
157+
}));
158+
} finally {
159+
knex.removeListener('query', capture);
160+
}
161+
expect(statements).toHaveLength(1);
162+
// better-sqlite3 quotes identifiers with backticks; the keyword is SYNTAX,
163+
// so it must appear unquoted and INSIDE the parentheses.
164+
expect(statements[0]).toContain('count(distinct `stage`)');
165+
// ⛔ The field must not have been interpolated as a string literal.
166+
expect(statements[0]).not.toContain("'stage'");
167+
});
168+
169+
/**
170+
* `COUNT(DISTINCT *)` is not valid SQL, so the driver refuses instead of
171+
* emitting it. ADR-0112: the assertion is `code` AND `status`, never a bare
172+
* `toThrow` — the un-fixed driver threw here too (a 501 from the aggregate
173+
* door), so "it threw" cannot tell the two behaviours apart.
174+
*/
175+
it('refuses count_distinct with no field — INVALID_QUERY / 400', async () => {
176+
const ast = {
177+
object: CONFORMANCE_OBJECT.name,
178+
aggregations: [{ function: 'count_distinct', alias: 'n' }],
179+
} as QueryAST;
180+
let err: (Error & { code?: string; status?: number }) | undefined;
181+
try {
182+
await driver.aggregate(CONFORMANCE_OBJECT.name, ast);
183+
} catch (e) {
184+
err = e as Error & { code?: string; status?: number };
185+
}
186+
expect(err, 'expected a refusal, but the aggregation resolved').toBeDefined();
187+
expect(err!.code).toBe('INVALID_QUERY');
188+
expect(err!.status).toBe(400);
189+
expect(err!.message).toContain('nothing to deduplicate');
190+
// ⛔ Not the capability-gap answer: this face DOES compile the function.
191+
expect(err!.message).not.toContain('capability gap');
192+
// #3867 — no driver-internal prefix on the wire.
193+
expect(err!.message).not.toContain('[sql-driver]');
194+
});
195+
196+
/**
197+
* The control that keeps the refusal above from being satisfiable by refusing
198+
* the field-less spelling in general: `count` still means `COUNT(*)`.
199+
*/
200+
it('count with no field still means COUNT(*)', async () => {
201+
const ast = {
202+
object: CONFORMANCE_OBJECT.name,
203+
aggregations: [{ function: 'count', alias: 'n' }],
204+
} as QueryAST;
205+
expect(await driver.aggregate(CONFORMANCE_OBJECT.name, ast)).toEqual([{ n: 6 }]);
206+
});
207+
});

0 commit comments

Comments
 (0)