Skip to content

Commit ac516ec

Browse files
os-warrenclaude
andauthored
test(driver-sql): route aggregate-vocabulary conformance through the live-dialect matrix (#12023)
* test(driver-sql): route aggregate-vocabulary conformance through the live-dialect matrix The suite hard-coded `client: 'better-sqlite3'`, so the shared aggregate vocabulary standard — which exists so two faces of one platform cannot answer one aggregation two ways — was measured on exactly one dialect of a driver that speaks three. ADR-0053 D-A3 makes the matrix `driver {SQLite, Postgres at minimum}`, and the pagination sibling already went through the testkit. All 18 tests now run per cell; nothing is dialect-gated. Two needed a per-dialect face, measured on live PG 16.13 rather than ported from SQLite: count comes back as the string "6" (bigint over the wire) while sum/avg/min/max over the float column come back as numbers, and identifier quoting is `"stage"` rather than `stage` in backticks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o * test(driver-sql): record the measured ablation for the dialect-axis conversion Predicted before running, measured on live PG 16.13 with the mutation confirmed on disk by anchored counts: 8 failed / 29 passed / 1 skipped, four in each executing cell. The PG cell failed the emitted-SQL case against pg's own `count(distinct "stage")` while the SQLite cell failed against the backtick spelling in the same run, which is what shows the derived quoting is not circular. Also records that the restore leg's `trap` reported success without restoring — its `git checkout` used a repo-relative path after the script had cd'd into the package. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o * test(driver-sql): state all three dialect readings, measured at CI parity MySQL 8.0.46 became reachable, so the per-dialect table no longer stops at two columns. The reading it adds sharpens the point: pg is the ONLY divergent cell — mysql2 hands COUNT's BIGINT back as a number, node-pg as a string — so the coercion is a property of one client library's type parsing, not of "live servers", and not uniform within pg either. Full matrix under OS_EXPECT_LIVE_DIALECT_MATRIX=1: 55 passed, 0 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e40a28c commit ac516ec

1 file changed

Lines changed: 160 additions & 12 deletions

File tree

packages/drivers/driver-sql/src/sql-driver-aggregation-conformance.test.ts

Lines changed: 160 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* aggregate that answers differently on one of them is one driver giving one
1212
* query two numbers, and only a shared table run on both can see it.
1313
*
14-
* ## Why a real better-sqlite3 database and not a SQL-string assertion
14+
* ## Why a real database and not a SQL-string assertion
1515
*
1616
* `count_distinct` is the first entry in the vocabulary whose lowering is not a
1717
* function name — `COUNT(DISTINCT x)` puts a keyword inside the argument list.
@@ -22,8 +22,65 @@
2222
* instrument choice `turso-remote-filter-logic-conformance.test.ts` makes, and
2323
* for the same reason.
2424
*
25+
* ## [#11456] The DRIVER axis — this suite runs on every dialect, not one
26+
*
27+
* It used to construct `client: 'better-sqlite3'` as a literal, so the shared
28+
* aggregate-vocabulary standard — which exists precisely so two faces of one
29+
* platform cannot answer one aggregation two ways — was measured on exactly one
30+
* dialect of a driver that speaks three. ADR-0053 D-A3 makes the matrix
31+
* `driver {SQLite, Postgres at minimum}`; its pagination sibling already went
32+
* through `live-dialect-matrix.testkit.ts`. It now does too, so `pg` and
33+
* `mysql` cells execute whenever `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL`
34+
* are provisioned, and are a NAMED skip (a red under
35+
* `OS_EXPECT_LIVE_DIALECT_MATRIX=1`) when they are not.
36+
*
37+
* ⛔ **No case is dialect-gated.** All eighteen tests run on every available
38+
* cell; the conversion narrows nothing. Two of them needed a per-dialect FACE
39+
* rather than a per-dialect exemption, and the difference matters — a `skipIf`
40+
* would have been the silent narrowing this card was filed about.
41+
*
42+
* Measured under full CI parity (`OS_EXPECT_LIVE_DIALECT_MATRIX=1`, both URLs
43+
* provisioned, `TZ=America/New_York`): **55 passed, 0 skipped** — 18 per cell
44+
* on all three, plus the axis guard. The suite it replaced ran 18, on one.
45+
*
46+
* ### The readings the faces are built from, measured 2026-08-25
47+
*
48+
* All three cells on live servers at CI parity — PostgreSQL 16.13
49+
* (`timezone=Asia/Shanghai`), MySQL 8.0.46 (`@@global.time_zone='+08:00'`),
50+
* process `TZ=America/New_York`. Measured, not ported:
51+
*
52+
* | reading | sqlite | pg | mysql |
53+
* | ----------------------------------- | ------------- | -------------- | ------------- |
54+
* | `count` / `count_distinct` result | `6` number | `"6"` STRING | `6` number |
55+
* | `sum`/`avg`/`min`/`max` over `score` | `210` number | `210` number | `210` number |
56+
* | identifier quoting in emitted SQL | `` `stage` `` | `"stage"` | `` `stage` `` |
57+
* | field-less `count_distinct` refusal | `INVALID_QUERY`/400 | identical | identical |
58+
*
59+
* ⚠️ Two things in that table are worth reading slowly, because the tempting
60+
* summary — "the live servers answer differently" — is wrong in both.
61+
*
62+
* FIRST: the divergent cell is **pg alone**, not "the live cells". mysql2 hands
63+
* `COUNT`'s `BIGINT` back as a JS number; node-pg hands the same `BIGINT` back
64+
* as a string rather than lose precision. So this is a property of one CLIENT
65+
* LIBRARY's type parsing, and a face switched on `cell.live` would have been
66+
* built on a distinction that is not the one doing the work.
67+
*
68+
* SECOND: within pg it is not uniform either. `count` arrives as a string while
69+
* `sum`/`avg`/`min`/`max` over this fixture's `score` arrive as numbers,
70+
* because `type: 'number'` lowers to a float column and node-pg parses float8
71+
* to a number. The wire type is a property of the RESULT type per aggregate,
72+
* not of the dialect — which is why the coercion below is unconditional rather
73+
* than switched on `cell.id`. A `cell.id === 'pg'` branch would be green today
74+
* and wrong the first time an aggregand's column type changed.
75+
*
76+
* {@link actualFor}'s `Number(r.n)` was already the right shape and needed no
77+
* change; it is now load-bearing rather than incidental, and that is why it is
78+
* spelled out here. The two tests that did NOT go through it are the two that
79+
* needed work — see their own notes.
80+
*
2581
* ## Reverse verification — direction predicted BEFORE it was run
2682
*
83+
* Measured on the SQLite cell (the only one that existed when they were taken).
2784
* Two reverts, because the two mistakes this file guards against fail in
2885
* different ways and only one of them needs a database to see.
2986
*
@@ -58,12 +115,57 @@
58115
* non-distinct lowering has nothing to refuse. `count_distinct(score)` stayed
59116
* green throughout, exactly as predicted, which is why the table carries both
60117
* columns.
118+
*
119+
* ## [#11456] Ablation — that the NEW cells execute, and can fail
120+
*
121+
* A converted matrix that finds zero live cells reports OK, so the conversion's
122+
* own claim needed its own measurement. Predicted BEFORE running: mutating the
123+
* `count_distinct` lowering to drop its `distinct` — revert (B) above, whose
124+
* SQLite direction is already recorded — reddens the `live postgres` cell on
125+
* the SAME four cases, proving that cell runs the assertions rather than
126+
* merely connecting.
127+
*
128+
* Measured on live PostgreSQL 16.13, the mutation confirmed on disk by anchored
129+
* counts (`distinct: true` 1 → 0, `distinct: false` 0 → 1) before any result
130+
* was read: **8 failed / 29 passed / 1 skipped**, four in EACH executing cell —
131+
* exactly the predicted direction and no other movement.
132+
*
133+
* The `live postgres` four, verbatim:
134+
*
135+
* - `count_distinct(stage)` ungrouped: `value: 4` where the case says `2`;
136+
* - `count_distinct(stage) grouped by region`: `west` 3 where the case says 2;
137+
* - the emitted-SQL case, on
138+
* `expected 'select count("stage") as "n" from "co…' to contain 'count(distinct "stage")'`;
139+
* - the field-less refusal, now RESOLVING (`expected undefined to be defined`)
140+
* — `count(*)` is valid, so a non-distinct lowering has nothing to refuse.
141+
*
142+
* ⚠️ The third one is the load-bearing observation for this conversion, and it
143+
* is why the derived quoting above is not circular: the PG cell failed against
144+
* `count(distinct "stage")` — pg's OWN quoting, derived from pg's OWN client —
145+
* while the SQLite cell failed against the backtick spelling in the same run.
146+
* One assertion, two dialect-correct expectations, both of them able to fail.
147+
* `count_distinct(score)` stayed GREEN on both cells throughout, as predicted,
148+
* which is what keeps the four above from being a suite that simply broke.
149+
*
150+
* The mutation was restored and the restore VERIFIED rather than trusted —
151+
* `sql-driver.ts` re-read as byte-identical to `origin/main`, and the suite
152+
* re-run green. That check earned its keep: the `trap … EXIT INT TERM` fired
153+
* but its `git checkout` used a repo-relative path after the script had `cd`'d
154+
* into the package, so git answered `error: pathspec … did not match any
155+
* file(s)` and the trap's own success line printed anyway. A restore leg that
156+
* reports success and does nothing leaves every later measurement running
157+
* against mutated code.
61158
*/
62159

63160
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
64161
import { AGGREGATION_CASES, AGGREGATION_ROWS } from '@objectstack/spec/data';
65162
import type { AggregationCase, QueryAST } from '@objectstack/spec/data';
66163
import { SqlDriver } from './index.js';
164+
import {
165+
DIALECT_CELLS,
166+
declareDialectCell,
167+
type DialectCell,
168+
} from './live-dialect-matrix.testkit.js';
67169

68170
const CONFORMANCE_OBJECT = {
69171
name: 'conformance_agg',
@@ -92,7 +194,8 @@ const astFor = (c: AggregationCase): QueryAST => ({
92194
* The rows a case must produce, in the table's own order: `group` ascending for
93195
* a grouped case, one `null`-grouped row otherwise. Numbers are compared as
94196
* numbers — SQLite hands `avg` back as a float and `count` as an integer, and
95-
* neither is the property under test.
197+
* [#11456] node-pg hands `count` back as the STRING `"6"` because `COUNT` is
198+
* `bigint`. None of those is the property under test; the value is.
96199
*/
97200
const actualFor = (c: AggregationCase, rows: Array<Record<string, unknown>>) => {
98201
// [#6401] The group value is read from the column the case SAYS it lands in —
@@ -104,20 +207,23 @@ const actualFor = (c: AggregationCase, rows: Array<Record<string, unknown>>) =>
104207
.sort((x, y) => String(x.group).localeCompare(String(y.group)));
105208
};
106209

107-
describe('[#6409] SqlDriver — aggregate vocabulary conformance', () => {
210+
function declareAggregateVocabularyConformance(cell: DialectCell): void {
211+
describe(`[#6409] SqlDriver — aggregate vocabulary conformance (${cell.label})`, () => {
108212
let driver: SqlDriver;
109213

110214
beforeAll(async () => {
111-
driver = new SqlDriver({
112-
client: 'better-sqlite3',
113-
connection: { filename: ':memory:' },
114-
useNullAsDefault: true,
115-
});
215+
driver = new SqlDriver(cell.config());
216+
// The live cells own a per-FILE schema that a killed run can leave behind
217+
// (`live-dialect-matrix.globalsetup.ts`'s teardown is best-effort by
218+
// design), and a surviving table would seed twelve rows into a fixture
219+
// whose every case counts six.
220+
await driver.execute(`drop table if exists ${CONFORMANCE_OBJECT.name}`).catch(() => {});
116221
await driver.initObjects([CONFORMANCE_OBJECT as any]);
117222
for (const row of AGGREGATION_ROWS) await driver.create(CONFORMANCE_OBJECT.name, { ...row });
118223
});
119224

120225
afterAll(async () => {
226+
await driver.execute(`drop table if exists ${CONFORMANCE_OBJECT.name}`).catch(() => {});
121227
await driver.disconnect();
122228
});
123229

@@ -155,6 +261,16 @@ describe('[#6409] SqlDriver — aggregate vocabulary conformance', () => {
155261
* property values cannot show: that the column arrives as a bound IDENTIFIER
156262
* (`??`) rather than interpolated into the statement text, which is what
157263
* keeps a caller's field name out of the SQL when `distinct` is in play.
264+
*
265+
* [#11456] The quoting is DERIVED from the cell's own dialect rather than
266+
* spelled per cell. Measured: better-sqlite3 emits `` `stage` `` and pg emits
267+
* `"stage"`, so the old literal backticks were a SQLite fact this suite was
268+
* asserting about every dialect. A per-cell table of quote characters would
269+
* work too and was rejected: the quote character is not the property under
270+
* test, and a hand-kept table would need an entry the day a fourth dialect
271+
* arrives — this cannot go stale. What IS asserted stays exactly what it was:
272+
* the keyword `distinct` unquoted and INSIDE the parentheses, and the column
273+
* bound as an identifier rather than interpolated as a string literal.
158274
*/
159275
it('count_distinct compiles to count(distinct "column"), the column bound as an identifier', async () => {
160276
const knex = (driver as any).knex;
@@ -169,9 +285,8 @@ describe('[#6409] SqlDriver — aggregate vocabulary conformance', () => {
169285
knex.removeListener('query', capture);
170286
}
171287
expect(statements).toHaveLength(1);
172-
// better-sqlite3 quotes identifiers with backticks; the keyword is SYNTAX,
173-
// so it must appear unquoted and INSIDE the parentheses.
174-
expect(statements[0]).toContain('count(distinct `stage`)');
288+
const quoted = knex.client.wrapIdentifier('stage', (x: string) => x);
289+
expect(statements[0]).toContain(`count(distinct ${quoted})`);
175290
// ⛔ The field must not have been interpolated as a string literal.
176291
expect(statements[0]).not.toContain("'stage'");
177292
});
@@ -181,6 +296,10 @@ describe('[#6409] SqlDriver — aggregate vocabulary conformance', () => {
181296
* emitting it. ADR-0112: the assertion is `code` AND `status`, never a bare
182297
* `toThrow` — the un-fixed driver threw here too (a 501 from the aggregate
183298
* door), so "it threw" cannot tell the two behaviours apart.
299+
*
300+
* [#11456] Measured identical on live pg: this refusal is raised by the
301+
* driver before any dialect is consulted, which is the answer this cell is
302+
* here to confirm rather than assume.
184303
*/
185304
it('refuses count_distinct with no field — INVALID_QUERY / 400', async () => {
186305
const ast = {
@@ -206,12 +325,41 @@ describe('[#6409] SqlDriver — aggregate vocabulary conformance', () => {
206325
/**
207326
* The control that keeps the refusal above from being satisfiable by refusing
208327
* the field-less spelling in general: `count` still means `COUNT(*)`.
328+
*
329+
* [#11456] It used to read `toEqual([{ n: 6 }])`, which carried three facts —
330+
* one row, the alias `n` as its ONLY key, and the value 6 — and could not
331+
* survive the live cell, because pg answers `{ n: "6" }`: `COUNT` is `bigint`
332+
* and node-pg hands bigints back as strings rather than lose precision. All
333+
* three facts are kept and spelled out separately instead. ⛔ Not relaxed to
334+
* a bare `toHaveProperty`: the key set is the DRIVER's own projection (it
335+
* emits an explicit `as "n"`, never `*`), so it is dialect-independent and
336+
* dropping it would be the silent narrowing this conversion exists to avoid.
209337
*/
210338
it('count with no field still means COUNT(*)', async () => {
211339
const ast = {
212340
object: CONFORMANCE_OBJECT.name,
213341
aggregations: [{ function: 'count', alias: 'n' }],
214342
} as QueryAST;
215-
expect(await driver.aggregate(CONFORMANCE_OBJECT.name, ast)).toEqual([{ n: 6 }]);
343+
const rows = (await driver.aggregate(CONFORMANCE_OBJECT.name, ast)) as Array<Record<string, unknown>>;
344+
expect(rows).toHaveLength(1);
345+
expect(Object.keys(rows[0]), 'the alias is the only projected column').toEqual(['n']);
346+
expect(Number(rows[0].n), 'count(*) over the six-row fixture').toBe(6);
216347
});
217348
});
349+
}
350+
351+
/**
352+
* [#11456] A matrix that silently finds zero cells reports OK — so the axis is
353+
* asserted to be real before it is iterated, the same guard the #11635 suite
354+
* carries. Without it, "converted to the live matrix" is a claim this file
355+
* could satisfy while running nothing at all.
356+
*/
357+
describe('[#11456] the dialect axis this suite runs', () => {
358+
it('runs every dialect this driver speaks', () => {
359+
expect(DIALECT_CELLS.map((c) => c.id)).toEqual(['sqlite', 'pg', 'mysql']);
360+
});
361+
});
362+
363+
for (const cell of DIALECT_CELLS) {
364+
declareDialectCell(cell, 'aggregate vocabulary conformance', declareAggregateVocabularyConformance);
365+
}

0 commit comments

Comments
 (0)