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
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,8 @@

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

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Knex } from 'knex';
import {
FILTER_COMPARAND_TYPE_CASES,
FILTER_COMPARAND_TYPE_ROWS,
parseFilterAST,
type FilterCondition,
} from '@objectstack/spec/data';
import { SqlDriver } from '../src/index.js';
import { SqlDriver, type SqlDriverConfig } from './sql-driver.js';
import {
DIALECT_CELLS,
declareUnprovisionedCell,
type DialectCell,
} from './live-dialect-matrix.testkit.js';

const TABLE = 'comparand_conformance';
/**
* Issue-prefixed object name: the live cells share one database with every
* other suite in this package, so the bare `comparand_conformance` this suite
* carried while it was SQLite-only would be a collision waiting to be read as
* a comparand-type failure.
*/
const TABLE = 'os7872_comparand_type';

/** The half of the table that reaches a driver at all — see the head note. */
const EXECUTED_CASES = FILTER_COMPARAND_TYPE_CASES.filter((c) => c.verdict !== 'door-refusal');

describe('[#7872] SqlDriver — comparand-type conformance (behind the door)', () => {
let driver: SqlDriver;
let knex: any;
/** The half the door settles upstream of every driver. */
const DOOR_REFUSAL_CASES = FILTER_COMPARAND_TYPE_CASES.filter((c) => c.verdict === 'door-refusal');

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

for (const cell of DIALECT_CELLS) {
if (!cell.available) {
declareUnprovisionedCell(cell, 'FILTER_COMPARAND_TYPE_CASES comparand-type door');
continue;
}
declareComparandTypeSweep(cell);
}

beforeAll(async () => {
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
function declareComparandTypeSweep(cell: DialectCell): void {
describe(`[#7872] SqlDriver — comparand-type conformance (${cell.label})`, () => {
let driver: SqlDriver;
let knexInstance: Knex;

beforeAll(async () => {
driver = new SqlDriver(cell.config());
// `getKnex()` is public API; the older shape of this file reached the
// `protected` field through `(driver as any).knex`, which erases every
// member of the driver to get one.
knexInstance = driver.getKnex();
// Live cells reuse one database, so the sweep starts from a dropped table.
await knexInstance.schema.dropTableIfExists(TABLE);
await knexInstance.schema.createTable(TABLE, (t: Knex.TableBuilder) => {
t.string('id').primary();
t.integer('qty');
t.string('label');
t.boolean('active');
// Nullable (knex's default): the `note: null` case is the declared null
// predicate, and it measures nothing against a NOT NULL column.
t.string('note');
});
await knexInstance(TABLE).insert(FILTER_COMPARAND_TYPE_ROWS.map((r) => ({ ...r })));
});
knex = (driver as any).knex;
await knex.schema.createTable(TABLE, (t: any) => {
t.string('id').primary();
t.integer('qty');
t.string('label');
t.boolean('active');
t.string('note');

afterAll(async () => {
await knexInstance?.schema.dropTableIfExists(TABLE).catch(() => {});
await driver?.disconnect?.();
});
await knex(TABLE).insert(FILTER_COMPARAND_TYPE_ROWS.map((r) => ({ ...r })));
});

afterAll(async () => {
await knex.destroy();
const ids = async (where: FilterCondition | undefined): Promise<string[]> => {
const rows = await driver.find(TABLE, { fields: ['id'], where });
return rows.map((r: any) => String(r.id)).sort((x, y) => x.localeCompare(y));
};

/**
* The fixture control. Every `matches` case below names row ids, so a cell
* whose insert silently dropped or coerced a row would answer wrong ids for
* a reason that has nothing to do with the comparand door.
*/
it('the fixture really is both rows', async () => {
expect(await ids(undefined)).toEqual(['1', '2']);
});

for (const c of EXECUTED_CASES) {
if (c.verdict === 'matches') {
it(c.name, async () => {
expect(await ids(parseFilterAST(c.filter())), c.note).toEqual([...c.expected]);
});
} else {
it(`${c.name} — executes without refusal`, async () => {
await expect(ids(parseFilterAST(c.filter()))).resolves.toBeDefined();
});
}
}
});
}

const ids = async (where: FilterCondition | undefined): Promise<string[]> => {
const rows = await driver.find(TABLE, { fields: ['id'], where });
return rows.map((r: any) => String(r.id)).sort((x, y) => x.localeCompare(y));
};

for (const c of FILTER_COMPARAND_TYPE_CASES) {
if (c.verdict === 'door-refusal') {
it(`${c.name} — refused at the door, before any SQL compiles`, () => {
let caught: (Error & { code?: string; status?: number }) | null = null;
try {
parseFilterAST(c.filter());
} catch (e) {
caught = e as Error & { code?: string; status?: number };
}
expect(caught, c.note).not.toBeNull();
expect(caught?.code, c.name).toBe(c.code);
expect(caught?.status, c.name).toBe(400);
for (const fragment of c.mustMention) expect(caught?.message).toContain(fragment);
});
} else if (c.verdict === 'matches') {
it(c.name, async () => {
expect(await ids(parseFilterAST(c.filter())), c.note).toEqual([...c.expected]);
});
} else {
it(`${c.name} — executes without refusal`, async () => {
await expect(ids(parseFilterAST(c.filter()))).resolves.toBeDefined();
});
// ── The door, which has no dialect ──────────────────────────────────────────

/**
* `parseFilterAST` runs upstream of every driver and is the same function on
* every cell, so these assertions are declared ONCE rather than once per
* dialect. Repeating a pure function across three cells would add rows to the
* report without adding a measurement — the shape this whole axis exists to
* make visible.
*/
describe('[#7872] the comparand-type door — refused before any dialect is chosen', () => {
for (const c of DOOR_REFUSAL_CASES) {
it(`${c.name} — refused at the door, before any SQL compiles`, () => {
let caught: (Error & { code?: string; status?: number }) | null = null;
try {
parseFilterAST(c.filter());
} catch (e) {
caught = e as Error & { code?: string; status?: number };
}
expect(caught, c.note).not.toBeNull();
// `code` AND `status`: a refusal outside the ADR-0112 envelope reaches
// the client as a 500-shaped body for a 400-class mistake.
expect(caught?.code, c.name).toBe(c.code);
expect(caught?.status, c.name).toBe(400);
for (const fragment of c.mustMention) expect(caught?.message).toContain(fragment);
});
}
});

// ── "compiles everywhere", at the layer a server is not needed for ──────────

/**
* Every accepted comparand type BINDS into a statement on each dialect.
*
* This is the claim `CASE_SETS` makes about this table in one word, checked on
* all three dialects including the one no server was provisionable for. It is
* a real check rather than a restatement: this driver refuses an unbindable
* comparand from inside `applyFilters` (the `isBindableComparand` gate the
* door's own set was measured from), which throws while the statement is being
* BUILT — before any connection exists. So a type that this driver could not
* bind on `pg` or `mysql2` fails here, with no server involved.
*/
describe('[#7872] every accepted comparand type binds, on every dialect', () => {
/** A driver that exposes the compiled WHERE without reaching into privates. */
class CompilerProbeDriver extends SqlDriver {
compileWhere(where: FilterCondition): string {
const builder: Knex.QueryBuilder = this.getKnex()(TABLE);
this.applyFilters(builder, where);
return builder.toString();
}
}

it('the fixture really is both rows', async () => {
expect(await ids(undefined)).toEqual(['1', '2']);
});
const probe = (config: SqlDriverConfig) => new CompilerProbeDriver(config);

const CLIENTS: readonly { id: string; config: SqlDriverConfig }[] = [
{
id: 'sqlite',
config: { client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true },
},
{ id: 'pg', config: { client: 'pg', connection: { host: '127.0.0.1' } } },
{ id: 'mysql', config: { client: 'mysql2', connection: { host: '127.0.0.1' } } },
];

for (const client of CLIENTS) {
describe(client.id, () => {
for (const c of EXECUTED_CASES) {
it(`${c.name} — binds`, () => {
// `parseFilterAST` is typed `FilterCondition | undefined`. Asserted
// rather than `!`-ed: a case that produced NO condition would compile
// a bare select, and every assertion below would then be measuring an
// empty statement instead of a bound comparand.
const condition = parseFilterAST(c.filter());
expect(condition, `${c.name} produced no condition to compile`).toBeDefined();
const sql = probe(client.config).compileWhere(condition as FilterCondition);
// A statement, with a `where` in it: `applyFilters` throwing is the
// failure this block is looking for, and a builder that silently
// applied NOTHING would render a bare select — which is the quiet
// way a comparand can be dropped rather than refused.
expect(sql, `${client.id} rendered no statement for ${c.name}`).toContain(TABLE);
expect(sql.toLowerCase(), `${client.id} dropped the predicate for ${c.name}`)
.toContain('where');
});
}
});
}
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,40 @@
* `CAST(… AS BINARY)` on MySQL. The per-dialect reasoning and the measurements
* behind each cell live on that function.
*
* ## [#12136] Deliberately the SQLite cell — measured, not assumed
*
* This suite names `dialectCell('sqlite')`, which declares its narrowness as a
* DECISION rather than leaving it spelled the same way an accident would be
* (#12014). The claim that declaring it loses no dialect coverage was measured
* BY OPERATOR rather than by file, because #6518 makes `FILTER_TEXT_CASES`
* answer differently per dialect (`GLOB` on SQLite, `LIKE` on Postgres, `LIKE`
* over `CAST(… AS BINARY)` on MySQL) and a wrong call here would hide a real
* gap:
*
* - Operators reached by THIS suite, read from comment-masked source:
* `$contains`, `$endsWith`, `$icontains`, `$notContains`, `$options`,
* `$regex`, `$startsWith` — plus `$or`.
* - Operators reached by `FILTER_TEXT_CASES`, read from the EXPORTED case
* filters rather than from the file's prose (its head note also mentions
* `$ilike` and `$not`, which no case actually exercises — the reason this
* was measured off the data): exactly the same seven.
*
* `sql-driver-text-case-conformance.test.ts` runs that whole table once per
* cell of `DIALECT_CELLS`, so all seven are already answered on all three
* dialects. The one extra, `$or`, is a logical combinator belonging to
* `FILTER_LOGIC_CASES`, which `sql-driver-or-filter.test.ts` routes through the
* same matrix.
*
* What is left over — and what makes SQLite the RIGHT cell rather than merely a
* tolerable one — is this file's own residue: the `GLOB` metacharacter class
* (`*`, `?`, `[`), the `lower(name) GLOB lower(…)` construct, and the assertion
* that `$contains` and `$icontains` stop answering identically. `GLOB` is
* emitted on the SQLite dialects and nowhere else, so those blocks are about
* SQLite BY CONSTRUCTION; running them on Postgres or MySQL would assert a
* construct those dialects never compile. The backslash limb is pinned
* per-dialect in `sql-driver-like-escape.test.ts`, which routes through the
* cells itself.
*
* ## The reverse verification, direction decided BEFORE it was run
*
* - **Refusal face** — predicted RED, measured RED. Restoring the deleted
Expand All@@ -47,6 +81,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { DriverOptions, FilterCondition } from '@objectstack/spec/data';
import { FILTER_TEXT_CASES, FILTER_TEXT_ROWS } from '@objectstack/spec/data';
import { SqlDriver } from './sql-driver.js';
import { dialectCell } from './live-dialect-matrix.testkit.js';

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

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