Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/pagination-filter-logic-driver-axis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
---

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

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

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

/**
* Declare a cell nobody provisioned: REPORTED, never omitted.
*
* A named skip locally (so `it was not run` is readable in the output), a
* failure under `OS_EXPECT_LIVE_DIALECT_MATRIX=1` — which is what stops the
* `Temporal Conformance (live PG + MySQL)` job from quietly degrading to
* SQLite-only coverage if its `env:` block is ever dropped.
*
* Lives here rather than in each consumer for the same reason `DIALECT_CELLS`
* does: a guard copy-pasted per suite is a guard that can weaken in one copy
* and nowhere else — and "the matrix silently found zero cells and reported
* OK" is the failure #4646 already paid for once.
*
* @param matrix which matrix this cell belongs to, e.g. `temporal conformance`
* — it names both the suite and the failure message.
*/
export function declareUnprovisionedCell(cell: DialectCell, matrix: string): void {
describe(`sql-driver — ${matrix} matrix (${cell.label})`, () => {
it.skipIf(!EXPECT_LIVE_DIALECTS)(
`is provisioned — set ${cell.env} to run this cell of the D-A3 driver axis`,
() => {
expect.fail(
`${cell.env} is unset while OS_EXPECT_LIVE_DIALECT_MATRIX=1: this runner declared it ` +
`provisions live Postgres and MySQL, so the ${cell.label} cell of the ${matrix} ` +
`matrix must not be skipped (ADR-0053 D-A3 "Postgres at minimum").`,
);
},
);
});
}

/** What a server reports about its own timezone. */
export interface ServerZone {
/** The dialect's own spelling: `Asia/Shanghai`, `+08:00`, `SYSTEM`, … */
Expand Down
209 changes: 134 additions & 75 deletions packages/plugins/driver-sql/src/sql-driver-or-filter.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

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

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { FILTER_LOGIC_CASES, FILTER_LOGIC_ROWS } from '@objectstack/spec/data';
import { SqlDriver } from '../src/index.js';
import {
DIALECT_CELLS,
declareUnprovisionedCell,
type DialectCell,
} from './live-dialect-matrix.testkit.js';

/**
* Issue-prefixed table names: the live cells share one database with every
* other suite in this package (and with each other's runs), so the bare `t` /
* `task` this suite used while it was SQLite-only would be a collision waiting
* to be read as a conformance failure.
*/
const FILTER_TABLE = 'os4714_filter_logic';
const DATE_WINDOW_TABLE = 'os4714_filter_logic_windows';

// ── The driver axis ─────────────────────────────────────────────────────────

for (const cell of DIALECT_CELLS) {
if (!cell.available) {
declareUnprovisionedCell(cell, 'filter-logic conformance');
continue;
}
declareFilterLogicSweep(cell);
}

function declareFilterLogicSweep(cell: DialectCell): void {
describe(`SqlDriver filter logic conformance (${cell.label})`, () => {
let driver: SqlDriver;
let knexInstance: any;

describe('SqlDriver filter logic conformance (SQLite)', () => {
let driver: SqlDriver;
let knexInstance: any;
beforeAll(async () => {
driver = new SqlDriver(cell.config());
knexInstance = (driver as any).knex;

beforeEach(async () => {
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
// Live cells reuse one database, so the sweep starts from a dropped table.
await knexInstance.schema.dropTableIfExists(FILTER_TABLE);
await knexInstance.schema.createTable(FILTER_TABLE, (t: any) => {
t.string('id').primary();
t.string('a');
t.string('b');
t.string('c');
t.string('owner');
t.string('status');
t.string('parent_object');
t.string('parent_id');
});
await knexInstance(FILTER_TABLE).insert([...FILTER_LOGIC_ROWS]);
});
knexInstance = (driver as any).knex;

await knexInstance.schema.createTable('t', (t: any) => {
t.string('id').primary();
t.string('a');
t.string('b');
t.string('c');
t.string('owner');
t.string('status');
t.string('parent_object');
t.string('parent_id');
afterAll(async () => {
await knexInstance?.schema.dropTableIfExists(FILTER_TABLE).catch(() => {});
await driver?.disconnect?.();
});
await knexInstance('t').insert([...FILTER_LOGIC_ROWS]);
});

afterEach(async () => {
await knexInstance.destroy();
});
describe('shared conformance cases', () => {
for (const c of FILTER_LOGIC_CASES) {
it(c.name, async () => {
const rows = await driver.find(FILTER_TABLE, { object: FILTER_TABLE, where: c.filter });
const got = rows
.map((r: any) => String(r.id))
.sort((x: string, y: string) => x.localeCompare(y));
expect(got, c.note).toEqual(c.expected);
});
}
});

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

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

it('matches only the rows inside the abutting windows', async () => {
const rows = await driver.find('task', {
object: 'task',
where: {
$or: [
{ end_date: { $gte: '2026-08-07', $lt: '2026-08-08' } },
{ end_date: { $gte: '2026-08-30', $lt: '2026-08-31' } },
],
},
it('matches only the rows inside the abutting windows', async () => {
const rows = await driver.find(DATE_WINDOW_TABLE, {
object: DATE_WINDOW_TABLE,
where: {
$or: [
{ end_date: { $gte: '2026-08-07', $lt: '2026-08-08' } },
{ end_date: { $gte: '2026-08-30', $lt: '2026-08-31' } },
],
},
});
expect(rows.map((r: any) => r.id).sort()).toEqual(['d07', 'd30']);
});
expect(rows.map((r: any) => r.id).sort()).toEqual(['d07', 'd30']);
});

it('keeps a window AND-ed with a sibling key in the same branch', async () => {
const rows = await driver.find('task', {
object: 'task',
where: { $or: [{ id: 'nope' }, { end_date: { $gte: '2026-08-07', $lt: '2026-08-31' }, id: 'd15' }] },
it('keeps a window AND-ed with a sibling key in the same branch', async () => {
const rows = await driver.find(DATE_WINDOW_TABLE, {
object: DATE_WINDOW_TABLE,
where: {
$or: [
{ id: 'nope' },
{ end_date: { $gte: '2026-08-07', $lt: '2026-08-31' }, id: 'd15' },
],
},
});
expect(rows.map((r: any) => r.id)).toEqual(['d15']);
});
expect(rows.map((r: any) => r.id)).toEqual(['d15']);
});
});
});
}
Loading
Loading