From 0823ecc8278c2aa0fdb8192245cb9bdbfd7dc03e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:04:33 +0000 Subject: [PATCH 1/3] wip(objectql): plugin driver conformance kit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2g15Dr6sEsgv4sGBkq --- .../objectql/src/plugin-driver-conformance.ts | 986 ++++++++++++++++++ 1 file changed, 986 insertions(+) create mode 100644 packages/objectql/src/plugin-driver-conformance.ts diff --git a/packages/objectql/src/plugin-driver-conformance.ts b/packages/objectql/src/plugin-driver-conformance.ts new file mode 100644 index 00000000000..866a2d6aebf --- /dev/null +++ b/packages/objectql/src/plugin-driver-conformance.ts @@ -0,0 +1,986 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27744] The plugin-driver conformance kit: the behaviour the engine relies on + * from a plugin driver, as a suite a driver that lives OUTSIDE this repository + * runs in its own CI (epic #27261 P2). A vendor driver (a closed-source + * Salesforce driver, say) cannot be tested here, so a change to the contract + * here can only be caught there — by this kit, imported from the published + * `@objectstack/objectql`. + * + * ## What it asserts + * + * The contract is the engine's correctness floor over a plugin driver + * (`external-driver-floor.ts`, #27277): the engine sends `find` a `where`, an + * `orderBy` and a `limit` of {@link EXTERNAL_ROW_CAP} + 1, and re-applies the + * whole query to whatever rows come back. So a driver may push any part of a + * filter down or none of it, and may refuse what it cannot translate — what it + * must never do is return FEWER rows than match, invent rows, or answer a + * failure as rows. The oracle is the spec's driver conformance tables + * (`FILTER_LOGIC_CASES`, `FILTER_TEXT_CASES`, `TEMPORAL_CASES`, + * `PAGINATION_*_CASES`), the standard every built-in driver is held to — never + * any one driver. + * + * Two tiers: + * + * - **`floor`** (the default) — what the engine relies on today. Each read is + * checked twice: the driver's own answer to the query the floor sends (every + * matching row, only fixture rows, or a request-shaped refusal), and the + * floored answer (the floor over the driver answers the table's `expected`). + * Plus row shape and value fidelity, write refusal statuses (from the + * caller's probes), the outage answer (from the caller's unavailable remote), + * and the `introspectSchema` shape when the driver offers one. + * - **`direct`** — the driver answers the whole query itself, as `find()`'s + * contract asks of a built-in driver: an exact filter, `count`, `findOne`, + * a deterministic paged walk, `limit` by presence. The floor does not need + * any of it; it is the oracle the epic's P3 verified-pushdown credential (a + * driver trusted to skip the floor) builds on, and a driver may run it to + * measure how far it is from that. + * + * `DriverCapabilities` carries no pushdown claim (the query bits were retired + * in 17.0.0), so pushdown is MEASURED, not read from `supports`: each floor + * read case reports whether the driver answered exactly, with a superset, or + * refused (`pushdown` on its result). + * + * Not covered: the text table's rejection rows (the floor refuses a retired + * operator before the driver is asked, so they test the engine, not a driver) + * and `TEMPORAL_TIME_CASES` (no plugin driver serves a time-of-day column + * through the floor's temporal comparand rule yet). + * + * ## How a driver runs it + * + * The caller hands a `setup` that builds the driver over a remote holding + * exactly a fixture's rows — a fake of the remote's API in front of the + * driver's real translation code, or a sandbox seeded with + * {@link pluginDriverConformanceFixtures}. Reads share one setup per fixture; + * each write probe gets a fresh one. Then either + * {@link runPluginDriverConformance} (any runner: a report, no test framework) + * or {@link definePluginDriverConformanceSuite} (one test per case, under the + * caller's `describe` / `it`, vitest and jest alike). + */ + +import type { DriverQuery, IDataDriver } from '@objectstack/spec/contracts'; +import { + FILTER_LOGIC_CASES, + FILTER_LOGIC_ROWS, + FILTER_TEXT_CASES, + FILTER_TEXT_ROWS, + PAGINATION_CASES, + PAGINATION_ROWS, + PAGINATION_UNORDERED_CASES, + PAGINATION_ZERO_LIMIT_CASES, + TEMPORAL_CASES, + TEMPORAL_NOW, + TEMPORAL_ROWS, + lowerFilterCondition, + type FilterCondition, +} from '@objectstack/spec/data'; +import { resolveFilterTokens } from '@objectstack/core'; +import { applyDriverQueryInMemory } from './driver-query-in-memory.js'; +import { declaredFieldClasses } from './having-filter.js'; +import { DatasourceUnavailableError } from './driver-connect-errors.js'; +import { createExternalDriverFloor } from './external-driver-floor.js'; +import { + EXTERNAL_READ_REFUSED_STATUSES, + EXTERNAL_ROW_CAP, + EXTERNAL_WRITE_REFUSED_CODE, + EXTERNAL_WRITE_REFUSED_STATUSES, + ExternalReadRefusedError, + ExternalWriteRefusedError, + type ExternalWriteOperation, +} from './external-driver-errors.js'; + +type Row = Record; + +// =========================================================================== +// Fixtures +// =========================================================================== + +/** The kit's fixtures, one per spec conformance table. */ +export type PluginDriverConformanceFixtureName = 'filter_logic' | 'filter_text' | 'temporal' | 'pagination'; + +/** The fixture names, in the order the kit runs them. */ +export const PLUGIN_DRIVER_CONFORMANCE_FIXTURE_NAMES: readonly PluginDriverConformanceFixtureName[] = + ['filter_logic', 'filter_text', 'temporal', 'pagination']; + +/** A declared field of a fixture object, as an object's `fields` record declares it. */ +export interface PluginDriverConformanceField { + type: 'text' | 'number' | 'datetime' | 'date'; +} + +/** + * One fixture: an object, its declared fields, and the rows its remote must + * hold — exactly these, no more. `null` is a real value here (the null family + * of the filter tables): a remote that stores it as `''` fails the shape case. + */ +export interface PluginDriverConformanceFixture { + name: PluginDriverConformanceFixtureName; + /** The object name the kit reads; a setup may answer another (`PluginDriverConformanceTarget.object`). */ + object: string; + fields: Readonly>; + /** A fresh copy on every call. `id` is a string on every row. */ + rows: Row[]; +} + +const FIXTURE_FIELDS: Record> = { + filter_logic: { + a: { type: 'text' }, b: { type: 'text' }, c: { type: 'text' }, d: { type: 'text' }, + owner: { type: 'text' }, status: { type: 'text' }, parent_object: { type: 'text' }, parent_id: { type: 'text' }, + }, + filter_text: { name: { type: 'text' }, score: { type: 'number' } }, + temporal: { at: { type: 'datetime' }, on: { type: 'date' } }, + pagination: { status: { type: 'text' }, rank: { type: 'number' }, name: { type: 'text' } }, +}; + +function fixtureRows(name: PluginDriverConformanceFixtureName): Row[] { + switch (name) { + case 'filter_logic': return FILTER_LOGIC_ROWS.map((row) => ({ ...row })); + case 'filter_text': return FILTER_TEXT_ROWS.map((row) => ({ ...row })); + // The wire form: a remote stores an instant however it likes, and the + // shape case accepts a native `Date` for the same instant back. + case 'temporal': return TEMPORAL_ROWS.map(({ id, at, on }) => ({ id, at, on })); + case 'pagination': return PAGINATION_ROWS.map((row) => ({ ...row })); + } +} + +/** Every fixture, fresh: what a remote is seeded with before the kit runs. */ +export function pluginDriverConformanceFixtures(): PluginDriverConformanceFixture[] { + return PLUGIN_DRIVER_CONFORMANCE_FIXTURE_NAMES.map((name) => ({ + name, + object: `conformance_${name}`, + fields: FIXTURE_FIELDS[name], + rows: fixtureRows(name), + })); +} + +const fixtureNamed = (name: PluginDriverConformanceFixtureName): PluginDriverConformanceFixture => + pluginDriverConformanceFixtures().find((f) => f.name === name)!; + +// =========================================================================== +// Options +// =========================================================================== + +/** What a setup answers: the driver under test, over a remote holding the fixture's rows. */ +export interface PluginDriverConformanceTarget { + driver: IDataDriver; + /** The object name the driver serves the fixture under; defaults to `fixture.object`. */ + object?: string; + /** Called once the kit is done with this driver. */ + teardown?(): void | Promise; +} + +/** + * A write the caller knows the outcome of. The kit cannot make a remote refuse + * a write, so the caller names one it refuses (a value its validation rejects, + * a record it locks) and one it accepts. + */ +export interface PluginDriverConformanceWriteProbe { + name: string; + operation: ExternalWriteOperation; + /** The arguments after the object name, e.g. `[id, patch]` for `update`. */ + args: readonly unknown[]; + /** + * `refused`: the remote judges the request and refuses it, so the caller + * must read `422 EXTERNAL_WRITE_REFUSED` with the remote's message. + * `accepted`: the write goes through. + */ + expect: 'refused' | 'accepted'; + /** The fixture the probe's remote holds; `filter_logic` when absent. */ + fixture?: PluginDriverConformanceFixtureName; +} + +/** The kit's tiers — see the module comment. */ +export type PluginDriverConformanceTier = 'floor' | 'direct'; + +export interface PluginDriverConformanceOptions { + /** Builds the driver over a remote holding exactly `fixture.rows`. */ + setup(fixture: PluginDriverConformanceFixture): PluginDriverConformanceTarget | Promise; + /** + * Builds the driver over a remote that is down — every request fails as an + * unreachable host or a server error does. Absent ⇒ the outage cases report + * `not-offered`. + */ + setupUnavailable?(fixture: PluginDriverConformanceFixture): PluginDriverConformanceTarget | Promise; + /** Absent or empty ⇒ the write cases report `not-offered`. */ + writeProbes?: readonly PluginDriverConformanceWriteProbe[]; + /** `['floor']` when absent. */ + tiers?: readonly PluginDriverConformanceTier[]; +} + +// =========================================================================== +// Cases and outcomes +// =========================================================================== + +/** How a driver answered a read's filter, measured (never declared). */ +export type PluginDriverPushdown = 'exact' | 'superset' | 'refused'; + +/** A case that ran and held. */ +export interface PluginDriverConformanceOutcome { + status: 'pass' | 'not-offered'; + /** Read cases: how the driver answered the filter it was sent. */ + pushdown?: PluginDriverPushdown; + /** Ordered read cases: did the driver's own rows arrive in the requested order? */ + order?: 'honoured' | 'not-honoured'; + /** `not-offered`: what was absent. */ + note?: string; +} + +/** A case the driver failed: the message names the query, the rule and the fix. */ +export class PluginDriverConformanceFailure extends Error { + constructor(readonly caseId: string, message: string) { + super(`${caseId}: ${message}`); + this.name = 'PluginDriverConformanceFailure'; + } +} + +export type PluginDriverConformanceGroup = + | 'shape' | 'filter' | 'count' | 'find-one' | 'order' | 'paging' | 'write' | 'outage' | 'introspect'; + +export interface PluginDriverConformanceCase { + /** Stable across releases: `tier/group/fixture/case name`. */ + id: string; + tier: PluginDriverConformanceTier; + group: PluginDriverConformanceGroup; + fixture: PluginDriverConformanceFixtureName; + name: string; + /** Resolves on a pass; rejects with {@link PluginDriverConformanceFailure} on a failure. */ + run(): Promise; +} + +export interface PluginDriverConformance { + cases: PluginDriverConformanceCase[]; + /** Tears down every setup the cases made. Call once, after the last case. */ + dispose(): Promise; +} + +// =========================================================================== +// The kit +// =========================================================================== + +/** The datasource name the kit's floor names in its refusals. */ +const DATASOURCE = 'conformance'; + +interface Session { + driver: IDataDriver; + object: string; + fixture: PluginDriverConformanceFixture; + warnings: string[]; +} + +/** + * The kit's cases for one driver, and the disposer of the setups they share. + * Each case calls `setup` lazily, so building the list costs nothing. + */ +export function createPluginDriverConformance(options: PluginDriverConformanceOptions): PluginDriverConformance { + const tiers = new Set(options.tiers ?? ['floor']); + for (const tier of tiers) { + if (tier !== 'floor' && tier !== 'direct') { + throw new TypeError(`plugin driver conformance: unknown tier '${String(tier)}' — the tiers are 'floor' and 'direct'`); + } + } + + const teardowns: Array<() => void | Promise> = []; + const shared = new Map>(); + + const open = async ( + build: PluginDriverConformanceOptions['setup'], + name: PluginDriverConformanceFixtureName, + ): Promise => { + const fixture = fixtureNamed(name); + const target = await build(fixture); + if (!target || typeof target.driver?.find !== 'function') { + throw new TypeError(`plugin driver conformance: setup('${name}') answered no driver with a find()`); + } + if (target.teardown) teardowns.push(() => target.teardown!()); + return { driver: target.driver, object: target.object ?? fixture.object, fixture, warnings: [] }; + }; + const sessionFor = (name: PluginDriverConformanceFixtureName): Promise => { + let session = shared.get(name); + if (!session) { + session = open(options.setup, name); + shared.set(name, session); + } + return session; + }; + + const cases: PluginDriverConformanceCase[] = []; + const add = ( + tier: PluginDriverConformanceTier, group: PluginDriverConformanceGroup, + fixture: PluginDriverConformanceFixtureName, name: string, + run: (id: string) => Promise, + ) => { + if (!tiers.has(tier)) return; + const id = `${tier}/${group}/${fixture}/${name}`; + cases.push({ id, tier, group, fixture, name, run: () => run(id) }); + }; + + // ── floor ──────────────────────────────────────────────────────────────── + for (const name of PLUGIN_DRIVER_CONFORMANCE_FIXTURE_NAMES) { + add('floor', 'shape', name, 'every row, as stored', async (id) => shapeCase(id, await sessionFor(name))); + } + for (const c of filterCases()) { + add('floor', 'filter', c.fixture, c.name, async (id) => floorFilterCase(id, await sessionFor(c.fixture), c)); + } + for (const c of FILTER_LOGIC_CASES) { + add('floor', 'count', 'filter_logic', c.name, async (id) => + floorCountCase(id, await sessionFor('filter_logic'), c.filter, c.expected)); + } + for (const c of PAGINATION_CASES) { + add('floor', 'order', 'pagination', c.name, async (id) => + floorOrderCase(id, await sessionFor('pagination'), c.orderBy, c.pageSize)); + } + const probes = options.writeProbes ?? []; + if (probes.length === 0) { + add('floor', 'write', 'filter_logic', 'write probes', async () => + ({ status: 'not-offered', note: 'no writeProbes were given' })); + } + for (const probe of probes) { + const fixture = probe.fixture ?? 'filter_logic'; + add('floor', 'write', fixture, probe.name, async (id) => + writeCase(id, await open(options.setup, fixture), probe)); + } + for (const verb of ['find', 'findOne', 'count'] as const) { + add('floor', 'outage', 'filter_logic', `${verb} while the remote is down`, async (id) => { + if (!options.setupUnavailable) return { status: 'not-offered', note: 'no setupUnavailable was given' }; + return outageCase(id, await open(options.setupUnavailable, 'filter_logic'), verb); + }); + } + add('floor', 'introspect', 'filter_logic', 'introspectSchema answers the spec shape', async (id) => + introspectCase(id, await sessionFor('filter_logic'))); + + // ── direct ─────────────────────────────────────────────────────────────── + for (const c of filterCases()) { + add('direct', 'filter', c.fixture, c.name, async (id) => directFilterCase(id, await sessionFor(c.fixture), c)); + } + for (const c of FILTER_LOGIC_CASES) { + add('direct', 'count', 'filter_logic', c.name, async (id) => + directCountCase(id, await sessionFor('filter_logic'), c.filter, c.expected.length)); + add('direct', 'find-one', 'filter_logic', c.name, async (id) => + directFindOneCase(id, await sessionFor('filter_logic'), c.filter, c.expected)); + } + for (const c of PAGINATION_CASES) { + add('direct', 'paging', 'pagination', c.name, async (id) => + directPagingCase(id, await sessionFor('pagination'), c.orderBy, c.pageSize)); + } + for (const c of PAGINATION_UNORDERED_CASES) { + add('direct', 'paging', 'pagination', `unordered — ${c.name}`, async (id) => + directPagingCase(id, await sessionFor('pagination'), undefined, c.pageSize)); + } + for (const c of PAGINATION_ZERO_LIMIT_CASES) { + add('direct', 'paging', 'pagination', `limit by presence — ${c.name}`, async (id) => + directZeroLimitCase(id, await sessionFor('pagination'), c.query, c.expectedRowCount)); + } + + return { + cases, + async dispose() { + const pending = teardowns.splice(0); + shared.clear(); + for (const teardown of pending) await teardown(); + }, + }; +} + +/** A report of one run: every case's id and status, and why each failure failed. */ +export interface PluginDriverConformanceReport { + /** The driver's `name`, from the first setup that ran. */ + driver: string | undefined; + passed: boolean; + results: Array<{ + id: string; + tier: PluginDriverConformanceTier; + group: PluginDriverConformanceGroup; + status: 'pass' | 'fail' | 'not-offered'; + pushdown?: PluginDriverPushdown; + order?: 'honoured' | 'not-honoured'; + message?: string; + }>; +} + +/** Runs every case in order, with no test framework, and answers a report. Never throws for a failing case. */ +export async function runPluginDriverConformance(options: PluginDriverConformanceOptions): Promise { + let driver: string | undefined; + const conformance = createPluginDriverConformance({ + ...options, + setup: async (fixture) => { + const target = await options.setup(fixture); + driver ??= target?.driver?.name; + return target; + }, + }); + const results: PluginDriverConformanceReport['results'] = []; + try { + for (const c of conformance.cases) { + try { + const outcome = await c.run(); + results.push({ + id: c.id, tier: c.tier, group: c.group, status: outcome.status, + ...(outcome.pushdown ? { pushdown: outcome.pushdown } : {}), + ...(outcome.order ? { order: outcome.order } : {}), + ...(outcome.note ? { message: outcome.note } : {}), + }); + } catch (error) { + results.push({ + id: c.id, tier: c.tier, group: c.group, status: 'fail', + message: error instanceof Error ? error.message : String(error), + }); + } + } + } finally { + await conformance.dispose(); + } + return { driver, passed: results.every((r) => r.status !== 'fail'), results }; +} + +/** The test-framework surface the suite registers into: vitest's and jest's `describe` / `it` / `afterAll`. */ +export interface PluginDriverConformanceTestApi { + describe(name: string, body: () => void): unknown; + it(name: string, body: () => Promise): unknown; + afterAll?(body: () => Promise): unknown; +} + +/** + * Registers one test per case, grouped by tier and group, under `title`. A + * `not-offered` case passes (it says so in the report, not here); a failing + * case's test fails with the case's message. + */ +export function definePluginDriverConformanceSuite( + api: PluginDriverConformanceTestApi, + options: PluginDriverConformanceOptions, + title = 'plugin driver conformance', +): void { + const conformance = createPluginDriverConformance(options); + api.describe(title, () => { + const groups = new Map(); + for (const c of conformance.cases) { + const key = `${c.tier} — ${c.group}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(c); + } + for (const [key, members] of groups) { + api.describe(key, () => { + for (const c of members) api.it(`${c.fixture}: ${c.name}`, async () => { await c.run(); }); + }); + } + api.afterAll?.(() => conformance.dispose()); + }); +} + +// =========================================================================== +// The filter cases, across the three tables +// =========================================================================== + +interface FilterCase { + fixture: 'filter_logic' | 'filter_text' | 'temporal'; + name: string; + /** The `where` as the engine hands it to a driver: tokens resolved, the shared lowering applied. */ + where: FilterCondition; + expected: readonly string[]; +} + +function filterCases(): FilterCase[] { + const out: FilterCase[] = []; + for (const c of FILTER_LOGIC_CASES) out.push({ fixture: 'filter_logic', name: c.name, where: c.filter, expected: c.expected }); + for (const c of FILTER_TEXT_CASES) { + if (c.expectRejection === true) continue; // the floor refuses it before the driver is asked + out.push({ fixture: 'filter_text', name: c.name, where: c.filter, expected: c.expected }); + } + const lowered = (filter: FilterCondition) => + lowerFilterCondition(filter, { isDatetimeColumn: (column) => column === 'at' }); + for (const c of TEMPORAL_CASES) { + out.push({ fixture: 'temporal', name: c.name, where: lowered(c.filter), expected: c.expected }); + if (c.tokenFilter) { + out.push({ + fixture: 'temporal', + name: `${c.name} — via relative tokens`, + where: lowered(resolveFilterTokens(c.tokenFilter, { now: new Date(TEMPORAL_NOW) })), + expected: c.expected, + }); + } + } + return out; +} + +// =========================================================================== +// Case bodies +// =========================================================================== + +const ROW_LIMIT = EXTERNAL_ROW_CAP + 1; + +/** A JSON rendering of a query for a failure message; a bigint or a cycle never throws here. */ +function show(value: unknown): string { + try { + return JSON.stringify(value, (_key, v) => (v instanceof Date ? v.toISOString() : v)) ?? String(value); + } catch { + return String(value); + } +} + +const sorted = (ids: Iterable) => [...ids].sort(); + +/** The HTTP status a thrown error declares, as the floor reads it: `status`, then `statusCode`. */ +function declaredStatus(error: unknown): number | undefined { + const { status, statusCode } = (error ?? {}) as { status?: unknown; statusCode?: unknown }; + const value = typeof status === 'number' ? status : typeof statusCode === 'number' ? statusCode : undefined; + return value !== undefined && Number.isInteger(value) ? value : undefined; +} + +const describeThrow = (error: unknown): string => { + const status = declaredStatus(error); + const message = error instanceof Error ? error.message : String(error); + return `${status === undefined ? 'with no status' : `with status ${status}`} (${show(message)})`; +}; + +/** The floor over the session's driver, recording what the driver itself answered to each `find`. */ +function floored(session: Session) { + const answers: Array<{ rows?: unknown; error?: unknown }> = []; + const inner = session.driver; + const recorder = new Proxy(inner, { + get(target, prop) { + if (prop === 'find') { + return async (object: string, query?: DriverQuery, opts?: unknown) => { + try { + const rows = await target.find(object, query ?? {}, opts as Parameters[2]); + answers.push({ rows }); + return rows; + } catch (error) { + answers.push({ error }); + throw error; + } + }; + } + const value = Reflect.get(target, prop, target); + return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(target) : value; + }, + }) as IDataDriver; + const driver = createExternalDriverFloor(recorder, { + datasource: DATASOURCE, + fieldsOf: () => session.fixture.fields, + logger: { warn: (message) => { session.warnings.push(message); } }, + }); + return { driver, answers }; +} + +/** + * The driver's own rows for a read, judged as the floor needs them: an array of + * fixture rows with string ids, no row twice, and every row in `mustInclude`. + * Answers their ids in arrival order. + */ +function judgeRows(id: string, session: Session, query: unknown, rows: unknown, mustInclude: readonly string[]): string[] { + const asked = `find('${session.object}', ${show(query)})`; + if (!Array.isArray(rows)) { + throw new PluginDriverConformanceFailure(id, + `${asked} answered ${rows === null ? 'null' : typeof rows}, not an array of rows. Return the rows; throw to refuse.`); + } + const known = new Set(session.fixture.rows.map((r) => r.id as string)); + const seen = new Set(); + const order: string[] = []; + for (const row of rows) { + const rowId = (row as Row | null)?.id; + if (typeof rowId !== 'string') { + throw new PluginDriverConformanceFailure(id, + `${asked} answered a row whose id is ${show(rowId)}; every row carries its id as a string.`); + } + if (!known.has(rowId)) { + throw new PluginDriverConformanceFailure(id, + `${asked} answered a row with id '${rowId}', which the remote does not hold; return only stored rows.`); + } + if (seen.has(rowId)) { + throw new PluginDriverConformanceFailure(id, + `${asked} answered the row '${rowId}' twice; return each row once (a paged remote read overlapped).`); + } + seen.add(rowId); + order.push(rowId); + } + const missing = mustInclude.filter((m) => !seen.has(m)); + if (missing.length > 0) { + throw new PluginDriverConformanceFailure(id, + `${asked} left out matching rows ${show(missing)}. The engine filters the rows a driver returns, so an ` + + 'extra row costs only transfer, but a missing one is lost: when part of a where cannot be translated, ' + + 'send the remote a WIDER filter (drop the untranslatable member of a top-level $and, or the whole $or ' + + 'or $not that holds it) or refuse with a status of 400.'); + } + return order; +} + +/** A driver throw read as the floor reads it: a request-shaped refusal passes, anything else fails. */ +function judgeReadThrow(id: string, session: Session, query: unknown, error: unknown): PluginDriverPushdown { + const status = declaredStatus(error); + if (status !== undefined && EXTERNAL_READ_REFUSED_STATUSES.has(status)) return 'refused'; + throw new PluginDriverConformanceFailure(id, + `find('${session.object}', ${show(query)}) threw ${describeThrow(error)}. The caller reads that as ` + + '503 ERR_DATASOURCE_UNAVAILABLE, "retry later", for a query no retry clears. Refuse a query the remote cannot ' + + `run with a status of ${sorted([...EXTERNAL_READ_REFUSED_STATUSES].map(String)).join(', ')} on the error.`); +} + +/** Is `got` the stored `want` of a field of this type? `null` and absent agree; a datetime may come back as a `Date`. */ +function sameStoredValue(type: PluginDriverConformanceField['type'], want: unknown, got: unknown): boolean { + if (want === null || want === undefined) return got === null || got === undefined; + if (type === 'datetime' || type === 'date') { + if (got instanceof Date) { + const iso = got.toISOString(); + return type === 'datetime' ? iso === new Date(want as string).toISOString() : iso.slice(0, 10) === want; + } + if (type === 'datetime' && typeof got === 'string') { + const instant = Date.parse(got); + return !Number.isNaN(instant) && instant === Date.parse(want as string); + } + } + return got === want; +} + +async function shapeCase(id: string, session: Session): Promise { + const query = { object: session.object, limit: ROW_LIMIT }; + let rows: unknown; + try { + rows = await session.driver.find(session.object, query as DriverQuery); + } catch (error) { + throw new PluginDriverConformanceFailure(id, + `find('${session.object}', ${show(query)}) threw ${describeThrow(error)} for the plainest read the engine sends: ` + + 'no filter, the row cap as its limit.'); + } + const all = session.fixture.rows.map((r) => r.id as string); + judgeRows(id, session, query, rows, all); + const byId = new Map(session.fixture.rows.map((r) => [r.id as string, r])); + for (const row of rows as Row[]) { + if ('_id' in row) { + throw new PluginDriverConformanceFailure(id, + `the row '${row.id as string}' carries '_id'; a row carries its id as 'id' and no storage key besides.`); + } + const stored = byId.get(row.id as string)!; + for (const [field, { type }] of Object.entries(session.fixture.fields)) { + if (!sameStoredValue(type, stored[field], row[field])) { + throw new PluginDriverConformanceFailure(id, + `the row '${row.id as string}' answered ${field} = ${show(row[field])}, but the remote holds ` + + `${show(stored[field])} (a ${type} field). The engine evaluates the filter over the values a driver ` + + 'returns, so a changed value (a null as an empty string, a number as text) changes which rows match.'); + } + } + } + return { status: 'pass' }; +} + +/** + * One read through the floor, judged on both sides: the driver's own answer to + * the query the floor sent it (every row in `mustInclude`, or a request-shaped + * refusal), then the floored answer, handed back for the case to compare. + */ +async function flooredRead( + id: string, session: Session, sent: Row, mustInclude: readonly string[], + read: (driver: IDataDriver) => Promise, +): Promise<{ refused: true } | { value: T; ids: string[] }> { + const { driver, answers } = floored(session); + let value: T | undefined; + let thrown: unknown; + try { + value = await read(driver); + } catch (error) { + thrown = error; + } + const own = answers[0]; + if (!own) { + throw thrown ?? new PluginDriverConformanceFailure(id, 'the floor answered without asking the driver'); + } + if ('error' in own) { + judgeReadThrow(id, session, sent, own.error); + if (!(thrown instanceof ExternalReadRefusedError)) { + throw new PluginDriverConformanceFailure(id, `the driver refused, but the caller read ${describeThrow(thrown)}.`); + } + return { refused: true }; + } + const ids = judgeRows(id, session, sent, own.rows, mustInclude); + if (thrown !== undefined) throw thrown; + return { value: value as T, ids }; +} + +async function floorFilterCase(id: string, session: Session, c: FilterCase): Promise { + const query = { object: session.object, where: c.where }; + const read = await flooredRead(id, session, { ...query, limit: ROW_LIMIT }, c.expected, + (driver) => driver.find(session.object, query as DriverQuery)); + if ('refused' in read) return { status: 'pass', pushdown: 'refused' }; + const got = sorted(read.value.map((r) => r.id as string)); + if (show(got) !== show(sorted(c.expected))) { + throw new PluginDriverConformanceFailure(id, + `through the floor, ${show(c.where)} answered ${show(got)}; the conformance table expects ${show(sorted(c.expected))}. ` + + 'The driver returned every matching row, so the values it returned differ from what the remote holds.'); + } + return { status: 'pass', pushdown: read.ids.length === c.expected.length ? 'exact' : 'superset' }; +} + +async function floorCountCase( + id: string, session: Session, where: FilterCondition, expected: readonly string[], +): Promise { + const query = { object: session.object, where }; + const read = await flooredRead(id, session, { ...query, limit: ROW_LIMIT }, expected, + (driver) => driver.count(session.object, query as DriverQuery)); + if ('refused' in read) return { status: 'pass', pushdown: 'refused' }; + if (read.value !== expected.length) { + throw new PluginDriverConformanceFailure(id, + `through the floor, count with ${show(where)} answered ${show(read.value)}; the conformance table expects ${expected.length}.`); + } + return { status: 'pass', pushdown: read.ids.length === expected.length ? 'exact' : 'superset' }; +} + +type OrderBy = ReadonlyArray<{ field: string; order: 'asc' | 'desc' }>; + +/** Do these rows arrive in `orderBy`'s order (ties in any order)? Absent values sort last, as the engine sorts. */ +function inOrder(rows: Row[], orderBy: OrderBy): boolean { + const compare = (x: Row, y: Row) => { + for (const { field, order } of orderBy) { + const a = x[field]; + const b = y[field]; + const absentA = a === null || a === undefined; + const absentB = b === null || b === undefined; + if (absentA || absentB) { + if (absentA !== absentB) return absentA ? 1 : -1; + continue; + } + if (a === b) continue; + const lt = (a as number) < (b as number); + return (order === 'desc' ? !lt : lt) ? -1 : 1; + } + return 0; + }; + return rows.every((row, i) => i === 0 || compare(rows[i - 1]!, row) <= 0); +} + +/** + * A paged walk is a partition in the requested order: every row exactly once, + * and the walk's sort keys equal one unpaged in-memory read's. + */ +function judgeWalk(id: string, session: Session, visited: Row[], orderBy: OrderBy | undefined, how: string): void { + const ids = visited.map((r) => r.id as string); + const all = session.fixture.rows.map((r) => r.id as string); + if (ids.length !== new Set(ids).size || show(sorted(ids)) !== show(sorted(all))) { + throw new PluginDriverConformanceFailure(id, + `${how} visited ${show(ids)}; a paged read visits every row exactly once (${all.length} rows). ` + + 'Order by a unique column after the requested keys, so a page boundary inside a run of equal keys is stable.'); + } + if (orderBy) { + const byId = new Map(session.fixture.rows.map((r) => [r.id as string, r])); + const keys = (rowId: string) => orderBy.map(({ field }) => byId.get(rowId)![field]); + const want = applyDriverQueryInMemory(session.fixture.rows, { orderBy }).map((r) => r.id as string); + if (show(ids.map(keys)) !== show(want.map(keys))) { + throw new PluginDriverConformanceFailure(id, `${how} visited the rows out of the order ${show(orderBy)}: ${show(ids)}.`); + } + } +} + +async function floorOrderCase( + id: string, session: Session, orderBy: OrderBy, pageSize: number, +): Promise { + const all = session.fixture.rows.map((r) => r.id as string); + const sent = { object: session.object, orderBy, limit: ROW_LIMIT }; + const visited: Row[] = []; + let honoured = true; + for (let offset = 0; offset < all.length; offset += pageSize) { + const query = { object: session.object, orderBy, limit: pageSize, offset }; + const read = await flooredRead(id, session, sent, all, (driver) => driver.find(session.object, query as DriverQuery)); + if ('refused' in read) return { status: 'pass', pushdown: 'refused' }; + const driverRows = read.ids.map((rowId) => session.fixture.rows.find((r) => r.id === rowId)!); + if (!inOrder(driverRows, orderBy)) honoured = false; + visited.push(...read.value); + } + judgeWalk(id, session, visited, orderBy, `through the floor, a walk of ${pageSize}-row pages by ${show(orderBy)}`); + return { status: 'pass', pushdown: 'exact', order: honoured ? 'honoured' : 'not-honoured' }; +} + +async function writeCase( + id: string, session: Session, probe: PluginDriverConformanceWriteProbe, +): Promise { + const driver = createExternalDriverFloor(session.driver, { + datasource: DATASOURCE, + fieldsOf: () => session.fixture.fields, + logger: { warn: (message) => { session.warnings.push(message); } }, + }); + const method = (driver as unknown as Record)[probe.operation]; + if (typeof method !== 'function') { + throw new PluginDriverConformanceFailure(id, `the driver offers no ${probe.operation}(); the probe cannot run.`); + } + const call = `${probe.operation}('${session.object}', ${probe.args.map(show).join(', ')})`; + try { + await (method as (...args: unknown[]) => Promise)(session.object, ...probe.args); + } catch (error) { + if (probe.expect === 'refused' && error instanceof ExternalWriteRefusedError && error.code === EXTERNAL_WRITE_REFUSED_CODE) { + return { status: 'pass' }; + } + const cause = (error as { cause?: unknown }).cause; + if (probe.expect === 'refused') { + throw new PluginDriverConformanceFailure(id, + `${call} was refused, but the driver threw ${describeThrow(cause ?? error)}, so the caller read ` + + `${(error as { code?: unknown }).code as string} instead of 422 EXTERNAL_WRITE_REFUSED with the remote's reason. ` + + `A write the remote refuses for the request's own sake declares one of ` + + `${sorted([...EXTERNAL_WRITE_REFUSED_STATUSES].map(String)).join(', ')} as the error's status.`); + } + throw new PluginDriverConformanceFailure(id, `${call} should go through, but threw ${describeThrow(cause ?? error)}.`); + } + if (probe.expect === 'refused') { + throw new PluginDriverConformanceFailure(id, `${call} should be refused by the remote, but it resolved.`); + } + return { status: 'pass' }; +} + +async function outageCase( + id: string, session: Session, verb: 'find' | 'findOne' | 'count', +): Promise { + const { driver, answers } = floored(session); + const query = { object: session.object, where: { status: 'active' } } as DriverQuery; + let answer: unknown; + try { + answer = await (driver[verb] as (o: string, q: DriverQuery) => Promise)(session.object, query); + } catch (error) { + if (error instanceof DatasourceUnavailableError) return { status: 'pass' }; + const own = answers[0]; + throw new PluginDriverConformanceFailure(id, + `while the remote is down, ${verb} reached the caller as ${(error as { code?: unknown }).code as string} ` + + `because the driver threw ${describeThrow(own && 'error' in own ? own.error : error)}. A failed or unreachable ` + + 'remote throws with no status or a 5xx, so the caller reads 503 ERR_DATASOURCE_UNAVAILABLE; a 400, 404 or 422 ' + + 'tells them their query is wrong.'); + } + throw new PluginDriverConformanceFailure(id, + `while the remote is down, ${verb} answered ${show(answer)}. Throw when the remote fails: an empty answer reads ` + + 'exactly like "no rows match".'); +} + +async function introspectCase(id: string, session: Session): Promise { + if (typeof session.driver.introspectSchema !== 'function') { + return { status: 'not-offered', note: 'the driver offers no introspectSchema()' }; + } + const schema = await session.driver.introspectSchema(); + const fail = (what: string): never => { + throw new PluginDriverConformanceFailure(id, `introspectSchema() answered ${what} (IntrospectedSchema, @objectstack/spec/contracts).`); + }; + if (schema === null || typeof schema !== 'object') fail(`${show(schema)}, not an object`); + if (typeof schema.dialect !== 'string' || schema.dialect === '') fail('no dialect string'); + if (typeof schema.introspectedAt !== 'string' || Number.isNaN(Date.parse(schema.introspectedAt))) { + fail(`introspectedAt ${show(schema.introspectedAt)}, not an ISO 8601 time`); + } + if (schema.tables === null || typeof schema.tables !== 'object' || Array.isArray(schema.tables)) { + fail('no tables record'); + } + for (const [key, table] of Object.entries(schema.tables)) { + if (table === null || typeof table !== 'object') fail(`table '${key}' as ${show(table)}`); + if (typeof table.name !== 'string' || table.name === '') fail(`table '${key}' with no name`); + if (!Array.isArray(table.columns)) fail(`table '${key}' with no columns array`); + for (const column of table.columns as unknown[]) { + const col = (column ?? {}) as Record; + const label = `column ${show(col.name)} of table '${key}'`; + if (typeof col.name !== 'string' || col.name === '') fail(`a ${label} with no name`); + if (typeof col.type !== 'string') fail(`${label} with no type string`); + if (typeof col.nullable !== 'boolean') fail(`${label} with no nullable boolean`); + if (typeof col.primaryKey !== 'boolean') { + fail(`${label} with no primaryKey boolean${'isPrimary' in col ? ' (it spells it isPrimary)' : ''}`); + } + } + if (table.indexes !== undefined) { + if (!Array.isArray(table.indexes)) fail(`table '${key}' with indexes that are not an array (omit the key when not read)`); + for (const index of table.indexes as unknown[]) { + const idx = (index ?? {}) as Record; + if (typeof idx.name !== 'string' || !Array.isArray(idx.columns) || typeof idx.unique !== 'boolean') { + fail(`table '${key}' with the index ${show(index)}, not { name, columns, unique }`); + } + } + } + } + return { status: 'pass' }; +} + +// ── direct ────────────────────────────────────────────────────────────────── + +async function directRead( + id: string, session: Session, verb: string, query: unknown, read: () => Promise, +): Promise<{ value: T } | { refused: true }> { + try { + return { value: await read() }; + } catch (error) { + const status = declaredStatus(error); + if (status !== undefined && EXTERNAL_READ_REFUSED_STATUSES.has(status)) return { refused: true }; + throw new PluginDriverConformanceFailure(id, + `${verb}('${session.object}', ${show(query)}) threw ${describeThrow(error)}; answer it, or refuse it with a status of 400.`); + } +} + +async function directFilterCase(id: string, session: Session, c: FilterCase): Promise { + const query = { object: session.object, where: c.where }; + const read = await directRead(id, session, 'find', query, () => session.driver.find(session.object, query as DriverQuery)); + if ('refused' in read) return { status: 'pass', pushdown: 'refused' }; + const ids = judgeRows(id, session, query, read.value, c.expected); + if (ids.length !== c.expected.length) { + const extra = ids.filter((rowId) => !c.expected.includes(rowId)); + throw new PluginDriverConformanceFailure(id, + `find('${session.object}', ${show(query)}) answered rows that do not match: ${show(sorted(extra))}. ` + + 'A driver trusted to skip the floor applies the whole where.'); + } + return { status: 'pass', pushdown: 'exact' }; +} + +async function directCountCase( + id: string, session: Session, where: FilterCondition, expected: number, +): Promise { + const query = { object: session.object, where }; + const read = await directRead(id, session, 'count', query, () => session.driver.count(session.object, query as DriverQuery)); + if ('refused' in read) return { status: 'pass', pushdown: 'refused' }; + if (read.value !== expected) { + throw new PluginDriverConformanceFailure(id, + `count('${session.object}', ${show(query)}) answered ${show(read.value)}; the conformance table expects ${expected}.`); + } + return { status: 'pass', pushdown: 'exact' }; +} + +async function directFindOneCase( + id: string, session: Session, where: FilterCondition, expected: readonly string[], +): Promise { + const query = { object: session.object, where, limit: 1 }; + const read = await directRead(id, session, 'findOne', query, () => session.driver.findOne(session.object, query as DriverQuery)); + if ('refused' in read) return { status: 'pass', pushdown: 'refused' }; + const row = read.value as Row | null | undefined; + if (expected.length === 0) { + if (row !== null && row !== undefined) { + throw new PluginDriverConformanceFailure(id, + `findOne('${session.object}', ${show(query)}) answered the row ${show(row.id)}, but no row matches; answer null.`); + } + return { status: 'pass', pushdown: 'exact' }; + } + if (row === null || row === undefined || !expected.includes(row.id as string)) { + throw new PluginDriverConformanceFailure(id, + `findOne('${session.object}', ${show(query)}) answered ${show(row?.id ?? row)}; one of ${show(expected)} matches.`); + } + return { status: 'pass', pushdown: 'exact' }; +} + +async function directPagingCase( + id: string, session: Session, orderBy: OrderBy | undefined, pageSize: number, +): Promise { + const visited: Row[] = []; + for (let offset = 0; offset < session.fixture.rows.length; offset += pageSize) { + const query = { object: session.object, ...(orderBy ? { orderBy } : {}), limit: pageSize, offset }; + const read = await directRead(id, session, 'find', query, () => session.driver.find(session.object, query as DriverQuery)); + if ('refused' in read) return { status: 'pass', pushdown: 'refused' }; + judgeRows(id, session, query, read.value, []); + const page = read.value as Row[]; + if (page.length > pageSize) { + throw new PluginDriverConformanceFailure(id, + `find('${session.object}', ${show(query)}) answered ${page.length} rows; limit is the most rows a page holds.`); + } + visited.push(...page); + } + judgeWalk(id, session, visited, orderBy, + `a walk of ${pageSize}-row pages${orderBy ? ` by ${show(orderBy)}` : ' with no orderBy'}`); + return { status: 'pass', pushdown: 'exact', ...(orderBy ? { order: 'honoured' as const } : {}) }; +} + +async function directZeroLimitCase( + id: string, session: Session, shape: { limit?: number; offset?: number }, expected: number, +): Promise { + const query = { object: session.object, ...shape }; + const read = await directRead(id, session, 'find', query, () => session.driver.find(session.object, query as DriverQuery)); + if ('refused' in read) return { status: 'pass', pushdown: 'refused' }; + judgeRows(id, session, query, read.value, []); + const length = (read.value as Row[]).length; + if (length !== expected) { + throw new PluginDriverConformanceFailure(id, + `find('${session.object}', ${show(query)}) answered ${length} rows; it must answer ${expected}. ` + + '`limit` is read by presence: `limit: 0` asks for no rows.'); + } + return { status: 'pass', pushdown: 'exact' }; +} From 515aa1ba0e72024efe0e0e6fca2b4ff1b9b78185 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:10:57 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(objectql):=20plugin=20driver=20conform?= =?UTF-8?q?ance=20kit=20=E2=80=94=20tests,=20exports,=20changeset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2g15Dr6sEsgv4sGBkq --- .../27744-plugin-driver-conformance-kit.md | 17 + packages/objectql/src/index.ts | 26 ++ .../src/plugin-driver-conformance.test.ts | 396 ++++++++++++++++++ .../objectql/src/plugin-driver-conformance.ts | 21 +- 4 files changed, 449 insertions(+), 11 deletions(-) create mode 100644 .changeset/27744-plugin-driver-conformance-kit.md create mode 100644 packages/objectql/src/plugin-driver-conformance.test.ts diff --git a/.changeset/27744-plugin-driver-conformance-kit.md b/.changeset/27744-plugin-driver-conformance-kit.md new file mode 100644 index 00000000000..2cb598ba869 --- /dev/null +++ b/.changeset/27744-plugin-driver-conformance-kit.md @@ -0,0 +1,17 @@ +--- +'@objectstack/objectql': patch +--- + +feat(objectql): a conformance kit a plugin driver outside this repository runs in its own CI, so a change to the contract the engine holds plugin drivers to is caught there + +Clause-②: yes +Metadata-protocol: none + +A plugin driver (a `driver.NAME` service a server plugin publishes, such as a vendor's closed-source Salesforce driver) serves an external datasource behind the engine's correctness floor. The floor's contract was pinned only by this package's own tests, which a driver living in another repository never runs. `@objectstack/objectql` now exports the kit that driver runs instead. + +- **What it asserts.** The floor's contract, against the spec's driver conformance tables (`FILTER_LOGIC_CASES`, `FILTER_TEXT_CASES`, `TEMPORAL_CASES`, `PAGINATION_CASES`): for every filter, the driver's own answer to the query the floor sends holds every matching row and only stored rows, or refuses with a status of 400, 404 or 422, and the floored answer equals the table's. Also: each row's stored values come back unchanged (a `null` stays `null`, a number stays a number), `count` through the floor, the floor's paged walk by every table `orderBy`, write refusals reaching the caller as `422 EXTERNAL_WRITE_REFUSED` (from write probes the caller names), an outage reaching the caller as `503 ERR_DATASOURCE_UNAVAILABLE` and never as rows (from an unavailable remote the caller builds), and the `introspectSchema()` shape when the driver offers it. +- **Pushdown is measured.** `DriverCapabilities` carries no pushdown claim, so each read case reports how the driver answered: `exact`, `superset` or `refused`. +- **A second tier, `direct`.** Opt-in: the driver answers the whole query itself (an exact filter, `count`, `findOne`, a deterministic paged walk, `limit` by presence). The floor does not need it; it is the oracle a later verified-pushdown credential, letting a tested driver skip the floor, will be built on. +- **How to run it.** Hand `setup(fixture)` a driver over a remote holding exactly the fixture's rows (a fake of the remote's API in front of the driver's real translation code, or a sandbox seeded from `pluginDriverConformanceFixtures()`). Then `definePluginDriverConformanceSuite({ describe, it, afterAll }, options)` registers one test per case under vitest or jest, or `runPluginDriverConformance(options)` answers a report with no test framework. Case ids (`tier/group/fixture/case name`) are stable. + +New exports: `createPluginDriverConformance`, `runPluginDriverConformance`, `definePluginDriverConformanceSuite`, `pluginDriverConformanceFixtures`, `PLUGIN_DRIVER_CONFORMANCE_FIXTURE_NAMES`, `PluginDriverConformanceFailure`, and their types. Nothing existing changes. diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 2ab9da9c453..6afbc883353 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -317,6 +317,32 @@ export { } from './external-driver-errors.js'; export type { ExternalWriteOperation } from './external-driver-errors.js'; export { needsExternalDriverFloor, unwrapExternalDriverFloor } from './external-driver-floor.js'; +// [#27744] The conformance kit a plugin driver outside this repository runs in +// its own CI: the floor's contract against the spec's driver conformance tables. +export { + createPluginDriverConformance, + runPluginDriverConformance, + definePluginDriverConformanceSuite, + pluginDriverConformanceFixtures, + PLUGIN_DRIVER_CONFORMANCE_FIXTURE_NAMES, + PluginDriverConformanceFailure, +} from './plugin-driver-conformance.js'; +export type { + PluginDriverConformance, + PluginDriverConformanceCase, + PluginDriverConformanceField, + PluginDriverConformanceFixture, + PluginDriverConformanceFixtureName, + PluginDriverConformanceGroup, + PluginDriverConformanceOptions, + PluginDriverConformanceOutcome, + PluginDriverConformanceReport, + PluginDriverConformanceTarget, + PluginDriverConformanceTestApi, + PluginDriverConformanceTier, + PluginDriverConformanceWriteProbe, + PluginDriverPushdown, +} from './plugin-driver-conformance.js'; export type { InsertManyRowOutcome } from './engine.js'; // [#5696] Thrown by `transaction(cb, base, { require: true })` when the // datasource cannot give a real transaction. Exported so a caller that fails diff --git a/packages/objectql/src/plugin-driver-conformance.test.ts b/packages/objectql/src/plugin-driver-conformance.test.ts new file mode 100644 index 00000000000..485d9765abb --- /dev/null +++ b/packages/objectql/src/plugin-driver-conformance.test.ts @@ -0,0 +1,396 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27744] The plugin-driver conformance kit (`plugin-driver-conformance.ts`), + * held to both halves of being an instrument: it passes the drivers that keep + * the floor's contract, and it FAILS each one that breaks a rule of it, on the + * case that names the rule. + * + * - The exact driver answers the whole query itself: both tiers pass. + * - The every-row driver ignores the query but its `limit` (the guide's first + * step): the floor tier passes, and the direct tier fails on the filter. + * - Each mutant breaks one rule; its failing case ids are pinned. + * + * The case batteries are pinned by count against the spec tables, so a kit + * that silently stopped generating a battery fails here, not in a vendor's CI. + */ + +import { afterAll, describe, expect, it } from 'vitest'; +import type { DriverQuery, IDataDriver } from '@objectstack/spec/contracts'; +import { + FILTER_LOGIC_CASES, + FILTER_TEXT_CASES, + PAGINATION_CASES, + PAGINATION_UNORDERED_CASES, + PAGINATION_ZERO_LIMIT_CASES, + TEMPORAL_CASES, +} from '@objectstack/spec/data'; +import { applyDriverQueryInMemory } from './driver-query-in-memory.js'; +import { + PLUGIN_DRIVER_CONFORMANCE_FIXTURE_NAMES, + createPluginDriverConformance, + definePluginDriverConformanceSuite, + pluginDriverConformanceFixtures, + runPluginDriverConformance, + type PluginDriverConformanceFixture, + type PluginDriverConformanceOptions, + type PluginDriverConformanceReport, + type PluginDriverConformanceWriteProbe, +} from './plugin-driver-conformance.js'; + +type Row = Record; + +interface Behaviour { + /** Apply `where` / `orderBy` / `offset` itself (exact), or answer every row up to `limit`. */ + pushdown: 'exact' | 'none'; + /** Rewrites the stored rows (a remote that stores a value differently). */ + store?: (row: Row) => Row; + /** Rewrites the `where` before it is applied (a translation slip). */ + translate?: (where: unknown) => unknown; + /** Throws instead of answering, for these `where`s. */ + refuse?: (where: unknown) => Error | undefined; + /** The rows `find` answers are rewritten last (duplicates, invented rows). */ + answer?: (rows: Row[]) => Row[]; + count?: 'exact' | 'every-row'; + /** A paged walk with no stable order: every other page (`offset` present) is cut from the reversed rows. */ + unstablePaging?: boolean; + /** The status a refused write declares. */ + writeRefusalStatus?: number; + introspectSchema?: IDataDriver['introspectSchema'] | null; +} + +/** The status a refusal declares, as `refuse()` in the plugin-driver guide does. */ +const refusal = (message: string, status?: number) => + Object.assign(new Error(message), status === undefined ? {} : { status }); + +/** + * A plugin driver over a fake remote holding `fixture.rows`. `find` applies + * the filter itself when `pushdown` is `exact`; every variant applies `limit` + * by presence, after the filter. + */ +function fakeDriver(fixture: PluginDriverConformanceFixture, behaviour: Behaviour): IDataDriver { + const stored = fixture.rows.map((row) => (behaviour.store ? behaviour.store({ ...row }) : { ...row })); + let pageCalls = 0; + const read = (query: DriverQuery = {}): Row[] => { + const where = behaviour.translate ? behaviour.translate(query.where) : query.where; + const thrown = behaviour.refuse?.(query.where); + if (thrown) throw thrown; + let rows: Row[]; + if (behaviour.pushdown === 'exact') { + rows = applyDriverQueryInMemory(stored, { + ...(where !== undefined ? { where: where as DriverQuery['where'] } : {}), + ...(query.orderBy ? { orderBy: query.orderBy as never } : {}), + ...(typeof query.offset === 'number' ? { offset: query.offset } : {}), + ...(typeof query.limit === 'number' ? { limit: query.limit } : {}), + }); + if (behaviour.unstablePaging && typeof query.limit === 'number' && typeof query.offset === 'number' && (pageCalls++ % 2 === 1)) { + const all = applyDriverQueryInMemory(stored, query.orderBy ? { orderBy: query.orderBy as never } : {}).reverse(); + rows = all.slice(query.offset ?? 0, (query.offset ?? 0) + query.limit); + } + } else { + rows = stored.map((row) => ({ ...row })); + if (typeof query.limit === 'number') rows = rows.slice(0, query.limit); + } + return behaviour.answer ? behaviour.answer(rows) : rows; + }; + const writable = (data: Row) => { + if (data.a === 'refuse_me') throw refusal('the remote validation rejects a = refuse_me', behaviour.writeRefusalStatus ?? 422); + }; + const driver: Record = { + name: 'conformance_fake', + version: '0.0.1', + supports: { transactionsUnsupported: true }, + async connect() {}, + async disconnect() {}, + async checkHealth() { return true; }, + async find(_object: string, query?: DriverQuery) { return read(query); }, + async findOne(_object: string, query?: DriverQuery) { return read({ ...query, limit: 1 })[0] ?? null; }, + async count(_object: string, query?: DriverQuery) { + return behaviour.count === 'every-row' ? stored.length : read({ where: query?.where }).length; + }, + async update(_object: string, id: string, data: Row) { + writable(data); + return { id, ...data }; + }, + async create(_object: string, data: Row) { + writable(data); + return { id: 'new_1', ...data }; + }, + async syncSchema() {}, + }; + if (behaviour.introspectSchema !== null) { + driver.introspectSchema = behaviour.introspectSchema ?? (async () => ({ + dialect: 'fake_rest', + introspectedAt: '2026-10-29T12:00:00.000Z', + tables: { + [fixture.object]: { + name: fixture.object, + columns: [ + { name: 'id', type: 'string', nullable: false, primaryKey: true }, + ...Object.entries(fixture.fields).map(([name, f]) => ({ name, type: f.type, nullable: true, primaryKey: false })), + ], + }, + }, + })); + } + return driver as unknown as IDataDriver; +} + +/** A driver over a remote that is down, answering each read with `outage()`. */ +function downDriver(fixture: PluginDriverConformanceFixture, outage: () => Promise): IDataDriver { + return { + ...(fakeDriver(fixture, { pushdown: 'exact' }) as unknown as Row), + find: outage, + findOne: outage, + count: outage, + } as unknown as IDataDriver; +} + +const PROBES: PluginDriverConformanceWriteProbe[] = [ + { name: 'an update the remote validation rejects', operation: 'update', args: ['1', { a: 'refuse_me' }], expect: 'refused' }, + { name: 'an update the remote accepts', operation: 'update', args: ['1', { a: 'ok' }], expect: 'accepted' }, +]; + +const options = (behaviour: Behaviour, extra: Partial = {}): PluginDriverConformanceOptions => ({ + setup: (fixture) => ({ driver: fakeDriver(fixture, behaviour) }), + setupUnavailable: (fixture) => ({ + driver: downDriver(fixture, async () => { throw new Error('connect ECONNREFUSED'); }), + }), + writeProbes: PROBES, + ...extra, +}); + +const failed = (report: PluginDriverConformanceReport) => report.results.filter((r) => r.status === 'fail').map((r) => r.id); +const messageOf = (report: PluginDriverConformanceReport, id: string) => report.results.find((r) => r.id === id)?.message ?? ''; + +const TEXT_ROWS_CASES = FILTER_TEXT_CASES.filter((c) => c.expectRejection !== true).length; +const TEMPORAL_FILTERS = TEMPORAL_CASES.length + TEMPORAL_CASES.filter((c) => c.tokenFilter).length; +const FILTERS = FILTER_LOGIC_CASES.length + TEXT_ROWS_CASES + TEMPORAL_FILTERS; + +describe('[#27744] the case batteries', () => { + it('generate one case per table row, per tier, and nothing calls setup until a case runs', () => { + let setups = 0; + const { cases } = createPluginDriverConformance({ + setup: (fixture) => { setups += 1; return { driver: fakeDriver(fixture, { pushdown: 'exact' }) }; }, + tiers: ['floor', 'direct'], + }); + const count = (tier: string, group: string) => cases.filter((c) => c.tier === tier && c.group === group).length; + expect(setups).toBe(0); + expect(count('floor', 'shape')).toBe(PLUGIN_DRIVER_CONFORMANCE_FIXTURE_NAMES.length); + expect(count('floor', 'filter')).toBe(FILTERS); + expect(count('floor', 'count')).toBe(FILTER_LOGIC_CASES.length); + expect(count('floor', 'order')).toBe(PAGINATION_CASES.length); + expect(count('floor', 'write')).toBe(1); // no probes given ⇒ one not-offered case, never silence + expect(count('floor', 'outage')).toBe(3); + expect(count('floor', 'introspect')).toBe(1); + expect(count('direct', 'filter')).toBe(FILTERS); + expect(count('direct', 'count')).toBe(FILTER_LOGIC_CASES.length); + expect(count('direct', 'find-one')).toBe(FILTER_LOGIC_CASES.length); + expect(count('direct', 'paging')).toBe( + PAGINATION_CASES.length + PAGINATION_UNORDERED_CASES.length + PAGINATION_ZERO_LIMIT_CASES.length); + // Ids are unique: the P3 credential keys on them. + expect(new Set(cases.map((c) => c.id)).size).toBe(cases.length); + // Plain floor tier unless asked. + expect(createPluginDriverConformance({ setup: () => { throw new Error('unused'); } }).cases.every((c) => c.tier === 'floor')).toBe(true); + }); + + it('refuses an unknown tier by name', () => { + expect(() => createPluginDriverConformance({ setup: () => { throw new Error('unused'); }, tiers: ['fast' as never] })) + .toThrow(/unknown tier 'fast'/); + }); + + it('hands out fixtures a remote can be seeded with: fresh copies, string ids, the null family intact', () => { + const first = pluginDriverConformanceFixtures(); + expect(first.map((f) => f.name)).toEqual([...PLUGIN_DRIVER_CONFORMANCE_FIXTURE_NAMES]); + for (const f of first) expect(f.rows.every((row) => typeof row.id === 'string')).toBe(true); + expect(first[0]!.rows.some((row) => row.d === null)).toBe(true); + first[0]!.rows[0]!.a = 'mutated'; + expect(pluginDriverConformanceFixtures()[0]!.rows[0]!.a).not.toBe('mutated'); + }); +}); + +describe('[#27744] drivers that keep the contract pass', () => { + it('the exact driver passes both tiers, every read answered exactly', async () => { + const report = await runPluginDriverConformance(options({ pushdown: 'exact' }, { tiers: ['floor', 'direct'] })); + expect(failed(report)).toEqual([]); + expect(report.passed).toBe(true); + expect(report.driver).toBe('conformance_fake'); + const floorFilters = report.results.filter((r) => r.tier === 'floor' && r.group === 'filter'); + expect(floorFilters.every((r) => r.pushdown === 'exact')).toBe(true); + expect(report.results.filter((r) => r.group === 'order').every((r) => r.order === 'honoured')).toBe(true); + expect(report.results.some((r) => r.status === 'not-offered')).toBe(false); + }); + + it('the every-row driver passes the floor tier, measured as a superset, and fails the direct tier on the filter', async () => { + const floor = await runPluginDriverConformance(options({ pushdown: 'none', count: 'every-row' })); + expect(failed(floor)).toEqual([]); + const narrowing = floor.results.filter((r) => r.group === 'filter' && r.pushdown === 'superset'); + expect(narrowing.length).toBeGreaterThan(0); + expect(floor.results.filter((r) => r.group === 'order').every((r) => r.order === 'not-honoured')).toBe(true); + + const direct = await runPluginDriverConformance(options({ pushdown: 'none', count: 'every-row' }, { tiers: ['direct'] })); + const directFailures = failed(direct); + expect(directFailures).toContain('direct/filter/filter_logic/single-key $or branches stay a plain OR'); + expect(directFailures.some((id) => id.startsWith('direct/count/'))).toBe(true); + }); + + it('a driver that refuses what it cannot translate (status 400) passes the floor tier, measured as refused', async () => { + const hasOr = (where: unknown) => JSON.stringify(where ?? null).includes('"$or"'); + const report = await runPluginDriverConformance(options({ + pushdown: 'exact', + refuse: (where) => (hasOr(where) ? refusal('the remote cannot run $or', 400) : undefined), + })); + expect(failed(report)).toEqual([]); + expect(report.results.some((r) => r.group === 'filter' && r.pushdown === 'refused')).toBe(true); + }); + + it('a remote that answers an instant as a native Date passes: the floor compares instants, not spellings', async () => { + const report = await runPluginDriverConformance(options({ + pushdown: 'none', + store: (row) => (typeof row.at === 'string' ? { ...row, at: new Date(row.at) } : row), + })); + expect(failed(report)).toEqual([]); + expect(report.results.filter((r) => r.id.startsWith('floor/filter/temporal/')).length).toBe(TEMPORAL_FILTERS); + }); + + it('missing optional pieces read as not-offered, never as a pass that measured something', async () => { + const report = await runPluginDriverConformance({ + setup: (fixture) => ({ driver: fakeDriver(fixture, { pushdown: 'exact', introspectSchema: null }) }), + }); + expect(report.passed).toBe(true); + const notOffered = report.results.filter((r) => r.status === 'not-offered').map((r) => r.group); + expect(notOffered.sort()).toEqual(['introspect', 'outage', 'outage', 'outage', 'write']); + }); + + it('each setup is torn down once, after the run', async () => { + const torn: string[] = []; + await runPluginDriverConformance(options({ pushdown: 'exact' }, { + setup: (fixture) => ({ driver: fakeDriver(fixture, { pushdown: 'exact' }), teardown: () => { torn.push(fixture.name); } }), + })); + // One shared setup per fixture, plus a fresh one per write probe. + expect(torn.sort()).toEqual([...PLUGIN_DRIVER_CONFORMANCE_FIXTURE_NAMES, 'filter_logic', 'filter_logic'].sort()); + }); +}); + +describe('[#27744] each broken rule fails on the case that names it', () => { + it('dropping a branch of an $or (a narrower filter) loses rows', async () => { + const report = await runPluginDriverConformance(options({ + pushdown: 'exact', + translate: (where) => { + const w = where as Row | undefined; + if (!w || !Array.isArray(w.$or)) return where; + const { $or, ...rest } = w; + return { ...rest, ...(($or as Row[])[0] ?? {}) }; + }, + })); + const id = 'floor/filter/filter_logic/single-key $or branches stay a plain OR'; + expect(failed(report)).toContain(id); + expect(messageOf(report, id)).toMatch(/left out matching rows/); + expect(messageOf(report, id)).toMatch(/WIDER filter/); + }); + + it('a null stored as an empty string fails the shape case and the null family', async () => { + const report = await runPluginDriverConformance(options({ + pushdown: 'exact', + store: (row) => (row.d === null ? { ...row, d: '' } : row), + })); + expect(failed(report)).toContain('floor/shape/filter_logic/every row, as stored'); + expect(messageOf(report, 'floor/shape/filter_logic/every row, as stored')).toMatch(/d = ""/); + expect(failed(report).some((id) => id.startsWith('floor/filter/filter_logic/'))).toBe(true); + }); + + it('a number stored as text fails the shape case', async () => { + const report = await runPluginDriverConformance(options({ + pushdown: 'none', + store: (row) => (typeof row.score === 'number' ? { ...row, score: String(row.score) } : row), + })); + expect(failed(report)).toContain('floor/shape/filter_text/every row, as stored'); + }); + + it('a refusal with no status reads as an outage to the caller, and fails', async () => { + const report = await runPluginDriverConformance(options({ + pushdown: 'exact', + refuse: (where) => (JSON.stringify(where ?? null).includes('"$or"') ? new Error('unsupported: $or') : undefined), + })); + const id = 'floor/filter/filter_logic/single-key $or branches stay a plain OR'; + expect(failed(report)).toContain(id); + expect(messageOf(report, id)).toMatch(/with no status/); + expect(messageOf(report, id)).toMatch(/503 ERR_DATASOURCE_UNAVAILABLE/); + }); + + it('answering a row twice, or a row the remote does not hold, fails', async () => { + const twice = await runPluginDriverConformance(options({ pushdown: 'none', answer: (rows) => [...rows, ...rows.slice(0, 1)] })); + expect(failed(twice)).toContain('floor/shape/pagination/every row, as stored'); + expect(messageOf(twice, 'floor/shape/pagination/every row, as stored')).toMatch(/twice/); + const invented = await runPluginDriverConformance(options({ pushdown: 'none', answer: (rows) => [...rows, { id: 'ghost' }] })); + expect(messageOf(invented, 'floor/shape/filter_logic/every row, as stored')).toMatch(/'ghost', which the remote does not hold/); + }); + + it('an outage answered as no rows, or as a 400, fails all three reads', async () => { + const empty = await runPluginDriverConformance(options({ pushdown: 'exact' }, { + setupUnavailable: (fixture) => ({ driver: downDriver(fixture, async () => []) }), + })); + expect(failed(empty).filter((id) => id.startsWith('floor/outage/'))).toHaveLength(3); // a count of 0 too + expect(messageOf(empty, 'floor/outage/filter_logic/find while the remote is down')).toMatch(/no rows match/); + expect(messageOf(empty, 'floor/outage/filter_logic/count while the remote is down')).toMatch(/answered 0/); + const asRefusal = await runPluginDriverConformance(options({ pushdown: 'exact' }, { + setupUnavailable: (fixture) => ({ driver: downDriver(fixture, async () => { throw refusal('upstream gone', 404); }) }), + })); + expect(failed(asRefusal).filter((id) => id.startsWith('floor/outage/'))).toHaveLength(3); + expect(messageOf(asRefusal, 'floor/outage/filter_logic/count while the remote is down')).toMatch(/query is wrong/); + }); + + it('a write refusal declaring a 5xx reaches the caller as an outage, and fails', async () => { + const report = await runPluginDriverConformance(options({ pushdown: 'exact', writeRefusalStatus: 500 })); + const id = 'floor/write/filter_logic/an update the remote validation rejects'; + expect(failed(report)).toEqual([id]); + expect(messageOf(report, id)).toMatch(/422 EXTERNAL_WRITE_REFUSED/); + }); + + it('a probe whose outcome differs from the remote fails both ways', async () => { + const report = await runPluginDriverConformance(options({ pushdown: 'exact' }, { + writeProbes: [ + { name: 'expected refused, accepted', operation: 'update', args: ['1', { a: 'ok' }], expect: 'refused' }, + { name: 'expected accepted, refused', operation: 'create', args: [{ a: 'refuse_me' }], expect: 'accepted' }, + ], + })); + expect(failed(report).sort()).toEqual([ + 'floor/write/filter_logic/expected accepted, refused', + 'floor/write/filter_logic/expected refused, accepted', + ]); + }); + + it('introspectSchema spelling the primary-key flag isPrimary fails with that name', async () => { + const report = await runPluginDriverConformance(options({ + pushdown: 'exact', + introspectSchema: async () => ({ + dialect: 'fake_rest', + introspectedAt: '2026-10-29T12:00:00.000Z', + tables: { t: { name: 't', columns: [{ name: 'id', type: 'string', nullable: false, isPrimary: true }] } }, + }) as never, + })); + const id = 'floor/introspect/filter_logic/introspectSchema answers the spec shape'; + expect(failed(report)).toEqual([id]); + expect(messageOf(report, id)).toMatch(/isPrimary/); + }); + + it('direct: a count that ignores the filter, and a paged walk with no tie-breaker, fail', async () => { + const counting = await runPluginDriverConformance(options({ pushdown: 'exact', count: 'every-row' }, { tiers: ['direct'] })); + expect(failed(counting).every((id) => id.startsWith('direct/count/'))).toBe(true); + expect(failed(counting).length).toBeGreaterThan(0); + const paging = await runPluginDriverConformance(options({ pushdown: 'exact', unstablePaging: true }, { tiers: ['direct'] })); + expect(failed(paging).some((id) => id.startsWith('direct/paging/pagination/'))).toBe(true); + expect(failed(paging).every((id) => id.startsWith('direct/paging/'))).toBe(true); + }); +}); + +// The suite form, as a vendor's CI registers it: one test per case. +const torn: string[] = []; +definePluginDriverConformanceSuite( + { describe, it, afterAll }, + { + ...options({ pushdown: 'exact' }, { tiers: ['floor', 'direct'] }), + setup: (fixture) => ({ driver: fakeDriver(fixture, { pushdown: 'exact' }), teardown: () => { torn.push(fixture.name); } }), + }, + '[#27744] definePluginDriverConformanceSuite — the exact driver', +); diff --git a/packages/objectql/src/plugin-driver-conformance.ts b/packages/objectql/src/plugin-driver-conformance.ts index 866a2d6aebf..eb1c7ad379f 100644 --- a/packages/objectql/src/plugin-driver-conformance.ts +++ b/packages/objectql/src/plugin-driver-conformance.ts @@ -77,7 +77,6 @@ import { } from '@objectstack/spec/data'; import { resolveFilterTokens } from '@objectstack/core'; import { applyDriverQueryInMemory } from './driver-query-in-memory.js'; -import { declaredFieldClasses } from './having-filter.js'; import { DatasourceUnavailableError } from './driver-connect-errors.js'; import { createExternalDriverFloor } from './external-driver-floor.js'; import { @@ -633,7 +632,7 @@ async function shapeCase(id: string, session: Session): Promise( async function floorFilterCase(id: string, session: Session, c: FilterCase): Promise { const query = { object: session.object, where: c.where }; const read = await flooredRead(id, session, { ...query, limit: ROW_LIMIT }, c.expected, - (driver) => driver.find(session.object, query as DriverQuery)); + (driver) => driver.find(session.object, query as unknown as DriverQuery)); if ('refused' in read) return { status: 'pass', pushdown: 'refused' }; const got = sorted(read.value.map((r) => r.id as string)); if (show(got) !== show(sorted(c.expected))) { @@ -712,7 +711,7 @@ async function floorCountCase( ): Promise { const query = { object: session.object, where }; const read = await flooredRead(id, session, { ...query, limit: ROW_LIMIT }, expected, - (driver) => driver.count(session.object, query as DriverQuery)); + (driver) => driver.count(session.object, query as unknown as DriverQuery)); if ('refused' in read) return { status: 'pass', pushdown: 'refused' }; if (read.value !== expected.length) { throw new PluginDriverConformanceFailure(id, @@ -775,7 +774,7 @@ async function floorOrderCase( let honoured = true; for (let offset = 0; offset < all.length; offset += pageSize) { const query = { object: session.object, orderBy, limit: pageSize, offset }; - const read = await flooredRead(id, session, sent, all, (driver) => driver.find(session.object, query as DriverQuery)); + const read = await flooredRead(id, session, sent, all, (driver) => driver.find(session.object, query as unknown as DriverQuery)); if ('refused' in read) return { status: 'pass', pushdown: 'refused' }; const driverRows = read.ids.map((rowId) => session.fixture.rows.find((r) => r.id === rowId)!); if (!inOrder(driverRows, orderBy)) honoured = false; @@ -824,7 +823,7 @@ async function outageCase( id: string, session: Session, verb: 'find' | 'findOne' | 'count', ): Promise { const { driver, answers } = floored(session); - const query = { object: session.object, where: { status: 'active' } } as DriverQuery; + const query = { object: session.object, where: { status: 'active' } } as unknown as DriverQuery; let answer: unknown; try { answer = await (driver[verb] as (o: string, q: DriverQuery) => Promise)(session.object, query); @@ -902,7 +901,7 @@ async function directRead( async function directFilterCase(id: string, session: Session, c: FilterCase): Promise { const query = { object: session.object, where: c.where }; - const read = await directRead(id, session, 'find', query, () => session.driver.find(session.object, query as DriverQuery)); + const read = await directRead(id, session, 'find', query, () => session.driver.find(session.object, query as unknown as DriverQuery)); if ('refused' in read) return { status: 'pass', pushdown: 'refused' }; const ids = judgeRows(id, session, query, read.value, c.expected); if (ids.length !== c.expected.length) { @@ -918,7 +917,7 @@ async function directCountCase( id: string, session: Session, where: FilterCondition, expected: number, ): Promise { const query = { object: session.object, where }; - const read = await directRead(id, session, 'count', query, () => session.driver.count(session.object, query as DriverQuery)); + const read = await directRead(id, session, 'count', query, () => session.driver.count(session.object, query as unknown as DriverQuery)); if ('refused' in read) return { status: 'pass', pushdown: 'refused' }; if (read.value !== expected) { throw new PluginDriverConformanceFailure(id, @@ -931,7 +930,7 @@ async function directFindOneCase( id: string, session: Session, where: FilterCondition, expected: readonly string[], ): Promise { const query = { object: session.object, where, limit: 1 }; - const read = await directRead(id, session, 'findOne', query, () => session.driver.findOne(session.object, query as DriverQuery)); + const read = await directRead(id, session, 'findOne', query, () => session.driver.findOne(session.object, query as unknown as DriverQuery)); if ('refused' in read) return { status: 'pass', pushdown: 'refused' }; const row = read.value as Row | null | undefined; if (expected.length === 0) { @@ -954,7 +953,7 @@ async function directPagingCase( const visited: Row[] = []; for (let offset = 0; offset < session.fixture.rows.length; offset += pageSize) { const query = { object: session.object, ...(orderBy ? { orderBy } : {}), limit: pageSize, offset }; - const read = await directRead(id, session, 'find', query, () => session.driver.find(session.object, query as DriverQuery)); + const read = await directRead(id, session, 'find', query, () => session.driver.find(session.object, query as unknown as DriverQuery)); if ('refused' in read) return { status: 'pass', pushdown: 'refused' }; judgeRows(id, session, query, read.value, []); const page = read.value as Row[]; @@ -973,7 +972,7 @@ async function directZeroLimitCase( id: string, session: Session, shape: { limit?: number; offset?: number }, expected: number, ): Promise { const query = { object: session.object, ...shape }; - const read = await directRead(id, session, 'find', query, () => session.driver.find(session.object, query as DriverQuery)); + const read = await directRead(id, session, 'find', query, () => session.driver.find(session.object, query as unknown as DriverQuery)); if ('refused' in read) return { status: 'pass', pushdown: 'refused' }; judgeRows(id, session, query, read.value, []); const length = (read.value as Row[]).length; From eb7990be468b9b2c888d52098d7a07c6c766eb05 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:37:28 +0000 Subject: [PATCH 3/3] test(driver-odata): run the plugin-driver conformance kit over the in-process OData service Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2g15Dr6sEsgv4sGBkq --- .../src/odata-driver.conformance-kit.test.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 packages/drivers/driver-odata/src/odata-driver.conformance-kit.test.ts diff --git a/packages/drivers/driver-odata/src/odata-driver.conformance-kit.test.ts b/packages/drivers/driver-odata/src/odata-driver.conformance-kit.test.ts new file mode 100644 index 00000000000..62e46e3d4a4 --- /dev/null +++ b/packages/drivers/driver-odata/src/odata-driver.conformance-kit.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * driver-odata through the plugin-driver conformance kit + * (`definePluginDriverConformanceSuite`, `@objectstack/objectql`): the same kit + * an out-of-repo plugin driver runs in its own CI, run here by the first + * in-repo plugin driver, so a kit change that a real driver cannot pass fails + * in this repository first. + * + * Each fixture is served by the in-process OData v4 service + * (`__fixtures__/odata-service.ts`), which evaluates the `$filter` the driver + * writes, over both collations. The floor tier only: the driver pushes down a + * superset when a `where` does not translate whole, which the floor needs and + * the direct tier (a driver that skips the floor) refuses by design. + */ + +import { afterAll, describe, it } from 'vitest'; +import { + definePluginDriverConformanceSuite, + type PluginDriverConformanceFixture, + type PluginDriverConformanceTarget, +} from '@objectstack/objectql'; +import { parseODataDriverConfig } from './config.js'; +import { ODataDriver } from './odata-driver.js'; +import type { FetchLike } from './transport.js'; +import { CONFORMANCE_ROOT, odataService, type EdmType, type ServiceProperty } from './__fixtures__/odata-service.js'; + +const ENTITY_SET = 'Rows'; +const EDM: Record = { + text: 'Edm.String', + number: 'Edm.Double', + datetime: 'Edm.DateTimeOffset', + date: 'Edm.Date', +}; + +/** The driver over `fetch`, bound to the fixture's object as an external object on the service's one entity set. */ +function driverOver(fixture: PluginDriverConformanceFixture, fetch: FetchLike): PluginDriverConformanceTarget { + const driver = new ODataDriver(parseODataDriverConfig({ name: 'conformance', serviceRoot: CONFORMANCE_ROOT }), { fetch }); + driver.registerExternalObject({ name: fixture.object, external: { remoteName: ENTITY_SET } }); + return { driver }; +} + +for (const collation of ['ordinal', 'case-insensitive'] as const) { + definePluginDriverConformanceSuite( + { describe, it, afterAll }, + { + setup: (fixture) => { + const properties: ServiceProperty[] = [ + { name: 'id', type: 'Edm.String', nullable: false }, + ...Object.entries(fixture.fields).map(([name, f]) => ({ name, type: EDM[f.type] })), + ]; + const service = odataService({ entitySet: ENTITY_SET, properties, rows: fixture.rows, collation, serverPageSize: 5 }); + return driverOver(fixture, service.fetch); + }, + setupUnavailable: (fixture) => driverOver(fixture, async () => { throw new TypeError('fetch failed: connect ECONNREFUSED'); }), + writeProbes: [ + // The service answers 409 to a second entity under an existing key. + { name: 'a create under a key the service already holds', operation: 'create', args: [{ id: '1', a: 'x' }], expect: 'refused' }, + { name: 'an update by key the service accepts', operation: 'update', args: ['1', { a: 'changed' }], expect: 'accepted' }, + ], + }, + `driver-odata through the plugin-driver conformance kit, over a ${collation} service`, + ); +}