diff --git a/.changeset/comparand-type-door-7872.md b/.changeset/comparand-type-door-7872.md new file mode 100644 index 0000000000..5ecac3d2d1 --- /dev/null +++ b/.changeset/comparand-type-door-7872.md @@ -0,0 +1,18 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": minor +"@objectstack/driver-sql": patch +"@objectstack/driver-turso": patch +--- + +feat(spec): the filter comparand-type door (#7872) — the shared compile face now defines the accepted literal comparand-type set as the measured superset `string | number | bigint | boolean | null | Date` and refuses everything else loudly (`INVALID_FILTER` / 400), for every driver at once. + +Previously the five drivers answered an unsupported comparand type five ways (measured, #7956): the SQL family refused by policy, driver-memory crashed on `BigInt` (a raw mingo `TypeError`) and silently answered zero rows for five other types, and driver-mongodb let the BSON encoder silently edit the query — `{qty: undefined}` reached the wire as `{}`, i.e. MATCH EVERYTHING. + +What changes for callers: + +- `parseFilterAST` (`@objectstack/spec/data`) now judges everything it returns — the object-form passthrough included — and the ObjectQL engine runs the same walk on object-form filters at its lowering seam, covering every engine verb on both doors. New exports: `normalizeFilterComparandTypes`, `isAcceptedFilterComparand`, `ACCEPTED_FILTER_COMPARAND_TYPES`, `ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE`, `FILTER_COMPARAND_BIGINT_EXACT_LIMIT`, and the `FILTER_COMPARAND_TYPE_CASES` conformance table all five driver suites now run. +- A filter carrying `undefined`, a function, a `Symbol`, a `Map`/`Set`/class instance, or a plain object in a scalar operator slot is now refused with `code: 'INVALID_FILTER'`, `status: 400`, and guidance naming the accepted set — it previously crashed, answered a silent wrong row count, or matched everything, depending on the driver. +- A `bigint` comparand is accepted and narrowed copy-on-write to its exact JS number at the door (so it now works on driver-memory too, instead of crashing); a bigint beyond ±2^53 is refused loudly instead of silently losing precision. +- `FieldReference` comparands (`{ $field: … }`), nested-relation/deep-equality structure, arrays outside `$in`/`$nin`/`$between`, and unknown/retired operators are deliberately untouched — their recorded rules and refusals stand. +- driver-sql and driver-turso source their comparand allow-list membership and refusal wording from the door instead of keeping local copies; their envelopes and direct-caller behavior are unchanged. diff --git a/packages/drivers/driver-memory/src/memory-comparand-type-conformance.test.ts b/packages/drivers/driver-memory/src/memory-comparand-type-conformance.test.ts new file mode 100644 index 0000000000..d74f5cf46f --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-comparand-type-conformance.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7872] `driver-memory` held to `FILTER_COMPARAND_TYPE_CASES` — the + * comparand-type door, both directions, on the mingo path a real query runs. + * + * This is the driver the card was filed over: `{qty: {$eq: BigInt(100)}}` + * escaped as a raw mingo `TypeError` out of `Query.compile` (mingo builds its + * cache key with `JSON.stringify`, which refuses a BigInt), and five other + * unsupported comparand types answered silent zero rows — on both faces. The + * driver is under the #5499 investment freeze, so NOTHING here patches it: the + * door (`parseFilterAST`, `@objectstack/spec/data`) refuses or narrows every + * comparand BEFORE the driver runs, and this suite proves the inheritance — + * door-validated input executes correctly (the bigint arrives as its exact + * number, so mingo never sees one), door-refused input never reaches mingo at + * all. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { + FILTER_COMPARAND_TYPE_CASES, + FILTER_COMPARAND_TYPE_ROWS, + parseFilterAST, + type FilterCondition, +} from '@objectstack/spec/data'; +import { InMemoryDriver } from './memory-driver.js'; + +const TABLE = 'comparand_conformance'; + +describe('[#7872] InMemoryDriver.find — comparand-type conformance (behind the door)', () => { + let driver: InMemoryDriver; + + beforeAll(async () => { + driver = new InMemoryDriver({ persistence: false }); + await driver.connect(); + await driver.syncSchema(TABLE, { + fields: { + id: { type: 'text', name: 'id' }, + qty: { type: 'number', name: 'qty' }, + label: { type: 'text', name: 'label' }, + active: { type: 'boolean', name: 'active' }, + note: { type: 'text', name: 'note' }, + }, + }); + for (const row of FILTER_COMPARAND_TYPE_ROWS) await driver.create(TABLE, { ...row }); + }); + + const ids = async (where: FilterCondition | undefined): Promise => { + const rows = await driver.find(TABLE, { fields: ['id'], where }); + return (rows as Array>) + .map((r) => 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 mingo runs`, () => { + 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(); + }); + } + } + + it('the fixture really is both rows', async () => { + expect(await ids(undefined)).toEqual(['1', '2']); + }); + + /** + * The inheritance boundary, made visible: the SAME bigint filter that the + * door narrows into a working query still crashes mingo when it is handed to + * the driver DIRECTLY (no platform path does this — both doors run the door + * walk — but direct construction is how #7872 measured it). This pin is what + * proves the door is doing the work rather than mingo having quietly learned + * BigInt; if mingo ever does, this test fails loudly and should be RETIRED + * along with its sentence in the door's docblock — the door's own behaviour + * above does not change either way. + */ + it('the crash cell still exists on the direct path — the door is what stands in front of it', async () => { + await expect( + driver.find(TABLE, { where: { qty: { $eq: BigInt(100) } } as unknown as FilterCondition }), + ).rejects.toThrow(/BigInt/); + }); +}); diff --git a/packages/drivers/driver-mongodb/src/mongodb-comparand-type-conformance.test.ts b/packages/drivers/driver-mongodb/src/mongodb-comparand-type-conformance.test.ts new file mode 100644 index 0000000000..0656238943 --- /dev/null +++ b/packages/drivers/driver-mongodb/src/mongodb-comparand-type-conformance.test.ts @@ -0,0 +1,185 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7872] `driver-mongodb` held to `FILTER_COMPARAND_TYPE_CASES` — the + * comparand-type door, both directions, answered without a server. + * + * ## Why the assertions run in-process rather than against mongod + * + * The same reason as `mongodb-filter-text-conformance.test.ts` (#6682): this + * package's real-mongod suites are opt-in (#5517), so a standard that needed a + * server would not run in CI. A `find()` performs exactly two judgeable steps + * before the wire — `translateFilter`, then the `mongodb` package's own BSON + * encoding — and #7956 measured this driver's divergence cells at precisely + * those two steps. This suite makes the same BSON-serialize-level judgement, + * stated here as the accepted substitute for a live server. + * + * ## The worst cell, and what "inherits via the shared path" means here + * + * This driver has NO comparand-type policy of its own, and the ruling keeps it + * that way (#5499 freeze — nothing here patches the driver). Measured on the + * wire: `{qty: undefined}` BSON-encodes to `{}` — a predicate the author wrote + * to CONSTRAIN reaching the server as MATCH EVERYTHING, the one divergence + * cell that returned MORE data rather than less. The door + * (`parseFilterAST`, `@objectstack/spec/data`) refuses that input before + * `translateFilter` runs; the reverse-direction pin below keeps the raw + * silent-edit visible so the door's job cannot be mistaken for a mongo + * behaviour change. + */ + +import { describe, it, expect } from 'vitest'; +import { BSON } from 'mongodb'; +import { + FILTER_COMPARAND_TYPE_CASES, + FILTER_COMPARAND_TYPE_ROWS, + parseFilterAST, + type ComparandTypeRow, + type FilterCondition, +} from '@objectstack/spec/data'; +import { translateFilter } from './mongodb-filter.js'; + +// ── A deliberately strict reader of the emitted document ──────────────────── +// Same discipline as `mongodb-filter-logic-translation.test.ts`'s `matchDoc`: +// every shape it does not model is a thrown error, never a silently-true +// predicate. + +class UnsupportedShape extends Error {} + +function compare(a: unknown, b: unknown): number | undefined { + if (typeof a !== typeof b) return undefined; // different BSON bracket → no order + if (typeof a === 'string' || typeof a === 'number') { + return a === b ? 0 : (a as any) < (b as any) ? -1 : 1; + } + throw new UnsupportedShape(`unsupported comparand type: ${typeof a}`); +} + +function matchOps(value: unknown, ops: Record): boolean { + for (const [op, arg] of Object.entries(ops)) { + switch (op) { + case '$eq': + if (value !== arg) return false; + break; + case '$ne': + if (value === arg) return false; + break; + case '$gt': + if (!((compare(value, arg) ?? 0) > 0)) return false; + break; + case '$gte': + if (!((compare(value, arg) ?? -1) >= 0)) return false; + break; + case '$lt': + if (!((compare(value, arg) ?? 0) < 0)) return false; + break; + case '$lte': + if (!((compare(value, arg) ?? 1) <= 0)) return false; + break; + case '$in': + if (!Array.isArray(arg)) throw new UnsupportedShape('$in without an array'); + if (!arg.includes(value)) return false; + break; + case '$nin': + if (!Array.isArray(arg)) throw new UnsupportedShape('$nin without an array'); + if (arg.includes(value)) return false; + break; + default: + throw new UnsupportedShape(`unsupported field operator '${op}'`); + } + } + return true; +} + +function matchField(value: unknown, cond: unknown): boolean { + if (cond !== null && typeof cond === 'object' && !Array.isArray(cond) && !(cond instanceof Date)) { + const keys = Object.keys(cond as Record); + const ops = keys.filter((k) => k.startsWith('$')); + if (ops.length === keys.length && keys.length > 0) { + return matchOps(value, cond as Record); + } + if (ops.length > 0) { + throw new UnsupportedShape(`mixed operator/literal keys on one field: ${keys.join(', ')}`); + } + } + return value === cond; +} + +function matchDoc(row: ComparandTypeRow, doc: Record): boolean { + for (const [key, value] of Object.entries(doc)) { + switch (key) { + case '$and': + if (!Array.isArray(value)) throw new UnsupportedShape('$and without an array'); + if (!value.every((sub) => matchDoc(row, sub as Record))) return false; + break; + case '$or': + if (!Array.isArray(value)) throw new UnsupportedShape('$or without an array'); + if (!value.some((sub) => matchDoc(row, sub as Record))) return false; + break; + default: + if (key.startsWith('$')) throw new UnsupportedShape(`unsupported document operator '${key}'`); + if (!matchField((row as any)[key], value)) return false; + } + } + return true; +} + +/** + * The wire round trip, then the ids the document selects. Serializing FIRST is + * the point: it is the step that silently edited `{qty: undefined}` to `{}` on + * this driver, so a case evaluated without the round trip would judge a + * document the server never sees. + */ +function selectAfterWire(doc: Record): string[] { + const wire = BSON.deserialize(BSON.serialize(doc)) as Record; + return FILTER_COMPARAND_TYPE_ROWS.filter((row) => matchDoc(row, wire)) + .map((row) => row.id) + .sort((x, y) => x.localeCompare(y)); +} + +describe('[#7872] driver-mongodb — comparand-type conformance (server-free, behind the door)', () => { + for (const c of FILTER_COMPARAND_TYPE_CASES) { + if (c.verdict === 'door-refusal') { + it(`${c.name} — refused at the door, before translateFilter runs`, () => { + 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, () => { + const validated = parseFilterAST(c.filter()) as FilterCondition; + const doc = translateFilter(validated) as Record; + expect(selectAfterWire(doc), c.note).toEqual([...c.expected]); + }); + } else { + it(`${c.name} — translates and BSON-serializes without refusal`, () => { + const validated = parseFilterAST(c.filter()) as FilterCondition; + const doc = translateFilter(validated) as Record; + expect(() => BSON.serialize(doc)).not.toThrow(); + }); + } + } + + /** + * The reverse direction, pinned at the exact step #7956 measured it: WITHOUT + * the door, the implicit-equality `undefined` still reaches the wire as `{}` + * — match everything. This driver stays frozen (#5499), so the silent edit + * is expected to persist on the direct path; the door is what stands in + * front of it, and the refusal case above is the cell's platform answer. If + * the mongodb package ever stops dropping undefined-valued keys, this pin + * fails loudly and should be retired with its sentence in the suite header. + */ + it('the silent-edit cell still exists on the direct path — {qty: undefined} wires to {}', () => { + const doc = translateFilter({ qty: undefined } as unknown as FilterCondition) as Record; + const wire = BSON.deserialize(BSON.serialize(doc)) as Record; + expect(wire).toEqual({}); + // …which is precisely "match everything": both fixture rows. + expect(FILTER_COMPARAND_TYPE_ROWS.filter((row) => matchDoc(row, wire)).map((r) => r.id)) + .toEqual(['1', '2']); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver-comparand-type-conformance.test.ts b/packages/drivers/driver-sql/src/sql-driver-comparand-type-conformance.test.ts new file mode 100644 index 0000000000..2028c54d30 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-comparand-type-conformance.test.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7872] `driver-sql` held to `FILTER_COMPARAND_TYPE_CASES` — the + * comparand-type door, both directions, on the compiled-SQL path. + * + * This driver is one of the two independent implementations the door's set was + * MEASURED from (`isBindableComparand` / `isRenderableTextComparand`, whose + * type membership is now sourced from the door instead of duplicated — see + * their [#7872] notes). The refusal direction is therefore doubly guarded + * here: the door refuses at the platform face, and this driver's own gate + * still refuses the same types for direct callers, in its own ADR-0112 + * 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. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + FILTER_COMPARAND_TYPE_CASES, + FILTER_COMPARAND_TYPE_ROWS, + parseFilterAST, + type FilterCondition, +} from '@objectstack/spec/data'; +import { SqlDriver } from '../src/index.js'; + +const TABLE = 'comparand_conformance'; + +describe('[#7872] SqlDriver — comparand-type conformance (behind the door)', () => { + let driver: SqlDriver; + let knex: any; + + beforeAll(async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + 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'); + }); + await knex(TABLE).insert(FILTER_COMPARAND_TYPE_ROWS.map((r) => ({ ...r }))); + }); + + afterAll(async () => { + await knex.destroy(); + }); + + const ids = async (where: FilterCondition | undefined): Promise => { + 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(); + }); + } + } + + it('the fixture really is both rows', async () => { + expect(await ids(undefined)).toEqual(['1', '2']); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 97d739bbfe..49ed7028ad 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -36,6 +36,13 @@ import { isNowDefaultToken, isRuntimeDefaultToken } from '@objectstack/spec/data // sentence about `$regex` are five sentences that drift apart. This driver // prints `why` VERBATIM. import { RETIRED_FILTER_OPERATORS } from '@objectstack/spec/data'; +// [#7872] The shared comparand-type door: the accepted six-type SET (this +// driver's own allowlists delegate their membership to it) and the sentence +// its refusals quote, so the set has one home instead of a copy per driver. +import { + isAcceptedFilterComparand, + ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, +} from '@objectstack/spec/data'; // [#7536] `$like`/`$ilike`'s pattern language, defined once in the spec: the // dangling-escape gate every face refuses on, and the LIKE→GLOB translation the // SQLite dialects need because GLOB is the only case-exact pattern operator @@ -1295,12 +1302,22 @@ const SCALAR_COMPARAND_OPERATORS: ReadonlySet = new Set([ * is not a primitive, a `Date` or a binary buffer is a shape better-sqlite3 * refuses outright and the other dialects mangle. (`ArrayBuffer.isView` covers * `Buffer`, which is a `Uint8Array`.) + * + * [#7872] The type membership is the shared comparand-type door's now + * (`isAcceptedFilterComparand`, `@objectstack/spec/data`) — this list and + * `RemoteTransport.serializeComparand`'s reached the identical six types twice + * independently, which is the measured fact the door was ruled from, so the SET + * is sourced there rather than duplicated here. Two driver-local extras stay, + * each recorded: `undefined` answers TRUE only because the #6050 walk refuses + * every undefined comparand FIRST with its purpose-written message, so this arm + * is unreachable on the filter path and flipping it would only swap which error + * fires for a direct caller; `ArrayBuffer.isView` is this driver's own bindable + * (blob columns) that the engine-level door does not admit — it remains + * reachable from direct driver calls, which is also where it was ever usable. */ function isBindableComparand(value: unknown): boolean { - if (value === null || value === undefined) return true; - const kind = typeof value; - if (kind === 'string' || kind === 'number' || kind === 'bigint' || kind === 'boolean') return true; - return value instanceof Date || ArrayBuffer.isView(value); + if (value === undefined) return true; + return isAcceptedFilterComparand(value) || ArrayBuffer.isView(value); } /** @@ -1358,10 +1375,10 @@ const LIST_COMPARAND_OPERATORS: ReadonlySet = new Set(['$in', '$nin', '$ * close one. See {@link unrenderableTextComparandError} for the rest. */ function isRenderableTextComparand(value: unknown): boolean { - if (value === null || value === undefined) return true; - const kind = typeof value; - if (kind === 'string' || kind === 'number' || kind === 'bigint' || kind === 'boolean') return true; - return value instanceof Date; + // [#7872] The six-type membership is the shared door's; `undefined` stays a + // recorded local admission (see the paragraph above — the #6050 walk refuses + // it first, so the arm is unreachable on the filter path). + return value === undefined || isAcceptedFilterComparand(value); } /** @@ -1453,8 +1470,8 @@ function assertCompilableComparand(field: string, op: string, value: unknown): v throw unsupportedFilterError( `Operator "${op}" on field "${field}" requires a single comparable value, but received ` + `${Array.isArray(value) ? 'an array' : `an object (${safeShapePreview(value)})`}, which cannot be ` + - `bound as a SQL parameter. Use a string, number, boolean, null, Date or binary value; ` + - `for a list use $in/$nin, and for a range use $between.`, + `bound as a SQL parameter. Use ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (or a binary ` + + `value); for a list use $in/$nin, and for a range use $between.`, ); } @@ -1477,9 +1494,9 @@ function unbindableListMemberError(field: string, op: string, value: unknown, in return unsupportedFilterError( `Operator "${op}" on field "${field}" has a value at index ${index} of its list that cannot be ` + `bound as a SQL parameter: ${safeShapePreview(value)}. Every member of an $in/$nin/$between ` + - `list is a comparand in its own right — use a string, number, boolean, null, Date or binary ` + - `value. Refusing rather than binding it: the member can equal no stored value, so the list ` + - `silently loses that entry (and a $nin loses the exclusion the caller wrote).`, + `list is a comparand in its own right — use ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} ` + + `(or a binary value). Refusing rather than binding it: the member can equal no stored value, ` + + `so the list silently loses that entry (and a $nin loses the exclusion the caller wrote).`, ); } @@ -1505,10 +1522,10 @@ function unrenderableTextComparandError(field: string, op: string, value: unknow return unsupportedFilterError( `Operator "${op}" on field "${field}" matches against the TEXT of a pattern, but received ` + `${Array.isArray(value) ? 'an array' : 'an object'} (${safeShapePreview(value)}). The spec ` + - `declares this comparand a string (filter.zod.ts StringOperatorSchema); a string, number, ` + - `boolean, null or Date is accepted. Refusing rather than stringifying it: String({}) is ` + - `"[object Object]", so the pattern that ran was one the caller never wrote — valid SQL, ` + - `and a row storing that literal text would have matched it.`, + `declares this comparand a string (filter.zod.ts StringOperatorSchema); ` + + `${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} is accepted. Refusing rather than stringifying ` + + `it: String({}) is "[object Object]", so the pattern that ran was one the caller never ` + + `wrote — valid SQL, and a row storing that literal text would have matched it.`, ); } diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-comparand-type-conformance.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-comparand-type-conformance.test.ts new file mode 100644 index 0000000000..8f1bc23887 --- /dev/null +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-comparand-type-conformance.test.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7872] `driver-sqlite-wasm` held to `FILTER_COMPARAND_TYPE_CASES` — the + * comparand-type door, both directions, on the sql.js execution path. + * + * This driver inherits `SqlDriver`'s filter compiler (so its refusal half for + * DIRECT callers is that driver's, now sourced from the door's set); what this + * suite adds is the executable proof that the WASM engine also runs every + * door-validated accepted type — an inherited compiler is not an inherited + * execution result (#6518's lesson: sql.js and better-sqlite3 answered case + * folding differently under one compiler). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + FILTER_COMPARAND_TYPE_CASES, + FILTER_COMPARAND_TYPE_ROWS, + parseFilterAST, + type FilterCondition, +} from '@objectstack/spec/data'; +import { SqliteWasmDriver } from './index.js'; + +const TABLE = 'comparand_conformance'; + +describe('[#7872] SqliteWasmDriver — comparand-type conformance (behind the door)', () => { + let driver: SqliteWasmDriver; + + beforeAll(async () => { + driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.initObjects([ + { + name: TABLE, + fields: { + qty: { type: 'number' }, + label: { type: 'string' }, + active: { type: 'boolean' }, + note: { type: 'string' }, + }, + }, + ]); + for (const row of FILTER_COMPARAND_TYPE_ROWS) { + await driver.create(TABLE, { ...row }, { bypassTenantAudit: true }); + } + }); + + afterAll(async () => { + await driver.disconnect(); + }); + + const ids = async (where: FilterCondition | undefined): Promise => { + const rows = await driver.find(TABLE, { fields: ['id'], where }); + return (rows as Array>) + .map((r) => 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(); + }); + } + } + + it('the fixture really is both rows', async () => { + expect(await ids(undefined)).toEqual(['1', '2']); + }); +}); diff --git a/packages/drivers/driver-turso/src/remote-transport.ts b/packages/drivers/driver-turso/src/remote-transport.ts index edcb699c31..0c93c64ebf 100644 --- a/packages/drivers/driver-turso/src/remote-transport.ts +++ b/packages/drivers/driver-turso/src/remote-transport.ts @@ -15,6 +15,14 @@ import type { Client, InStatement, ResultSet } from '@libsql/client'; import { StandardErrorCode } from '@objectstack/spec/api'; import { FILTER_OPERATORS, LOGICAL_OPERATORS, RETIRED_FILTER_OPERATORS } from '@objectstack/spec/data'; +// [#7872] The shared comparand-type door. `serializeComparand`'s allow-list and +// `driver-sql`'s reached the identical six types twice independently — the +// measured fact the door was ruled from — so the SET and the sentence the +// refusal quotes are sourced there rather than kept as this transport's copy. +import { + isAcceptedFilterComparand, + ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, +} from '@objectstack/spec/data'; // [#7536] `$like`/`$ilike`'s pattern language, from the spec's one definition — // the dangling-escape gate and the LIKE→GLOB translation. Shared with // `SqlDriver`'s local emitter so this transport and its local twin cannot fork @@ -2951,14 +2959,11 @@ export class RemoteTransport { // falls to the allow-list below and is named rather than laundered. if (value === null) return null; if (isBindableObjectComparand(value)) return value.toISOString(); - if ( - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'bigint' || - typeof value === 'boolean' - ) { - return value; - } + // [#7872] The remaining scalar membership (string / number / bigint / + // boolean — null and Date answered above) is the shared door's set, asked + // of the one predicate every face now shares instead of this transport's + // own copy of it. + if (isAcceptedFilterComparand(value)) return value; throw this.uncompilableComparand(object, field, op, value); } @@ -3002,9 +3007,10 @@ export class RemoteTransport { } return invalidFilterError( `[RemoteTransport] Filter comparand ${target} ${shown} is ${describeValue(value)}, which this ` + - `transport cannot bind. A comparison value must be a string, number, bigint, boolean, null or ` + - `Date. Refusing rather than binding its JSON text — that compiles to valid SQL matching zero ` + - `rows, which is indistinguishable from "no rows matched" (#1004, #1058).`, + `transport cannot bind. A comparison value must be ` + + `${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE}. Refusing rather than binding its JSON text ` + + `— that compiles to valid SQL matching zero rows, which is indistinguishable from ` + + `"no rows matched" (#1004, #1058).`, ); } diff --git a/packages/drivers/driver-turso/src/turso-comparand-type-conformance.test.ts b/packages/drivers/driver-turso/src/turso-comparand-type-conformance.test.ts new file mode 100644 index 0000000000..f5cded8099 --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-comparand-type-conformance.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7872] `driver-turso` held to `FILTER_COMPARAND_TYPE_CASES` — the + * comparand-type door, both directions, on the local (SqlDriver-inherited) + * execution path. + * + * The REMOTE transport's own half of this policy predates the door and stays + * pinned in `remote-transport-comparand-refusal.test.ts` — including the + * native `bigint` BIND for a direct transport caller, which the door + * deliberately leaves reachable (an engine-path bigint arrives already + * narrowed to its exact number; a direct caller keeps libsql's own binding). + * `serializeComparand`'s type membership and its refusal sentence are sourced + * from the door's set since #7872, so the two faces cannot drift; the replica + * mode shares the local engine this suite drives. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + FILTER_COMPARAND_TYPE_CASES, + FILTER_COMPARAND_TYPE_ROWS, + parseFilterAST, + type FilterCondition, +} from '@objectstack/spec/data'; +import { TursoDriver } from './turso-driver.js'; + +const TABLE = 'comparand_conformance'; + +describe('[#7872] TursoDriver — comparand-type conformance (local mode, behind the door)', () => { + let driver: TursoDriver; + + beforeAll(async () => { + driver = new TursoDriver({ url: ':memory:' }); + expect(driver.transportMode).toBe('local'); + await driver.initObjects([ + { + name: TABLE, + fields: { + qty: { type: 'number' }, + label: { type: 'string' }, + active: { type: 'boolean' }, + note: { type: 'string' }, + }, + }, + ]); + for (const row of FILTER_COMPARAND_TYPE_ROWS) { + await driver.create(TABLE, { ...row }, { bypassTenantAudit: true }); + } + }); + + afterAll(async () => { + await driver.disconnect(); + }); + + const ids = async (where: FilterCondition | undefined): Promise => { + const rows = await driver.find(TABLE, { fields: ['id'], where }); + return (rows as Array>) + .map((r) => 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(); + }); + } + } + + it('the fixture really is both rows', async () => { + expect(await ids(undefined)).toEqual(['1', '2']); + }); +}); diff --git a/packages/objectql/src/engine-comparand-type-door.test.ts b/packages/objectql/src/engine-comparand-type-door.test.ts new file mode 100644 index 0000000000..5c8cd185ac --- /dev/null +++ b/packages/objectql/src/engine-comparand-type-door.test.ts @@ -0,0 +1,197 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7872] The comparand-type door at the engine's filter collection point — + * Door 2's OBJECT form, the one form neither door routes through + * `parseFilterAST`. + * + * The door's definition lives in `@objectstack/spec/data` + * (`filter-comparand-type.ts`, where the ruling is quoted) and runs in two + * places: inside `parseFilterAST` (Door 1's array lowering, analytics' + * normalizer, every direct caller) and at `lowerWhereFilterArray`'s non-array + * branch — this file's subject — because a `FilterCondition` OBJECT reaches + * the engine without ever passing `parseFilterAST`: Door 1 gates on + * `isFilterAST` (arrays only) and Door 2 is the engine call itself. The #7956 + * divergence matrix arrived through exactly this form. + * + * Same collection point, same envelope, same "no driver read on refusal" + * discipline as the #5869 shape gate beside it. The shared per-driver pins + * live in `FILTER_COMPARAND_TYPE_CASES`; this file pins what only the engine + * can: that BOTH forms are covered on every engine verb, that refusal happens + * BEFORE the driver, and that the bigint narrowing is copy-on-write on the + * caller's bag. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import type { EngineQueryOptions } from '@objectstack/spec/data'; +import { ObjectQL } from './engine.js'; + +const deal = { + name: 'deal', + label: 'Deal', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + stage: { name: 'stage', type: 'text' as const }, + amount: { name: 'amount', type: 'number' as const }, + owner_id: { name: 'owner_id', type: 'text' as const }, + }, +}; + +interface SeenRead { ast: any } + +/** Minimal recording driver — the same witness shape as the #5158 lowering suite. */ +function makeRecordingDriver() { + const rows = new Map>(); + const reads: SeenRead[] = []; + const writes: SeenRead[] = []; + const matches = (row: any, where: any): boolean => { + if (where == null) return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and') { if (!(v as any[]).every((w) => matches(row, w))) return false; continue; } + if (k === '$or') { if (!(v as any[]).some((w) => matches(row, w))) return false; continue; } + if (v && typeof v === 'object' && !Array.isArray(v)) { + const ops = v as Record; + if ('$eq' in ops && row[k] !== ops.$eq) return false; + if ('$gt' in ops && !((row[k] as any) > (ops.$gt as any))) return false; + if ('$in' in ops && !(ops.$in as unknown[]).includes(row[k])) return false; + continue; + } + if (row[k] !== v) return false; + } + return true; + }; + const run = (ast: any) => [...rows.values()].filter((r) => matches(r, ast?.where)); + const driver: any = { + name: 'recording', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(_o: string, ast: any) { reads.push({ ast }); return run(ast); }, + async findOne(_o: string, ast: any) { reads.push({ ast }); return run(ast)[0] ?? null; }, + async count(_o: string, ast: any) { reads.push({ ast }); return run(ast).length; }, + async aggregate(_o: string, ast: any) { reads.push({ ast }); return run(ast); }, + async create(_o: string, data: Record) { + const id = (data.id as string) ?? `r_${rows.size + 1}`; + const row = { ...data, id }; rows.set(id, row); return row; + }, + async update(_o: string, id: string, data: Record) { + const cur = rows.get(id); if (!cur) throw new Error(`nf ${id}`); + const up = { ...cur, ...data, id }; rows.set(id, up); return up; + }, + async updateMany(_o: string, ast: any, data: Record) { + writes.push({ ast }); + const hit = run(ast); + for (const r of hit) rows.set(r.id as string, { ...r, ...data }); + return hit.length; + }, + async delete(_o: string, id: string) { return rows.delete(id); }, + async deleteMany(_o: string, ast: any) { + writes.push({ ast }); + const hit = run(ast); + for (const r of hit) rows.delete(r.id as string); + return hit.length; + }, + async bulkCreate(o: string, batch: Record[]) { + return Promise.all(batch.map((r) => this.create(o, r))); + }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, reads, writes }; +} + +describe('[#7872] the comparand-type door at the engine collection point', () => { + let engine: ObjectQL; + let reads: SeenRead[]; + let writes: SeenRead[]; + + beforeEach(async () => { + const rec = makeRecordingDriver(); + reads = rec.reads; + writes = rec.writes; + engine = new ObjectQL(); + engine.registerDriver(rec.driver, true); + await engine.init(); + engine.registry.registerObject(deal, 'test'); + await engine.insert('deal', { id: 'd1', stage: 'won', amount: 10, owner_id: 'u1' }); + await engine.insert('deal', { id: 'd2', stage: 'lost', amount: 20, owner_id: 'u2' }); + reads.length = 0; + writes.length = 0; + }); + + const refusalOf = async (p: Promise) => + p.then(() => null, (e: any) => e as Error & { code?: string; status?: number }); + + // ── the OBJECT form, the form the matrix arrived through ──────────────── + + it.each([ + ['a Symbol, operator form', { stage: { $eq: Symbol('x') } }], + ['a Map, operator form', { stage: { $ne: new Map() } }], + ['a function, implicit form', { stage: () => 1 }], + ['undefined, implicit form — the mongo worst cell', { stage: undefined }], + ['undefined, operator form', { amount: { $gt: undefined } }], + ['a plain object in a scalar slot', { amount: { $eq: { v: 10 } } }], + ['an oversized bigint', { amount: { $eq: 2n ** 53n + 1n } }], + ])('refuses %s with the envelope, and NO driver read runs', async (_label, where) => { + // NOT erased: `FilterCondition`'s index signature admits these values, so + // the call type-checks as written — that a type-legal filter still has to + // be refused at runtime is exactly why the door exists (#5869's note). + const err = await refusalOf(engine.find('deal', { where })); + expect(err).not.toBeNull(); + expect(err).toMatchObject({ status: 400, code: 'INVALID_FILTER' }); + // The engine's wording contract: the refusal names the entry point… + expect(err!.message).toMatch(/^find\('deal'\): /); + // …and the caller learns the query never ran. + expect(err!.message).toMatch(/NOT applied/); + expect(reads).toHaveLength(0); + }); + + it('covers every engine verb that collects a filter — read and write sides', async () => { + const where = { stage: undefined }; + for (const call of [ + () => engine.find('deal', { where }), + () => engine.findOne('deal', { where }), + () => engine.count('deal', { where }), + () => engine.update('deal', { stage: 'x' }, { where, multi: true }), + () => engine.delete('deal', { where, multi: true }), + ]) { + const err = await refusalOf(call()); + expect(err).not.toBeNull(); + expect(err).toMatchObject({ status: 400, code: 'INVALID_FILTER' }); + } + expect(reads).toHaveLength(0); + expect(writes).toHaveLength(0); + }); + + // ── the ARRAY form inherits the door through parseFilterAST ───────────── + + it('refuses a bad comparand arriving in a FilterArray triple', async () => { + // The cast names the contract being bypassed: `FilterArray` is INPUT-ONLY + // sugar `EngineQueryOptions.where` deliberately excludes (#5285), and this + // case exists to prove the lowered triple inherits the door. + const err = await refusalOf( + engine.find('deal', { where: ['stage', '=', new Map()] } as unknown as EngineQueryOptions), + ); + expect(err).toMatchObject({ status: 400, code: 'INVALID_FILTER' }); + expect(reads).toHaveLength(0); + }); + + // ── bigint: accepted, narrowed, copy-on-write ─────────────────────────── + + it('narrows an exact-range bigint before the driver — the memory crash cell dies here', async () => { + const rows = await engine.find('deal', { where: { amount: { $gt: BigInt(15) } } }); + expect(rows.map((r: any) => r.id)).toEqual(['d2']); + const seen = reads[0]?.ast?.where?.amount?.$gt; + expect(seen).toBe(15); + expect(typeof seen).toBe('number'); + }); + + it('the narrowing is copy-on-write — the caller’s bag is not edited under them', async () => { + const where = { amount: { $gt: BigInt(15) } }; + await engine.find('deal', { where }); + expect(typeof where.amount.$gt).toBe('bigint'); + }); + + it('a clean object filter still reaches the driver untouched', async () => { + await engine.find('deal', { where: { stage: 'won', amount: { $gt: 5 } } }); + expect(reads[0]?.ast?.where).toEqual({ stage: 'won', amount: { $gt: 5 } }); + }); +}); diff --git a/packages/objectql/src/engine-filter-array-lowering.test.ts b/packages/objectql/src/engine-filter-array-lowering.test.ts index 97114931c8..9f55e2d8d7 100644 --- a/packages/objectql/src/engine-filter-array-lowering.test.ts +++ b/packages/objectql/src/engine-filter-array-lowering.test.ts @@ -511,13 +511,30 @@ describe('Door 2 lowers FilterArray to FilterCondition before the driver (#5158) expect(lastWhere()).toEqual(where); }); - it('does not descend into a deep-equality comparand that merely LOOKS like an operator map', async () => { - // `{ $eq: {...} }` holds DATA. A gate that walked into it would refuse a - // stored document whose own key happens to be `$in` — a stricter contract - // than any backend applies. + it('a deep-equality comparand that LOOKS like an operator map is refused as a TYPE, never misread as operators (#7872)', async () => { + // HISTORY: until #7872 this case pinned pass-through — `{ $eq: {...} }` + // held DATA, and only driver-memory / driver-mongodb gave it deep-equality + // semantics while the SQL family refused it ("cannot be bound"). The + // #7872 ruling closed that divergence in the SQL family's direction: a + // plain object in a scalar operator slot is outside the accepted + // comparand-type set and is refused at the door. What SURVIVES of the old + // pin is its actual concern — the gate must not walk INTO the object and + // misread a data key that happens to be spelled `$in` as a malformed list + // operator. So: refused, with the comparand-TYPE envelope naming the + // OUTER slot, and never with #5869's "requires an ARRAY" shape wording + // about the inner key. const where = { stage: { $eq: { $in: 'not-an-operator-here' } } }; - await engine.find('deal', { where }); - expect(lastWhere()).toEqual(where); + const err = await engine.find('deal', { where }) + .then(() => null, (e: any) => e); + expect(err).not.toBeNull(); + expect(err).toMatchObject({ status: 400, code: 'INVALID_FILTER' }); + // The refusal is about the OUTER comparand's type… + expect(err.message).toContain('where.stage.$eq'); + expect(err.message).toContain('a plain object'); + // …and not a second opinion about the inner `$in` key. + expect(err.message).not.toContain('requires an ARRAY'); + // No driver ran. + expect(reads).toHaveLength(0); }); it('a scalar on a NON-collection operator is untouched', async () => { diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 58b1f34f5d..3e71576849 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -28,7 +28,12 @@ import type { ValidateDataIssue, ValidateDataResponse } from '@objectstack/spec/ import { parseAutonumberFormat, renderAutonumber, resolveAutonumberFormat, readAutonumberCounter, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY, isCurrentUserDefaultToken, isNowDefaultToken } from '@objectstack/spec/data'; // [#5158] Door 2's lowering sink — the SAME pair the protocol face (Door 1) // runs, so `FilterArray` has exactly one lowering in the product. -import { isFilterAST, parseFilterAST, VALID_AST_OPERATORS } from '@objectstack/spec/data'; +import { + isFilterAST, + parseFilterAST, + normalizeFilterComparandTypes, + VALID_AST_OPERATORS, +} from '@objectstack/spec/data'; // [#5574] D6, executable. The ceiling and the refusal message live in // `packages/spec/src/data/bulk-write-hook-conformance.ts` so BOTH phases and // both verbs enforce one definition; the engine raises, the contract decides. @@ -626,6 +631,19 @@ function lowerWhereFilterArray( // one either way — it reads the lowered condition, which is what both doors // produce. assertListComparandShapes(object, operation, where); + // [#7872] The comparand-type door, on the OBJECT form. `parseFilterAST` + // runs the same walk on everything it lowers or passes through, but + // NEITHER door routes an object-form filter through it — Door 1 gates on + // `isFilterAST` first and Door 2 is this very branch — so without this + // call the dominant form would bypass the door entirely (the #7956 + // divergence matrix arrived through it). Shape gate first (#5869 keeps + // its pinned wording for the list-operator shapes), type door second; + // the walk is copy-on-write, so the common path allocates nothing and a + // narrowed bigint replaces the bag rather than editing the caller's. + const normalized = normalizeFilterComparandTypes(where, `${operation}('${object}')`); + if (normalized !== where) { + return { ...(bag as Record), where: normalized } as T; + } return bag; } diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index e1043dcff8..967f80d553 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -2,6 +2,8 @@ "description": "Every exported `name (kind)` of one published entry point of @objectstack/spec — the breadth half of the ADR-0059 backward-compatibility gate. Sharded by entry point (#5837) so two PRs touching different entry points never share a file. Reads the BUILT dist/*.d.ts: regenerate with `pnpm --filter @objectstack/spec gen:api-surface` after a real build.", "entry": "./data", "exports": [ + "ACCEPTED_FILTER_COMPARAND_TYPES (const)", + "ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE (const)", "AGGREGATION_CASES (const)", "AGGREGATION_ROWS (const)", "ALL_OPERATORS (const)", @@ -61,6 +63,12 @@ "CalendarDateValueSchema (const)", "ClockTimeValue (type)", "ClockTimeValueSchema (const)", + "ComparandTypeCase (type)", + "ComparandTypeCompilesCase (interface)", + "ComparandTypeMatchesCase (interface)", + "ComparandTypeProbe (class)", + "ComparandTypeRefusalCase (interface)", + "ComparandTypeRow (interface)", "ComparisonOperatorSchema (const)", "Compatibility (type)", "ConditionalValidation (type)", @@ -228,6 +236,9 @@ "FIELD_KEY_GUIDANCE (const)", "FILE_REFERENCE_TYPES (const)", "FILTER_ARRAY_LOGIC_KEYWORDS (const)", + "FILTER_COMPARAND_BIGINT_EXACT_LIMIT (const)", + "FILTER_COMPARAND_TYPE_CASES (const)", + "FILTER_COMPARAND_TYPE_ROWS (const)", "FILTER_LOGIC_CASES (const)", "FILTER_LOGIC_ROWS (const)", "FILTER_OPERATORS (const)", @@ -630,6 +641,7 @@ "hasDanglingLikeEscape (function)", "hasDynamicTokens (function)", "hookForm (const)", + "isAcceptedFilterComparand (function)", "isApiOperationAllowed (function)", "isApiPrimitive (function)", "isAppResolvedDefaultToken (function)", @@ -659,6 +671,7 @@ "matchesLikePattern (function)", "missingFieldValues (function)", "nextUtcCalendarDay (function)", + "normalizeFilterComparandTypes (function)", "objectForm (const)", "objectTitleCompleteness (function)", "parseAutonumberFormat (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 46753b9b51..e2611b87fa 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -2,6 +2,8 @@ "description": "Which SOURCE DECLARATION each name exported by one public entry point of @objectstack/spec resolves to, after its alias chain is unwound: `# ()`. Two exports share an origin string iff they are the same declaration — so equal origins across two entries are a harmless re-export, and different origins under one name are the #4411 dual-source trap. Generated from src/ (no build needed) and read by the export-surface pin tests, which compare against it instead of each building their own ts.createProgram — that was ~55s of compilation per CI lap and a non-deterministic timeout that ejected unrelated PRs from the merge queue (#4796). Sharded by entry point (#5837) so two retirement PRs never share a file. Carries NO line numbers: the pins asserted the line as `\\d+`, and recording it would rewrite this artifact on every edit that shifts a line in any .zod.ts. Regenerate with `pnpm --filter @objectstack/spec gen:export-origins` and read the diff.", "entry": "./data", "exports": { + "ACCEPTED_FILTER_COMPARAND_TYPES": "src/data/filter-comparand-type.ts#ACCEPTED_FILTER_COMPARAND_TYPES (const)", + "ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE": "src/data/filter-comparand-type.ts#ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE (const)", "AGGREGATION_CASES": "src/data/aggregation-conformance.ts#AGGREGATION_CASES (const)", "AGGREGATION_ROWS": "src/data/aggregation-conformance.ts#AGGREGATION_ROWS (const)", "ALL_OPERATORS": "src/data/filter.zod.ts#ALL_OPERATORS (const)", @@ -61,6 +63,12 @@ "CalendarDateValueSchema": "src/data/field-value.zod.ts#CalendarDateValueSchema (const)", "ClockTimeValue": "src/data/field-value.zod.ts#ClockTimeValue (type)", "ClockTimeValueSchema": "src/data/field-value.zod.ts#ClockTimeValueSchema (const)", + "ComparandTypeCase": "src/data/filter-comparand-type-conformance.ts#ComparandTypeCase (type)", + "ComparandTypeCompilesCase": "src/data/filter-comparand-type-conformance.ts#ComparandTypeCompilesCase (interface)", + "ComparandTypeMatchesCase": "src/data/filter-comparand-type-conformance.ts#ComparandTypeMatchesCase (interface)", + "ComparandTypeProbe": "src/data/filter-comparand-type-conformance.ts#ComparandTypeProbe (class)", + "ComparandTypeRefusalCase": "src/data/filter-comparand-type-conformance.ts#ComparandTypeRefusalCase (interface)", + "ComparandTypeRow": "src/data/filter-comparand-type-conformance.ts#ComparandTypeRow (interface)", "ComparisonOperatorSchema": "src/data/filter.zod.ts#ComparisonOperatorSchema (const)", "Compatibility": "src/data/type-compat.ts#Compatibility (type)", "ConditionalValidation": "src/data/validation.zod.ts#ConditionalValidation (type)", @@ -228,6 +236,9 @@ "FIELD_KEY_GUIDANCE": "src/data/authoring-key-lint.ts#FIELD_KEY_GUIDANCE (const)", "FILE_REFERENCE_TYPES": "src/data/field-value.zod.ts#FILE_REFERENCE_TYPES (const)", "FILTER_ARRAY_LOGIC_KEYWORDS": "src/data/filter.zod.ts#FILTER_ARRAY_LOGIC_KEYWORDS (const)", + "FILTER_COMPARAND_BIGINT_EXACT_LIMIT": "src/data/filter-comparand-type.ts#FILTER_COMPARAND_BIGINT_EXACT_LIMIT (const)", + "FILTER_COMPARAND_TYPE_CASES": "src/data/filter-comparand-type-conformance.ts#FILTER_COMPARAND_TYPE_CASES (const)", + "FILTER_COMPARAND_TYPE_ROWS": "src/data/filter-comparand-type-conformance.ts#FILTER_COMPARAND_TYPE_ROWS (const)", "FILTER_LOGIC_CASES": "src/data/filter-logic-conformance.ts#FILTER_LOGIC_CASES (const)", "FILTER_LOGIC_ROWS": "src/data/filter-logic-conformance.ts#FILTER_LOGIC_ROWS (const)", "FILTER_OPERATORS": "src/data/filter.zod.ts#FILTER_OPERATORS (const)", @@ -630,6 +641,7 @@ "hasDanglingLikeEscape": "src/data/filter.zod.ts#hasDanglingLikeEscape (function)", "hasDynamicTokens": "src/data/autonumber-format.ts#hasDynamicTokens (function)", "hookForm": "src/data/hook.form.ts#hookForm (const)", + "isAcceptedFilterComparand": "src/data/filter-comparand-type.ts#isAcceptedFilterComparand (function)", "isApiOperationAllowed": "src/data/api-derivation.ts#isApiOperationAllowed (function)", "isApiPrimitive": "src/data/api-derivation.ts#isApiPrimitive (function)", "isAppResolvedDefaultToken": "src/data/default-value-tokens.ts#isAppResolvedDefaultToken (function)", @@ -659,6 +671,7 @@ "matchesLikePattern": "src/data/filter.zod.ts#matchesLikePattern (function)", "missingFieldValues": "src/data/autonumber-format.ts#missingFieldValues (function)", "nextUtcCalendarDay": "src/data/calendar-day.ts#nextUtcCalendarDay (function)", + "normalizeFilterComparandTypes": "src/data/filter-comparand-type.ts#normalizeFilterComparandTypes (function)", "objectForm": "src/data/object.form.ts#objectForm (const)", "objectTitleCompleteness": "src/data/display-name.ts#objectTitleCompleteness (function)", "parseAutonumberFormat": "src/data/autonumber-format.ts#parseAutonumberFormat (function)", diff --git a/packages/spec/src/data/filter-comparand-type-conformance.ts b/packages/spec/src/data/filter-comparand-type-conformance.ts new file mode 100644 index 0000000000..56b1aa4de7 --- /dev/null +++ b/packages/spec/src/data/filter-comparand-type-conformance.ts @@ -0,0 +1,262 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Canonical conformance cases for the Filter Protocol's **comparand-type + * door** (#7872) — the pins the ruling asked for, in both directions, run on + * every driver path: + * + * 1. **Each of the six accepted types compiles on every driver path** — + * `string | number | bigint | boolean | null | Date`, the measured superset + * of #7956's divergence matrix. `bigint` arrives at the driver already + * narrowed to its exact number by the door, which is what makes this true + * on the memory path (mingo cannot serialize a bigint at any value — the + * crash cell this card was filed over). + * 2. **A refused type gets the loud refusal** — `undefined`, Symbol, function, + * `Map`, a class instance, a plain object in a scalar slot, a bigint beyond + * ±2^53 — with the `INVALID_FILTER` / 400 envelope, BEFORE any driver runs. + * This includes the two worst measured cells, which become refusals: the + * mongo silent-edit cell (`{qty: undefined}` encoded to `{}` = match + * everything) and the memory BigInt crash cell (a raw mingo `TypeError`). + * + * ## How a driver suite consumes this table + * + * The door sits UPSTREAM of every driver (`parseFilterAST` and the engine's + * lowering seam), so a driver's conformance is two-sided: + * + * - `door-refusal` cases: assert `parseFilterAST(filter)` throws the envelope + * (`code` AND `status`, plus {@link ComparandTypeRefusalCase.mustMention}) — + * proving the input can never reach this driver through a platform door. + * This is how the frozen drivers (#5499) inherit the policy without a + * driver-local patch. + * - `matches` / `compiles` cases: hand `parseFilterAST(filter)` — the + * door-validated, bigint-narrowed condition — to the driver's own execution + * path and assert the row ids (`matches`) or merely that execution succeeds + * (`compiles` — used for `Date`, whose ROW agreement is temporal-conformance's + * subject, not this table's). + * + * `driver-mongodb` evaluates its emitted documents in-process (its real-mongod + * suites are opt-in, #5517) — the same server-free judgement its logic/text + * conformance suites make, stated in its suite as the accepted substitute. + * + * ## What belongs here + * + * Comparand TYPE policy only. Comparand SHAPE (`$in: 'scalar'`, `$between` + * arity) is the engine gate's subject (#5869); text-operator VALUE rules + * (`$icontains: 42` is refused per-operator) are `FILTER_TEXT_CASES`'; storage + * forms are `TEMPORAL_CASES`'. The same one-axis bar the sibling tables set. + * + * @see https://github.com/objectstack-ai/objectstack/issues/7872 (the ruling) + * @see https://github.com/objectstack-ai/objectstack/issues/7956 (the matrix) + */ + +import type { FilterCondition } from './filter.zod'; +import { ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE } from './filter-comparand-type'; + +/** A row in the conformance fixture. */ +export interface ComparandTypeRow { + id: string; + /** number — the column the #7956 matrix probed (`{qty: {$eq: …}}`). */ + qty: number; + label: string; + active: boolean; + note: string | null; +} + +/** + * The fixture: #7956's one-row table, plus a second row so a wrongly-inverted + * predicate returns visibly wrong ids rather than the same count by luck. + */ +export const FILTER_COMPARAND_TYPE_ROWS: readonly ComparandTypeRow[] = [ + { id: '1', qty: 100, label: 'alpha', active: true, note: null }, + { id: '2', qty: 250, label: 'beta', active: false, note: 'kept' }, +] as const; + +/** The class-instance refused comparand — the matrix row's shape, shared so every suite probes the same value. */ +export class ComparandTypeProbe { + constructor(readonly v: number = 100) {} +} + +interface ComparandTypeCaseBase { + /** Stable identifier, usable as a test name. */ + readonly name: string; + /** + * Builds the filter under test. A FACTORY rather than a value because + * several cases carry a fresh `Date` / `Map` / class instance, and a shared + * mutable instance across five suites would let one suite's run change what + * another judged. + */ + readonly filter: () => FilterCondition; + /** Why the case is here — surfaced in failure output. */ + readonly note?: string; +} + +/** A case whose door-validated filter must be EVALUATED, matching exactly {@link expected}. */ +export interface ComparandTypeMatchesCase extends ComparandTypeCaseBase { + readonly verdict: 'matches'; + /** Ids of matching rows, ascending. */ + readonly expected: readonly string[]; +} + +/** + * A case whose door-validated filter must EXECUTE without refusal; the row set + * is deliberately not asserted (per-backend storage forms are another table's + * subject). + */ +export interface ComparandTypeCompilesCase extends ComparandTypeCaseBase { + readonly verdict: 'compiles'; +} + +/** A case the DOOR must refuse — before any driver runs. */ +export interface ComparandTypeRefusalCase extends ComparandTypeCaseBase { + readonly verdict: 'door-refusal'; + /** The ADR-0112 code the refusal must carry, beside `status: 400`. */ + readonly code: 'INVALID_FILTER'; + /** Substrings the refusal message must contain. */ + readonly mustMention: readonly string[]; +} + +export type ComparandTypeCase = + | ComparandTypeMatchesCase + | ComparandTypeCompilesCase + | ComparandTypeRefusalCase; + +/** + * The cases. Direction one (the six accepted types, operator form, + * implicit-equality form and list members), then direction two (the refused + * types at every judged position, the two worst measured cells included). + */ +export const FILTER_COMPARAND_TYPE_CASES: readonly ComparandTypeCase[] = [ + // ── Direction one: the six accepted types compile on every driver path ──── + { + name: 'string compiles and matches — operator form', + filter: () => ({ label: { $eq: 'beta' } }), + verdict: 'matches', + expected: ['2'], + }, + { + name: 'number compiles and matches — the #7956 control cell', + filter: () => ({ qty: { $eq: 100 } }), + verdict: 'matches', + expected: ['1'], + note: 'The matrix\'s control: this row returning on every driver is what made its zeros real answers.', + }, + { + name: 'bigint compiles and matches — the crash cell, dead (#7872)', + filter: () => ({ qty: { $eq: BigInt(100) } }), + verdict: 'matches', + expected: ['1'], + note: 'driver-memory answered this exact filter with a raw mingo TypeError out of Query.compile; ' + + 'the door narrows the bigint to its exact number, so no driver path sees a bigint at all.', + }, + { + name: 'bigint compiles and matches — implicit-equality form', + filter: () => ({ qty: BigInt(250) }), + verdict: 'matches', + expected: ['2'], + }, + { + name: 'bigint compiles and matches — as an $in member', + filter: () => ({ qty: { $in: [BigInt(100), 999] } }), + verdict: 'matches', + expected: ['1'], + note: '$in/$nin members are comparands in their own right (#5234) — the door narrows each.', + }, + { + name: 'boolean compiles and matches — implicit-equality form', + filter: () => ({ active: true }), + verdict: 'matches', + expected: ['1'], + }, + { + name: 'null compiles and matches — the declared null predicate', + filter: () => ({ note: null }), + verdict: 'matches', + expected: ['1'], + note: 'null IS a comparand and IS the null predicate (#6050\'s untouched half) — the door must not confuse it with undefined.', + }, + { + name: 'Date compiles — row agreement is temporal-conformance\'s subject', + filter: () => ({ label: { $gte: new Date('2020-01-01T00:00:00.000Z') as unknown as string } }), + verdict: 'compiles', + note: 'A Date comparand must pass the door and execute everywhere; what it MATCHES against a stored ' + + 'text/date column legitimately differs per storage form (ADR-0053), so no row set is asserted here.', + }, + + // ── Direction two: everything else is refused loudly, at the door ───────── + { + name: 'undefined is refused — operator form', + filter: () => ({ qty: { $eq: undefined as unknown as number } }), + verdict: 'door-refusal', + code: 'INVALID_FILTER', + mustMention: ['undefined', ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE], + }, + { + name: 'undefined is refused — implicit-equality form (the mongo silent-edit worst cell, dead)', + filter: () => ({ qty: undefined as unknown as number }), + verdict: 'door-refusal', + code: 'INVALID_FILTER', + mustMention: ['undefined', 'null'], + note: 'Measured on the wire: {qty: undefined} BSON-encoded to {} — MATCH EVERYTHING, the one cell ' + + 'that returned MORE data rather than less. It is also the one refused value that arrives by ' + + 'accident ({owner: someVar} with someVar unset), so the message carries the null/omit prescription.', + }, + { + name: 'undefined is refused — as an $in member', + filter: () => ({ qty: { $in: [100, undefined as unknown as number] } }), + verdict: 'door-refusal', + code: 'INVALID_FILTER', + mustMention: ['$in[1]'], + }, + { + name: 'a function is refused', + filter: () => ({ qty: { $eq: ((): number => 1) as unknown as number } }), + verdict: 'door-refusal', + code: 'INVALID_FILTER', + mustMention: ['a function', ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE], + }, + { + name: 'a Symbol is refused', + filter: () => ({ qty: { $eq: Symbol('x') as unknown as number } }), + verdict: 'door-refusal', + code: 'INVALID_FILTER', + mustMention: ['a Symbol', ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE], + }, + { + name: 'a Map is refused', + filter: () => ({ qty: { $eq: new Map() as unknown as number } }), + verdict: 'door-refusal', + code: 'INVALID_FILTER', + mustMention: ['a Map instance', ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE], + }, + { + name: 'a class instance is refused', + filter: () => ({ qty: { $eq: new ComparandTypeProbe() as unknown as number } }), + verdict: 'door-refusal', + code: 'INVALID_FILTER', + mustMention: ['instance', ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE], + }, + { + name: 'a class instance is refused — implicit-equality form', + filter: () => ({ qty: new ComparandTypeProbe() as unknown as number }), + verdict: 'door-refusal', + code: 'INVALID_FILTER', + mustMention: ['instance'], + note: 'On the wire this encoded to {qty: {"v":100}} — a document match the author never wrote.', + }, + { + name: 'a plain object in a scalar slot is refused', + filter: () => ({ qty: { $eq: { v: 100 } as unknown as number } }), + verdict: 'door-refusal', + code: 'INVALID_FILTER', + mustMention: ['a plain object', ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE], + note: 'The SQL family already refused this ("cannot be bound"); the door makes the answer uniform ' + + 'instead of deep-equality-on-two-drivers, refusal-on-three.', + }, + { + name: 'a bigint beyond ±2^53 is refused — precision loss must not answer silently', + filter: () => ({ qty: { $eq: (BigInt(2) ** BigInt(53) + BigInt(1)) as unknown as number } }), + verdict: 'door-refusal', + code: 'INVALID_FILTER', + mustMention: ['2^53', ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE], + }, +] as const; diff --git a/packages/spec/src/data/filter-comparand-type.test.ts b/packages/spec/src/data/filter-comparand-type.test.ts new file mode 100644 index 0000000000..8333a25a97 --- /dev/null +++ b/packages/spec/src/data/filter-comparand-type.test.ts @@ -0,0 +1,269 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7872] The comparand-type door — unit pins for the module itself, and for + * `parseFilterAST` as the compile face that runs it. + * + * The cross-driver pins (each accepted type compiles on every driver path, each + * refused type gets the loud refusal) live in + * `filter-comparand-type-conformance.ts` and the five driver suites that + * import it; this file pins the door's own contract — the set, the envelope, + * the copy-on-write narrowing, and the boundaries it deliberately does not + * cross. + */ + +import { describe, it, expect } from 'vitest'; +import { + ACCEPTED_FILTER_COMPARAND_TYPES, + ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, + FILTER_COMPARAND_BIGINT_EXACT_LIMIT, + isAcceptedFilterComparand, + normalizeFilterComparandTypes, + parseFilterAST, + FieldOperatorsSchema, +} from './index'; +import { StandardErrorCode } from '../api/errors.zod'; + +/** The refusal, caught — `toThrow()` alone carries one bit where this needs three (#6142/#6050). */ +function refusalOf(fn: () => unknown): (Error & { code?: string; status?: number }) | null { + try { + fn(); + return null; + } catch (e) { + return e as Error & { code?: string; status?: number }; + } +} + +class Money { + constructor(readonly v: number) {} +} + +describe('the accepted set (#7872 ruling)', () => { + it('is exactly the measured superset — string | number | bigint | boolean | null | Date', () => { + expect([...ACCEPTED_FILTER_COMPARAND_TYPES]).toEqual([ + 'string', 'number', 'bigint', 'boolean', 'null', 'Date', + ]); + // The sentence the SQL family's refusals quote — byte-identical to the + // wording driver-turso pinned before the door existed, so reconciling that + // driver to the door changes no message. + expect(ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE) + .toBe('a string, number, bigint, boolean, null or Date'); + }); + + it('isAcceptedFilterComparand answers the six types TRUE and the measured divergence rows FALSE', () => { + for (const v of ['x', 0, 100, NaN, 10n, true, false, null, new Date()]) { + expect(isAcceptedFilterComparand(v), String(typeof v)).toBe(true); + } + for (const v of [undefined, Symbol('x'), () => 1, new Map(), new Set(), new Money(1), {}, { a: 1 }, [1]]) { + expect(isAcceptedFilterComparand(v), Object.prototype.toString.call(v)).toBe(false); + } + }); + + it('the door code is the registered ADR-0112 code — the literal cannot drift from the enum', () => { + // `data/` cannot import `api/` (the reverse edge would be a cycle), so the + // module spells the literal; this pin is what keeps the two identical. + const err = refusalOf(() => normalizeFilterComparandTypes({ qty: Symbol('x') })); + expect(err?.code).toBe(StandardErrorCode.enum.INVALID_FILTER); + }); + + it('the judged operator vocabulary is reconciled against FieldOperatorsSchema — a new operator cannot skip the door silently', () => { + // The door hardcodes two sets (scalar-comparand + list-comparand) for + // walk-cost reasons; this pins their union to the schema's own keys. + const declared = Object.keys(FieldOperatorsSchema.shape).sort(); + const judgedScalar = [ + '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', + '$contains', '$notContains', '$startsWith', '$endsWith', '$icontains', + '$like', '$ilike', '$null', '$exists', + ]; + const judgedList = ['$in', '$nin', '$between']; + expect([...judgedScalar, ...judgedList].sort()).toEqual(declared); + // …and behaviourally: every declared scalar slot refuses a Symbol. + for (const op of judgedScalar) { + const err = refusalOf(() => normalizeFilterComparandTypes({ qty: { [op]: Symbol('x') } })); + expect(err?.code, op).toBe('INVALID_FILTER'); + expect(err?.status, op).toBe(400); + } + }); +}); + +describe('refusals — the measured divergence rows die at the door', () => { + // The #7956 matrix rows, at the operator form, the implicit-equality form, + // and as list members: previously crash (memory × BigInt beyond range), + // silent zero rows (memory), refusal (SQL family), or a silently EDITED wire + // document (mongo × undefined → {} = match everything, the worst cell). + const refused: Array<[string, unknown]> = [ + ['undefined', undefined], + ['a function', () => 1], + ['a Symbol', Symbol('x')], + ['a Map', new Map()], + ['a Set', new Set()], + ['a class instance', new Money(100)], + ]; + + it.each(refused)('refuses %s at the operator form { qty: { $eq: V } }', (_name, value) => { + const err = refusalOf(() => normalizeFilterComparandTypes({ qty: { $eq: value } })); + expect(err).not.toBeNull(); + expect(err?.code).toBe('INVALID_FILTER'); + expect(err?.status).toBe(400); + expect(err?.message).toContain('where.qty.$eq'); + expect(err?.message).toContain(ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE); + expect(err?.message).toContain('NOT applied'); + expect(err?.message.length).toBeLessThan(500); // the client bound (#5423) + }); + + it.each(refused)('refuses %s at the implicit-equality form { qty: V } — the mongo worst cell arrives here', (_name, value) => { + const err = refusalOf(() => normalizeFilterComparandTypes({ qty: value })); + expect(err?.code).toBe('INVALID_FILTER'); + expect(err?.status).toBe(400); + expect(err?.message).toContain('where.qty'); + }); + + it.each(refused)('refuses %s as an $in member — each member is a comparand in its own right (#5234)', (_name, value) => { + const err = refusalOf(() => normalizeFilterComparandTypes({ qty: { $in: [100, value] } })); + expect(err?.code).toBe('INVALID_FILTER'); + expect(err?.message).toContain('where.qty.$in[1]'); + }); + + it('the undefined refusal names the accident and the null prescription — it is the one value that arrives by mistake', () => { + const err = refusalOf(() => normalizeFilterComparandTypes({ owner: undefined })); + expect(err?.message).toMatch(/undefined/); + expect(err?.message).toMatch(/null/); + expect(err?.message).toMatch(/omit/); + }); + + it('refuses a PLAIN OBJECT where a scalar operator comparand belongs — the SQL family already did', () => { + const err = refusalOf(() => normalizeFilterComparandTypes({ qty: { $eq: { a: 1 } } })); + expect(err?.code).toBe('INVALID_FILTER'); + expect(err?.message).toContain('a plain object'); + }); + + it('refuses inside $and / $or / $not — the walk reaches every literal position', () => { + for (const filter of [ + { $and: [{ ok: 1 }, { qty: Symbol('x') }] }, + { $or: [{ qty: { $ne: new Map() } }] }, + { $not: { qty: undefined } }, + ]) { + const err = refusalOf(() => normalizeFilterComparandTypes(filter)); + expect(err?.code, JSON.stringify(Object.keys(filter))).toBe('INVALID_FILTER'); + } + }); + + it('a refusal fires on the FIRST bad comparand and names its path', () => { + const err = refusalOf(() => + normalizeFilterComparandTypes({ $and: [{ a: 1 }, { b: { $in: ['x', Symbol('y')] } }] })); + expect(err?.message).toContain('where.$and[1].b.$in[1]'); + }); +}); + +describe('bigint — accepted, and NARROWED copy-on-write (#7872; the memory crash cell dies here)', () => { + it('narrows an exact-range bigint to its number, without touching the caller’s object', () => { + const original = { qty: { $eq: BigInt(100) } }; + const out = normalizeFilterComparandTypes(original); + expect(out).toEqual({ qty: { $eq: 100 } }); + expect(typeof (out as any).qty.$eq).toBe('number'); + // Copy-on-write: the caller's bag is not edited under them… + expect(typeof original.qty.$eq).toBe('bigint'); + // …and a filter with nothing to narrow returns the SAME reference. + const clean = { qty: { $eq: 100 } }; + expect(normalizeFilterComparandTypes(clean)).toBe(clean); + }); + + it('narrows at the implicit form, inside list members, and under combinators', () => { + expect(normalizeFilterComparandTypes({ qty: 100n })).toEqual({ qty: 100 }); + expect(normalizeFilterComparandTypes({ qty: { $in: [1n, 2] } })).toEqual({ qty: { $in: [1, 2] } }); + expect(normalizeFilterComparandTypes({ qty: { $between: [1n, 9n] } })) + .toEqual({ qty: { $between: [1, 9] } }); + expect(normalizeFilterComparandTypes({ $or: [{ qty: { $gt: 5n } }] })) + .toEqual({ $or: [{ qty: { $gt: 5 } }] }); + }); + + it('2^53 itself is the last exact value — accepted on both signs', () => { + const max = FILTER_COMPARAND_BIGINT_EXACT_LIMIT; + expect(normalizeFilterComparandTypes({ qty: max })).toEqual({ qty: 2 ** 53 }); + expect(normalizeFilterComparandTypes({ qty: -max })).toEqual({ qty: -(2 ** 53) }); + }); + + it('a bigint beyond ±2^53 is refused loudly — precision loss must not answer silently', () => { + const err = refusalOf(() => + normalizeFilterComparandTypes({ qty: FILTER_COMPARAND_BIGINT_EXACT_LIMIT + 1n })); + expect(err?.code).toBe('INVALID_FILTER'); + expect(err?.status).toBe(400); + expect(err?.message).toContain('2^53'); + expect(err?.message.length).toBeLessThan(500); + }); +}); + +describe('boundaries the door deliberately does not cross', () => { + it('leaves a FieldReference alone at every position — #5222/#7596/#7597 own its fate', () => { + for (const filter of [ + { amount: { $gt: { $field: 'budget' } } }, + { amount: { $eq: { $field: 'budget' } } }, + { amount: { $field: 'budget' } }, + { amount: { $in: [{ $field: 'budget' }] } }, + ]) { + expect(normalizeFilterComparandTypes(filter)).toBe(filter); + } + }); + + it('does not descend into a no-$-key plain object — nested-relation / deep-equality structure (#5869 boundary)', () => { + const filter = { author: { name: 'x' } }; + expect(normalizeFilterComparandTypes(filter)).toBe(filter); + }); + + it('does not judge arrays outside the list operators — their semantics are per-driver today', () => { + const filter = { tags: ['a', 'b'] }; + expect(normalizeFilterComparandTypes(filter)).toBe(filter); + const eq = { tags: { $eq: ['a', 'b'] } }; + expect(normalizeFilterComparandTypes(eq)).toBe(eq); + }); + + it('does not judge an unknown or retired operator’s comparand — the downstream refusals carry the prescriptions', () => { + const unknown = { qty: { $wat: Symbol('x') } }; + expect(normalizeFilterComparandTypes(unknown)).toBe(unknown); + const retired = { name: { $regex: 'ac.*' } }; + expect(normalizeFilterComparandTypes(retired)).toBe(retired); + }); + + it('does not judge a non-array list-operator comparand — that SHAPE belongs to the engine’s #5869 gate', () => { + const filter = { stage: { $in: 'won' } }; + expect(normalizeFilterComparandTypes(filter)).toBe(filter); + }); + + it('keeps the zero-operator constraint for the driver refusal that names it (#5240)', () => { + const filter = { qty: {} }; + expect(normalizeFilterComparandTypes(filter)).toBe(filter); + }); +}); + +describe('parseFilterAST is the compile face that runs the door (#7872)', () => { + it('judges the OBJECT passthrough — the form that used to leave unexamined', () => { + const err = refusalOf(() => parseFilterAST({ qty: { $eq: Symbol('x') } })); + expect(err?.code).toBe('INVALID_FILTER'); + expect(err?.status).toBe(400); + }); + + it('judges the lowered AST form — a bad comparand in a triple is refused, not lowered', () => { + const err = refusalOf(() => parseFilterAST(['qty', '=', new Map()])); + expect(err?.code).toBe('INVALID_FILTER'); + const nested = refusalOf(() => parseFilterAST(['and', ['a', '=', 1], ['qty', '=', undefined]])); + expect(nested?.code).toBe('INVALID_FILTER'); + }); + + it('narrows a bigint arriving through either form', () => { + expect(parseFilterAST(['qty', '=', BigInt(100)])).toEqual({ qty: 100 }); + expect(parseFilterAST({ qty: { $eq: BigInt(100) } })).toEqual({ qty: { $eq: 100 } }); + }); + + it('returns the SAME object reference for a clean passthrough — the historical contract survives the door', () => { + const filter = { status: 'active', qty: { $gt: 5 } }; + expect(parseFilterAST(filter)).toBe(filter); + }); + + it('lowers a clean AST exactly as before the door', () => { + expect(parseFilterAST(['status', '=', 'active'])).toEqual({ status: 'active' }); + expect(parseFilterAST(['and', ['priority', '=', 'high'], ['status', '=', 'active']])) + .toEqual({ $and: [{ priority: 'high' }, { status: 'active' }] }); + expect(parseFilterAST([])).toBeUndefined(); + expect(parseFilterAST(null)).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/data/filter-comparand-type.ts b/packages/spec/src/data/filter-comparand-type.ts new file mode 100644 index 0000000000..fa118edf17 --- /dev/null +++ b/packages/spec/src/data/filter-comparand-type.ts @@ -0,0 +1,402 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7872] The Filter Protocol's **comparand-type door** — the one place that + * decides which JS types a LITERAL comparand may have, for every driver. + * + * ## The ruling this enforces + * + * Maintainer, 2026-08-12 (#7872): the shared filter-compilation face in + * `packages/spec` defines the accepted comparand-type set as the **measured + * superset — `string | number | bigint | boolean | null | Date`** — and + * refuses everything else loudly at the compile face. The earlier "any + * JSON-representable literal" wording was explicitly rejected: `bigint` is not + * JSON-representable yet was accepted by four of five drivers, so the set is + * defined from measurement (#7956's divergence matrix), not from a JSON slogan. + * + * ## Why a door, and why here + * + * The four-driver divergence matrix (#7956, measured on `main`) found two camps + * and one hole: the SQL family (`driver-sql`, the inherited + * `driver-sqlite-wasm`, `driver-turso` both transports) had converged twice + * independently on exactly this allow-list, in the ADR-0112 envelope; + * `driver-memory` crashed on `BigInt` (a raw mingo `TypeError` out of + * `Query.compile`) and answered silent zero rows for five other types; and + * `driver-mongodb` let the BSON encoder silently EDIT the document — + * `{qty: undefined}` encoded to `{}`, a filter that matches EVERY row (the + * matrix's worst cell: an amplifying, disclosure-shaped wrong answer when a + * tenant or RLS predicate sits in the same object). Both driver families that + * lack a policy are under the #5499 investment freeze, so the policy cannot be + * grown per driver; it is promoted here, to the face the SQL family already + * agrees with, and the frozen drivers inherit it by receiving already-validated + * input (they sit behind the engine's lowering seam — see + * `@objectstack/objectql`'s `lowerWhereFilterArray` — and behind + * {@link parseFilterAST}, which calls this walk on everything it returns). + * + * ## `bigint` is accepted — and NARROWED, copy-on-write + * + * `bigint` is in the accepted set (4/5 drivers execute it; `RemoteTransport` + * enumerates it as legal on purpose and a test pins the binding). But + * `driver-memory`'s engine (mingo) cannot serialize one at any value, and a + * `bigint` beyond ±2^53 silently loses precision on any backend that stores + * doubles — a comparison that runs and answers the wrong rows. So the door + * accepts a `bigint` whose value is exactly representable as a JS number and + * REWRITES it to that number (the same declared-conversion pattern as + * `RemoteTransport`'s `Date` → ISO 8601), and refuses one beyond ±2^53 loudly + * rather than letting the precision loss answer silently. This is what makes + * "each of the six accepted types compiles on every driver path" true on the + * memory path too, without touching the frozen driver: after this door, no + * `bigint` reaches mingo at all. Direct driver callers (not going through the + * platform's doors) keep the drivers' native `bigint` binding, which stays + * pinned in `driver-turso`'s own suite. + * + * ## What is a LITERAL comparand — the positions this door judges + * + * The six-type set governs literal comparands and nothing else: + * + * - the implicit-equality comparand — `{ qty: V }` — when `V` is not filter + * STRUCTURE (see below); + * - a declared operator's scalar comparand — `{ qty: { $eq: V } }`, and every + * other key of `FieldOperatorsSchema`; + * - each MEMBER of a list operator's array — `$in` / `$nin` / `$between` — a + * comparand in its own right (#5234's split, applied at the shared face). + * + * Deliberately NOT judged, each for a recorded reason: + * + * - **Filter structure.** A PLAIN object (prototype `Object.prototype` or + * `null`, the `isFilterNode` convention shared with `driver-sql` #5134) in a + * field's value position is an operator spec or a nested-relation / + * deep-equality condition — `assertListComparandShapes` (#5869) records why + * descending into the no-`$`-key form would invent a contract no backend + * agrees with. A plain object sitting where a SCALAR belongs — `{ $eq: {…} }` + * — IS judged, and refused, which is what the SQL family already did. + * - **A `FieldReference`** (`{ $field: 'other_column' }`) anywhere: it is not a + * literal, and its per-position legality is ruled elsewhere (#5222 compiles + * it on the ordering operators; #7596 rules it out of list members by name; + * #7597 pins the hand-authored implicit form's fate). The door steps around + * every one of those decisions. + * - **Arrays outside `$in`/`$nin`/`$between`.** An array in an implicit or + * scalar-operator position is answered per driver today (`driver-sql` refuses + * it with its own message; the document stores give it array-equality + * semantics); the matrix did not measure it and the ruling does not name it, + * so the door leaves it to the layers that already answer it. + * - **An operator outside the declared vocabulary** (`$wat`, retired `$regex`): + * the unknown-/retired-operator refusals downstream carry the specific + * prescriptions (`RETIRED_FILTER_OPERATORS`), which a generic type refusal + * here would preempt with a worse message. + * - **List-operator comparands that are not arrays at all** (`{ $in: 'won' }`): + * that is a SHAPE defect, owned by the engine's #5869 gate, whose wording + * (#5346/#5348) is pinned by consumers. + * + * ## Refusal envelope + * + * Every refusal carries `code: 'INVALID_FILTER'` and `status: 400` (ADR-0112 + * class 1 — a caller mistake). The literal is spelled here rather than imported + * from `../api/errors.zod` because `api/` imports from `data/` and the reverse + * edge would be a cycle; `filter-comparand-type.test.ts` pins the literal to + * `StandardErrorCode.enum.INVALID_FILTER` so the two cannot drift. Messages + * front-load operator, field, path, received type and the accepted set, and end + * with the "NOT applied" sentence, inside the 500-char client bound (#5423). + * + * @see FILTER_COMPARAND_TYPE_CASES — the conformance table every driver runs. + * @see https://github.com/objectstack-ai/objectstack/issues/7872 (the ruling) + * @see https://github.com/objectstack-ai/objectstack/issues/7956 (the matrix) + */ + +/** + * The accepted comparand-type set, as type names. The order is the SQL + * family's established message order, which {@link ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} + * renders and the drivers' own refusals now quote instead of hand-copying. + */ +export const ACCEPTED_FILTER_COMPARAND_TYPES = [ + 'string', + 'number', + 'bigint', + 'boolean', + 'null', + 'Date', +] as const; + +/** + * The accepted set as the sentence fragment the platform's refusals share — + * byte-identical to the wording `driver-turso`'s `RemoteTransport` pinned in + * its own suite before this door existed, so reconciling that driver to the + * door changes no message. + */ +export const ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE = + 'a string, number, bigint, boolean, null or Date'; + +/** + * The largest `bigint` magnitude the door will narrow to a JS number: 2^53, + * the last integer `number` represents exactly (`Number.MAX_SAFE_INTEGER` is + * 2^53 − 1, and 2^53 itself is still exact). Beyond it, `Number(v) !== v` and + * a comparison built on the narrowed value would answer the wrong rows without + * a word — the silent-wrong-answer direction the whole door exists to refuse. + */ +export const FILTER_COMPARAND_BIGINT_EXACT_LIMIT = 2n ** 53n; + +/** + * Is `value` one of the six accepted literal comparand types? + * + * This predicate is the SET, single-sourced: `driver-sql`'s + * `isBindableComparand` / `isRenderableTextComparand` and `driver-turso`'s + * `RemoteTransport.serializeComparand` delegate their type membership here + * (each keeps its own envelope and its recorded driver-local extras — binary + * bindables, the unreachable `undefined` arm — declared at the use site). + * + * Note `bigint` answers TRUE at any magnitude: membership is a TYPE question. + * The magnitude rule belongs to {@link normalizeFilterComparandTypes}, which is + * where a bigint is narrowed or refused. + */ +export function isAcceptedFilterComparand(value: unknown): boolean { + if (value === null) return true; + switch (typeof value) { + case 'string': + case 'number': + case 'bigint': + case 'boolean': + return true; + default: + return value instanceof Date; + } +} + +/** + * The declared field-operator vocabulary, split by what the comparand IS. + * `filter-comparand-type.test.ts` reconciles the union of these two sets + * against `FieldOperatorsSchema`'s own keys, so an operator added to the + * schema cannot silently skip the door. + */ +const SCALAR_COMPARAND_OPERATORS: ReadonlySet = new Set([ + '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', + '$contains', '$notContains', '$startsWith', '$endsWith', '$icontains', + '$like', '$ilike', + '$null', '$exists', +]); + +const LIST_COMPARAND_OPERATORS: ReadonlySet = new Set([ + '$in', '$nin', '$between', +]); + +/** + * Filter STRUCTURE rather than a comparand: a PLAIN object — prototype + * `Object.prototype` or `null`. The same load-bearing prototype check as + * `driver-sql`'s `isFilterNode` (#5134): a `Date`, a `Map` or a class instance + * satisfies `typeof x === 'object'` while being data, not structure. + */ +function isFilterNode(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * A `FieldReferenceSchema` comparand — `{ $field: 'other_column' }`. Matches + * `parseFilterAST`'s `isFieldReferenceComparand` and `driver-sql`'s + * `fieldReferenceOf`: the `$field` value must be a string, or the object is + * not a reference on any path. + */ +function isFieldReference(value: unknown): boolean { + return isFilterNode(value) && typeof value.$field === 'string'; +} + +/** The word the refusal uses for the offending value's type. */ +function describeComparandType(value: unknown): string { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (Array.isArray(value)) return 'an array'; + const kind = typeof value; + if (kind === 'function') return 'a function'; + if (kind === 'symbol') return 'a Symbol'; + if (kind !== 'object') return `a ${kind}`; + const ctor = (value as { constructor?: { name?: string } }).constructor; + return ctor?.name && ctor.name !== 'Object' ? `a ${ctor.name} instance` : 'a plain object'; +} + +/** + * A short, bounded rendering of the offending value — for a human reading a + * 400, not a dump. Bounded because the whole message is truncated at the + * 500-char client bound (#5423), so everything load-bearing is front-loaded. + */ +function preview(value: unknown): string { + let text: string; + try { + if (typeof value === 'bigint') text = `${value}n`; + else if (typeof value === 'symbol' || typeof value === 'function') text = String(value); + else text = JSON.stringify(value) ?? String(value); + } catch { + text = String(value); + } + return text.length > 40 ? `${text.slice(0, 39)}…` : text; +} + +/** + * The wire envelope every filter refusal on the platform carries — ADR-0112 + * class 1. See the module note for why the code is a literal here. + */ +function invalidComparandError(context: string | undefined, message: string): Error { + const err = new Error(`${context ? `${context}: ` : ''}${message}`) as Error & { + code?: string; + status?: number; + }; + err.code = 'INVALID_FILTER'; + err.status = 400; + return err; +} + +const NOT_APPLIED = + 'The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.'; + +/** `undefined` gets its own sentence — it is the one refused value that arrives by ACCIDENT. */ +function undefinedComparandRefusal(context: string | undefined, path: string): Error { + return invalidComparandError( + context, + `Filter comparand at ${path} is undefined. { key: undefined } cannot be told apart from an ` + + `omitted key, yet the two mean OPPOSITE things (a predicate vs no constraint) — one ` + + `backend even encoded it as MATCH EVERYTHING. Write null for the null predicate, or omit ` + + `the key. A comparison value must be ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE}. ` + + NOT_APPLIED, + ); +} + +function comparandTypeRefusal(context: string | undefined, path: string, value: unknown): Error { + return invalidComparandError( + context, + `Filter comparand at ${path} is ${describeComparandType(value)} (${preview(value)}), which ` + + `no driver can compare. A comparison value must be ` + + `${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE}. Refusing rather than guessing: the backends ` + + `disagreed on this input (crash / zero rows / silently edited query). ${NOT_APPLIED}`, + ); +} + +function bigintPrecisionRefusal(context: string | undefined, path: string, value: bigint): Error { + return invalidComparandError( + context, + `Filter comparand at ${path} is the bigint ${preview(value)}, whose magnitude exceeds 2^53 ` + + `— it has no exact JS-number form, so a comparison built on it would silently answer the ` + + `wrong rows on double-storing backends. Compare within ±2^53, or store and compare the ` + + `value as a string. A comparison value must be ` + + `${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE}. ${NOT_APPLIED}`, + ); +} + +/** The verdict for one literal comparand position. */ +type ComparandVerdict = + | { kind: 'keep' } + | { kind: 'narrow'; value: number } + | { kind: 'refuse'; error: Error }; + +/** + * Judge one LITERAL comparand. `skip` answers (as `keep`) the shapes the door + * deliberately does not judge — see the module note's list. + */ +function judgeLiteralComparand( + context: string | undefined, + path: string, + value: unknown, +): ComparandVerdict { + if (value === undefined) return { kind: 'refuse', error: undefinedComparandRefusal(context, path) }; + if (typeof value === 'bigint') { + if (value <= FILTER_COMPARAND_BIGINT_EXACT_LIMIT && value >= -FILTER_COMPARAND_BIGINT_EXACT_LIMIT) { + return { kind: 'narrow', value: Number(value) }; + } + return { kind: 'refuse', error: bigintPrecisionRefusal(context, path, value) }; + } + if (isAcceptedFilterComparand(value)) return { kind: 'keep' }; + // Not judged: arrays (per-driver semantics outside the list operators), + // field references, and plain-object filter STRUCTURE — the caller routes + // those before asking. What reaches this line is a non-plain object + // (Map / Set / class instance), a Symbol or a function. + if (Array.isArray(value) || isFieldReference(value)) return { kind: 'keep' }; + return { kind: 'refuse', error: comparandTypeRefusal(context, path, value) }; +} + +/** + * Walk one `FilterCondition` and enforce the comparand-type set on every + * literal comparand position; narrow exact-range bigints copy-on-write. + * + * Returns the SAME reference when nothing narrowed (the overwhelmingly common + * path allocates nothing — the same contract as the engine's lowering seam); + * throws the ADR-0112 `INVALID_FILTER` / 400 envelope on the first refused + * comparand. + * + * `context` is an optional caller prefix (`find('deal')`) matching the + * engine's refusal wording contract (#5346); the spec-level callers pass none. + */ +export function normalizeFilterComparandTypes(node: T, context?: string, path = 'where'): T { + if (!isFilterNode(node)) return node; + let out: Record | undefined; + for (const [key, value] of Object.entries(node)) { + const here = `${path}.${key}`; + let next: unknown = value; + if (key === '$and' || key === '$or') { + if (Array.isArray(value)) { + let copy: unknown[] | undefined; + value.forEach((child, index) => { + const normalized = normalizeFilterComparandTypes(child, context, `${here}[${index}]`); + if (normalized !== child) { + copy ??= [...value]; + copy[index] = normalized; + } + }); + if (copy) next = copy; + } + } else if (key === '$not') { + next = normalizeFilterComparandTypes(value, context, here); + } else if (!key.startsWith('$')) { + next = normalizeFieldSpec(context, here, value); + } + // Any other `$` key at node level is a logical operator this door does not + // judge — an unknown one is already refused downstream, by name. + if (next !== value) { + out ??= { ...(node as Record) }; + out[key] = next; + } + } + return (out ?? node) as T; +} + +/** One field constraint: `{ field: }`. */ +function normalizeFieldSpec(context: string | undefined, path: string, spec: unknown): unknown { + // A plain object is filter STRUCTURE: an operator spec, a nested-relation / + // deep-equality condition (not descended into — see the module note), or a + // field reference (stepped around — #7597 owns its fate). + if (isFilterNode(spec)) { + if (isFieldReference(spec)) return spec; + const keys = Object.keys(spec); + if (!keys.some((key) => key.startsWith('$'))) return spec; + let out: Record | undefined; + for (const [op, comparand] of Object.entries(spec)) { + let next: unknown = comparand; + if (SCALAR_COMPARAND_OPERATORS.has(op)) { + next = applyVerdict(judgeLiteralComparand(context, `${path}.${op}`, comparand), comparand); + } else if (LIST_COMPARAND_OPERATORS.has(op) && Array.isArray(comparand)) { + // Each member is a comparand in its own right (#5234). A non-array + // comparand here is a SHAPE defect owned by the engine's #5869 gate. + let copy: unknown[] | undefined; + comparand.forEach((member, index) => { + const verdict = judgeLiteralComparand(context, `${path}.${op}[${index}]`, member); + if (verdict.kind === 'refuse') throw verdict.error; + if (verdict.kind === 'narrow') { + copy ??= [...comparand]; + copy[index] = verdict.value; + } + }); + if (copy) next = copy; + } + if (next !== comparand) { + out ??= { ...spec }; + out[op] = next; + } + } + return out ?? spec; + } + // Everything else in a field's value position is the implicit-equality + // LITERAL comparand. + return applyVerdict(judgeLiteralComparand(context, path, spec), spec); +} + +function applyVerdict(verdict: ComparandVerdict, value: unknown): unknown { + if (verdict.kind === 'refuse') throw verdict.error; + return verdict.kind === 'narrow' ? verdict.value : value; +} diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 9ed7092e88..dcdd981a0f 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; +import { normalizeFilterComparandTypes } from './filter-comparand-type'; /** * Unified Query DSL Specification @@ -1709,6 +1710,19 @@ function convertComparison(node: [string, string, unknown]): FilterCondition { * If the input is already a FilterCondition object (not an array), it is returned as-is. * If the input is `null` or `undefined`, it is returned as-is. * + * ## This is the comparand-type door (#7872) + * + * Whatever this function returns — the lowered AST form or the object + * passthrough — has passed {@link normalizeFilterComparandTypes} first: every + * LITERAL comparand is one of the accepted six types + * (`string | number | bigint | boolean | null | Date`), an exact-range + * `bigint` has been narrowed to its number (copy-on-write, so the object + * passthrough returns the same reference unless a bigint was narrowed), and + * everything else has been refused with the `INVALID_FILTER` / 400 envelope. + * The object form used to pass through this function UNEXAMINED, which is how + * five drivers came to answer an unsupported comparand type five ways + * (#7956's divergence matrix); the door module's header carries the ruling. + * * @example * // Simple condition * parseFilterAST(["status", "=", "active"]) @@ -1725,6 +1739,16 @@ function convertComparison(node: [string, string, unknown]): FilterCondition { * // → { status: "active" } */ export function parseFilterAST(filter: unknown): FilterCondition | undefined { + const lowered = lowerFilterAST(filter); + return lowered === undefined ? undefined : normalizeFilterComparandTypes(lowered); +} + +/** + * The lowering half of {@link parseFilterAST} — exactly its historical body, + * recursion included, split out so the comparand-type door (#7872) runs ONCE + * over the finished condition rather than once per recursion level. + */ +function lowerFilterAST(filter: unknown): FilterCondition | undefined { if (filter == null) return undefined; if (!Array.isArray(filter)) return filter as FilterCondition; if (filter.length === 0) return undefined; @@ -1734,7 +1758,7 @@ export function parseFilterAST(filter: unknown): FilterCondition | undefined { // Logical node: ["and", cond1, cond2, ...] or ["or", cond1, cond2, ...] if (typeof first === 'string' && (first.toLowerCase() === 'and' || first.toLowerCase() === 'or')) { const logicOp = `$${first.toLowerCase()}` as '$and' | '$or'; - const children = filter.slice(1).map((child: unknown) => parseFilterAST(child)).filter(Boolean) as FilterCondition[]; + const children = filter.slice(1).map((child: unknown) => lowerFilterAST(child)).filter(Boolean) as FilterCondition[]; if (children.length === 0) return undefined; if (children.length === 1) return children[0]; return { [logicOp]: children } as FilterCondition; @@ -1748,7 +1772,7 @@ export function parseFilterAST(filter: unknown): FilterCondition | undefined { // Legacy flat array: [[field, op, val], [field, op, val], ...] // All elements are sub-arrays → treat as implicit AND if (filter.every((item: unknown) => Array.isArray(item))) { - const children = filter.map((child: unknown) => parseFilterAST(child)).filter(Boolean) as FilterCondition[]; + const children = filter.map((child: unknown) => lowerFilterAST(child)).filter(Boolean) as FilterCondition[]; if (children.length === 0) return undefined; if (children.length === 1) return children[0]; return { $and: children } as FilterCondition; diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index 36c8792d6e..97527ea83f 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -2,6 +2,15 @@ export * from './query.zod'; export * from './filter.zod'; +// The comparand-type door (#7872) — the accepted literal comparand set +// (`string | number | bigint | boolean | null | Date`, the measured superset +// of #7956's divergence matrix), the walk `parseFilterAST` and the engine's +// lowering seam enforce it with, and the sentence the SQL family's refusals +// quote instead of hand-copying. Everything outside the set is refused with +// the `INVALID_FILTER` / 400 envelope at the compile face, so the frozen +// drivers (#5499) inherit one answer instead of crashing (memory × BigInt) or +// letting the BSON encoder edit the query (mongo × undefined → match-all). +export * from './filter-comparand-type'; // Canonical conformance cases for the filter logical combinators — the shared // standard the five independent FilterCondition backends are each checked // against, so they cannot drift apart again (#3774; the fifth — MongoDB's @@ -22,6 +31,13 @@ export * from './filter-verdict'; // explicitly out of that table's scope, and this one needs an `expectRejection` // discriminant it deliberately never grew (#5701). export * from './filter-text-conformance'; +// Canonical conformance cases for the comparand-type door (#7872) — each of +// the six accepted types compiles on every driver path, and each refused type +// gets the loud INVALID_FILTER refusal at the door, the two worst measured +// cells (mongo × undefined silent-edit, memory × BigInt crash) included. A +// sibling of the text table for the same reason that table is a sibling of the +// logic table: comparand TYPE is its own axis, out of both of their scopes. +export * from './filter-comparand-type-conformance'; export * from './temporal-conformance'; // Canonical conformance cases for deterministic paged reads — the standard // every driver's `find()` is held to whenever `limit`/`offset` slice the result diff --git a/scripts/check-driver-conformance.mjs b/scripts/check-driver-conformance.mjs index 35a447c4f0..d121c79c08 100644 --- a/scripts/check-driver-conformance.mjs +++ b/scripts/check-driver-conformance.mjs @@ -166,6 +166,11 @@ const CASE_SETS = [ marker: 'AGGREGATION_CASES', what: 'the value each declared AggregationFunction produces, dedup and NULLs included — #6409', }, + { + file: 'filter-comparand-type-conformance.ts', + marker: 'FILTER_COMPARAND_TYPE_CASES', + what: 'the comparand-type door: the six accepted types compile everywhere, everything else is refused loudly — #7872', + }, ]; // ── The ledger ──────────────────────────────────────────────────────────────