Skip to content

Commit f0beff3

Browse files
os-warrenclaude
andauthored
test(driver-sql): empty the dialect ledger, and promote the matrix-routing measurement to an invariant (#12302)
* test(driver-sql): route the comparand-type cell through the dialect matrix, and promote MATRIXED Resolves the two seeded DIALECT_LEDGER rows and turns the census measurement "every dialect-scored cell has at least one matrix-routed suite" into an enforced invariant. - sql-driver-comparand-type-conformance.test.ts now iterates DIALECT_CELLS. Its case-set claims the six accepted comparand types "compile everywhere"; that sentence had only ever been measured on SQLite. Measured on live PostgreSQL 16.13: all eight executed cases answer the case-set's row ids. The door-refusal half stays declared once — parseFilterAST is upstream of every driver and has no dialect in it. - sql-driver-icontains-and-retired-operators.test.ts declares dialectCell('sqlite'). Measured BY OPERATOR before marking: the seven text operators it reaches are exactly the seven FILTER_TEXT_CASES reaches, and sql-driver-text-case-conformance.test.ts runs that table once per cell. - check-driver-conformance.mjs gains MATRIXED, the per-cell invariant, and DIALECT_LEDGER is now empty. A ledger row deliberately does NOT clear MATRIXED: it excuses an undeclared FILE, not a CELL with no matrix-routed suite anywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o * test(driver-sql): assert the parsed condition before compiling it in the bind probe `parseFilterAST` is typed `FilterCondition | undefined`, which `tsc --noEmit` caught and the vitest run could not. Asserted rather than `!`-ed: a case that produced no condition would compile a bare select, and the binding assertions would then be measuring an empty statement rather than a bound comparand. 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 0d91eaa commit f0beff3

3 files changed

Lines changed: 461 additions & 124 deletions

File tree

‎packages/drivers/driver-sql/src/sql-driver-comparand-type-conformance.test.ts‎

Lines changed: 221 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
/**
44
* [#7872] `driver-sql` held to `FILTER_COMPARAND_TYPE_CASES` — the
5-
* comparand-type door, both directions, on the compiled-SQL path.
5+
* comparand-type door, both directions, on the compiled-SQL path, across the
6+
* DRIVER axis (ADR-0053 D-A3).
67
*
78
* This driver is one of the two independent implementations the door's set was
89
* MEASURED from (`isBindableComparand` / `isRenderableTextComparand`, whose
@@ -13,75 +14,243 @@
1314
* envelope (pinned by `sql-driver-silent-empty-predicate.test.ts` and
1415
* siblings). This suite pins the door half, so the shared table drives every
1516
* backend identically.
17+
*
18+
* # Why this runs the matrix (#12136, the follow-up #12014 sized)
19+
*
20+
* The case-set's own headline is that the six accepted types **compile
21+
* everywhere** — and until #12136 that sentence was measured on ONE dialect.
22+
* `check-driver-conformance.mjs`'s dialect axis recorded the consequence
23+
* exactly: `FILTER_COMPARAND_TYPE` was the only dialect-scored cell with no
24+
* matrix-routed suite at all, so "everywhere" was a claim the census printed
25+
* and nothing executed. That is #12014's thesis one level in — a suite whose
26+
* NAME says conformance while its COVERAGE says SQLite — and it is why the
27+
* conversion is earned rather than tidy.
28+
*
29+
* ## What is per-dialect here, and what deliberately is not
30+
*
31+
* The case-set has two directions and only one of them has a dialect:
32+
*
33+
* - `matches` / `compiles` cases hand the door-validated condition to THIS
34+
* DRIVER'S execution path, so they are run once per cell. This is the half
35+
* "compile everywhere" is about, and the half that was measured on SQLite
36+
* alone.
37+
* - `door-refusal` cases assert `parseFilterAST` throws BEFORE any driver
38+
* runs. `parseFilterAST` is a pure platform function with no dialect in it
39+
* — running it once per cell would not measure three things, it would
40+
* measure one thing three times and report the repetition as coverage.
41+
* They therefore run once, in the dialect-independent block below, which
42+
* says so in its own name.
43+
*
44+
* # Which cells actually executed, and which did not
45+
*
46+
* Recorded here rather than implied, because a matrix that reports OK while
47+
* finding zero live cells is the failure `declareUnprovisionedCell` exists for
48+
* (#4646):
49+
*
50+
* - **sqlite** — always runs, embedded.
51+
* - **live postgres** — RUN, on PostgreSQL 16.13 (the same version #11456's
52+
* `42883` divergence was measured on). All eight executed cases answered
53+
* the case-set's row ids, so on this dialect "compile everywhere" is now a
54+
* measurement rather than a claim.
55+
* - **live mysql** — NOT run: no MySQL server was provisionable in the
56+
* container this landed from, so it is a declared SKIP, and the MySQL arm
57+
* rests on the compiled-SQL block at the bottom rather than on execution.
58+
* Saying so is the point — the cell is skipped BY NAME, and
59+
* `OS_EXPECT_LIVE_DIALECT_MATRIX=1` turns that skip into a failure for a
60+
* runner that believes it provisioned one. The same disclosure
61+
* `sql-driver-text-case-conformance.test.ts` makes for its own MySQL cell.
62+
*
63+
* # The compiled-SQL layer, and why it is not redundant with the rows
64+
*
65+
* The last block asserts that every accepted comparand type BINDS into a
66+
* statement on each of the three dialects. On the cells that run, rows are the
67+
* stronger witness and the binding is a bonus. On the cell that does NOT run,
68+
* the binding is the only thing this repo can check at all — and it is the
69+
* layer where a type-level refusal would surface, since a comparand this
70+
* driver cannot bind throws out of `applyFilters` before any server is
71+
* reached. knex builds a Postgres or MySQL statement without needing a server
72+
* to send it to, which is what makes the un-provisioned cell checkable here.
1673
*/
1774

1875
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
76+
import type { Knex } from 'knex';
1977
import {
2078
FILTER_COMPARAND_TYPE_CASES,
2179
FILTER_COMPARAND_TYPE_ROWS,
2280
parseFilterAST,
2381
type FilterCondition,
2482
} from '@objectstack/spec/data';
25-
import { SqlDriver } from '../src/index.js';
83+
import { SqlDriver, type SqlDriverConfig } from './sql-driver.js';
84+
import {
85+
DIALECT_CELLS,
86+
declareUnprovisionedCell,
87+
type DialectCell,
88+
} from './live-dialect-matrix.testkit.js';
2689

27-
const TABLE = 'comparand_conformance';
90+
/**
91+
* Issue-prefixed object name: the live cells share one database with every
92+
* other suite in this package, so the bare `comparand_conformance` this suite
93+
* carried while it was SQLite-only would be a collision waiting to be read as
94+
* a comparand-type failure.
95+
*/
96+
const TABLE = 'os7872_comparand_type';
97+
98+
/** The half of the table that reaches a driver at all — see the head note. */
99+
const EXECUTED_CASES = FILTER_COMPARAND_TYPE_CASES.filter((c) => c.verdict !== 'door-refusal');
28100

29-
describe('[#7872] SqlDriver — comparand-type conformance (behind the door)', () => {
30-
let driver: SqlDriver;
31-
let knex: any;
101+
/** The half the door settles upstream of every driver. */
102+
const DOOR_REFUSAL_CASES = FILTER_COMPARAND_TYPE_CASES.filter((c) => c.verdict === 'door-refusal');
103+
104+
// ── The driver axis ─────────────────────────────────────────────────────────
105+
106+
for (const cell of DIALECT_CELLS) {
107+
if (!cell.available) {
108+
declareUnprovisionedCell(cell, 'FILTER_COMPARAND_TYPE_CASES comparand-type door');
109+
continue;
110+
}
111+
declareComparandTypeSweep(cell);
112+
}
32113

33-
beforeAll(async () => {
34-
driver = new SqlDriver({
35-
client: 'better-sqlite3',
36-
connection: { filename: ':memory:' },
37-
useNullAsDefault: true,
114+
function declareComparandTypeSweep(cell: DialectCell): void {
115+
describe(`[#7872] SqlDriver — comparand-type conformance (${cell.label})`, () => {
116+
let driver: SqlDriver;
117+
let knexInstance: Knex;
118+
119+
beforeAll(async () => {
120+
driver = new SqlDriver(cell.config());
121+
// `getKnex()` is public API; the older shape of this file reached the
122+
// `protected` field through `(driver as any).knex`, which erases every
123+
// member of the driver to get one.
124+
knexInstance = driver.getKnex();
125+
// Live cells reuse one database, so the sweep starts from a dropped table.
126+
await knexInstance.schema.dropTableIfExists(TABLE);
127+
await knexInstance.schema.createTable(TABLE, (t: Knex.TableBuilder) => {
128+
t.string('id').primary();
129+
t.integer('qty');
130+
t.string('label');
131+
t.boolean('active');
132+
// Nullable (knex's default): the `note: null` case is the declared null
133+
// predicate, and it measures nothing against a NOT NULL column.
134+
t.string('note');
135+
});
136+
await knexInstance(TABLE).insert(FILTER_COMPARAND_TYPE_ROWS.map((r) => ({ ...r })));
38137
});
39-
knex = (driver as any).knex;
40-
await knex.schema.createTable(TABLE, (t: any) => {
41-
t.string('id').primary();
42-
t.integer('qty');
43-
t.string('label');
44-
t.boolean('active');
45-
t.string('note');
138+
139+
afterAll(async () => {
140+
await knexInstance?.schema.dropTableIfExists(TABLE).catch(() => {});
141+
await driver?.disconnect?.();
46142
});
47-
await knex(TABLE).insert(FILTER_COMPARAND_TYPE_ROWS.map((r) => ({ ...r })));
48-
});
49143

50-
afterAll(async () => {
51-
await knex.destroy();
144+
const ids = async (where: FilterCondition | undefined): Promise<string[]> => {
145+
const rows = await driver.find(TABLE, { fields: ['id'], where });
146+
return rows.map((r: any) => String(r.id)).sort((x, y) => x.localeCompare(y));
147+
};
148+
149+
/**
150+
* The fixture control. Every `matches` case below names row ids, so a cell
151+
* whose insert silently dropped or coerced a row would answer wrong ids for
152+
* a reason that has nothing to do with the comparand door.
153+
*/
154+
it('the fixture really is both rows', async () => {
155+
expect(await ids(undefined)).toEqual(['1', '2']);
156+
});
157+
158+
for (const c of EXECUTED_CASES) {
159+
if (c.verdict === 'matches') {
160+
it(c.name, async () => {
161+
expect(await ids(parseFilterAST(c.filter())), c.note).toEqual([...c.expected]);
162+
});
163+
} else {
164+
it(`${c.name} — executes without refusal`, async () => {
165+
await expect(ids(parseFilterAST(c.filter()))).resolves.toBeDefined();
166+
});
167+
}
168+
}
52169
});
170+
}
53171

54-
const ids = async (where: FilterCondition | undefined): Promise<string[]> => {
55-
const rows = await driver.find(TABLE, { fields: ['id'], where });
56-
return rows.map((r: any) => String(r.id)).sort((x, y) => x.localeCompare(y));
57-
};
58-
59-
for (const c of FILTER_COMPARAND_TYPE_CASES) {
60-
if (c.verdict === 'door-refusal') {
61-
it(`${c.name} — refused at the door, before any SQL compiles`, () => {
62-
let caught: (Error & { code?: string; status?: number }) | null = null;
63-
try {
64-
parseFilterAST(c.filter());
65-
} catch (e) {
66-
caught = e as Error & { code?: string; status?: number };
67-
}
68-
expect(caught, c.note).not.toBeNull();
69-
expect(caught?.code, c.name).toBe(c.code);
70-
expect(caught?.status, c.name).toBe(400);
71-
for (const fragment of c.mustMention) expect(caught?.message).toContain(fragment);
72-
});
73-
} else if (c.verdict === 'matches') {
74-
it(c.name, async () => {
75-
expect(await ids(parseFilterAST(c.filter())), c.note).toEqual([...c.expected]);
76-
});
77-
} else {
78-
it(`${c.name} — executes without refusal`, async () => {
79-
await expect(ids(parseFilterAST(c.filter()))).resolves.toBeDefined();
80-
});
172+
// ── The door, which has no dialect ──────────────────────────────────────────
173+
174+
/**
175+
* `parseFilterAST` runs upstream of every driver and is the same function on
176+
* every cell, so these assertions are declared ONCE rather than once per
177+
* dialect. Repeating a pure function across three cells would add rows to the
178+
* report without adding a measurement — the shape this whole axis exists to
179+
* make visible.
180+
*/
181+
describe('[#7872] the comparand-type door — refused before any dialect is chosen', () => {
182+
for (const c of DOOR_REFUSAL_CASES) {
183+
it(`${c.name} — refused at the door, before any SQL compiles`, () => {
184+
let caught: (Error & { code?: string; status?: number }) | null = null;
185+
try {
186+
parseFilterAST(c.filter());
187+
} catch (e) {
188+
caught = e as Error & { code?: string; status?: number };
189+
}
190+
expect(caught, c.note).not.toBeNull();
191+
// `code` AND `status`: a refusal outside the ADR-0112 envelope reaches
192+
// the client as a 500-shaped body for a 400-class mistake.
193+
expect(caught?.code, c.name).toBe(c.code);
194+
expect(caught?.status, c.name).toBe(400);
195+
for (const fragment of c.mustMention) expect(caught?.message).toContain(fragment);
196+
});
197+
}
198+
});
199+
200+
// ── "compiles everywhere", at the layer a server is not needed for ──────────
201+
202+
/**
203+
* Every accepted comparand type BINDS into a statement on each dialect.
204+
*
205+
* This is the claim `CASE_SETS` makes about this table in one word, checked on
206+
* all three dialects including the one no server was provisionable for. It is
207+
* a real check rather than a restatement: this driver refuses an unbindable
208+
* comparand from inside `applyFilters` (the `isBindableComparand` gate the
209+
* door's own set was measured from), which throws while the statement is being
210+
* BUILT — before any connection exists. So a type that this driver could not
211+
* bind on `pg` or `mysql2` fails here, with no server involved.
212+
*/
213+
describe('[#7872] every accepted comparand type binds, on every dialect', () => {
214+
/** A driver that exposes the compiled WHERE without reaching into privates. */
215+
class CompilerProbeDriver extends SqlDriver {
216+
compileWhere(where: FilterCondition): string {
217+
const builder: Knex.QueryBuilder = this.getKnex()(TABLE);
218+
this.applyFilters(builder, where);
219+
return builder.toString();
81220
}
82221
}
83222

84-
it('the fixture really is both rows', async () => {
85-
expect(await ids(undefined)).toEqual(['1', '2']);
86-
});
223+
const probe = (config: SqlDriverConfig) => new CompilerProbeDriver(config);
224+
225+
const CLIENTS: readonly { id: string; config: SqlDriverConfig }[] = [
226+
{
227+
id: 'sqlite',
228+
config: { client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true },
229+
},
230+
{ id: 'pg', config: { client: 'pg', connection: { host: '127.0.0.1' } } },
231+
{ id: 'mysql', config: { client: 'mysql2', connection: { host: '127.0.0.1' } } },
232+
];
233+
234+
for (const client of CLIENTS) {
235+
describe(client.id, () => {
236+
for (const c of EXECUTED_CASES) {
237+
it(`${c.name} — binds`, () => {
238+
// `parseFilterAST` is typed `FilterCondition | undefined`. Asserted
239+
// rather than `!`-ed: a case that produced NO condition would compile
240+
// a bare select, and every assertion below would then be measuring an
241+
// empty statement instead of a bound comparand.
242+
const condition = parseFilterAST(c.filter());
243+
expect(condition, `${c.name} produced no condition to compile`).toBeDefined();
244+
const sql = probe(client.config).compileWhere(condition as FilterCondition);
245+
// A statement, with a `where` in it: `applyFilters` throwing is the
246+
// failure this block is looking for, and a builder that silently
247+
// applied NOTHING would render a bare select — which is the quiet
248+
// way a comparand can be dropped rather than refused.
249+
expect(sql, `${client.id} rendered no statement for ${c.name}`).toContain(TABLE);
250+
expect(sql.toLowerCase(), `${client.id} dropped the predicate for ${c.name}`)
251+
.toContain('where');
252+
});
253+
}
254+
});
255+
}
87256
});

‎packages/drivers/driver-sql/src/sql-driver-icontains-and-retired-operators.test.ts‎

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,40 @@
2323
* `CAST(… AS BINARY)` on MySQL. The per-dialect reasoning and the measurements
2424
* behind each cell live on that function.
2525
*
26+
* ## [#12136] Deliberately the SQLite cell — measured, not assumed
27+
*
28+
* This suite names `dialectCell('sqlite')`, which declares its narrowness as a
29+
* DECISION rather than leaving it spelled the same way an accident would be
30+
* (#12014). The claim that declaring it loses no dialect coverage was measured
31+
* BY OPERATOR rather than by file, because #6518 makes `FILTER_TEXT_CASES`
32+
* answer differently per dialect (`GLOB` on SQLite, `LIKE` on Postgres, `LIKE`
33+
* over `CAST(… AS BINARY)` on MySQL) and a wrong call here would hide a real
34+
* gap:
35+
*
36+
* - Operators reached by THIS suite, read from comment-masked source:
37+
* `$contains`, `$endsWith`, `$icontains`, `$notContains`, `$options`,
38+
* `$regex`, `$startsWith` — plus `$or`.
39+
* - Operators reached by `FILTER_TEXT_CASES`, read from the EXPORTED case
40+
* filters rather than from the file's prose (its head note also mentions
41+
* `$ilike` and `$not`, which no case actually exercises — the reason this
42+
* was measured off the data): exactly the same seven.
43+
*
44+
* `sql-driver-text-case-conformance.test.ts` runs that whole table once per
45+
* cell of `DIALECT_CELLS`, so all seven are already answered on all three
46+
* dialects. The one extra, `$or`, is a logical combinator belonging to
47+
* `FILTER_LOGIC_CASES`, which `sql-driver-or-filter.test.ts` routes through the
48+
* same matrix.
49+
*
50+
* What is left over — and what makes SQLite the RIGHT cell rather than merely a
51+
* tolerable one — is this file's own residue: the `GLOB` metacharacter class
52+
* (`*`, `?`, `[`), the `lower(name) GLOB lower(…)` construct, and the assertion
53+
* that `$contains` and `$icontains` stop answering identically. `GLOB` is
54+
* emitted on the SQLite dialects and nowhere else, so those blocks are about
55+
* SQLite BY CONSTRUCTION; running them on Postgres or MySQL would assert a
56+
* construct those dialects never compile. The backslash limb is pinned
57+
* per-dialect in `sql-driver-like-escape.test.ts`, which routes through the
58+
* cells itself.
59+
*
2660
* ## The reverse verification, direction decided BEFORE it was run
2761
*
2862
* - **Refusal face** — predicted RED, measured RED. Restoring the deleted
@@ -47,6 +81,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
4781
import type { DriverOptions, FilterCondition } from '@objectstack/spec/data';
4882
import { FILTER_TEXT_CASES, FILTER_TEXT_ROWS } from '@objectstack/spec/data';
4983
import { SqlDriver } from './sql-driver.js';
84+
import { dialectCell } from './live-dialect-matrix.testkit.js';
5085

5186
/** The error a refused filter produced — never a bare `toThrow()` (see below). */
5287
interface WireBearingError extends Error {
@@ -76,11 +111,11 @@ describe('[#5702] SqlDriver — $icontains, and the retired $regex/$options', ()
76111
let driver: CompilerProbeDriver;
77112

78113
beforeAll(async () => {
79-
driver = new CompilerProbeDriver({
80-
client: 'better-sqlite3',
81-
connection: { filename: ':memory:' },
82-
useNullAsDefault: true,
83-
});
114+
// [#12136] The SQLite cell BY NAME, not a hard-coded client literal. The
115+
// value is unchanged — this cell's `config()` is byte-for-byte the literal
116+
// that stood here — but a named cell is a STATED stance, which is the whole
117+
// distinction #12014 found the repo could not spell.
118+
driver = new CompilerProbeDriver(dialectCell('sqlite').config());
84119
await driver.initObjects([{ name: 'txt', fields: { name: { type: 'string' } } }]);
85120
for (const row of FILTER_TEXT_ROWS) {
86121
await driver.create('txt', { ...row }, BYPASS);

0 commit comments

Comments
 (0)