diff --git a/.changeset/21333-objectql-boolean-comparand-door.md b/.changeset/21333-objectql-boolean-comparand-door.md new file mode 100644 index 00000000000..db91ee4fb6f --- /dev/null +++ b/.changeset/21333-objectql-boolean-comparand-door.md @@ -0,0 +1,31 @@ +--- +"@objectstack/objectql": minor +--- + +fix(objectql)!: a comparand against a declared boolean field is narrowed to its boolean at the engine's filter door, and any string other than "true" / "false" / "1" / "0" is refused with `INVALID_FILTER` / 400 + +Clause-②: yes (narrowing) + + + +**BREAKING**: this narrows what a filter may compare a declared `boolean` or `toggle` field with, at every filter position and through every door that reaches the engine's filter walk (`engine.find` / `findOne` / `count` / `aggregate` / `update` / `delete`, and every spelling the data API hands it). It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes. + +**What was accepted before.** A string compared with a boolean field was neither refused nor read as a boolean: the engine handed it to the driver as written, and every answer was a 200. Measured on two rows (one `true`, one `false`) on InMemoryDriver and on SqlDriver over SQLite, through `engine.find`, `engine.aggregate` and the protocol's `findData` with each spelling the `POST /api/v1/data/:object/query` and `GET /api/v1/data/:object` routes hand it: + +- `"true"` (implicit, `$eq`, `$in`) and `"false"` (implicit), and both through `?filter=`, `?$filter=`, the filter AST and the bare query parameter (`?flag=true`), matched no row on either driver; +- `$ne "true"` and `$nin ["true"]` returned both rows, the true row included; +- `"yes"` matched no row, and `$ne "yes"` both rows; +- `1`, `"1"`, `0` and `"0"` at `where` (and `"1"` / `"0"` through every spelling above) matched the right row on SQLite and no row on InMemoryDriver (`$ne 1` returned both rows there); +- the per-aggregation `filter` and `having` (the engine's own evaluator) answered `"true"` with no row and no group, and `$ne "true"` with every one. + +**What is answered now.** At `where` (both spellings), the per-aggregation `filter` and `having`, on every verb that collects a filter, before any driver is asked for a row: + +- `true` / `false` are handed to the driver as written; +- `1` / `0`, `"1"` / `"0"` and `"true"` / `"false"` are narrowed to `true` / `false`, so every driver receives the one boolean each names. Measured on InMemoryDriver and on SqlDriver over SQLite, `?flag=true` and `?flag=1` now return the true row; any other driver receives the same narrowed boolean by mechanism (PostgreSQL and MySQL not measured); +- any other string, a different letter case (`"TRUE"`), surrounding whitespace, a blank and a `{placeholder}` included, is refused `INVALID_FILTER` / 400. The message names the field, its declared type, the comparand and its position, and says what is wrong with it. + +The accepted set is the one the record validator already admits when a boolean field is WRITTEN. The rule lives in `@objectstack/spec/data`'s `filter-boolean-comparand-declared-type.ts`, and the engine applies it in the same walk that judges number comparands. + +**The remedy.** Write `true` or `false`. In a querystring, where every value is a string, write `true` / `false` or `1` / `0`. + +**Unchanged.** A boolean comparand, `null` (the null test) and the flag operators (`$null`, `$exists`, `$empty`) answer as before, and so does every comparand against a field that is not boolean. A number other than `1` / `0` against a boolean field is still handed to the driver as written. A filter on a `formula` field is still refused one step earlier, as before. diff --git a/.changeset/21333-spec-boolean-comparand-contract.md b/.changeset/21333-spec-boolean-comparand-contract.md new file mode 100644 index 00000000000..df894647417 --- /dev/null +++ b/.changeset/21333-spec-boolean-comparand-contract.md @@ -0,0 +1,19 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): the boolean-comparand declared-type contract in `@objectstack/spec/data` — the comparands a declared boolean field accepts in a filter, the boolean each narrows to, and the refusal words + +Clause-②: yes + +**What it declares.** `filter-boolean-comparand-declared-type.ts`, the boolean twin of `filter-number-comparand-declared-type.ts`: + +- `BOOLEAN_COMPARAND_SPELLINGS`: the accepted non-boolean spellings, `1` / `0`, `"1"` / `"0"` and `"true"` / `"false"`, each with the boolean it narrows to. This is the set the record validator admits when a boolean field is written. `readBooleanComparand` reads a comparand by it, and names why a string is not one (`NON_BOOLEAN_STRING_FORMS`: `empty`, `padded`, `letter-case`, `placeholder`, `not-a-boolean`). +- `BOOLEAN_COMPARAND_DOOR_JUDGED_TYPES` (`BOOLEAN_VALUE_TYPES` itself), and the judged positions, which are the number door's lists by identity. +- `booleanComparandFieldVerdict` and `booleanComparandDoorVerdict`, the pure verdict: `narrows`, `door-refusal` (`INVALID_FILTER` / 400), `passes` or `deferred`. +- `booleanComparandRefusalMessage`: the refusal words, inside the 500-character client bound. +- `BOOLEAN_COMPARAND_READING_CASES`, `BOOLEAN_COMPARAND_DOOR_FIXTURE` and the derived `BOOLEAN_COMPARAND_DOOR_CASES`, for a door's suite to drive. + +**What the verdict answers `door-refusal` for.** A string other than the four accepted ones, compared with a declared boolean field, at the value positions of a filter (the implicit comparand, `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte`, and each member of `$in` / `$nin` / `$between`). + +**What moves for consumers.** Nothing in this package refuses or narrows a filter, and every existing export is unchanged. The door that applies the verdict ships in the same release in `@objectstack/objectql`, whose changeset states what changes for a caller. diff --git a/packages/objectql/src/boolean-comparand-declared-type-door.ts b/packages/objectql/src/boolean-comparand-declared-type-door.ts new file mode 100644 index 00000000000..c922944c908 --- /dev/null +++ b/packages/objectql/src/boolean-comparand-declared-type-door.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21333] The BOOLEAN-comparand arm of the engine's one field-aware filter + * walk — the boolean twin of the number-comparand door (#20351), at the same + * seam, the same positions and the same three filter positions (`where` on + * both spellings, the per-aggregation `filter`, `having`). + * + * ## What it answers + * + * A comparand against a declared boolean field (`boolean`, `toggle`, a + * `formula` returning `boolean`): + * + * - `true` / `false` pass, as written; + * - `1` / `0`, `"1"` / `"0"` and `"true"` / `"false"` NARROW to `true` / + * `false`, copy-on-write, so every backend receives the one boolean each + * spelling names — a bare query parameter (`?active=true`) is always a + * string, and before this arm `"true"` matched no row on any driver while + * `1` matched no row on InMemoryDriver; + * - any other string (`"yes"`, `"TRUE"`, `""`, a `{placeholder}`) is refused + * `INVALID_FILTER` / 400, naming the field and its declared type, before + * any driver is resolved. + * + * The contract — the accepted spellings, the pure verdict, the refusal words, + * the case table — is lane (1), `@objectstack/spec/data`'s + * `filter-boolean-comparand-declared-type.ts`. ⛔ Nothing here reads a + * spelling: the verdict does. + * + * ## Why an arm and not a door of its own + * + * `number-comparand-declared-type-door.ts`'s `walkCondition` is the one filter + * walk the engine runs at all three positions with each column's declaration + * in hand, and every position-specific fact (the site kind, the relation arm, + * the copy-on-write discipline, the depth bound and the combinators descended) + * lives in it. A second walk would redraw every one of those boundaries. So + * the walk asks this arm at each field key the number arm does not judge (the + * two classes are disjoint), exactly as it asks the no-operator-object arm + * (`no-operator-object-door.ts`) — and, like that module, ⛔ nothing here + * walks a filter. + * + * ## One door for every surface + * + * Every REST spelling reaches the walk unchanged: the `POST …/query` body's + * `where`, the `filter` / `$filter` JSON and the `FilterArray` sugar + * (`parseFilterAST` lowers it first), and the bare query parameters, which + * `metadata-protocol`'s `findData` folds into an implicit `where` of strings. + * ⛔ No per-door coercion: the REST layer and the protocol hand the strings + * through, and this arm is the only place one becomes a boolean. + * + * @see booleanComparandDoorVerdict — the pure verdict (lane 1, `@objectstack/spec`). + * @see https://github.com/objectstack-ai/objectstack/issues/21333 + */ + +import { + booleanComparandDoorVerdict, + booleanComparandFieldVerdict, + booleanComparandRefusalMessage, + type BooleanComparandDoorFieldMeta, + type BooleanComparandRefusalSite, +} from '@objectstack/spec/data'; + +/** A comparand the arm refuses: the site the contract's words are written from. */ +export type NonBooleanComparand = BooleanComparandRefusalSite; + +/** The arm's answer for one comparand: kept (possibly narrowed), or refused at a site. */ +export type BooleanArmAnswer = + | { readonly refused: false; readonly value: unknown } + | { readonly refused: true; readonly site: NonBooleanComparand }; + +/** + * The field meta the arm judges, or `null` when it does not judge this + * declaration — the spec's field verdict decides (`deferred` and + * `not-judged` are both "nothing to judge here"), never a list here. + */ +export function booleanArmFieldMeta(meta: BooleanComparandDoorFieldMeta | null): BooleanComparandDoorFieldMeta | null { + return meta !== null && booleanComparandFieldVerdict(meta) === 'judged' ? meta : null; +} + +/** + * One comparand at a judged position: the spec's verdict, routed. `aggregated` + * is the walk's site fact — `having`'s columns are the aggregated row's, not a + * declared field — and only ever written when true. + */ +export function judgeBooleanComparand( + meta: BooleanComparandDoorFieldMeta, + field: string, + comparand: unknown, + path: string, + aggregated: boolean, +): BooleanArmAnswer { + const verdict = booleanComparandDoorVerdict(meta, comparand); + if (verdict.verdict === 'narrows') return { refused: false, value: verdict.value }; + if (verdict.verdict !== 'door-refusal') return { refused: false, value: comparand }; + return { + refused: true, + site: { + field, + declaredType: meta.type, + ...(meta.returnType === undefined ? {} : { returnType: meta.returnType }), + path, + value: comparand, + form: verdict.form, + ...(aggregated ? { aggregated: true as const } : {}), + }, + }; +} + +/** The arm's refusal words — the contract's, behind the engine's caller prefix. */ +export function nonBooleanComparandRefusalMessage(site: NonBooleanComparand, context: string): string { + return booleanComparandRefusalMessage(site, context); +} diff --git a/packages/objectql/src/engine-boolean-comparand-declared-type-door.test.ts b/packages/objectql/src/engine-boolean-comparand-declared-type-door.test.ts new file mode 100644 index 00000000000..78950021a0d --- /dev/null +++ b/packages/objectql/src/engine-boolean-comparand-declared-type-door.test.ts @@ -0,0 +1,455 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21333] The BOOLEAN-comparand arm of the engine's field-aware filter walk — + * lane (2) of the boolean twin of the number-comparand door. The contract (the + * accepted spellings, the verdict, the words, the fixture and the derived case + * table) is `@objectstack/spec/data`'s `filter-boolean-comparand-declared-type.ts`; + * this file is its CONSUMER, driven the way that module prescribes, through a + * recording driver. + * + * ## What ran before the arm, measured on `origin/main` `6c5bef5f4` + * + * Two rows of a declared `boolean` field (one `true`, one `false`), through + * `engine.find`, `engine.aggregate` (`where`, the per-aggregation `filter`) + * and every spelling the REST doors hand `findData` (the `POST …/query` body's + * `where`, `?filter=` JSON, `?$filter=`, a `FilterArray`, a bare `?f=` query + * parameter), on InMemoryDriver and on SqlDriver over SQLite: + * + * | comparand | InMemoryDriver | SQLite | correct | + * |:--|:--|:--|:--| + * | `"true"` / `"false"` — implicit, `$eq`, `$in`, every REST spelling | 0 rows | 0 rows | 1 row | + * | `$ne "true"`, `$nin ["true"]`, `$ne "false"` | **2 rows** | **2 rows** | 1 row | + * | `"yes"` / `"TRUE"` (`$ne "yes"`) | 0 rows (2) | 0 rows (2) | 400 | + * | `1` / `"1"` / `0` / `"0"` — `where` and every REST spelling | **0 rows** | 1 row | 1 row | + * | `$ne 1` / `$ne "1"` | **2 rows** | 1 row | 1 row | + * | `true` / `false` / `$ne true` / `$in [true]` (the controls) | 1 row | 1 row | 1 row | + * + * The per-aggregation `filter` (the engine's own evaluator) answered `"true"` + * with 0, `$ne "true"` with 2 and `1` / `"1"` with 1, on both drivers; `having` + * over a groupBy of the field kept no group for `"true"` and both groups for + * `$ne "true"`. After the arm every cell answers its correct column on both + * drivers (re-measured on the same harness against freshly built dists). + * + * ## Why a recording driver carries the pin + * + * The arm answers before any driver is resolved: a refusal reads nothing, and + * a narrowed comparand reaches the driver as the very boolean its control + * hands it — pinned below as byte-identical driver input, so each driver's + * answer for `"true"` IS its answer for `true`, which the table measured + * correct on both. This package depends on neither driver, and the in-memory + * driver's test consumers are a closed census (`check:driver-memory-census`). + * + * @see https://github.com/objectstack-ai/objectstack/issues/21333 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { + BOOLEAN_COMPARAND_DOOR_CASES, + BOOLEAN_COMPARAND_DOOR_FIXTURE, + BOOLEAN_COMPARAND_DOOR_FIXTURE_OBJECT, + BOOLEAN_COMPARAND_DOOR_LIST_OPERATORS, + BOOLEAN_COMPARAND_DOOR_SCALAR_OPERATORS, + NON_BOOLEAN_STRING_FORMS, + lowerFilterCondition, + type BooleanComparandDoorCase, + type BooleanComparandDoorNarrowsCase, + type BooleanComparandDoorRefusalCase, + type EngineAggregateOptions, + type EngineQueryOptions, + type FilterCondition, +} from '@objectstack/spec/data'; +import { ObjectQL } from './engine.js'; +import { narrowHavingNumberComparands, narrowNumberComparands } from './number-comparand-declared-type-door.js'; + +const OBJECT = BOOLEAN_COMPARAND_DOOR_FIXTURE_OBJECT; + +/** What a driver receives is the door's output after the engine's shared lowering (the NULL-polarity guards). */ +const lowered = (where: unknown): unknown => lowerFilterCondition(where, { isDatetimeColumn: () => false }); + +interface SeenRead { ast: any } + +/** Minimal recording driver — the witness shape the sibling door suites use. */ +function makeRecordingDriver() { + const rows = new Map>(); + const reads: SeenRead[] = []; + const writes: SeenRead[] = []; + const run = (_ast: any) => [...rows.values()]; + 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 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) ?? {}; + const up = { ...cur, ...data, id }; rows.set(id, up); return up; + }, + async updateMany(_o: string, ast: any) { writes.push({ ast }); return 0; }, + async delete(_o: string, id: string) { return rows.delete(id); }, + async deleteMany(_o: string, ast: any) { writes.push({ ast }); return 0; }, + 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 }; +} + +type Thrown = (Error & { code?: string; status?: number; httpStatus?: number }) | null; + +const refusalOf = async (p: Promise): Promise => + p.then(() => null, (e: any) => e as Error & { code?: string; status?: number }); + +const isFormulaCase = (c: BooleanComparandDoorCase): boolean => c.declaredType === 'formula'; + +const SEEDED = [ + { id: 'rt', f_text: 'a', f_boolean: true, f_toggle: true }, + { id: 'rf', f_text: 'b', f_boolean: false, f_toggle: false }, +]; + +/** The card's table: the filter as written, and the control that names the same boolean. */ +const CARD: ReadonlyArray = [ + ['implicit "true"', { f_boolean: 'true' }, { f_boolean: true }], + ['$eq "true"', { f_boolean: { $eq: 'true' } }, { f_boolean: { $eq: true } }], + ['$in ["true"]', { f_boolean: { $in: ['true'] } }, { f_boolean: { $in: [true] } }], + ['$ne "true" (returned the true row)', { f_boolean: { $ne: 'true' } }, { f_boolean: { $ne: true } }], + ['$nin ["true"]', { f_boolean: { $nin: ['true'] } }, { f_boolean: { $nin: [true] } }], + ['implicit "false"', { f_boolean: 'false' }, { f_boolean: false }], + ['$ne "false"', { f_boolean: { $ne: 'false' } }, { f_boolean: { $ne: false } }], + ['control 1', { f_boolean: 1 }, { f_boolean: true }], + ['control "1"', { f_boolean: '1' }, { f_boolean: true }], + ['control 0', { f_boolean: 0 }, { f_boolean: false }], + ['control "0"', { f_boolean: '0' }, { f_boolean: false }], + ['$ne 1', { f_boolean: { $ne: 1 } }, { f_boolean: { $ne: true } }], + ['a toggle field: "true"', { f_toggle: 'true' }, { f_toggle: true }], +]; + +/** The refused comparands of the card, and their neighbours. */ +const REFUSED: ReadonlyArray = [ + ['implicit "yes" (the card)', { f_boolean: 'yes' }], + ['$ne "yes"', { f_boolean: { $ne: 'yes' } }], + ['a $in member "yes"', { f_boolean: { $in: [true, 'yes'] } }], + ['"TRUE"', { f_boolean: { $eq: 'TRUE' } }], + ['a toggle field: "on"', { f_toggle: 'on' }], +]; + +describe('[#21333] the boolean-comparand arm 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(BOOLEAN_COMPARAND_DOOR_FIXTURE as any, 'test'); + for (const row of SEEDED) await engine.insert(OBJECT, { ...row }); + reads.length = 0; + writes.length = 0; + }); + + /** The `where` the driver received for one `find`, after the shared lowering. */ + const driverWhere = async (where: FilterCondition): Promise => { + reads.length = 0; + await engine.find(OBJECT, { where }); + expect(reads, JSON.stringify(where)).toHaveLength(1); + return reads[0]?.ast?.where; + }; + + // ── the derived case table, driven end to end ──────────────────────────── + + const REFUSALS = BOOLEAN_COMPARAND_DOOR_CASES.filter( + (c): c is BooleanComparandDoorRefusalCase => c.verdict === 'door-refusal' && !isFormulaCase(c)); + const NARROWS = BOOLEAN_COMPARAND_DOOR_CASES.filter( + (c): c is BooleanComparandDoorNarrowsCase => c.verdict === 'narrows' && !isFormulaCase(c)); + const PASSES = BOOLEAN_COMPARAND_DOOR_CASES.filter((c) => c.verdict === 'passes' && !isFormulaCase(c)); + const FORMULA = BOOLEAN_COMPARAND_DOOR_CASES.filter(isFormulaCase); + + it('GUARD the case table is partitioned exactly, every refused form is driven, and every position both ways', () => { + expect(BOOLEAN_COMPARAND_DOOR_CASES.length).toBe(REFUSALS.length + NARROWS.length + PASSES.length + FORMULA.length); + expect(new Set(REFUSALS.map((c) => c.form))).toEqual(new Set(NON_BOOLEAN_STRING_FORMS)); + const positions = (cs: readonly BooleanComparandDoorCase[]) => + new Set(cs.filter((c) => c.key === 'f_boolean').map((c) => c.position.replace(/\[\d\]$/, ''))); + const judged = ['f_boolean', ...BOOLEAN_COMPARAND_DOOR_SCALAR_OPERATORS.map((op) => `f_boolean.${op}`), + ...BOOLEAN_COMPARAND_DOOR_LIST_OPERATORS.map((op) => `f_boolean.${op}`)]; + for (const p of judged) { + expect(positions(REFUSALS).has(p), `refused at ${p}`).toBe(true); + expect(positions(NARROWS).has(p), `narrowed at ${p}`).toBe(true); + } + }); + + it('refuses every door-refusal case with the ADR-0112 envelope, in the contract\'s words, and NO driver read runs', async () => { + for (const c of REFUSALS) { + reads.length = 0; + const err = await refusalOf(engine.find(OBJECT, { where: c.filter() })); + expect(err, `${c.name}: expected a refusal`).not.toBeNull(); + expect({ code: err!.code, status: err!.status }, c.name).toEqual({ code: c.code, status: c.status }); + for (const substring of c.mustMention) expect(err!.message, `${c.name}: ${substring}`).toContain(substring); + expect(err!.message, c.name).toMatch(/^find\('boolean_door_probe'\): /); + expect(err!.message, c.name).toContain('which is not a boolean'); + expect(reads, `${c.name}: the driver must not have been read`).toHaveLength(0); + } + }); + + it('narrows every accepted spelling to its boolean — the driver receives the expected filter, the caller\'s is untouched', async () => { + for (const c of NARROWS) { + const filter = c.filter(); + const asWritten = JSON.stringify(filter); + expect(await driverWhere(filter), c.name).toEqual(lowered(c.expectedFilter())); + expect(JSON.stringify(filter), `${c.name}: the caller's filter must not be edited`).toBe(asWritten); + } + }); + + it('lets every passing case through UNCHANGED — a boolean, null, a non-boolean field, a flag, a reference', async () => { + for (const c of PASSES) { + const filter = c.filter(); + expect(await driverWhere(filter), c.name).toEqual(lowered(filter)); + } + }); + + it('NAMED DIVERGENCE — every formula case is refused one door EARLIER, by the unmaterializable-field door, with INVALID_FIELD', async () => { + expect(FORMULA.length).toBeGreaterThan(0); + for (const c of FORMULA) { + reads.length = 0; + const err = await refusalOf(engine.find(OBJECT, { where: c.filter() })); + expect({ code: err?.code, status: err?.status }, c.name).toEqual({ code: 'INVALID_FIELD', status: 400 }); + expect(reads, c.name).toHaveLength(0); + } + // …and the walk itself judges the class by its return type. + const schema = engine.registry.getObject(OBJECT); + expect(() => narrowNumberComparands(OBJECT, 'find', schema, { f_formula_boolean: 'yes' })).toThrow(/declared formula field returning boolean/); + expect(narrowNumberComparands(OBJECT, 'find', schema, { f_formula_boolean: 'true' })).toEqual({ f_formula_boolean: true }); + const untyped = { f_formula_untyped: 'yes' }; + expect(narrowNumberComparands(OBJECT, 'find', schema, untyped)).toBe(untyped); + }); + + // ── the card's table ───────────────────────────────────────────────────── + + it('the card\'s table: each comparand reaches the driver as the very filter its boolean control does', async () => { + for (const [name, written, control] of CARD) { + expect(await driverWhere(written), name).toEqual(await driverWhere(control)); + } + // The controls themselves reach the driver as written (after the shared lowering). + for (const control of [{ f_boolean: true }, { f_boolean: false }, { f_boolean: { $ne: true } }] as FilterCondition[]) { + expect(await driverWhere(control), JSON.stringify(control)).toEqual(lowered(control)); + } + }); + + it('the card\'s refusals: 400 INVALID_FILTER naming the field and its declared type, before any read', async () => { + for (const [name, where] of REFUSED) { + reads.length = 0; + const err = await refusalOf(engine.find(OBJECT, { where })); + expect({ code: err?.code, status: err?.status }, name).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err!.message, name).toMatch(/filter on 'f_(boolean|toggle)' compares a declared (boolean|toggle) field against /); + expect(reads, name).toHaveLength(0); + } + }); + + it('a filter with nothing to narrow is returned BY REFERENCE — the controls allocate nothing', () => { + const schema = engine.registry.getObject(OBJECT); + for (const where of [ + { f_boolean: true }, + { f_boolean: { $ne: false } }, + { f_boolean: { $in: [true, false] } }, + { f_boolean: { $null: true } }, + { f_text: 'true' }, + ]) { + expect(narrowNumberComparands(OBJECT, 'find', schema, where), JSON.stringify(where)).toBe(where); + } + }); + + // ── the door's reach: every verb, both filter forms, nested structure ──── + + it('covers every engine verb that collects a filter — read and write sides', async () => { + const where = { f_boolean: 'yes' }; + for (const call of [ + () => engine.find(OBJECT, { where }), + () => engine.findOne(OBJECT, { where }), + () => engine.count(OBJECT, { where }), + () => engine.aggregate(OBJECT, { where, aggregations: [{ function: 'count', alias: 'n' }] } as EngineAggregateOptions), + () => engine.update(OBJECT, { f_text: 'x' }, { where, multi: true }), + () => engine.delete(OBJECT, { where, multi: true }), + ]) { + const err = await refusalOf(call()); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err!.message).toContain("filter on 'f_boolean' compares a declared boolean field"); + } + expect(reads).toHaveLength(0); + expect(writes).toHaveLength(0); + }); + + it('a write verb scoped by "true" updates the rows true names — the narrowing reaches the write side too', async () => { + await engine.update(OBJECT, { f_text: 'x' }, { where: { f_boolean: 'true' }, multi: true }); + expect(writes).toHaveLength(1); + expect(writes[0]?.ast?.where).toEqual(lowered({ f_boolean: true })); + }); + + it('answers the FilterArray sugar the same — one answer per comparand, not per spelling', async () => { + const err = await refusalOf(engine.find(OBJECT, { where: [['f_boolean', '=', 'yes']] } as unknown as EngineQueryOptions)); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + // `parseFilterAST` lowers `=` to the implicit comparand, so the path is the key's own. + expect(err!.message).toContain('against "yes" at where.f_boolean,'); + expect(reads).toHaveLength(0); + for (const [v, b] of [['true', true], ['false', false], ['1', true], ['0', false]] as const) { + reads.length = 0; + await engine.find(OBJECT, { where: [['f_boolean', '=', v]] } as unknown as EngineQueryOptions); + expect(reads[0]?.ast?.where, v).toEqual(lowered({ f_boolean: b })); + } + reads.length = 0; + await engine.find(OBJECT, { where: [['f_boolean', '!=', 'true']] } as unknown as EngineQueryOptions); + expect(reads[0]?.ast?.where).toEqual(lowered({ f_boolean: { $ne: true } })); + }); + + it('reaches inside $and / $or / $not — structure does not launder the comparand', async () => { + for (const where of [ + { $and: [{ f_text: 'a' }, { f_boolean: 'yes' }] }, + { $or: [{ f_text: 'a' }, { f_toggle: { $in: [true, 'on'] } }] }, + { $not: { f_boolean: { $ne: 'nope' } } }, + ]) { + const err = await refusalOf(engine.find(OBJECT, { where: where as FilterCondition })); + expect({ code: err?.code, status: err?.status }, JSON.stringify(where)).toEqual({ code: 'INVALID_FILTER', status: 400 }); + } + expect(reads).toHaveLength(0); + expect(await driverWhere({ $or: [{ f_text: 'a' }, { $not: { f_boolean: { $in: ['true', 0] } } }] })) + .toEqual(lowered({ $or: [{ f_text: 'a' }, { $not: { f_boolean: { $in: [true, false] } } }] })); + }); + + it('refuses a {placeholder} against a boolean field UNRESOLVED — before the token resolver, in the arm\'s words', async () => { + const err = await refusalOf(engine.find( + OBJECT, + { where: { f_boolean: '{current_user_id}' }, context: { userId: 'u1' } } as EngineQueryOptions, + )); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err!.message).toContain('{placeholder}'); + expect(reads).toHaveLength(0); + }); + + it('the judge-only judgeFilter gives the verdict execution gives, without a read', () => { + expect(engine.judgeFilter(OBJECT, { f_boolean: 'yes' })).toMatchObject({ ok: false, code: 'INVALID_FILTER', status: 400 }); + expect(engine.judgeFilter(OBJECT, { f_boolean: 'true' })).toEqual({ ok: true }); + expect(reads).toHaveLength(0); + }); + + // ── engine.aggregate: `where`, the per-aggregation `filter`, `having` ──── + + it('aggregate\'s `where` — the card\'s count row — reaches the driver narrowed, and "yes" is refused', async () => { + const counted = async (where: FilterCondition) => { + reads.length = 0; + await engine.aggregate(OBJECT, { where, aggregations: [{ function: 'count', alias: 'n' }] } as EngineAggregateOptions); + expect(reads.length, JSON.stringify(where)).toBeGreaterThan(0); + return reads[0]?.ast?.where; + }; + expect(await counted({ f_boolean: 'true' })).toEqual(await counted({ f_boolean: true })); + expect(await counted({ f_boolean: { $ne: 'true' } })).toEqual(await counted({ f_boolean: { $ne: true } })); + const err = await refusalOf(engine.aggregate(OBJECT, { + where: { f_boolean: 'yes' }, aggregations: [{ function: 'count', alias: 'n' }], + } as EngineAggregateOptions)); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + }); + + it('a per-aggregation filter counts what its boolean counts, and refuses "yes" rooted at its position — no read', async () => { + const count = async (filter: FilterCondition) => { + const rows = await engine.aggregate(OBJECT, { + aggregations: [{ function: 'count', alias: 'all' }, { function: 'count', alias: 'm', filter }], + } as EngineAggregateOptions); + return Number((rows[0] as Record).m); + }; + for (const [name, written, control] of CARD) { + expect(await count(written), name).toBe(await count(control)); + expect(await count(written), name).toBe(1); + } + reads.length = 0; + const err = await refusalOf(engine.aggregate(OBJECT, { + aggregations: [{ function: 'count', alias: 'all' }, { function: 'count', alias: 'bad', filter: { f_boolean: { $ne: 'yes' } } }], + } as EngineAggregateOptions)); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err!.message).toContain('aggregations[1].filter.f_boolean.$ne'); + expect(err!.message).toMatch(/^aggregate\('boolean_door_probe'\): filter on 'f_boolean' compares a declared boolean field/); + expect(reads).toHaveLength(0); + }); + + it('`having` over a groupBy of the boolean field keeps the group its boolean keeps, and refuses "yes" as an aggregated column', async () => { + const groups = async (having: FilterCondition) => + (await engine.aggregate(OBJECT, { + groupBy: ['f_boolean'], + aggregations: [{ function: 'count', alias: 'n' }], + having, + } as EngineAggregateOptions)).map((r) => (r as Record).f_boolean); + expect(await groups({ f_boolean: 'true' })).toEqual([true]); + expect(await groups({ f_boolean: 'true' })).toEqual(await groups({ f_boolean: true })); + expect(await groups({ f_boolean: { $ne: 'true' } })).toEqual([false]); + expect(await groups({ f_boolean: '0' })).toEqual([false]); + reads.length = 0; + const err = await refusalOf(engine.aggregate(OBJECT, { + groupBy: ['f_boolean'], aggregations: [{ function: 'count', alias: 'n' }], having: { f_boolean: 'yes' }, + } as EngineAggregateOptions)); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err!.message).toContain("filter on 'f_boolean' compares a boolean aggregated column against \"yes\" at having.f_boolean"); + expect(err!.message).not.toContain('declared'); + expect(reads).toHaveLength(0); + }); + + it('GUARD the having walk narrows copy-on-write and judges a column only by a boolean type', () => { + const classes = new Map([['flag', 'boolean' as const], ['n', 'numeric' as const]]); + const types = new Map([['flag', 'boolean'], ['n', 'number'], ['label', 'text']]); + const having = { flag: { $in: ['true', 0] }, label: { $eq: 'yes' } }; + expect(narrowHavingNumberComparands(OBJECT, having, classes as any, types)).toEqual({ flag: { $in: [true, false] }, label: { $eq: 'yes' } }); + expect(having.flag.$in).toEqual(['true', 0]); + const untouched = { flag: true, n: { $gt: 1 } }; + expect(narrowHavingNumberComparands(OBJECT, untouched, classes as any, types)).toBe(untouched); + }); + + // ── the REST doors that reach findData ─────────────────────────────────── + + describe('the REST doors — one answer however the query arrived', () => { + let protocol: ObjectStackProtocolImplementation; + + beforeEach(() => { + protocol = new ObjectStackProtocolImplementation(engine); + }); + + const DOORS: ReadonlyArray<{ door: string; query: (comparand: string) => Record }> = [ + // `POST /data/:object/query` body. + { door: 'where object', query: (v) => ({ where: { f_boolean: v } }) }, + // `GET /data/:object?filter=` — the JSON nested in a querystring value. + { door: 'filter JSON string', query: (v) => ({ filter: JSON.stringify({ f_boolean: v }) }) }, + // `?$filter=` — the OData spelling. + { door: '$filter string', query: (v) => ({ $filter: JSON.stringify({ f_boolean: v }) }) }, + // `?filter=[["f_boolean","=",…]]` — the AST sugar. + { door: 'filter AST', query: (v) => ({ filter: JSON.stringify([['f_boolean', '=', v]]) }) }, + // `GET /data/:object?f_boolean=…` — a bare query parameter: what the GET + // route hands `findData` is `req.query` itself, every value a string. + { door: 'bare query parameter', query: (v) => ({ f_boolean: v }) }, + ]; + + it.each(DOORS)('the $door door refuses "yes" against a declared boolean field, naming the type — no read', async ({ query }) => { + const err = await refusalOf(protocol.findData({ object: OBJECT, query: query('yes') } as any)); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err!.message).toContain("filter on 'f_boolean' compares a declared boolean field against \"yes\""); + expect(reads).toHaveLength(0); + }); + + it.each(DOORS)('the $door door hands the driver the boolean "true" / "false" / "1" / "0" names', async ({ query }) => { + for (const [v, b] of [['true', true], ['false', false], ['1', true], ['0', false]] as const) { + reads.length = 0; + await expect(protocol.findData({ object: OBJECT, query: query(v) } as any), v).resolves.toBeDefined(); + const seen = reads.find((r) => JSON.stringify(r.ast?.where ?? null).includes('f_boolean')); + expect(seen, v).toBeDefined(); + const text = JSON.stringify(seen!.ast.where); + expect(text, v).toContain(`"f_boolean"`); + expect(text, v).toContain(String(b)); + expect(text, v).not.toContain(`"${v}"`); + } + }); + }); +}); diff --git a/packages/objectql/src/engine-number-comparand-declared-type-door.test.ts b/packages/objectql/src/engine-number-comparand-declared-type-door.test.ts index f00585caf64..0c07563285a 100644 --- a/packages/objectql/src/engine-number-comparand-declared-type-door.test.ts +++ b/packages/objectql/src/engine-number-comparand-declared-type-door.test.ts @@ -19,13 +19,18 @@ * - `passes` / `deferred`: the driver read ran and received the filter as * written. * - * ## One partition, measured rather than dropped + * ## Two partitions, measured rather than dropped * * - **`formula`** — refused one door EARLIER, by the #8296 materializable door, * with `INVALID_FIELD`, whatever its return type: no driver materialises a * formula column. Pinned in the direction it answers (the contract's module * header says the suite partitions these rows out), and the door's own walk * is pinned to judge the class correctly for the day that neighbour opens. + * - **[#21333] a declared `boolean` / `toggle` field** — the census's `$gt "abc"` + * there passes the NUMBER verdict (not this door's subject) and is refused by + * the same walk's BOOLEAN arm, in that arm's words. Pinned in the direction + * it answers; the arm's own suite is + * `engine-boolean-comparand-declared-type-door.test.ts`. * * [#20446] The `$empty` row used to be a second partition, pinned at the door * alone while `$empty` was staged out of `FILTER_OPERATORS`. It is in that @@ -44,6 +49,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { + booleanComparandDoorVerdict, NON_NUMERIC_STRING_FORMS, NON_NUMERIC_VALUE_FORMS, NUMBER_COMPARAND_DOOR_CASES, @@ -125,7 +131,18 @@ const refusalOf = async (p: Promise): Promise => const isFormulaCase = (c: NumberComparandDoorCase): boolean => c.declaredType === 'formula'; /** The `$empty` row — driven end to end since #20446, see the header. */ const isEmptyFlagCase = (c: NumberComparandDoorCase): boolean => c.position.endsWith('.$empty'); -const engineDriven = (c: NumberComparandDoorCase): boolean => !isFormulaCase(c); +/** + * [#21333] A row the NUMBER verdict passes on a declared BOOLEAN field (the + * census's `$gt "abc"` on `f_boolean` / `f_toggle`) that the walk's boolean arm + * refuses: the same walk judges both arms, so at the engine it is the boolean + * arm's refusal — pinned below, and driven in full by + * `engine-boolean-comparand-declared-type-door.test.ts`. + */ +const isBooleanArmCase = (c: NumberComparandDoorCase): boolean => + !isFormulaCase(c) + && !/\.\$(null|exists|empty)$/.test(c.position) + && booleanComparandDoorVerdict({ type: c.declaredType }, c.comparand).verdict === 'door-refusal'; +const engineDriven = (c: NumberComparandDoorCase): boolean => !isFormulaCase(c) && !isBooleanArmCase(c); const SEEDED = [ { id: 'r1', f_number: 5 }, @@ -160,10 +177,11 @@ describe('[#20351] the number-comparand declared-type door at the engine collect const PASSES = NUMBER_COMPARAND_DOOR_CASES.filter((c) => c.verdict === 'passes' && engineDriven(c)); const DEFERRED = NUMBER_COMPARAND_DOOR_CASES.filter((c) => c.verdict === 'deferred' && engineDriven(c)); const FORMULA = NUMBER_COMPARAND_DOOR_CASES.filter(isFormulaCase); + const BOOLEAN_ARM = NUMBER_COMPARAND_DOOR_CASES.filter(isBooleanArmCase); it('GUARD the case table is partitioned exactly, and every partition that carries a verdict is non-empty', () => { expect(NUMBER_COMPARAND_DOOR_CASES.length).toBe( - REFUSALS.length + NARROWS.length + PASSES.length + DEFERRED.length + FORMULA.length, + REFUSALS.length + NARROWS.length + PASSES.length + DEFERRED.length + FORMULA.length + BOOLEAN_ARM.length, ); expect(REFUSALS.length).toBeGreaterThan(0); expect(NARROWS.length).toBeGreaterThan(0); @@ -225,6 +243,23 @@ describe('[#20351] the number-comparand declared-type door at the engine collect } }); + it('NAMED DIVERGENCE [#21333] — the census rows on a declared BOOLEAN field pass the number verdict and are refused by the walk\'s boolean arm', async () => { + expect(BOOLEAN_ARM.map((c) => c.name).sort()).toEqual( + NUMBER_COMPARAND_DOOR_CASES + .filter((c) => c.name.startsWith('[census]') && (c.key === 'f_boolean' || c.key === 'f_toggle')) + .map((c) => c.name).sort(), + ); + for (const c of BOOLEAN_ARM) { + expect(c.verdict, c.name).toBe('passes'); + reads.length = 0; + const err = await refusalOf(engine.find(OBJECT, { where: c.filter() })); + expect({ code: err?.code, status: err?.status }, c.name).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err!.message, c.name).toContain(`filter on '${c.key}' compares a declared ${c.declaredType} field`); + expect(err!.message, c.name).toContain('which is not a boolean'); + expect(reads, c.name).toHaveLength(0); + } + }); + it('NAMED DIVERGENCE — every formula case is refused one door EARLIER, by #8296, with INVALID_FIELD', async () => { for (const c of FORMULA) { reads.length = 0; diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 37377a08832..4d1c354b297 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -1073,6 +1073,10 @@ function lowerWhereFilterArray( // lowers it once it can read (`ObjectQL.lowerRelationConditions`) — or // refused in words of its own (a key the related object does not declare, // a second level, a related object that is not registered). + // [#21333] …and the walk's BOOLEAN arm: against a declared boolean field + // `"true"` / `"false"`, `1` / `0` and `"1"` / `"0"` narrow to their + // boolean and any other string is refused `INVALID_FILTER` / 400 — the one + // place a bare query parameter's `"true"` becomes `true`. const numeric = narrowNumberComparands(object, operation, schema, where, 'where', { schemaOf }); // [#7872] The comparand-type door, on the OBJECT form. `parseFilterAST` // runs the same walk on everything it lowers or passes through, but @@ -1160,7 +1164,8 @@ function lowerWhereFilterArray( // [#20351] Same door as the object branch, on the LOWERED condition — the // array sugar (`[['amount','>','abc']]`) names numeric fields too. [#20546] // …and lowers `['amount', '=', { a: 1 }]` to the no-operator object its - // second arm refuses. + // second arm refuses. [#21333] …and `['active', '=', 'true']` to the + // comparand its boolean arm narrows. lowered.where = narrowNumberComparands(object, operation, schema, condition, 'where', { schemaOf }); return lowered as T; } @@ -17227,6 +17232,9 @@ export class ObjectQL implements IObjectQLEngine { // counted no row, silently, on every driver. [#20745] So did // `{ owner: { region: 'NA' } }` beneath a lookup; a JSON object // here is refused alike, one answer per filter at every position. + // [#21333] …and its boolean arm: `"true"` against a declared + // boolean field counted no row (every row under `$ne`) on both + // drivers; it is narrowed to `true` here, `"yes"` refused. const numeric = narrowNumberComparands( object, 'aggregate', this._registry.getObject(object), aggFilter, `aggregations[${i}].filter`, ); @@ -17375,6 +17383,9 @@ export class ObjectQL implements IObjectQLEngine { // driver, where its `where` twin answered two ways. [#20745] A // relation or JSON column's type is judged too (a lookup groupBy's // nested-relation `having` kept no group on every driver). + // [#21333] …and, by the same types, the boolean arm: over a groupBy + // of a boolean field `"true"` kept no group (`$ne "true"` every + // group) on both drivers; it is narrowed to `true`, `"yes"` refused. const numeric = narrowHavingNumberComparands( object, having, havingColumnClasses, aggregatedRowColumnTypes(query.groupBy, query.aggregations, declaredFields), diff --git a/packages/objectql/src/number-comparand-declared-type-door.ts b/packages/objectql/src/number-comparand-declared-type-door.ts index 251644d8b07..cc4b1198938 100644 --- a/packages/objectql/src/number-comparand-declared-type-door.ts +++ b/packages/objectql/src/number-comparand-declared-type-door.ts @@ -153,6 +153,18 @@ * door and the lowering find a condition at the same boundaries by * construction. The per-aggregation `filter` and `having` keep the refusal. * + * ## [#21333] …and the boolean arm, at all three positions + * + * The same walk judges a comparand against a declared BOOLEAN field + * (`boolean-comparand-declared-type-door.ts`, by `@objectstack/spec/data`'s + * `filter-boolean-comparand-declared-type.ts`): `1` / `0`, `"1"` / `"0"` and + * `"true"` / `"false"` narrow to their boolean, copy-on-write, and any other + * string is refused `INVALID_FILTER` / 400. It is asked at every field key the + * number arm does not judge (the classes are disjoint), at the same positions + * ({@link judgeFieldSpec}) — so the functions below, and the engine sites that + * call them, run both arms. At `having` it reads each column's TYPE (a groupBy + * of a boolean field), as the no-operator-object arm does. + * * @see numberComparandDoorVerdict — the pure verdict (lane 1, `@objectstack/spec`). * @see https://github.com/objectstack-ai/objectstack/issues/20336 (the contract) * @see https://github.com/objectstack-ai/objectstack/issues/20351 (this door) @@ -165,9 +177,16 @@ import { numberComparandDoorVerdict, numberComparandFieldVerdict, numberComparandRefusalMessage, + type BooleanComparandDoorFieldMeta, type NumberComparandDoorFieldMeta, type NumberComparandRefusalSite, } from '@objectstack/spec/data'; +import { + booleanArmFieldMeta, + judgeBooleanComparand, + nonBooleanComparandRefusalMessage, + type NonBooleanComparand, +} from './boolean-comparand-declared-type-door.js'; import { invalidFilterError } from './filter-comparand-shape.js'; import type { AggregatedColumnClass } from './having-filter.js'; import { @@ -203,6 +222,12 @@ export type NonNumericComparand = NumberComparandRefusalSite; interface KeyFacts { /** The number arm's field meta — `null` when that arm has nothing to judge here. */ readonly number: NumberComparandDoorFieldMeta | null; + /** + * [#21333] The boolean arm's field meta — `null` when that arm has nothing to + * judge here. The two classes are disjoint; the walk asks this arm only + * where the number arm does not judge. + */ + readonly boolean: BooleanComparandDoorFieldMeta | null; /** * [#20546] The column the no-operator-object arm judges — else `null`. * [#20745] Any of its three kinds (a scalar-valued, a relation or a @@ -277,6 +302,7 @@ interface WalkContext extends RefusalSiteContext { /** The first refusal the walk met, and which arm raised it. */ type Refusal = | { readonly arm: 'number'; readonly site: NonNumericComparand } + | { readonly arm: 'boolean'; readonly site: NonBooleanComparand } | { readonly arm: 'no-operator-object'; readonly site: NoOperatorObjectRefusal } | { readonly arm: 'relation'; readonly site: RelationConditionRefusal }; @@ -315,6 +341,26 @@ function fieldMetaOf(def: unknown): NumberComparandDoorFieldMeta | null { return typeof returnType === 'string' ? { type, returnType } : { type }; } +/** + * [#21333] One comparand arm's judgment of ONE comparand at a judged position: + * the number arm ({@link judgeComparand}) or the boolean arm + * (`boolean-comparand-declared-type-door.ts`). {@link judgeFieldSpec} hands it + * every judged position — the implicit comparand, each scalar operator, each + * list member — so both arms judge at the same boundaries by construction. + */ +type JudgeOne = (field: string, comparand: unknown, path: string) => Outcome; + +/** [#21333] The number arm, bound to a field's meta and the position's site. */ +const numberArm = (meta: NumberComparandDoorFieldMeta, ctx: RefusalSiteContext): JudgeOne => + (field, comparand, path) => judgeComparand(meta, field, comparand, path, ctx); + +/** [#21333] The boolean arm, bound to a field's meta and the position's site. */ +const booleanArm = (meta: BooleanComparandDoorFieldMeta, ctx: RefusalSiteContext): JudgeOne => + (field, comparand, path) => { + const answer = judgeBooleanComparand(meta, field, comparand, path, ctx.aggregated); + return answer.refused ? { ok: false, refusal: { arm: 'boolean', site: answer.site } } : kept(answer.value); + }; + /** * One comparand at a judged position: the spec's verdict, routed. Whatever * the comparand is — a string, a boolean, a `Date`, an array (#20502) — the @@ -348,16 +394,19 @@ function judgeComparand( }; } -/** One judged field's constraint: `{ amount: }`. */ +/** + * One judged field's constraint: `{ amount: }`. [#21333] `judge` is the + * arm that judges the field — the number arm or the boolean arm; the positions + * walked here are the same for both. + */ function judgeFieldSpec( - meta: NumberComparandDoorFieldMeta, + judge: JudgeOne, field: string, spec: unknown, path: string, - ctx: RefusalSiteContext, ): Outcome { // Not filter structure → an implicit-equality comparand, judged at this path. - if (!isFilterNode(spec)) return judgeComparand(meta, field, spec, path, ctx); + if (!isFilterNode(spec)) return judge(field, spec, path); // A field spec with no `$` key is a deep-equality / nested-relation // condition; the #5869 gate records why descending into one would invent a // contract no backend agrees with. [#20546] Under a column that holds @@ -372,7 +421,7 @@ function judgeFieldSpec( for (const op of ops) { const comparand = spec[op]; if (SCALAR_OPERATORS.has(op)) { - const judged = judgeComparand(meta, field, comparand, `${path}.${op}`, ctx); + const judged = judge(field, comparand, `${path}.${op}`); if (!judged.ok) return judged; if (judged.value !== comparand) (out ??= { ...spec })[op] = judged.value; continue; @@ -382,7 +431,7 @@ function judgeFieldSpec( if (!LIST_OPERATORS.has(op) || !Array.isArray(comparand)) continue; let members: unknown[] | undefined; for (const [index, member] of comparand.entries()) { - const judged = judgeComparand(meta, field, member, `${path}.${op}[${index}]`, ctx); + const judged = judge(field, member, `${path}.${op}[${index}]`); if (!judged.ok) return judged; if (judged.value !== member) (members ??= [...comparand])[index] = judged.value; } @@ -475,8 +524,15 @@ function walkCondition(factsOf: FactsOf, node: unknown, path: string, depth: num // Only a judged field can refuse or narrow a comparand; a `formula` // whose return type is unreadable is `deferred`, and everything else is // `not-judged` — the spec's verdict, never a list here. - if (!meta || numberComparandFieldVerdict(meta) !== 'judged') continue; - judged = judgeFieldSpec(meta, key, value, here, ctx); + if (meta && numberComparandFieldVerdict(meta) === 'judged') { + judged = judgeFieldSpec(numberArm(meta, ctx), key, value, here); + } else { + // [#21333] …and where the number arm does not judge, the boolean arm + // may: the two classes are disjoint, so at most one arm judges a key. + const booleanMeta = booleanArmFieldMeta(facts.boolean); + if (!booleanMeta) continue; + judged = judgeFieldSpec(booleanArm(booleanMeta, ctx), key, value, here); + } } } if (!judged.ok) return judged; @@ -514,11 +570,13 @@ function declaredFactsOf(schema: unknown): FactsOf | null { // column all the same, and the arm judges it by the type it stores; // every other undeclared key keeps the registry-less tolerance. const provisioned = provisionedNoOperatorObjectColumn(key); - return provisioned === null ? null : { number: null, column: provisioned }; + return provisioned === null ? null : { number: null, boolean: null, column: provisioned }; } const meta = fieldMetaOf(fields[key]); if (!meta) return null; - return { number: meta, column: declaredNoOperatorObjectColumn(fields[key]) }; + // [#21333] The same declaration slice serves both comparand arms; each + // arm's field verdict decides whether it judges the key. + return { number: meta, boolean: meta, column: declaredNoOperatorObjectColumn(fields[key]) }; }; } @@ -549,9 +607,11 @@ function refuse(context: string, refusal: Refusal): never { throw invalidFilterError( refusal.arm === 'number' ? numberComparandRefusalMessage(refusal.site, context) - : refusal.arm === 'relation' - ? relationConditionRefusalMessage(refusal.site, context) - : noOperatorObjectRefusalMessage(refusal.site, context), + : refusal.arm === 'boolean' + ? nonBooleanComparandRefusalMessage(refusal.site, context) + : refusal.arm === 'relation' + ? relationConditionRefusalMessage(refusal.site, context) + : noOperatorObjectRefusalMessage(refusal.site, context), ); } @@ -571,6 +631,10 @@ function refuse(context: string, refusal: Refusal): never { * (`WHERE_SITE`); only `where` itself ever reaches a live driver bind, so the * not-a-number / boolean / date clauses name PostgreSQL's server error there * alone (#20510). + * + * [#21333] The same walk runs the boolean arm: a comparand against a declared + * boolean field is narrowed to its boolean (`"true"`, `1`, `"0"`, …) or, for + * any other string, refused — `INVALID_FILTER` / 400, the contract's words. */ export function narrowNumberComparands( object: string, @@ -666,6 +730,9 @@ export function narrowHavingNumberComparands( const kind = type === undefined ? null : noOperatorObjectColumnKind(type); return { number: classes.get(key) === 'numeric' ? { type: 'number' } : null, + // [#21333] A groupBy projection (or a `min` / `max`) of a boolean + // field carries that field's type; the boolean arm judges it by that. + boolean: type === undefined ? null : { type }, column: kind === null ? null : { kind, type: type as string }, }; }, diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 91354442d84..ebcb2c7b3d3 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -50,6 +50,15 @@ "AutoPersistenceConfigSchema (const)", "AutonumberFormatSource (interface)", "AutonumberToken (type)", + "BOOLEAN_COMPARAND_DOOR_CASES (const)", + "BOOLEAN_COMPARAND_DOOR_FIXTURE (const)", + "BOOLEAN_COMPARAND_DOOR_FIXTURE_FIELDS (const)", + "BOOLEAN_COMPARAND_DOOR_FIXTURE_OBJECT (const)", + "BOOLEAN_COMPARAND_DOOR_JUDGED_TYPES (const)", + "BOOLEAN_COMPARAND_DOOR_LIST_OPERATORS (const)", + "BOOLEAN_COMPARAND_DOOR_SCALAR_OPERATORS (const)", + "BOOLEAN_COMPARAND_READING_CASES (const)", + "BOOLEAN_COMPARAND_SPELLINGS (const)", "BOOLEAN_VALUE_TYPES (const)", "BOUNDED_STRING_FIELD_TYPES (const)", "BUILTIN_DRIVER_IDS (const)", @@ -58,6 +67,17 @@ "BaseEngineOptions (type)", "BaseEngineOptionsSchema (const)", "BaseValidationRuleShape (interface)", + "BooleanComparandDoorCase (type)", + "BooleanComparandDoorDeferredCase (interface)", + "BooleanComparandDoorFieldMeta (interface)", + "BooleanComparandDoorFixtureField (interface)", + "BooleanComparandDoorNarrowsCase (interface)", + "BooleanComparandDoorPassesCase (interface)", + "BooleanComparandDoorRefusalCase (interface)", + "BooleanComparandDoorVerdict (type)", + "BooleanComparandReading (type)", + "BooleanComparandReadingCase (type)", + "BooleanComparandRefusalSite (interface)", "BuiltinDriverId (type)", "BulkPerRowHookBudgetVerdict (type)", "BulkWriteHookDispatchContractEntry (interface)", @@ -445,6 +465,7 @@ "MysqlConfig (type)", "MysqlConfigParsed (type)", "MysqlConfigSchema (const)", + "NON_BOOLEAN_STRING_FORMS (const)", "NON_NUMERIC_STRING_FORMS (const)", "NON_NUMERIC_VALUE_FORMS (const)", "NON_TEXT_STORED_VALUE_TYPES (const)", @@ -480,6 +501,7 @@ "NoSQLQueryOptionsSchema (const)", "NoSQLTransactionOptions (type)", "NoSQLTransactionOptionsSchema (const)", + "NonBooleanStringForm (type)", "NonNumericComparandForm (type)", "NonNumericStringForm (type)", "NonNumericValueForm (type)", @@ -778,6 +800,9 @@ "asciiCaseInsensitiveRegexSource (function)", "assertListComparandShapes (function)", "bareDateRangePresetComparandMessage (function)", + "booleanComparandDoorVerdict (function)", + "booleanComparandFieldVerdict (function)", + "booleanComparandRefusalMessage (function)", "canServeApiOperation (function)", "canonicalAstOperator (function)", "canonicalizeSqlType (function)", @@ -899,6 +924,7 @@ "platformProvisionsStorage (function)", "provisionPrimary (function)", "readAutonumberCounter (function)", + "readBooleanComparand (function)", "readNumericString (function)", "redactDatasourceConfig (function)", "redactUrlCredentialQueryParams (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 595920d9bfa..ca4da9bce20 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -47,6 +47,15 @@ "AutoPersistenceConfigSchema": "src/data/driver/memory.zod.ts#AutoPersistenceConfigSchema (const)", "AutonumberFormatSource": "src/data/autonumber-format.ts#AutonumberFormatSource (interface)", "AutonumberToken": "src/data/autonumber-format.ts#AutonumberToken (type)", + "BOOLEAN_COMPARAND_DOOR_CASES": "src/data/filter-boolean-comparand-declared-type.ts#BOOLEAN_COMPARAND_DOOR_CASES (const)", + "BOOLEAN_COMPARAND_DOOR_FIXTURE": "src/data/filter-boolean-comparand-declared-type.ts#BOOLEAN_COMPARAND_DOOR_FIXTURE (const)", + "BOOLEAN_COMPARAND_DOOR_FIXTURE_FIELDS": "src/data/filter-boolean-comparand-declared-type.ts#BOOLEAN_COMPARAND_DOOR_FIXTURE_FIELDS (const)", + "BOOLEAN_COMPARAND_DOOR_FIXTURE_OBJECT": "src/data/filter-boolean-comparand-declared-type.ts#BOOLEAN_COMPARAND_DOOR_FIXTURE_OBJECT (const)", + "BOOLEAN_COMPARAND_DOOR_JUDGED_TYPES": "src/data/filter-boolean-comparand-declared-type.ts#BOOLEAN_COMPARAND_DOOR_JUDGED_TYPES (const)", + "BOOLEAN_COMPARAND_DOOR_LIST_OPERATORS": "src/data/filter-boolean-comparand-declared-type.ts#BOOLEAN_COMPARAND_DOOR_LIST_OPERATORS (const)", + "BOOLEAN_COMPARAND_DOOR_SCALAR_OPERATORS": "src/data/filter-boolean-comparand-declared-type.ts#BOOLEAN_COMPARAND_DOOR_SCALAR_OPERATORS (const)", + "BOOLEAN_COMPARAND_READING_CASES": "src/data/filter-boolean-comparand-declared-type.ts#BOOLEAN_COMPARAND_READING_CASES (const)", + "BOOLEAN_COMPARAND_SPELLINGS": "src/data/filter-boolean-comparand-declared-type.ts#BOOLEAN_COMPARAND_SPELLINGS (const)", "BOOLEAN_VALUE_TYPES": "src/data/field-value.zod.ts#BOOLEAN_VALUE_TYPES (const)", "BOUNDED_STRING_FIELD_TYPES": "src/data/field.zod.ts#BOUNDED_STRING_FIELD_TYPES (const)", "BUILTIN_DRIVER_IDS": "src/data/driver/config-registry.zod.ts#BUILTIN_DRIVER_IDS (const)", @@ -55,6 +64,17 @@ "BaseEngineOptions": "src/data/data-engine.zod.ts#BaseEngineOptions (type)", "BaseEngineOptionsSchema": "src/data/data-engine.zod.ts#BaseEngineOptionsSchema (const)", "BaseValidationRuleShape": "src/data/validation.zod.ts#BaseValidationRuleShape (interface)", + "BooleanComparandDoorCase": "src/data/filter-boolean-comparand-declared-type.ts#BooleanComparandDoorCase (type)", + "BooleanComparandDoorDeferredCase": "src/data/filter-boolean-comparand-declared-type.ts#BooleanComparandDoorDeferredCase (interface)", + "BooleanComparandDoorFieldMeta": "src/data/filter-boolean-comparand-declared-type.ts#BooleanComparandDoorFieldMeta (interface)", + "BooleanComparandDoorFixtureField": "src/data/filter-boolean-comparand-declared-type.ts#BooleanComparandDoorFixtureField (interface)", + "BooleanComparandDoorNarrowsCase": "src/data/filter-boolean-comparand-declared-type.ts#BooleanComparandDoorNarrowsCase (interface)", + "BooleanComparandDoorPassesCase": "src/data/filter-boolean-comparand-declared-type.ts#BooleanComparandDoorPassesCase (interface)", + "BooleanComparandDoorRefusalCase": "src/data/filter-boolean-comparand-declared-type.ts#BooleanComparandDoorRefusalCase (interface)", + "BooleanComparandDoorVerdict": "src/data/filter-boolean-comparand-declared-type.ts#BooleanComparandDoorVerdict (type)", + "BooleanComparandReading": "src/data/filter-boolean-comparand-declared-type.ts#BooleanComparandReading (type)", + "BooleanComparandReadingCase": "src/data/filter-boolean-comparand-declared-type.ts#BooleanComparandReadingCase (type)", + "BooleanComparandRefusalSite": "src/data/filter-boolean-comparand-declared-type.ts#BooleanComparandRefusalSite (interface)", "BuiltinDriverId": "src/data/driver/config-registry.zod.ts#BuiltinDriverId (type)", "BulkPerRowHookBudgetVerdict": "src/data/bulk-write-hook-conformance.ts#BulkPerRowHookBudgetVerdict (type)", "BulkWriteHookDispatchContractEntry": "src/data/bulk-write-hook-conformance.ts#BulkWriteHookDispatchContractEntry (interface)", @@ -435,6 +455,7 @@ "MysqlConfig": "src/data/driver/mysql.zod.ts#MysqlConfig (type)", "MysqlConfigParsed": "src/data/driver/mysql.zod.ts#MysqlConfigParsed (type)", "MysqlConfigSchema": "src/data/driver/mysql.zod.ts#MysqlConfigSchema (const)", + "NON_BOOLEAN_STRING_FORMS": "src/data/filter-boolean-comparand-declared-type.ts#NON_BOOLEAN_STRING_FORMS (const)", "NON_NUMERIC_STRING_FORMS": "src/data/filter-number-comparand-declared-type.ts#NON_NUMERIC_STRING_FORMS (const)", "NON_NUMERIC_VALUE_FORMS": "src/data/filter-number-comparand-declared-type.ts#NON_NUMERIC_VALUE_FORMS (const)", "NON_TEXT_STORED_VALUE_TYPES": "src/data/field-value.zod.ts#NON_TEXT_STORED_VALUE_TYPES (const)", @@ -470,6 +491,7 @@ "NoSQLQueryOptionsSchema": "src/data/driver-nosql.zod.ts#NoSQLQueryOptionsSchema (const)", "NoSQLTransactionOptions": "src/data/driver-nosql.zod.ts#NoSQLTransactionOptions (type)", "NoSQLTransactionOptionsSchema": "src/data/driver-nosql.zod.ts#NoSQLTransactionOptionsSchema (const)", + "NonBooleanStringForm": "src/data/filter-boolean-comparand-declared-type.ts#NonBooleanStringForm (type)", "NonNumericComparandForm": "src/data/filter-number-comparand-declared-type.ts#NonNumericComparandForm (type)", "NonNumericStringForm": "src/data/filter-number-comparand-declared-type.ts#NonNumericStringForm (type)", "NonNumericValueForm": "src/data/filter-number-comparand-declared-type.ts#NonNumericValueForm (type)", @@ -765,6 +787,9 @@ "asciiCaseInsensitiveRegexSource": "src/data/filter.zod.ts#asciiCaseInsensitiveRegexSource (function)", "assertListComparandShapes": "src/data/filter-comparand-shape.ts#assertListComparandShapes (function)", "bareDateRangePresetComparandMessage": "src/data/date-range-presets.ts#bareDateRangePresetComparandMessage (function)", + "booleanComparandDoorVerdict": "src/data/filter-boolean-comparand-declared-type.ts#booleanComparandDoorVerdict (function)", + "booleanComparandFieldVerdict": "src/data/filter-boolean-comparand-declared-type.ts#booleanComparandFieldVerdict (function)", + "booleanComparandRefusalMessage": "src/data/filter-boolean-comparand-declared-type.ts#booleanComparandRefusalMessage (function)", "canServeApiOperation": "src/data/api-derivation.ts#canServeApiOperation (function)", "canonicalAstOperator": "src/data/filter.zod.ts#canonicalAstOperator (function)", "canonicalizeSqlType": "src/data/type-compat.ts#canonicalizeSqlType (function)", @@ -886,6 +911,7 @@ "platformProvisionsStorage": "src/data/injected-system-column-provenance.ts#platformProvisionsStorage (function)", "provisionPrimary": "src/data/display-name.ts#provisionPrimary (function)", "readAutonumberCounter": "src/data/autonumber-format.ts#readAutonumberCounter (function)", + "readBooleanComparand": "src/data/filter-boolean-comparand-declared-type.ts#readBooleanComparand (function)", "readNumericString": "src/data/filter-number-comparand-declared-type.ts#readNumericString (function)", "redactDatasourceConfig": "src/data/datasource-credential-redaction.ts#redactDatasourceConfig (function)", "redactUrlCredentialQueryParams": "src/data/datasource-credential-redaction.ts#redactUrlCredentialQueryParams (function)", diff --git a/packages/spec/src/data/filter-boolean-comparand-declared-type.test.ts b/packages/spec/src/data/filter-boolean-comparand-declared-type.test.ts new file mode 100644 index 00000000000..3af2f9307bd --- /dev/null +++ b/packages/spec/src/data/filter-boolean-comparand-declared-type.test.ts @@ -0,0 +1,305 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21333] Pins for the boolean-comparand declared-type door's CONTRACT — the + * accepted spellings, the verdict, the refusal words, the fixture and the + * derived case table. The door itself is `@objectstack/objectql`'s arm of the + * engine's field-aware walk; this file keeps honest that the data it consumes + * says what the module header argues, in both directions. + */ + +import { describe, it, expect } from 'vitest'; +import { BOOLEAN_VALUE_TYPES } from './field-value.zod'; +import { FieldSchema, FieldType } from './field.zod'; +import { ObjectSchema } from './object.zod'; +import { parseFilterAST } from './filter.zod'; +import { + NUMBER_COMPARAND_DOOR_LIST_OPERATORS, + NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS, + numberComparandFieldVerdict, +} from './filter-number-comparand-declared-type'; +import { + BOOLEAN_COMPARAND_DOOR_CASES, + BOOLEAN_COMPARAND_DOOR_FIXTURE, + BOOLEAN_COMPARAND_DOOR_FIXTURE_FIELDS, + BOOLEAN_COMPARAND_DOOR_FIXTURE_OBJECT, + BOOLEAN_COMPARAND_DOOR_JUDGED_TYPES, + BOOLEAN_COMPARAND_DOOR_LIST_OPERATORS, + BOOLEAN_COMPARAND_DOOR_SCALAR_OPERATORS, + BOOLEAN_COMPARAND_READING_CASES, + BOOLEAN_COMPARAND_SPELLINGS, + NON_BOOLEAN_STRING_FORMS, + booleanComparandDoorVerdict, + booleanComparandFieldVerdict, + booleanComparandRefusalMessage, + readBooleanComparand, + type BooleanComparandDoorCase, + type BooleanComparandDoorNarrowsCase, + type BooleanComparandDoorRefusalCase, +} from './filter-boolean-comparand-declared-type'; +import { StandardErrorCode } from '../api/errors.zod'; + +const isRefusal = (c: BooleanComparandDoorCase): c is BooleanComparandDoorRefusalCase => c.verdict === 'door-refusal'; +const isNarrows = (c: BooleanComparandDoorCase): c is BooleanComparandDoorNarrowsCase => c.verdict === 'narrows'; + +/** The comparand at a case's position inside a filter built for it. */ +function comparandAt(c: BooleanComparandDoorCase, filter: Record): unknown { + const spec = filter[c.key]; + const tail = c.position.slice(c.key.length); + if (tail === '') return spec; + const m = /^\.(\$\w+)(?:\[(\d)\])?$/.exec(tail)!; + const at = (spec as Record)[m[1]]; + return m[2] === undefined ? at : (at as unknown[])[Number(m[2])]; +} + +// ── The accepted spellings ─────────────────────────────────────────────────── + +describe('[#21333] the accepted spellings', () => { + it('are exactly the record validator\'s write-side set: true / false, 1 / 0, "1" / "0", "true" / "false"', () => { + expect([...BOOLEAN_COMPARAND_SPELLINGS.entries()]).toEqual([ + [1, true], [0, false], ['1', true], ['0', false], ['true', true], ['false', false], + ]); + for (const b of [true, false]) expect(readBooleanComparand(b)).toEqual({ boolean: true, value: b }); + }); + + it('every reading row answers what its row says', () => { + for (const row of BOOLEAN_COMPARAND_READING_CASES) { + const reading = readBooleanComparand(row.input); + if (row.boolean === null) expect(reading, row.why).toBeNull(); + else if (row.boolean) expect(reading, row.why).toEqual({ boolean: true, value: row.value }); + else expect(reading, row.why).toEqual({ boolean: false, form: row.form }); + } + }); + + it('every refused form has at least one row, and no row names a form outside the vocabulary', () => { + const forms = new Set(BOOLEAN_COMPARAND_READING_CASES.flatMap((r) => (r.boolean === false ? [r.form] : []))); + expect([...forms].sort()).toEqual([...NON_BOOLEAN_STRING_FORMS].sort()); + }); + + it('every accepted non-boolean spelling has a row', () => { + for (const [spelling, value] of BOOLEAN_COMPARAND_SPELLINGS) { + expect(BOOLEAN_COMPARAND_READING_CASES.some((r) => r.input === spelling && r.boolean === true && r.value === value), String(spelling)) + .toBe(true); + } + }); + + it('folds no case and trims nothing — one spelling per value', () => { + expect(readBooleanComparand('TRUE')).toEqual({ boolean: false, form: 'letter-case' }); + expect(readBooleanComparand(' false')).toEqual({ boolean: false, form: 'padded' }); + expect(readBooleanComparand(' YES ')).toEqual({ boolean: false, form: 'not-a-boolean' }); + }); + + it('never reads a placeholder — no filter token resolves to a boolean', () => { + for (const token of ['{current_user_id}', '{current_org_id}', '{record_id}', '{today}', '{not_a_token}']) { + expect(readBooleanComparand(token), token).toEqual({ boolean: false, form: 'placeholder' }); + } + }); +}); + +// ── Which fields, which positions ──────────────────────────────────────────── + +describe('[#21333] the judged fields and positions', () => { + it('are BOOLEAN_VALUE_TYPES itself, by identity — nothing re-listed', () => { + expect(BOOLEAN_COMPARAND_DOOR_JUDGED_TYPES).toBe(BOOLEAN_VALUE_TYPES); + expect([...BOOLEAN_COMPARAND_DOOR_JUDGED_TYPES].sort()).toEqual(['boolean', 'toggle']); + }); + + it('judges a formula by its returnType, and defers on an unreadable one', () => { + expect(booleanComparandFieldVerdict({ type: 'formula', returnType: 'boolean' })).toBe('judged'); + expect(booleanComparandFieldVerdict({ type: 'formula', returnType: 'text' })).toBe('not-judged'); + expect(booleanComparandFieldVerdict({ type: 'formula' })).toBe('deferred'); + expect(booleanComparandFieldVerdict({ type: 'number' })).toBe('not-judged'); + }); + + it('are the number door\'s positions BY IDENTITY — the engine judges both arms in one walk', () => { + expect(BOOLEAN_COMPARAND_DOOR_SCALAR_OPERATORS).toBe(NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS); + expect(BOOLEAN_COMPARAND_DOOR_LIST_OPERATORS).toBe(NUMBER_COMPARAND_DOOR_LIST_OPERATORS); + }); + + it('judges a field class disjoint from the number door\'s — at most one arm judges a key', () => { + const metas = [ + ...FieldType.options.map((type) => ({ type })), + ...['number', 'text', 'boolean', 'date'].map((returnType) => ({ type: 'formula', returnType })), + ]; + for (const meta of metas) { + const booleanJudges = booleanComparandFieldVerdict(meta) === 'judged'; + const numberJudges = numberComparandFieldVerdict(meta) === 'judged'; + expect(booleanJudges && numberJudges, JSON.stringify(meta)).toBe(false); + } + expect(metas.filter((m) => booleanComparandFieldVerdict(m) === 'judged')).toEqual([ + { type: 'boolean' }, { type: 'toggle' }, { type: 'formula', returnType: 'boolean' }, + ]); + }); +}); + +// ── The verdict ────────────────────────────────────────────────────────────── + +describe('[#21333] booleanComparandDoorVerdict', () => { + const field = { type: 'boolean' }; + + it('refuses "yes" on a boolean field with the INVALID_FILTER / 400 envelope and its form', () => { + expect(booleanComparandDoorVerdict(field, 'yes')) + .toEqual({ verdict: 'door-refusal', form: 'not-a-boolean', code: 'INVALID_FILTER', status: 400 }); + expect(StandardErrorCode.enum.INVALID_FILTER).toBe('INVALID_FILTER'); + }); + + it('narrows every accepted non-boolean spelling to its boolean, on boolean and toggle alike', () => { + for (const type of ['boolean', 'toggle']) { + for (const [spelling, value] of BOOLEAN_COMPARAND_SPELLINGS) { + expect(booleanComparandDoorVerdict({ type }, spelling), `${type} ${String(spelling)}`).toEqual({ verdict: 'narrows', value }); + } + } + }); + + it('passes a boolean, null, and a non-string outside the accepted set — answered as written', () => { + for (const comparand of [true, false, null, 2, -1, 0.5, 1n, new Date(0), [true], { $field: 'f_toggle' }, undefined]) { + expect(booleanComparandDoorVerdict(field, comparand), String(comparand)).toEqual({ verdict: 'passes' }); + } + }); + + it('passes any comparand on a field that is not boolean, and defers on an unreadable formula', () => { + expect(booleanComparandDoorVerdict({ type: 'text' }, 'yes')).toEqual({ verdict: 'passes' }); + expect(booleanComparandDoorVerdict({ type: 'number' }, 'true')).toEqual({ verdict: 'passes' }); + expect(booleanComparandDoorVerdict({ type: 'formula' }, 'yes')).toEqual({ verdict: 'deferred' }); + }); +}); + +// ── The words ──────────────────────────────────────────────────────────────── + +describe('[#21333] booleanComparandRefusalMessage', () => { + const site = { field: 'active', declaredType: 'boolean', path: 'where.active.$ne', value: 'yes', form: 'not-a-boolean' as const }; + + it('names the field, its declared type, the comparand, its position and the remedy — behind the caller prefix', () => { + const message = booleanComparandRefusalMessage(site, "find('task')"); + expect(message.startsWith("find('task'): filter on 'active' compares a declared boolean field against \"yes\" at where.active.$ne")) + .toBe(true); + expect(message).toContain('which is not a boolean'); + expect(message).toContain('NOT applied'); + expect(message).toContain('Write true or false'); + expect(booleanComparandRefusalMessage(site).startsWith("filter on 'active'")).toBe(true); + }); + + it('names a formula\'s return type, and an aggregated column as one', () => { + expect(booleanComparandRefusalMessage({ ...site, declaredType: 'formula', returnType: 'boolean' })) + .toContain('a declared formula field returning boolean'); + const aggregated = booleanComparandRefusalMessage({ ...site, path: 'having.active', aggregated: true }); + expect(aggregated).toContain('compares a boolean aggregated column against'); + expect(aggregated).not.toContain('declared'); + }); + + it('says something different for every form, and carries no tracker number', () => { + const messages = NON_BOOLEAN_STRING_FORMS.map((form) => booleanComparandRefusalMessage({ ...site, form })); + expect(new Set(messages).size).toBe(NON_BOOLEAN_STRING_FORMS.length); + for (const m of messages) expect(m).not.toMatch(/#\d/); + }); + + it('stays inside the 500-character client bound for every refusal in the case table, at the longest position', () => { + for (const c of BOOLEAN_COMPARAND_DOOR_CASES.filter(isRefusal)) { + const message = booleanComparandRefusalMessage({ + field: c.key, declaredType: c.declaredType, returnType: c.returnType, + path: `aggregations[0].filter.${c.position}`, value: c.comparand, form: c.form, + }, `aggregate('${BOOLEAN_COMPARAND_DOOR_FIXTURE_OBJECT}')`); + expect(message.length, c.name).toBeLessThanOrEqual(500); + } + }); + + it('front-loads what a caller acts on: with 40-character names the head still ends inside the first 500 characters', () => { + const name = 'f'.repeat(40); + for (const form of NON_BOOLEAN_STRING_FORMS) { + const message = booleanComparandRefusalMessage({ + field: name, declaredType: 'formula', returnType: 'boolean', + path: `aggregations[12].filter.${name}.$between[1]`, value: 'x'.repeat(200), form, + }, `aggregate('${'o'.repeat(40)}')`); + const head = message.slice(0, message.indexOf(' The filter was NOT applied')); + expect(head.length, form).toBeLessThanOrEqual(500); + expect(message.slice(0, 500), form).toContain(head); + } + }); +}); + +// ── The fixture and the case table ─────────────────────────────────────────── + +describe('[#21333] the fixture', () => { + it('every field is a legal FieldSchema input, and the object a legal ObjectSchema input', () => { + const names = BOOLEAN_COMPARAND_DOOR_FIXTURE_FIELDS.map((f) => f.name); + expect(new Set(names).size).toBe(names.length); + for (const f of BOOLEAN_COMPARAND_DOOR_FIXTURE_FIELDS) { + const r = FieldSchema.safeParse(f); + expect(r.success, `${f.name}: ${r.success ? '' : JSON.stringify(r.error.issues)}`).toBe(true); + } + const obj = ObjectSchema.safeParse(BOOLEAN_COMPARAND_DOOR_FIXTURE); + expect(obj.success, obj.success ? '' : JSON.stringify(obj.error.issues)).toBe(true); + expect(BOOLEAN_COMPARAND_DOOR_FIXTURE.name).toBe(BOOLEAN_COMPARAND_DOOR_FIXTURE_OBJECT); + }); + + it('carries every judged type', () => { + for (const type of BOOLEAN_COMPARAND_DOOR_JUDGED_TYPES) { + expect(BOOLEAN_COMPARAND_DOOR_FIXTURE_FIELDS.some((f) => f.type === type), type).toBe(true); + } + }); +}); + +describe('[#21333] BOOLEAN_COMPARAND_DOOR_CASES', () => { + it('has unique case names — they are used as test names', () => { + const names = BOOLEAN_COMPARAND_DOOR_CASES.map((c) => c.name); + expect(new Set(names).size).toBe(names.length); + }); + + it('covers all four verdicts', () => { + expect([...new Set(BOOLEAN_COMPARAND_DOOR_CASES.map((c) => c.verdict))].sort()) + .toEqual(['deferred', 'door-refusal', 'narrows', 'passes']); + }); + + it('the census refuses "yes" and narrows "true" exactly on the boolean class and a formula returning boolean', () => { + const census = BOOLEAN_COMPARAND_DOOR_CASES.filter((c) => c.name.startsWith('[census]')); + const judged = ['f_boolean', 'f_toggle', 'f_formula_boolean']; + for (const c of census) { + if (judged.includes(c.key)) expect(c.verdict, c.name).toBe(c.comparand === 'yes' ? 'door-refusal' : 'narrows'); + else if (c.key === 'f_formula_untyped') expect(c.verdict, c.name).toBe('deferred'); + else expect(c.verdict, c.name).toBe('passes'); + } + }); + + it('the flag operators and a field reference pass — the door never judges them', () => { + for (const c of BOOLEAN_COMPARAND_DOOR_CASES.filter((x) => x.name.startsWith('[unjudged]'))) { + expect(c.verdict, c.name).toBe('passes'); + } + }); + + it('every refusal carries the ADR-0112 envelope, and the words name the key, the declared type, the comparand and its position', () => { + for (const c of BOOLEAN_COMPARAND_DOOR_CASES.filter(isRefusal)) { + expect(c.code, c.name).toBe(StandardErrorCode.enum.INVALID_FILTER); + expect(c.status, c.name).toBe(400); + const message = booleanComparandRefusalMessage({ + field: c.key, declaredType: c.declaredType, returnType: c.returnType, + path: `where.${c.position}`, value: c.comparand, form: c.form, + }, `find('${BOOLEAN_COMPARAND_DOOR_FIXTURE_OBJECT}')`); + for (const s of c.mustMention) expect(message, c.name).toContain(s); + } + }); + + it('a narrowed case\'s expected filter is its filter with the one comparand replaced by its boolean', () => { + for (const c of BOOLEAN_COMPARAND_DOOR_CASES.filter(isNarrows)) { + const expected = c.expectedFilter() as Record; + expect(comparandAt(c, expected), c.name).toBe(c.value); + expect(comparandAt(c, c.filter() as Record), c.name).toBe(c.comparand); + } + }); + + it('every filter passes the SYNTAX door — a refusal can only be a field-aware door\'s', () => { + for (const c of BOOLEAN_COMPARAND_DOOR_CASES) { + expect(() => parseFilterAST(c.filter()), c.name).not.toThrow(); + if (isNarrows(c)) expect(() => parseFilterAST(c.expectedFilter()), c.name).not.toThrow(); + } + }); + + it('the factories return a fresh object per call — no suite can edit what another judges', () => { + const c = BOOLEAN_COMPARAND_DOOR_CASES.find(isNarrows)!; + expect(c.filter()).not.toBe(c.filter()); + expect(c.filter()).toEqual(c.filter()); + const reference = BOOLEAN_COMPARAND_DOOR_CASES.find((x) => typeof x.comparand === 'object' && x.comparand !== null)!; + const first = comparandAt(reference, reference.filter() as Record); + expect(first).not.toBe(comparandAt(reference, reference.filter() as Record)); + expect(first).toEqual(reference.comparand); + }); +}); diff --git a/packages/spec/src/data/filter-boolean-comparand-declared-type.ts b/packages/spec/src/data/filter-boolean-comparand-declared-type.ts new file mode 100644 index 00000000000..011ce61eb83 --- /dev/null +++ b/packages/spec/src/data/filter-boolean-comparand-declared-type.ts @@ -0,0 +1,587 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21333] The BOOLEAN-comparand **declared-type door** — which comparands a + * field whose declared type is boolean may be compared against, and the one + * boolean the door narrows each accepted spelling to. The boolean twin of + * `filter-number-comparand-declared-type.ts` (#20336), in the same two lanes: + * this module is lane (1), the CONTRACT — the accepted spellings, the pure + * verdict, the refusal words, a fixture and the derived case table. Lane (2), + * the door, is `@objectstack/objectql`'s, an arm of the one filter walk the + * engine runs at its field-aware seam. THIS MODULE WRITES NO DOOR, and + * `packages/spec` carries no runtime logic (Prime Directive #2). + * + * ## The direction this encodes (triage's ruling, recorded on #21333) + * + * > - A comparand against a declared `boolean` field accepts the values the + * > door already answers correctly (`true` / `false`, `1` / `0`, `"1"` / + * > `"0"`), plus the canonical strings `"true"` / `"false"`. + * > - Those strings coerce at the door, because bare query parameters are + * > always strings. Any other string (`"yes"`) is refused 400 with the + * > declared type named. + * > - It is one door for every surface: query `where`, `filter` in its three + * > spellings, and bare query parameters. ⛔ No per-door coercion. + * + * ## What the door closes, measured on the card's base (`6c5bef5f4`) + * + * Two rows of a `boolean` field (one `true`, one `false`), through + * `engine.find`, `engine.aggregate` and every spelling the REST doors hand the + * protocol's `findData`: + * + * | comparand | InMemoryDriver | SqlDriver, SQLite | + * |:--|:--|:--| + * | `"true"` / `"false"` (implicit, `$eq`, `$in`) | 0 rows | 0 rows | + * | `$ne "true"` / `$nin ["true"]` | **2 rows** | **2 rows** | + * | `"yes"` | 0 rows (`$ne`: 2) | 0 rows (`$ne`: 2) | + * | `1` / `"1"` / `0` / `"0"` | **0 rows** (`$ne 1`: 2) | 1 row | + * | `true` / `false` | 1 row | 1 row | + * + * Every answer a 200. The per-aggregation `filter` and `having` (the engine's + * own evaluator) answered `"true"` with no row and no group and `$ne "true"` + * with every one, on both drivers. So the ruling's "already answers correctly" + * holds for `1` / `0` / `"1"` / `"0"` on SQLite alone: InMemoryDriver compares + * a stored `true` with `1` strictly, and answers it with no row. + * + * ## The door's answers — NARROW every accepted spelling to its boolean + * + * - **A boolean** passes, as written. + * - **`1` / `0`, `"1"` / `"0"`, `"true"` / `"false"`** narrow to `true` / + * `false`, copy-on-write, so every backend receives the one value each + * spelling names — the number door's answer for a numeric string, and the + * reason the narrowing covers the numbers too: left as written, a number is + * read two ways (the SQL backends bind `1` against a stored 1; the memory + * matcher compares it with a stored `true` and matches nothing). The set is + * exactly the one the record validator's boolean arm admits on WRITE + * (`@objectstack/objectql`'s `record-validator.ts`), so a value a record may + * be written with is a value a filter may compare it with. + * - **Any other string** is refused, `INVALID_FILTER` / 400, naming the field + * and its declared type ({@link NonBooleanStringForm}): `"yes"`, `"TRUE"`, + * `" true "`, `""`, a `{placeholder}` (every filter token resolves to an id + * or a date, never a boolean — the number door argues the same), and so on. + * ⛔ No case folding and no trimming: one spelling per value, the write + * side's. + * - **Everything else passes this verdict** — `null` (the null test), a number + * other than `1` / `0`, a `bigint`, a `Date`, an array, a plain object, a + * `{ $field }` reference. The ruling refuses strings; a non-string outside the + * accepted set is answered as written (no stored boolean equals `2`), and + * whatever the comparand-type and comparand-shape doors refuse they refuse + * in their own words. + * + * ## Which fields, which positions + * + * A field whose declared type is a member of `BOOLEAN_VALUE_TYPES` + * (`field-value.zod.ts` — `boolean`, `toggle`), by reference; a `formula` + * whose `returnType` is `boolean`, read through the text door's + * `FORMULA_RETURN_TYPE_AS_FIELD_TYPE`, and deferred without a readable one. + * As with the number door, no formula filter reaches the engine seam today: + * the unmaterializable-field door refuses every one first (#8296). + * + * The positions are the number door's, BY IDENTITY + * ({@link BOOLEAN_COMPARAND_DOOR_SCALAR_OPERATORS}, + * {@link BOOLEAN_COMPARAND_DOOR_LIST_OPERATORS}): the implicit comparand, the + * scalar operators and every member of the list operators. The engine judges + * both arms in one walk, so the two doors cannot disagree about where a value + * sits. Not judged: the flags (`$null` / `$exists` / `$empty`), the text + * operators, a `{ $field }` reference and a dotted key. + * + * ## The refusal words live here — {@link booleanComparandRefusalMessage} + * + * `INVALID_FILTER` / 400, the existing filter envelope (ADR-0112 class 1); no + * code is minted, and the code is spelled as a literal for the reason + * `filter-comparand-type.ts` records. The message names the field, its declared + * type, the comparand, its position and what is wrong, ahead of the remedy — + * the REST layer truncates a 4xx message at 500 characters. + * + * ## How the engine suite consumes {@link BOOLEAN_COMPARAND_DOOR_CASES} + * + * Register {@link BOOLEAN_COMPARAND_DOOR_FIXTURE} against a recording driver + * and run each case through `find`: a `door-refusal` rejects with `code` AND + * `status` and no driver read runs; a `narrows` case hands the driver + * `c.expectedFilter()`; a `passes` / `deferred` case hands it the filter as + * written. The `formula` rows are refused one door earlier, as noted above. + * + * @see NUMBER_COMPARAND_DOOR_CASES — the twin this module is shaped after. + * @see https://github.com/objectstack-ai/objectstack/issues/21333 (this door) + */ + +import type { FilterCondition } from './filter.zod'; +import { BOOLEAN_VALUE_TYPES } from './field-value.zod'; +import { FORMULA_RETURN_TYPE_AS_FIELD_TYPE } from './filter-text-operator-declared-type'; +import { + NUMBER_COMPARAND_DOOR_LIST_OPERATORS, + NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS, +} from './filter-number-comparand-declared-type'; +import { classifyFilterToken } from './context-tokens.zod'; +import { shapePreview } from './filter-comparand-refusal-text'; + +/* ──────────────────────────────────────────────────────────────────────────── + * The accepted spellings + * ──────────────────────────────────────────────────────────────────────────── */ + +/** + * Every non-boolean spelling the door accepts, and the boolean it narrows to — + * the record validator's write-side set, exactly. A boolean itself is accepted + * as written and is not listed. + */ +export const BOOLEAN_COMPARAND_SPELLINGS: ReadonlyMap = new Map([ + [1, true], + [0, false], + ['1', true], + ['0', false], + ['true', true], + ['false', false], +]); + +/** + * Why a string is not a boolean spelling — the forms + * {@link readBooleanComparand} tells apart, so a refusal can say what to fix. + * + * - `empty` — empty or whitespace only. + * - `padded` — an accepted spelling with surrounding whitespace. + * - `letter-case` — `"TRUE"`, `"False"`: an accepted spelling in another case. + * - `placeholder` — a `{token}`; no filter token resolves to a boolean. + * - `not-a-boolean` — no boolean reading at all (`"yes"`, `"on"`, `"2"`). + */ +export const NON_BOOLEAN_STRING_FORMS = [ + 'empty', + 'padded', + 'letter-case', + 'placeholder', + 'not-a-boolean', +] as const; + +export type NonBooleanStringForm = (typeof NON_BOOLEAN_STRING_FORMS)[number]; + +/** + * What {@link readBooleanComparand} answers: the boolean a comparand names, why + * a string names none, or `null` for a comparand that is not this reading's + * subject (a non-string outside the accepted set — see the module header). + */ +export type BooleanComparandReading = + | { readonly boolean: true; readonly value: boolean } + | { readonly boolean: false; readonly form: NonBooleanStringForm } + | null; + +/** Read `comparand` by the accepted spellings. Pure. */ +export function readBooleanComparand(comparand: unknown): BooleanComparandReading { + if (typeof comparand === 'boolean') return { boolean: true, value: comparand }; + if (typeof comparand !== 'string' && typeof comparand !== 'number') return null; + const value = BOOLEAN_COMPARAND_SPELLINGS.get(comparand); + if (value !== undefined) return { boolean: true, value }; + if (typeof comparand === 'number') return null; + const trimmed = comparand.trim(); + if (trimmed === '') return { boolean: false, form: 'empty' }; + if (trimmed !== comparand) { + return BOOLEAN_COMPARAND_SPELLINGS.has(trimmed) || BOOLEAN_COMPARAND_SPELLINGS.has(trimmed.toLowerCase()) + ? { boolean: false, form: 'padded' } + : readBooleanComparand(trimmed); + } + if (classifyFilterToken(comparand) !== null) return { boolean: false, form: 'placeholder' }; + if (BOOLEAN_COMPARAND_SPELLINGS.has(comparand.toLowerCase())) return { boolean: false, form: 'letter-case' }; + return { boolean: false, form: 'not-a-boolean' }; +} + +/* ──────────────────────────────────────────────────────────────────────────── + * The fields and positions the door judges + * ──────────────────────────────────────────────────────────────────────────── */ + +/** The declared types the door judges — `BOOLEAN_VALUE_TYPES` itself, by identity. */ +export const BOOLEAN_COMPARAND_DOOR_JUDGED_TYPES: ReadonlySet = BOOLEAN_VALUE_TYPES; + +/** The operators whose single comparand the door judges — the number door's list, by identity. */ +export const BOOLEAN_COMPARAND_DOOR_SCALAR_OPERATORS = NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS; + +/** The list operators each of whose MEMBERS the door judges — the number door's list, by identity. */ +export const BOOLEAN_COMPARAND_DOOR_LIST_OPERATORS = NUMBER_COMPARAND_DOOR_LIST_OPERATORS; + +/** The slice of a field definition the door reads. */ +export interface BooleanComparandDoorFieldMeta { + type: string; + /** `formula` only — the declared return type, when authoring could prove one. */ + returnType?: string | undefined; +} + +/** + * Is the field one the door judges? `judged` for the boolean class (and a + * `formula` returning `boolean`), `deferred` for a `formula` whose + * `returnType` is unreadable, `not-judged` for everything else. + */ +export function booleanComparandFieldVerdict( + field: BooleanComparandDoorFieldMeta, +): 'judged' | 'not-judged' | 'deferred' { + if (field.type === 'formula') { + const asFieldType = typeof field.returnType === 'string' + ? FORMULA_RETURN_TYPE_AS_FIELD_TYPE.get(field.returnType) + : undefined; + if (asFieldType === undefined) return 'deferred'; + return booleanComparandFieldVerdict({ type: asFieldType }); + } + return BOOLEAN_COMPARAND_DOOR_JUDGED_TYPES.has(field.type) ? 'judged' : 'not-judged'; +} + +/** + * The door's four answers for ONE comparand at a judged position. + * + * - `door-refusal` — a string that is not an accepted spelling, refused before + * any driver runs (`INVALID_FILTER` / 400). + * - `narrows` — an accepted non-boolean spelling; the door replaces it with + * `value`. + * - `passes` — not this door's subject (the field is not boolean, or the + * comparand is a boolean, `null`, or a non-string outside the accepted set); + * nothing changes. + * - `deferred` — a `formula` whose `returnType` is unreadable; nothing changes. + */ +export type BooleanComparandDoorVerdict = + | { + readonly verdict: 'door-refusal'; + readonly form: NonBooleanStringForm; + readonly code: 'INVALID_FILTER'; + readonly status: 400; + } + | { readonly verdict: 'narrows'; readonly value: boolean } + | { readonly verdict: 'passes' } + | { readonly verdict: 'deferred' }; + +/** + * The door's verdict for `comparand` at a judged position of a filter on + * `field` (an UNDOTTED key naming a declared field). Pure: two inputs, no I/O. + */ +export function booleanComparandDoorVerdict( + field: BooleanComparandDoorFieldMeta, + comparand: unknown, +): BooleanComparandDoorVerdict { + const judged = booleanComparandFieldVerdict(field); + if (judged === 'deferred') return { verdict: 'deferred' }; + if (judged === 'not-judged') return { verdict: 'passes' }; + const reading = readBooleanComparand(comparand); + if (reading === null || typeof comparand === 'boolean') return { verdict: 'passes' }; + if (reading.boolean) return { verdict: 'narrows', value: reading.value }; + return { verdict: 'door-refusal', form: reading.form, code: 'INVALID_FILTER', status: 400 }; +} + +/* ──────────────────────────────────────────────────────────────────────────── + * The refusal words + * ──────────────────────────────────────────────────────────────────────────── */ + +/** What is wrong with the comparand, per form — the clause after "which is not a boolean:". */ +const FORM_SENTENCE: Readonly> = { + 'empty': 'a blank string names no boolean (to match a missing value, write {"$eq": null}).', + 'padded': 'it carries surrounding whitespace.', + 'letter-case': 'only the lower-case spellings "true" and "false" are read as a boolean.', + 'placeholder': 'a {placeholder} resolves to an id or a date, never to a boolean.', + 'not-a-boolean': 'it has no boolean reading.', +}; + +/** The consequence and the remedy, after the load-bearing head. */ +const BOOLEAN_COMPARAND_REFUSAL_TAIL = + ' The filter was NOT applied. Write true or false; the strings "true" / "false", 1 / 0 and ' + + '"1" / "0" are read the same.'; + +/** Where the refused comparand sits, and what the door read there. */ +export interface BooleanComparandRefusalSite { + /** The filter key — a declared field of the object, or (`aggregated`) an aggregated-row column. */ + readonly field: string; + /** + * Its declared `type` — or, when {@link BooleanComparandRefusalSite.aggregated} + * is set, the type the engine derived for the column; the message does not + * print it for one. + */ + readonly declaredType: string; + /** `formula` only — its declared `returnType`. */ + readonly returnType?: string; + /** The key path of the comparand, e.g. `where.active.$ne` or `where.active.$in[1]`. */ + readonly path: string; + /** The refused comparand — a string. */ + readonly value: unknown; + /** Why it is not a boolean — `door-refusal`'s `form`. */ + readonly form: NonBooleanStringForm; + /** + * `true` when `field` names an AGGREGATED-row column (`having`) rather than a + * declared field of the object: the message then reads "a boolean aggregated + * column", never "a declared … field". Default `false`. + */ + readonly aggregated?: boolean; +} + +/** + * The refusal the door prints, in one place: the field, its declared type, the + * comparand (bounded), its position, what is wrong with it and the remedy. + * `context` is the caller prefix the engine's refusals carry (`find('task')`). + */ +export function booleanComparandRefusalMessage(site: BooleanComparandRefusalSite, context?: string): string { + const subject = site.aggregated + ? 'a boolean aggregated column' + : `a declared ${site.returnType === undefined ? `${site.declaredType} field` : `${site.declaredType} field returning ${site.returnType}`}`; + return ( + `${context ? `${context}: ` : ''}filter on '${site.field}' compares ${subject} against ` + + `${shapePreview(site.value)} at ${site.path}, which is not a boolean: ${FORM_SENTENCE[site.form]}` + + BOOLEAN_COMPARAND_REFUSAL_TAIL + ); +} + +/* ──────────────────────────────────────────────────────────────────────────── + * The readings' case table + * ──────────────────────────────────────────────────────────────────────────── */ + +/** One row of {@link BOOLEAN_COMPARAND_READING_CASES}. */ +export type BooleanComparandReadingCase = + | { readonly input: unknown; readonly boolean: true; readonly value: boolean; readonly why: string } + | { readonly input: string; readonly boolean: false; readonly form: NonBooleanStringForm; readonly why: string } + | { readonly input: unknown; readonly boolean: null; readonly why: string }; + +const reads = (input: unknown, value: boolean, why: string): BooleanComparandReadingCase => + ({ input, boolean: true, value, why }); +const refused = (input: string, form: NonBooleanStringForm, why: string): BooleanComparandReadingCase => + ({ input, boolean: false, form, why }); +const unread = (input: unknown, why: string): BooleanComparandReadingCase => + ({ input, boolean: null, why }); + +/** + * The readings, row by row — the table the door's suites drive. Each row says + * why; the module header argues the set. + */ +export const BOOLEAN_COMPARAND_READING_CASES: readonly BooleanComparandReadingCase[] = [ + reads(true, true, 'A boolean is the canonical comparand.'), + reads(false, false, 'A boolean is the canonical comparand.'), + reads('true', true, 'The canonical string — a bare query parameter is always a string.'), + reads('false', false, 'The canonical string — `?flag=false`.'), + reads(1, true, 'The SQL storage form; the write side admits it.'), + reads(0, false, 'The SQL storage form; the write side admits it.'), + reads('1', true, '`?flag=1` — a stringified storage form.'), + reads('0', false, '`?flag=0` — a stringified storage form.'), + refused('yes', 'not-a-boolean', 'The card\'s own comparand: no boolean reading.'), + refused('no', 'not-a-boolean', 'No boolean reading.'), + refused('on', 'not-a-boolean', 'An HTML checkbox value, not a boolean spelling.'), + refused('t', 'not-a-boolean', 'A PostgreSQL text form, not one of the accepted spellings.'), + refused('2', 'not-a-boolean', 'A numeric string other than "1" / "0".'), + refused('1.0', 'not-a-boolean', 'Only the exact spelling "1" is accepted.'), + refused('TRUE', 'letter-case', 'One spelling per value: lower case.'), + refused('False', 'letter-case', 'One spelling per value: lower case.'), + refused(' true ', 'padded', 'Padding is refused, as the number grammar refuses it.'), + refused('1\n', 'padded', 'A trailing line break is padding too.'), + refused('', 'empty', 'A blank names no boolean; the null test is {"$eq": null}.'), + refused(' ', 'empty', 'Whitespace only.'), + refused('{current_user_id}', 'placeholder', 'Resolves to a user id, never a boolean.'), + refused('{today}', 'placeholder', 'Resolves to a YYYY-MM-DD day, never a boolean.'), + unread(null, 'The null test, not a value to read as a boolean.'), + unread(2, 'A number other than 1 / 0: answered as written (the ruling refuses strings).'), + unread(-1, 'A number other than 1 / 0: answered as written.'), +]; + +/* ──────────────────────────────────────────────────────────────────────────── + * The fixture and the door's derived case table + * ──────────────────────────────────────────────────────────────────────────── */ + +/** A field of {@link BOOLEAN_COMPARAND_DOOR_FIXTURE} — a legal `FieldSchema` input. */ +export interface BooleanComparandDoorFixtureField { + readonly name: string; + readonly type: string; + /** `formula` — a CEL expression, present so the field is a legal declaration. */ + readonly expression?: string; + /** `formula` — the declared return type under test, or absent for the deferred row. */ + readonly returnType?: 'boolean' | 'text'; +} + +/** The fixture object's name. */ +export const BOOLEAN_COMPARAND_DOOR_FIXTURE_OBJECT = 'boolean_door_probe'; + +/** + * The fixture: each judged type, a `text` field the door must pass, and a + * `formula` returning `boolean`, one returning `text` and one with none. Each + * is a legal `FieldSchema` input (pinned). The neighbour is a class no other + * field-aware door judges, so a `passes` row is observable at the engine as + * "the filter reached the driver unchanged" — a `number` or `date` field + * would be refused `"yes"` by the number or temporal door instead. + */ +export const BOOLEAN_COMPARAND_DOOR_FIXTURE_FIELDS: readonly BooleanComparandDoorFixtureField[] = [ + ...[...BOOLEAN_VALUE_TYPES].map((type) => ({ name: `f_${type}`, type })), + { name: 'f_text', type: 'text' }, + { name: 'f_formula_boolean', type: 'formula', expression: 'true', returnType: 'boolean' }, + { name: 'f_formula_text', type: 'formula', expression: '"a"', returnType: 'text' }, + { name: 'f_formula_untyped', type: 'formula', expression: 'true' }, +]; + +/** The fixture object, in the `{ name, fields }` shape `registerObject` takes. */ +export const BOOLEAN_COMPARAND_DOOR_FIXTURE = { + name: BOOLEAN_COMPARAND_DOOR_FIXTURE_OBJECT, + label: 'Boolean-comparand door probe', + fields: Object.fromEntries([ + ['id', { name: 'id', type: 'text' }], + ...BOOLEAN_COMPARAND_DOOR_FIXTURE_FIELDS.map((f) => [f.name, f] as const), + ]) as Readonly>, +} as const; + +interface BooleanComparandDoorCaseBase { + /** Stable identifier, usable as a test name. */ + readonly name: string; + /** The filter key under test — a fixture field. */ + readonly key: string; + /** The field's declared type. */ + readonly declaredType: string; + /** `formula` only — the declared return type, when present. */ + readonly returnType?: string; + /** The comparand's position below the key: `f_boolean.$ne`, `f_boolean.$in[1]`, or `f_boolean` (implicit). */ + readonly position: string; + /** The comparand at that position. */ + readonly comparand: unknown; + /** Builds the filter under test — a factory, so no suite can edit what another judges. */ + readonly filter: () => FilterCondition; +} + +/** A case the door must refuse — before any driver runs. */ +export interface BooleanComparandDoorRefusalCase extends BooleanComparandDoorCaseBase { + readonly verdict: 'door-refusal'; + readonly form: NonBooleanStringForm; + /** The ADR-0112 code the refusal must carry … */ + readonly code: 'INVALID_FILTER'; + /** … beside this status. */ + readonly status: 400; + /** Substrings the message must contain: the key, the declared type, the comparand and its position. */ + readonly mustMention: readonly string[]; +} + +/** A case the door must rewrite — the accepted spelling replaced by its boolean. */ +export interface BooleanComparandDoorNarrowsCase extends BooleanComparandDoorCaseBase { + readonly verdict: 'narrows'; + readonly value: boolean; + /** The filter the driver must receive. */ + readonly expectedFilter: () => FilterCondition; +} + +/** A case the door neither refuses nor rewrites. */ +export interface BooleanComparandDoorPassesCase extends BooleanComparandDoorCaseBase { + readonly verdict: 'passes'; +} + +/** A case the door records NO verdict for — a `formula` whose return type is unreadable. */ +export interface BooleanComparandDoorDeferredCase extends BooleanComparandDoorCaseBase { + readonly verdict: 'deferred'; +} + +export type BooleanComparandDoorCase = + | BooleanComparandDoorRefusalCase + | BooleanComparandDoorNarrowsCase + | BooleanComparandDoorPassesCase + | BooleanComparandDoorDeferredCase; + +/** Where a comparand sits under a key: implicit, one operator, or one member of a list operator. */ +type Slot = + | { readonly kind: 'implicit' } + | { readonly kind: 'scalar'; readonly op: string } + | { readonly kind: 'list'; readonly op: string; readonly index: 0 | 1 }; + +/** The boolean beside the comparand under test in a list operator — always a legal member. */ +const LIST_NEIGHBOUR = false; + +function slotPosition(key: string, slot: Slot): string { + if (slot.kind === 'implicit') return key; + if (slot.kind === 'scalar') return `${key}.${slot.op}`; + return `${key}.${slot.op}[${slot.index}]`; +} + +/** A `{ $field }` reference is mutable: every filter gets its own, so no suite can move another's. */ +const freshComparand = (comparand: unknown): unknown => + (typeof comparand === 'object' && comparand !== null ? { ...comparand } : comparand); + +function filterAt(key: string, slot: Slot, given: unknown): FilterCondition { + const comparand = freshComparand(given); + if (slot.kind === 'implicit') return { [key]: comparand } as FilterCondition; + if (slot.kind === 'scalar') return { [key]: { [slot.op]: comparand } } as FilterCondition; + const list = slot.index === 0 ? [comparand, LIST_NEIGHBOUR] : [LIST_NEIGHBOUR, comparand]; + return { [key]: { [slot.op]: list } } as FilterCondition; +} + +/** Does the door judge this slot at all? A flag operator's comparand is never handed to it. */ +function isJudgedSlot(slot: Slot): boolean { + if (slot.kind !== 'scalar') return true; + return (BOOLEAN_COMPARAND_DOOR_SCALAR_OPERATORS as readonly string[]).includes(slot.op); +} + +/** The four groups of {@link BOOLEAN_COMPARAND_DOOR_CASES}, which also prefix each case name. */ +type CaseGroup = 'census' | 'position' | 'reading' | 'unjudged'; + +function caseFor( + group: CaseGroup, + field: BooleanComparandDoorFixtureField, + slot: Slot, + comparand: unknown, +): BooleanComparandDoorCase { + const verdict: BooleanComparandDoorVerdict = isJudgedSlot(slot) + ? booleanComparandDoorVerdict(field, comparand) + : { verdict: 'passes' }; + const position = slotPosition(field.name, slot); + const declared = field.returnType ? `${field.type} returning ${field.returnType}` : field.type; + const base = { + name: `[${group}] ${position} = ${shapePreview(comparand)} over ${declared} — ${verdict.verdict}`, + key: field.name, + declaredType: field.type, + ...(field.returnType ? { returnType: field.returnType } : {}), + position, + comparand, + filter: () => filterAt(field.name, slot, comparand), + }; + switch (verdict.verdict) { + case 'door-refusal': + return { + ...base, + verdict: 'door-refusal', + form: verdict.form, + code: verdict.code, + status: verdict.status, + mustMention: [field.name, field.type, shapePreview(comparand), position], + }; + case 'narrows': + return { ...base, verdict: 'narrows', value: verdict.value, expectedFilter: () => filterAt(field.name, slot, verdict.value) }; + case 'passes': + return { ...base, verdict: 'passes' }; + case 'deferred': + return { ...base, verdict: 'deferred' }; + } +} + +const fixtureField = (name: string): BooleanComparandDoorFixtureField => + BOOLEAN_COMPARAND_DOOR_FIXTURE_FIELDS.find((f) => f.name === name)!; + +/** Every position the door judges: implicit, each scalar operator, each list operator's two members. */ +const JUDGED_SLOTS: readonly Slot[] = [ + { kind: 'implicit' }, + ...BOOLEAN_COMPARAND_DOOR_SCALAR_OPERATORS.map((op): Slot => ({ kind: 'scalar', op })), + ...BOOLEAN_COMPARAND_DOOR_LIST_OPERATORS.flatMap((op): Slot[] => [ + { kind: 'list', op, index: 0 }, + { kind: 'list', op, index: 1 }, + ]), +]; + +/** + * The cases, derived rather than hand-kept: + * + * 1. **The type census** — every fixture field, `$eq` against `"yes"` and + * against `"true"`: refused and narrowed on the boolean class (and `formula` + * returning `boolean`), passed everywhere else, deferred on the untyped + * `formula`. + * 2. **The positions** — every judged position on `f_boolean`, four ways: a + * refused string, the canonical string, a stringified storage form, and a + * boolean (passes). + * 3. **The readings** — every {@link BOOLEAN_COMPARAND_READING_CASES} row at + * `$eq` on `f_boolean`. + * 4. **The unjudged positions** — `$null`, `$exists`, `$empty` and a + * `{ $field }` reference on `f_boolean` pass. + */ +export const BOOLEAN_COMPARAND_DOOR_CASES: readonly BooleanComparandDoorCase[] = [ + ...BOOLEAN_COMPARAND_DOOR_FIXTURE_FIELDS.flatMap((field) => [ + caseFor('census', field, { kind: 'scalar', op: '$eq' }, 'yes'), + caseFor('census', field, { kind: 'scalar', op: '$eq' }, 'true'), + ]), + ...JUDGED_SLOTS.flatMap((slot) => [ + caseFor('position', fixtureField('f_boolean'), slot, 'yes'), + caseFor('position', fixtureField('f_boolean'), slot, 'true'), + caseFor('position', fixtureField('f_boolean'), slot, '0'), + caseFor('position', fixtureField('f_boolean'), slot, true), + ]), + ...BOOLEAN_COMPARAND_READING_CASES.map((row) => + caseFor('reading', fixtureField('f_boolean'), { kind: 'scalar', op: '$eq' }, row.input)), + caseFor('unjudged', fixtureField('f_boolean'), { kind: 'scalar', op: '$null' }, true), + caseFor('unjudged', fixtureField('f_boolean'), { kind: 'scalar', op: '$exists' }, false), + caseFor('unjudged', fixtureField('f_boolean'), { kind: 'scalar', op: '$empty' }, true), + caseFor('unjudged', fixtureField('f_boolean'), { kind: 'scalar', op: '$eq' }, { $field: 'f_toggle' }), +]; diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index e51f4fc2569..f9492e5de4e 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -105,6 +105,13 @@ export * from './filter-cross-field-comparison-class'; // which the record validator's number arm reads on the write side. The engine // door is its own card; this module is the contract only. export * from './filter-number-comparand-declared-type'; +// [#21333] The BOOLEAN twin of the door above — the contract half of the +// triage ruling: a comparand against a declared boolean field accepts true / +// false, 1 / 0, "1" / "0" and "true" / "false" (the record validator's +// write-side set), each narrowed to its boolean at the engine's field-aware +// seam, and any other string is refused with INVALID_FILTER 400. The engine +// door is objectql's; this module is the contract only. +export * from './filter-boolean-comparand-declared-type'; 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