Skip to content

Commit 1a651dd

Browse files
committed
test(driver-sql): run the pagination and filter-logic matrices across the driver axis (#4714)
#4245 (PR #4713) put the D-A3 driver axis under the temporal matrix and left a reusable `live-dialect-matrix.testkit.ts` behind. Two other consumers of the shared `@objectstack/spec/data` matrices in this package were still pinned to one engine: - `sql-driver-pagination-conformance.test.ts` — `client: 'better-sqlite3'` in both describes, over `PAGINATION_CASES` / `PAGINATION_UNORDERED_CASES`; - `sql-driver-or-filter.test.ts` — same client, over `FILTER_LOGIC_CASES`, with the describe named `(SQLite)`. The pagination one is the costly pin. Its own head note says the property half proves nothing on SQLite: twelve rows come back in rowid order every time, so the partition check passes with or without the tie-breaker. That is a local fact about one engine, stated as a permanent excuse — on a real server the property is the half with teeth, which is what objectui#3106 was reported as. Both files now sweep once per cell of `DIALECT_CELLS` over the same cases, asserting the same row-id sets cell for cell. Measured on a live PG 16 @ Asia/Shanghai and MariaDB 10.11 @ +08:00 under TZ=America/New_York: 46 tests per cell, all three cells identical. With `paginationTieBreaker` sabotaged to return null, the live-postgres cell reports 11 distinct ids over a 12-row walk — one row served twice, another never — while every sorted case on SQLite stays green. The cell exists and it bites. - Table names carry the issue prefix (`os4714_*`); the bare `t` / `task` this suite used while SQLite-only would collide with a parallel suite on a shared live database and read as a conformance failure. - `declareUnprovisionedCell` moves into the testkit and the temporal file now calls it too, so one definition of the non-vacuity guard serves all three matrices: missing URL is a named skip, and a red under `OS_EXPECT_LIVE_DIALECT_MATRIX=1` (#4646). - No server-timezone axis here: nothing in these matrices compares an instant, so requiring a non-UTC server would only manufacture reds that say nothing about pagination or `$or`. - The unpaged-unordered read keeps its exact-sequence pin on SQLite only. #4363 promises the driver adds nothing to that shape, not that the server returns a particular order — MySQL hands back InnoDB primary-key order and Postgres its heap order, and pinning either would assert the server's plan as our contract. The contract half (no ORDER BY emitted) is asserted per dialect. - No new CI job: `Temporal Conformance (live PG + MySQL)` already runs the whole package against both servers. `@objectstack/spec/data` is consumed, not edited: no case was softened and no tie-breaker was added to the fixture to keep a dialect green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
1 parent d52d4fe commit 1a651dd

5 files changed

Lines changed: 527 additions & 315 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
---
3+
4+
Test-only: run the two remaining `@objectstack/spec/data` shared matrices
5+
`driver-sql` consumes — `PAGINATION_CASES` / `PAGINATION_UNORDERED_CASES` and
6+
`FILTER_LOGIC_CASES` — across the ADR-0053 D-A3 DRIVER axis (`driver {SQLite,
7+
Postgres at minimum}`) instead of a hard-coded `better-sqlite3` client (#4714,
8+
finishing what #4245 started for the temporal matrix). Both files now sweep once
9+
per cell of `DIALECT_CELLS` — SQLite always, live Postgres and MySQL whenever
10+
`OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL` are provisioned — over the same
11+
cases, asserting the same row-id sets cell for cell, with issue-prefixed table
12+
names so parallel suites cannot collide on a live server. The paged-read
13+
property test is the one that gains: on SQLite it passes with or without the
14+
tie-breaker (a twelve-row table hands ties back in rowid order every time),
15+
while on live Postgres removing the tie-breaker makes it serve one row twice and
16+
another never — objectui#3106 verbatim. `declareUnprovisionedCell` moves into
17+
`live-dialect-matrix.testkit.ts` so all three matrices share one non-vacuity
18+
guard: a missing URL is a named skip, and a red under
19+
`OS_EXPECT_LIVE_DIALECT_MATRIX=1`. No new CI job — the existing
20+
`Temporal Conformance (live PG + MySQL)` workflow already runs this whole
21+
package against both servers. Releases nothing.

packages/plugins/driver-sql/src/live-dialect-matrix.testkit.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
* Test-only: not exported from `index.ts`.
4343
*/
4444

45-
import { expect } from 'vitest';
45+
import { describe, expect, it } from 'vitest';
4646
import type { SqlDriver, SqlDriverConfig } from './sql-driver.js';
4747

4848
/** The dialects `driver-sql` speaks that the matrices are run across. */
@@ -134,6 +134,37 @@ export const DIALECT_CELLS: readonly DialectCell[] = [
134134
/** The live cells only — the ones the server-timezone axis applies to. */
135135
export const LIVE_DIALECT_CELLS = DIALECT_CELLS.filter((c) => c.live);
136136

137+
/**
138+
* Declare a cell nobody provisioned: REPORTED, never omitted.
139+
*
140+
* A named skip locally (so `it was not run` is readable in the output), a
141+
* failure under `OS_EXPECT_LIVE_DIALECT_MATRIX=1` — which is what stops the
142+
* `Temporal Conformance (live PG + MySQL)` job from quietly degrading to
143+
* SQLite-only coverage if its `env:` block is ever dropped.
144+
*
145+
* Lives here rather than in each consumer for the same reason `DIALECT_CELLS`
146+
* does: a guard copy-pasted per suite is a guard that can weaken in one copy
147+
* and nowhere else — and "the matrix silently found zero cells and reported
148+
* OK" is the failure #4646 already paid for once.
149+
*
150+
* @param matrix which matrix this cell belongs to, e.g. `temporal conformance`
151+
* — it names both the suite and the failure message.
152+
*/
153+
export function declareUnprovisionedCell(cell: DialectCell, matrix: string): void {
154+
describe(`sql-driver — ${matrix} matrix (${cell.label})`, () => {
155+
it.skipIf(!EXPECT_LIVE_DIALECTS)(
156+
`is provisioned — set ${cell.env} to run this cell of the D-A3 driver axis`,
157+
() => {
158+
expect.fail(
159+
`${cell.env} is unset while OS_EXPECT_LIVE_DIALECT_MATRIX=1: this runner declared it ` +
160+
`provisions live Postgres and MySQL, so the ${cell.label} cell of the ${matrix} ` +
161+
`matrix must not be skipped (ADR-0053 D-A3 "Postgres at minimum").`,
162+
);
163+
},
164+
);
165+
});
166+
}
167+
137168
/** What a server reports about its own timezone. */
138169
export interface ServerZone {
139170
/** The dialect's own spelling: `Asia/Shanghai`, `+08:00`, `SYSTEM`, … */
Lines changed: 134 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* Filter logical-combinator conformance for the SQL compiler, on a real engine
5-
* (in-memory better-sqlite3).
4+
* Filter logical-combinator conformance for the SQL compiler, on real engines.
65
*
76
* The shared cases come from `@objectstack/spec/data` so this backend,
87
* `driver-memory`, `formula`'s `matchesFilterCondition` and `read-scope-sql`
@@ -18,96 +17,156 @@
1817
* The SQL-specific cases below the conformance sweep cover ground the shared
1918
* table deliberately leaves out: a real DATE-typed column, and columns whose
2019
* values are not the shared fixture's plain strings.
20+
*
21+
* # The DRIVER axis (#4714, ADR-0053 D-A3)
22+
*
23+
* D-A3 declares the matrix over `driver {SQLite, Postgres at minimum}`. This
24+
* suite used to hard-code `client: 'better-sqlite3'` — its describe was even
25+
* named `(SQLite)` — so what it proved was that ONE engine executes the
26+
* compiled predicate as the table says, while `where`-clause grouping and
27+
* three-valued logic are precisely where dialects are free to differ. A
28+
* compiler bug that only Postgres or MySQL can see had nothing to fail.
29+
*
30+
* So the sweep runs once per cell of `DIALECT_CELLS` — SQLite always, live
31+
* Postgres and MySQL when `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL` are
32+
* provisioned — over the SAME `FILTER_LOGIC_CASES`, asserting the SAME row-id
33+
* sets cell for cell. A cell nobody provisioned is a named skip, and a red under
34+
* `OS_EXPECT_LIVE_DIALECT_MATRIX=1` (`declareUnprovisionedCell`): a matrix that
35+
* silently found zero live cells must not report OK (#4646). No new CI job — the
36+
* `Temporal Conformance (live PG + MySQL)` workflow already runs this whole
37+
* package against both servers.
38+
*
39+
* The shared cases are consumed here, never edited: if a live cell goes red the
40+
* finding is that dialect's compile, not the case. (Nothing here is temporal, so
41+
* the D-B3 server-timezone axis does not apply — requiring a non-UTC server
42+
* would only manufacture reds that say nothing about `$or`.)
2143
*/
2244

23-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
45+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2446
import { FILTER_LOGIC_CASES, FILTER_LOGIC_ROWS } from '@objectstack/spec/data';
2547
import { SqlDriver } from '../src/index.js';
48+
import {
49+
DIALECT_CELLS,
50+
declareUnprovisionedCell,
51+
type DialectCell,
52+
} from './live-dialect-matrix.testkit.js';
53+
54+
/**
55+
* Issue-prefixed table names: the live cells share one database with every
56+
* other suite in this package (and with each other's runs), so the bare `t` /
57+
* `task` this suite used while it was SQLite-only would be a collision waiting
58+
* to be read as a conformance failure.
59+
*/
60+
const FILTER_TABLE = 'os4714_filter_logic';
61+
const DATE_WINDOW_TABLE = 'os4714_filter_logic_windows';
62+
63+
// ── The driver axis ─────────────────────────────────────────────────────────
64+
65+
for (const cell of DIALECT_CELLS) {
66+
if (!cell.available) {
67+
declareUnprovisionedCell(cell, 'filter-logic conformance');
68+
continue;
69+
}
70+
declareFilterLogicSweep(cell);
71+
}
72+
73+
function declareFilterLogicSweep(cell: DialectCell): void {
74+
describe(`SqlDriver filter logic conformance (${cell.label})`, () => {
75+
let driver: SqlDriver;
76+
let knexInstance: any;
2677

27-
describe('SqlDriver filter logic conformance (SQLite)', () => {
28-
let driver: SqlDriver;
29-
let knexInstance: any;
78+
beforeAll(async () => {
79+
driver = new SqlDriver(cell.config());
80+
knexInstance = (driver as any).knex;
3081

31-
beforeEach(async () => {
32-
driver = new SqlDriver({
33-
client: 'better-sqlite3',
34-
connection: { filename: ':memory:' },
35-
useNullAsDefault: true,
82+
// Live cells reuse one database, so the sweep starts from a dropped table.
83+
await knexInstance.schema.dropTableIfExists(FILTER_TABLE);
84+
await knexInstance.schema.createTable(FILTER_TABLE, (t: any) => {
85+
t.string('id').primary();
86+
t.string('a');
87+
t.string('b');
88+
t.string('c');
89+
t.string('owner');
90+
t.string('status');
91+
t.string('parent_object');
92+
t.string('parent_id');
93+
});
94+
await knexInstance(FILTER_TABLE).insert([...FILTER_LOGIC_ROWS]);
3695
});
37-
knexInstance = (driver as any).knex;
3896

39-
await knexInstance.schema.createTable('t', (t: any) => {
40-
t.string('id').primary();
41-
t.string('a');
42-
t.string('b');
43-
t.string('c');
44-
t.string('owner');
45-
t.string('status');
46-
t.string('parent_object');
47-
t.string('parent_id');
97+
afterAll(async () => {
98+
await knexInstance?.schema.dropTableIfExists(FILTER_TABLE).catch(() => {});
99+
await driver?.disconnect?.();
48100
});
49-
await knexInstance('t').insert([...FILTER_LOGIC_ROWS]);
50-
});
51101

52-
afterEach(async () => {
53-
await knexInstance.destroy();
54-
});
102+
describe('shared conformance cases', () => {
103+
for (const c of FILTER_LOGIC_CASES) {
104+
it(c.name, async () => {
105+
const rows = await driver.find(FILTER_TABLE, { object: FILTER_TABLE, where: c.filter });
106+
const got = rows
107+
.map((r: any) => String(r.id))
108+
.sort((x: string, y: string) => x.localeCompare(y));
109+
expect(got, c.note).toEqual(c.expected);
110+
});
111+
}
112+
});
55113

56-
describe('shared conformance cases', () => {
57-
for (const c of FILTER_LOGIC_CASES) {
58-
it(c.name, async () => {
59-
const rows = await driver.find('t', { object: 't', where: c.filter });
60-
const got = rows
61-
.map((r: any) => String(r.id))
62-
.sort((x: string, y: string) => x.localeCompare(y));
63-
expect(got, c.note).toEqual(c.expected);
114+
/**
115+
* The abutting-window pattern the automation skill docs recommend and the CLI
116+
* flow linter blesses (`lint-flow-patterns`): each tier is one field carrying
117+
* two operators. "Windows tile the timeline so each record matches exactly one
118+
* tier" only holds if those operators AND — under the old compile every tier
119+
* degenerated to `d >= lo OR d < hi`, i.e. matched every row.
120+
*
121+
* The shared table pins this shape on plain strings; this pins it on a real
122+
* date column, where value coercion also runs — and now on each dialect's own
123+
* DATE type, which is where a bare `YYYY-MM-DD` comparand stops being one
124+
* agreed thing (the D-B2 divergence, measured on PG @ Asia/Shanghai).
125+
*/
126+
describe('multi-operator date windows inside $or', () => {
127+
beforeAll(async () => {
128+
await knexInstance.schema.dropTableIfExists(DATE_WINDOW_TABLE);
129+
await knexInstance.schema.createTable(DATE_WINDOW_TABLE, (t: any) => {
130+
t.string('id').primary();
131+
t.date('end_date');
132+
});
133+
await knexInstance(DATE_WINDOW_TABLE).insert([
134+
{ id: 'd07', end_date: '2026-08-07' },
135+
{ id: 'd15', end_date: '2026-08-15' },
136+
{ id: 'd30', end_date: '2026-08-30' },
137+
{ id: 'd60', end_date: '2026-09-29' },
138+
]);
64139
});
65-
}
66-
});
67140

68-
/**
69-
* The abutting-window pattern the automation skill docs recommend and the CLI
70-
* flow linter blesses (`lint-flow-patterns`): each tier is one field carrying
71-
* two operators. "Windows tile the timeline so each record matches exactly one
72-
* tier" only holds if those operators AND — under the old compile every tier
73-
* degenerated to `d >= lo OR d < hi`, i.e. matched every row.
74-
*
75-
* The shared table pins this shape on plain strings; this pins it on a real
76-
* date column, where value coercion also runs.
77-
*/
78-
describe('multi-operator date windows inside $or', () => {
79-
beforeEach(async () => {
80-
await knexInstance.schema.createTable('task', (t: any) => {
81-
t.string('id').primary();
82-
t.date('end_date');
141+
afterAll(async () => {
142+
await knexInstance?.schema.dropTableIfExists(DATE_WINDOW_TABLE).catch(() => {});
83143
});
84-
await knexInstance('task').insert([
85-
{ id: 'd07', end_date: '2026-08-07' },
86-
{ id: 'd15', end_date: '2026-08-15' },
87-
{ id: 'd30', end_date: '2026-08-30' },
88-
{ id: 'd60', end_date: '2026-09-29' },
89-
]);
90-
});
91144

92-
it('matches only the rows inside the abutting windows', async () => {
93-
const rows = await driver.find('task', {
94-
object: 'task',
95-
where: {
96-
$or: [
97-
{ end_date: { $gte: '2026-08-07', $lt: '2026-08-08' } },
98-
{ end_date: { $gte: '2026-08-30', $lt: '2026-08-31' } },
99-
],
100-
},
145+
it('matches only the rows inside the abutting windows', async () => {
146+
const rows = await driver.find(DATE_WINDOW_TABLE, {
147+
object: DATE_WINDOW_TABLE,
148+
where: {
149+
$or: [
150+
{ end_date: { $gte: '2026-08-07', $lt: '2026-08-08' } },
151+
{ end_date: { $gte: '2026-08-30', $lt: '2026-08-31' } },
152+
],
153+
},
154+
});
155+
expect(rows.map((r: any) => r.id).sort()).toEqual(['d07', 'd30']);
101156
});
102-
expect(rows.map((r: any) => r.id).sort()).toEqual(['d07', 'd30']);
103-
});
104157

105-
it('keeps a window AND-ed with a sibling key in the same branch', async () => {
106-
const rows = await driver.find('task', {
107-
object: 'task',
108-
where: { $or: [{ id: 'nope' }, { end_date: { $gte: '2026-08-07', $lt: '2026-08-31' }, id: 'd15' }] },
158+
it('keeps a window AND-ed with a sibling key in the same branch', async () => {
159+
const rows = await driver.find(DATE_WINDOW_TABLE, {
160+
object: DATE_WINDOW_TABLE,
161+
where: {
162+
$or: [
163+
{ id: 'nope' },
164+
{ end_date: { $gte: '2026-08-07', $lt: '2026-08-31' }, id: 'd15' },
165+
],
166+
},
167+
});
168+
expect(rows.map((r: any) => r.id)).toEqual(['d15']);
109169
});
110-
expect(rows.map((r: any) => r.id)).toEqual(['d15']);
111170
});
112171
});
113-
});
172+
}

0 commit comments

Comments
 (0)