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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/comparand-type-door-7872.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string[]> => {
const rows = await driver.find(TABLE, { fields: ['id'], where });
return (rows as Array<Record<string, unknown>>)
.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/);
});
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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<string, unknown>);
const ops = keys.filter((k) => k.startsWith('$'));
if (ops.length === keys.length && keys.length > 0) {
return matchOps(value, cond as Record<string, unknown>);
}
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<string, unknown>): 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<string, unknown>))) 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<string, unknown>))) 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, unknown>): string[] {
const wire = BSON.deserialize(BSON.serialize(doc)) as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const wire = BSON.deserialize(BSON.serialize(doc)) as Record<string, unknown>;
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']);
});
});
Original file line number Diff line number Diff line change
@@ -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<string[]> => {
const rows = await driver.find(TABLE, { fields: ['id'], where });
return rows.map((r: any) => String(r.id)).sort((x, y) => x.localeCompare(y));
};

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

it('the fixture really is both rows', async () => {
expect(await ids(undefined)).toEqual(['1', '2']);
});
});
Loading
Loading