From 198c679c210f8ce7ce6dd35c6c1abaf798295904 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 11:04:33 +0000 Subject: [PATCH 1/4] fix(spec)!: the boolean verdict refuses a number other than 1 / 0, a Date and an array Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- .../boolean-comparand-declared-type-door.ts | 18 +- ...olean-comparand-declared-type-door.test.ts | 172 +++++++++++++- ...er-boolean-comparand-declared-type.test.ts | 98 +++++++- .../filter-boolean-comparand-declared-type.ts | 218 +++++++++++++++--- 4 files changed, 464 insertions(+), 42 deletions(-) diff --git a/packages/objectql/src/boolean-comparand-declared-type-door.ts b/packages/objectql/src/boolean-comparand-declared-type-door.ts index c922944c908..74ec7fbba58 100644 --- a/packages/objectql/src/boolean-comparand-declared-type-door.ts +++ b/packages/objectql/src/boolean-comparand-declared-type-door.ts @@ -19,7 +19,15 @@ * `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. + * any driver is resolved; + * - [#21382] and so is a number other than `1` / `0`, a `Date` and an array + * (at a scalar slot or as a list member) — before, each reached the drivers + * as written: PostgreSQL answered `2` or a `Date` with a 500, the others + * with an empty 200, and an array `$in` member split 200 / 400 across + * drivers. A `bigint` is read as the number it names. The spec's verdict + * was widened; this file changed only in these words, because the arm + * already routed every comparand to it and carried the refused value as + * written. * * The contract — the accepted spellings, the pure verdict, the refusal words, * the case table — is lane (1), `@objectstack/spec/data`'s @@ -77,9 +85,11 @@ export function booleanArmFieldMeta(meta: BooleanComparandDoorFieldMeta | 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. + * One comparand at a judged position: the spec's verdict, routed. Whatever the + * comparand is — a string, a number, a `Date`, an array (#21382) — the verdict + * alone decides; this function only turns its answer into an outcome. + * `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, 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 index 78950021a0d..3687b26ea80 100644 --- a/packages/objectql/src/engine-boolean-comparand-declared-type-door.test.ts +++ b/packages/objectql/src/engine-boolean-comparand-declared-type-door.test.ts @@ -52,7 +52,9 @@ import { BOOLEAN_COMPARAND_DOOR_LIST_OPERATORS, BOOLEAN_COMPARAND_DOOR_SCALAR_OPERATORS, NON_BOOLEAN_STRING_FORMS, + NON_BOOLEAN_VALUE_FORMS, lowerFilterCondition, + normalizeFilterComparandTypes, type BooleanComparandDoorCase, type BooleanComparandDoorNarrowsCase, type BooleanComparandDoorRefusalCase, @@ -177,7 +179,9 @@ describe('[#21333] the boolean-comparand arm at the engine collection point', () 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)); + // Every refused string form is driven — and [#21382] every non-string form + // (a number other than 1 / 0, a Date, an array) beside them. + expect(new Set(REFUSALS.map((c) => c.form))).toEqual(new Set([...NON_BOOLEAN_STRING_FORMS, ...NON_BOOLEAN_VALUE_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}`), @@ -409,6 +413,172 @@ describe('[#21333] the boolean-comparand arm at the engine collection point', () expect(narrowHavingNumberComparands(OBJECT, untouched, classes as any, types)).toBe(untouched); }); + // ── [#21382] a number other than 1 / 0, a Date, an array: one refusal at every position ── + + /** + * The non-string comparands the widened verdict refuses, each a value the + * card measured: `where` answered PostgreSQL's 500 and an empty 200 + * elsewhere, and an array as a `$in` member split 200 / 400 across drivers. + */ + const NON_STRING: ReadonlyArray unknown, () => unknown, string]> = [ + ['implicit 2 (the card)', (v) => v, () => 2, 'number'], + ['$eq -1', (v) => ({ $eq: v }), () => -1, 'number'], + ['$ne 0.5', (v) => ({ $ne: v }), () => 0.5, 'number'], + ['a $in member 2', (v) => ({ $in: [false, v] }), () => 2, 'number'], + ['implicit Date', (v) => v, () => new Date(Date.UTC(2026, 0, 1)), 'date'], + ['a $nin member Date', (v) => ({ $nin: [v, true] }), () => new Date(Date.UTC(2026, 0, 1)), 'date'], + ['a $in member [true] (the card)', (v) => ({ $in: [false, v] }), () => [true], 'array'], + ['$gt [true]', (v) => ({ $gt: v }), () => [true], 'array'], + ]; + + /** What the contract says is wrong, per non-string form — the clause after "which is not a boolean:". */ + const CLAUSE: Readonly> = { + number: 'which is not a boolean: only the numbers 1 and 0 are read as a boolean', + date: 'which is not a boolean: a Date is an instant, not a boolean', + array: 'which is not a boolean: a list is not one boolean', + }; + + it('[#21382] where: refuses a number other than 1 / 0, a Date or an array in the arm\'s words, on both spellings — no read', async () => { + const schema = engine.registry.getObject(OBJECT); + for (const [name, at, value, form] of NON_STRING) { + reads.length = 0; + const err = await refusalOf(engine.find(OBJECT, { where: { f_boolean: at(value()) } as FilterCondition })); + expect(err, name).not.toBeNull(); + expect({ code: err!.code, status: err!.status }, name).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err!.message, name).toMatch(/^find\('boolean_door_probe'\): filter on 'f_boolean' compares a declared boolean field against /); + expect(err!.message, name).toContain(CLAUSE[form]); + expect(() => narrowNumberComparands(OBJECT, 'find', schema, { f_toggle: at(value()) }), name) + .toThrow(/compares a declared toggle field/); + expect(reads, name).toHaveLength(0); + } + // The FilterArray sugar lowers through `parseFilterAST` first: the same answer. + for (const [op, v] of [['=', 2], ['!=', -1], ['>', new Date(0)]] as const) { + const err = await refusalOf(engine.find(OBJECT, { where: [['f_boolean', op, v]] } as unknown as EngineQueryOptions)); + expect({ code: err?.code, status: err?.status }, op).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err!.message, op).toContain("filter on 'f_boolean' compares a declared boolean field"); + } + for (const where of [ + { $and: [{ f_text: 'a' }, { f_boolean: 2 }] }, + { $or: [{ f_text: 'a' }, { f_toggle: { $in: [true, new Date(0)] } }] }, + { $not: { f_boolean: { $nin: [[false]] } } }, + ]) { + const err = await refusalOf(engine.find(OBJECT, { where: where as FilterCondition })); + expect({ code: err?.code, status: err?.status }, String(Object.keys(where))).toEqual({ code: 'INVALID_FILTER', status: 400 }); + } + expect(reads).toHaveLength(0); + expect(engine.judgeFilter(OBJECT, { f_boolean: { $in: [false, [true]] } })).toMatchObject({ ok: false, code: 'INVALID_FILTER', status: 400 }); + }); + + it('[#21382] the per-aggregation filter: refuses each, rooted at its own position, in the arm\'s words — no read', async () => { + for (const [name, at, value, form] of NON_STRING) { + reads.length = 0; + const err = await refusalOf(engine.aggregate(OBJECT, { + aggregations: [ + { function: 'count', alias: 'all' }, + { function: 'count', alias: 'bad', filter: { f_boolean: at(value()) } }, + ], + } as EngineAggregateOptions)); + expect(err, name).not.toBeNull(); + expect({ code: err!.code, status: err!.status }, name).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err!.message, name).toContain('aggregations[1].filter.f_boolean'); + expect(err!.message, name).toMatch(/^aggregate\('boolean_door_probe'\): filter on 'f_boolean' compares a declared boolean field/); + expect(err!.message, name).toContain(CLAUSE[form]); + expect(reads, name).toHaveLength(0); + } + }); + + it('[#21382] having over a groupBy of the boolean field: refuses each as an aggregated column — no read', async () => { + for (const [name, at, value, form] of NON_STRING) { + reads.length = 0; + const err = await refusalOf(engine.aggregate(OBJECT, { + groupBy: ['f_boolean'], aggregations: [{ function: 'count', alias: 'n' }], having: { f_boolean: at(value()) }, + } as EngineAggregateOptions)); + expect(err, name).not.toBeNull(); + expect({ code: err!.code, status: err!.status }, name).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err!.message, name).toContain("filter on 'f_boolean' compares a boolean aggregated column against"); + expect(err!.message, name).toContain('having.f_boolean'); + expect(err!.message, name).toContain(CLAUSE[form]); + expect(reads, name).toHaveLength(0); + } + }); + + it('[#21382] the controls at all three positions: true and 1 answer exactly what they answered before', async () => { + for (const control of [true, 1]) { + expect(await driverWhere({ f_boolean: control }), String(control)).toEqual(lowered({ f_boolean: true })); + expect(await driverWhere({ f_boolean: { $in: [false, control] } }), String(control)) + .toEqual(lowered({ f_boolean: { $in: [false, true] } })); + const counted = await engine.aggregate(OBJECT, { + aggregations: [{ function: 'count', alias: 'all' }, { function: 'count', alias: 'm', filter: { f_boolean: control } }], + } as EngineAggregateOptions); + expect(Number((counted[0] as Record).m), String(control)).toBe(1); + const groups = (await engine.aggregate(OBJECT, { + groupBy: ['f_boolean'], aggregations: [{ function: 'count', alias: 'n' }], having: { f_boolean: { $ne: control } }, + } as EngineAggregateOptions)).map((r) => (r as Record).f_boolean); + expect(groups, String(control)).toEqual([false]); + } + // null keeps its meaning: the null test reaches the driver as written. + expect(await driverWhere({ f_boolean: null })).toEqual(lowered({ f_boolean: null })); + }); + + it('[#21382] a bigint is read as the number it names, at every position and on both spellings — one answer per value', async () => { + // `JSON.stringify` cannot print a bigint, so these read the recording driver directly. + const where = async (w: unknown) => { + reads.length = 0; + await engine.find(OBJECT, { where: w } as EngineQueryOptions); + return reads[0]?.ast?.where; + }; + // 1n / 0n narrow like 1 / 0 — on the object spelling (this door first) and + // on the FilterArray spelling (the comparand-type door first) alike. + expect(await where({ f_boolean: 1n })).toEqual(lowered({ f_boolean: true })); + expect(await where({ f_boolean: { $ne: 0n } })).toEqual(lowered({ f_boolean: { $ne: false } })); + expect(await where([['f_boolean', '=', 1n]])).toEqual(lowered({ f_boolean: true })); + const counted = await engine.aggregate(OBJECT, { + aggregations: [{ function: 'count', alias: 'all' }, { function: 'count', alias: 'm', filter: { f_boolean: { $in: [false, 1n] } } }], + } as EngineAggregateOptions); + expect(Number((counted[0] as Record).m)).toBe(2); + const groups = (await engine.aggregate(OBJECT, { + groupBy: ['f_boolean'], aggregations: [{ function: 'count', alias: 'n' }], having: { f_boolean: 1n }, + } as EngineAggregateOptions)).map((r) => (r as Record).f_boolean); + expect(groups).toEqual([true]); + // 2n is refused as a number on every spelling and at every position, in the same words. + reads.length = 0; + for (const call of [ + () => engine.find(OBJECT, { where: { f_boolean: 2n } } as EngineQueryOptions), + () => engine.find(OBJECT, { where: [['f_boolean', '=', 2n]] } as unknown as EngineQueryOptions), + () => engine.aggregate(OBJECT, { + aggregations: [{ function: 'count', alias: 'all' }, { function: 'count', alias: 'm', filter: { f_boolean: 2n } }], + } as EngineAggregateOptions), + () => engine.aggregate(OBJECT, { + groupBy: ['f_boolean'], aggregations: [{ function: 'count', alias: 'n' }], having: { f_boolean: 2n }, + } as EngineAggregateOptions), + ]) { + const err = await refusalOf(call()); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err!.message).toContain('against 2 at'); + expect(err!.message).toContain('only the numbers 1 and 0 are read as a boolean'); + } + expect(reads).toHaveLength(0); + }); + + it('[#21382] a value OUTSIDE the accepted comparand types is the comparand-TYPE door\'s refusal, in that door\'s words, on both spellings', async () => { + const context = `find('${OBJECT}')`; + for (const [name, value] of [['a plain object', { a: 1 }], ['undefined', undefined], ['a Map', new Map()]] as const) { + const where = { f_boolean: { $eq: value } }; + // The verdict passes it, so the arm has no second opinion … + expect(() => narrowNumberComparands(OBJECT, 'find', engine.registry.getObject(OBJECT), where), name).not.toThrow(); + // … and the engine answers exactly what the comparand-type door answers. + let expected: Error | undefined; + try { normalizeFilterComparandTypes(where, context); } catch (e) { expected = e as Error; } + expect(expected, name).toBeDefined(); + const err = await refusalOf(engine.find(OBJECT, { where: where as FilterCondition })); + expect({ code: err?.code, status: err?.status }, name).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err!.message, name).toBe(expected!.message); + } + const sugar = await refusalOf(engine.find(OBJECT, { where: [['f_boolean', '=', { a: 1 }]] } as unknown as EngineQueryOptions)); + expect({ code: sugar?.code, status: sugar?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(reads).toHaveLength(0); + }); + // ── the REST doors that reach findData ─────────────────────────────────── describe('the REST doors — one answer however the query arrived', () => { 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 index 3af2f9307bd..357caae74e2 100644 --- a/packages/spec/src/data/filter-boolean-comparand-declared-type.test.ts +++ b/packages/spec/src/data/filter-boolean-comparand-declared-type.test.ts @@ -29,6 +29,7 @@ import { BOOLEAN_COMPARAND_READING_CASES, BOOLEAN_COMPARAND_SPELLINGS, NON_BOOLEAN_STRING_FORMS, + NON_BOOLEAN_VALUE_FORMS, booleanComparandDoorVerdict, booleanComparandFieldVerdict, booleanComparandRefusalMessage, @@ -151,12 +152,60 @@ describe('[#21333] booleanComparandDoorVerdict', () => { } }); - 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]) { + it('passes a boolean, null, a reference and every value the comparand-type door refuses itself', () => { + for (const comparand of [true, false, null, { $field: 'f_toggle' }, undefined, { a: 1 }, new Map(), Symbol('s')]) { expect(booleanComparandDoorVerdict(field, comparand), String(comparand)).toEqual({ verdict: 'passes' }); } }); + it('[#21382] refuses a number other than 1 / 0, a Date and an array — each by what it is, with the 400 envelope', () => { + const refusal = (form: string) => ({ verdict: 'door-refusal', form, code: 'INVALID_FILTER', status: 400 }); + for (const type of ['boolean', 'toggle']) { + for (const n of [2, -1, 0.5, -0.5, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER]) { + expect(booleanComparandDoorVerdict({ type }, n), `${type} ${n}`).toEqual(refusal('number')); + } + for (const d of [new Date(0), new Date(Date.UTC(2026, 0, 1)), new Date(Number.NaN)]) { + expect(booleanComparandDoorVerdict({ type }, d), `${type} ${String(d)}`).toEqual(refusal('date')); + } + for (const a of [[true], [], [1], ['true'], [[false]]]) { + expect(booleanComparandDoorVerdict({ type }, a), `${type} ${JSON.stringify(a)}`).toEqual(refusal('array')); + } + } + expect(booleanComparandDoorVerdict({ type: 'formula', returnType: 'boolean' }, 2)).toEqual(refusal('number')); + // …while on a field that is not boolean none of them is this door's subject. + for (const comparand of [2, new Date(0), [true]]) { + expect(booleanComparandDoorVerdict({ type: 'text' }, comparand)).toEqual({ verdict: 'passes' }); + expect(booleanComparandDoorVerdict({ type: 'formula' }, comparand)).toEqual({ verdict: 'deferred' }); + } + }); + + it('[#21382] reads a bigint as the number it names — 1n / 0n narrow like 1 / 0, any other is refused as a number', () => { + expect(readBooleanComparand(1n)).toEqual({ boolean: true, value: true }); + expect(readBooleanComparand(0n)).toEqual({ boolean: true, value: false }); + expect(readBooleanComparand(-0n)).toEqual({ boolean: true, value: false }); + expect(booleanComparandDoorVerdict(field, 1n)).toEqual({ verdict: 'narrows', value: true }); + expect(booleanComparandDoorVerdict(field, 0n)).toEqual({ verdict: 'narrows', value: false }); + for (const b of [2n, -1n, 2n ** 64n]) { + expect(readBooleanComparand(b), String(b)).toBeNull(); + expect(booleanComparandDoorVerdict(field, b), String(b)) + .toEqual({ verdict: 'door-refusal', form: 'number', code: 'INVALID_FILTER', status: 400 }); + } + }); + + it('[#21382] the accepted set is unchanged — the widening adds refusals only', () => { + // Every accepted spelling still narrows, a boolean still passes, null is still the null test. + for (const [spelling, value] of BOOLEAN_COMPARAND_SPELLINGS) { + expect(booleanComparandDoorVerdict(field, spelling)).toEqual({ verdict: 'narrows', value }); + } + expect(booleanComparandDoorVerdict(field, true)).toEqual({ verdict: 'passes' }); + expect(booleanComparandDoorVerdict(field, null)).toEqual({ verdict: 'passes' }); + // The string rule is untouched: what it refused, it refuses in the same form. + for (const row of BOOLEAN_COMPARAND_READING_CASES) { + if (row.boolean !== false) continue; + expect(booleanComparandDoorVerdict(field, row.input), row.input).toMatchObject({ verdict: 'door-refusal', form: row.form }); + } + }); + 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' }); @@ -187,12 +236,30 @@ describe('[#21333] booleanComparandRefusalMessage', () => { 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); + it('says something different for every form, string and non-string alike, and carries no tracker number', () => { + const forms = [...NON_BOOLEAN_STRING_FORMS, ...NON_BOOLEAN_VALUE_FORMS]; + const messages = forms.map((form) => booleanComparandRefusalMessage({ ...site, form })); + expect(new Set(messages).size).toBe(forms.length); for (const m of messages) expect(m).not.toMatch(/#\d/); }); + it('[#21382] renders a non-string comparand as what it is — a Date by name, a non-finite number by name, never as JSON null', () => { + const at = (value: unknown, form: 'number' | 'date' | 'array') => + booleanComparandRefusalMessage({ ...site, path: 'where.active.$eq', value, form }); + expect(at(2, 'number')).toContain("against 2 at where.active.$eq, which is not a boolean: only the numbers 1 and 0 are read as a boolean"); + expect(at(Number.NaN, 'number')).toContain('against NaN at'); + expect(at(Number.NEGATIVE_INFINITY, 'number')).toContain('against -Infinity at'); + expect(at(2n, 'number')).toContain('against 2 at'); + expect(at(new Date(Date.UTC(2026, 0, 1)), 'date')).toContain('against Date(2026-01-01T00:00:00.000Z) at'); + expect(at(new Date(Number.NaN), 'date')).toContain('against Date(Invalid Date) at'); + expect(at(new Date(0), 'date')).toContain('compare a Date with a date or datetime field'); + expect(at([true], 'array')).toContain('against [true] at'); + expect(at([true], 'array')).toContain('use $in, each member a boolean'); + // No clause names a backend: the server error was a `where` fact, and the + // engine evaluates the per-aggregation filter and having itself. + for (const form of NON_BOOLEAN_VALUE_FORMS) expect(at(2, form)).not.toMatch(/postgres/i); + }); + 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({ @@ -205,7 +272,7 @@ describe('[#21333] booleanComparandRefusalMessage', () => { 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) { + for (const form of [...NON_BOOLEAN_STRING_FORMS, ...NON_BOOLEAN_VALUE_FORMS]) { const message = booleanComparandRefusalMessage({ field: name, declaredType: 'formula', returnType: 'boolean', path: `aggregations[12].filter.${name}.$between[1]`, value: 'x'.repeat(200), form, @@ -266,6 +333,25 @@ describe('[#21333] BOOLEAN_COMPARAND_DOOR_CASES', () => { } }); + it('[#21382] the value group refuses every non-string form at every position the shape door leaves to it', () => { + const value = BOOLEAN_COMPARAND_DOOR_CASES.filter((c) => c.name.startsWith('[value]')); + const refused = value.filter(isRefusal); + expect(new Set(refused.map((c) => c.form))).toEqual(new Set(NON_BOOLEAN_VALUE_FORMS)); + const positionsOf = (form: string) => + new Set(refused.filter((c) => c.key === 'f_boolean' && c.form === form).map((c) => c.position)); + const judged = ['f_boolean', ...BOOLEAN_COMPARAND_DOOR_SCALAR_OPERATORS.map((op) => `f_boolean.${op}`), + ...BOOLEAN_COMPARAND_DOOR_LIST_OPERATORS.flatMap((op) => [`f_boolean.${op}[0]`, `f_boolean.${op}[1]`])]; + expect([...positionsOf('number')].sort()).toEqual([...judged].sort()); + expect([...positionsOf('date')].sort()).toEqual([...judged].sort()); + // The array rows skip the equality slots, where the comparand-shape door speaks first. + const equality = new Set(['f_boolean', 'f_boolean.$eq', 'f_boolean.$ne']); + expect([...positionsOf('array')].sort()).toEqual(judged.filter((p) => !equality.has(p)).sort()); + // Every judged field is refused a number; null and the non-boolean field's rows pass. + expect(new Set(refused.filter((c) => c.comparand === -1).map((c) => c.key))) + .toEqual(new Set(['f_boolean', 'f_toggle', 'f_formula_boolean'])); + for (const c of value.filter((x) => x.comparand === null || x.key === 'f_text')) 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); diff --git a/packages/spec/src/data/filter-boolean-comparand-declared-type.ts b/packages/spec/src/data/filter-boolean-comparand-declared-type.ts index 011ce61eb83..41a2437312d 100644 --- a/packages/spec/src/data/filter-boolean-comparand-declared-type.ts +++ b/packages/spec/src/data/filter-boolean-comparand-declared-type.ts @@ -60,12 +60,56 @@ * 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. + * - **A number other than `1` / `0`, a `Date`, an array** — refused the same + * way ({@link NonBooleanValueForm}); see the next section. + * - **A `bigint`** is read as the number it names: `1n` / `0n` narrow like + * `1` / `0`, and any other `bigint` is refused as a number. That is the + * number the comparand-type door (`filter-comparand-type.ts`) rewrites it to, + * and that door runs BEFORE this one on the `FilterArray` spelling and at + * `having` but AFTER it on the object spelling of `where` and on a + * per-aggregation `filter` — so only reading a `bigint` as its number gives + * one answer at every position. Passed as written, `1n` reached the drivers + * as `1` from the object spelling, unnarrowed (no row on InMemoryDriver, the + * true row on SQLite), and `2n` as `2` (PostgreSQL's server error). + * - **`null`** passes: it is the null test (`{ flag: null }`, `{ $ne: null }`). + * - **Everything else passes this verdict** — `undefined`, a plain object, a + * `{ $field }` reference, a `Map` or class instance. A `{ $field }` reference + * is not a literal. The rest are outside the comparand-type door's accepted + * set, and that door refuses them with `INVALID_FILTER` / 400 on every field, + * at every position and on both filter spellings, in words that name the + * set; the engine runs the two doors in a different order per position, so a + * second refusal here would answer one mistake in two sets of words + * depending on where it was written. The number door argues the same. + * + * ## [#21382] The non-string comparands: refused, not answered as written + * + * Triage's direction (recorded on #21382, inheriting #20502's for the number + * door): *the published verdict refuses, with `INVALID_FILTER` / 400 naming + * the field, any comparand against a declared boolean field that is outside + * its accepted set — another number, a `Date`, an object, and an array at a + * scalar slot or as a list member; the engine door consumes that verdict and + * nothing else; `null` keeps its meaning.* Until then the verdict judged + * strings only, and a non-string outside the accepted set reached the backends + * as written. Measured on `69a12a0952` through `engine.find` / + * `engine.aggregate`, two rows (one `true`, one `false`); a `where` cell reads + * implicit, `$eq`, `$ne`, and a `$in` member beside `false`: + * + * | comparand, position | InMemoryDriver | SqlDriver, SQLite | SqlDriver, PostgreSQL 16 | + * |:--|:--|:--|:--| + * | `2`, `-1`, `0.5`, a `Date`, `where` | no row, no row, both, the false row | the same | **500 `DATABASE_ERROR`** at every slot | + * | the same, per-aggregation `filter` / `having` | count 0, 0, 2, 1 / the groups alike | the same | the same | + * | an array `[true]` as a `$in` member, `where` | **the false row (200)** | 400, the driver's | 400, the driver's | + * | the same, per-aggregation `filter` / `having` | count 1 / the false group: the member dropped | the same | the same | + * + * So one client mistake was a server fault on PostgreSQL and an empty 200 + * elsewhere, and an array member split 200 / 400 across drivers. Each is now + * refused before any read, on every driver and at every position. An array + * at an EQUALITY slot (implicit, `$eq`, `$ne`) is refused one door earlier + * still, by the comparand-shape door, whose remedy is the one for that slot; + * the verdict answers `door-refusal` for it too, and the case table places its + * array rows where the shape door does not speak. An object is the + * comparand-type door's refusal, as above: refused before this change, and + * still refused, in that door's words. * * ## Which fields, which positions * @@ -99,9 +143,12 @@ * `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. + * Every comparand in the table survives `JSON.stringify` (no `bigint` row), so + * a suite may print or send any filter it builds. * * @see NUMBER_COMPARAND_DOOR_CASES — the twin this module is shaped after. * @see https://github.com/objectstack-ai/objectstack/issues/21333 (this door) + * @see https://github.com/objectstack-ai/objectstack/issues/21382 (the non-string comparands) */ import type { FilterCondition } from './filter.zod'; @@ -155,16 +202,22 @@ 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). + * subject (a non-string outside the accepted set, which the verdict judges by + * what it IS — {@link NonBooleanValueForm}; 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. */ +/** + * Read `comparand` by the accepted spellings. Pure. [#21382] A `bigint` is read + * as the number it names (`1n` as `1`), the number the comparand-type door + * rewrites it to — see the module header. + */ export function readBooleanComparand(comparand: unknown): BooleanComparandReading { if (typeof comparand === 'boolean') return { boolean: true, value: comparand }; + if (typeof comparand === 'bigint') return readBooleanComparand(Number(comparand)); if (typeof comparand !== 'string' && typeof comparand !== 'number') return null; const value = BOOLEAN_COMPARAND_SPELLINGS.get(comparand); if (value !== undefined) return { boolean: true, value }; @@ -181,6 +234,55 @@ export function readBooleanComparand(comparand: unknown): BooleanComparandReadin return { boolean: false, form: 'not-a-boolean' }; } +/* ──────────────────────────────────────────────────────────────────────────── + * The comparands that are not strings + * ──────────────────────────────────────────────────────────────────────────── */ + +/** + * [#21382] The NON-string comparands the verdict refuses against a judged + * field, by what they are — the module header says why each is here and why + * nothing else is: + * + * - `number` — a number other than `1` / `0` (`2`, `-1`, `0.5`, `NaN`), or a + * `bigint` naming one. + * - `date` — a `Date` instance, valid or not. + * - `array` — a list where one value belongs (a scalar operator's comparand, + * or a member of a list operator's list). + */ +export const NON_BOOLEAN_VALUE_FORMS = ['number', 'date', 'array'] as const; + +export type NonBooleanValueForm = (typeof NON_BOOLEAN_VALUE_FORMS)[number]; + +/** Every reason the verdict refuses a comparand: a string's form, or a non-string's. */ +export type NonBooleanComparandForm = NonBooleanStringForm | NonBooleanValueForm; + +/** + * The {@link NonBooleanValueForm} a comparand the reading does not read is, or + * `null` for one the verdict passes (`null`, and everything outside the + * comparand-type door's accepted set, which that door refuses itself). Asked + * only after {@link readBooleanComparand} answered `null`, so a number here is + * never `1` / `0`. + */ +function nonBooleanValueForm(comparand: unknown): NonBooleanValueForm | null { + if (typeof comparand === 'number' || typeof comparand === 'bigint') return 'number'; + if (comparand instanceof Date) return 'date'; + if (Array.isArray(comparand)) return 'array'; + return null; +} + +/** + * The comparand as a refusal renders it: {@link shapePreview}, except that a + * `Date` is named as one (its JSON form is a quoted string, which would read as + * a string the door refuses rather than the instant it is), and a non-finite + * number by its own name (its JSON form is `null`, the null test). + */ +function comparandPreview(comparand: unknown): string { + if (typeof comparand === 'number' && !Number.isFinite(comparand)) return String(comparand); + if (!(comparand instanceof Date)) return shapePreview(comparand); + const time = comparand.getTime(); + return `Date(${Number.isNaN(time) ? 'Invalid Date' : comparand.toISOString()})`; +} + /* ──────────────────────────────────────────────────────────────────────────── * The fields and positions the door judges * ──────────────────────────────────────────────────────────────────────────── */ @@ -222,19 +324,20 @@ export function booleanComparandFieldVerdict( /** * 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`. + * - `door-refusal` — refused before any driver runs (`INVALID_FILTER` / 400): + * a string that is not an accepted spelling, a number other than `1` / `0`, + * a `Date` or an array ({@link NonBooleanComparandForm}). + * - `narrows` — an accepted non-boolean spelling (or a `bigint` naming `1` / + * `0`); 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. + * comparand is a boolean, `null`, or a value the comparand-type door refuses + * itself — see the module header); nothing changes. * - `deferred` — a `formula` whose `returnType` is unreadable; nothing changes. */ export type BooleanComparandDoorVerdict = | { readonly verdict: 'door-refusal'; - readonly form: NonBooleanStringForm; + readonly form: NonBooleanComparandForm; readonly code: 'INVALID_FILTER'; readonly status: 400; } @@ -253,8 +356,14 @@ export function booleanComparandDoorVerdict( const judged = booleanComparandFieldVerdict(field); if (judged === 'deferred') return { verdict: 'deferred' }; if (judged === 'not-judged') return { verdict: 'passes' }; + if (typeof comparand === 'boolean') return { verdict: 'passes' }; const reading = readBooleanComparand(comparand); - if (reading === null || typeof comparand === 'boolean') return { verdict: 'passes' }; + if (reading === null) { + // [#21382] Not a spelling: judged by what it IS. + const form = nonBooleanValueForm(comparand); + if (form === null) return { verdict: 'passes' }; + return { verdict: 'door-refusal', form, code: 'INVALID_FILTER', status: 400 }; + } if (reading.boolean) return { verdict: 'narrows', value: reading.value }; return { verdict: 'door-refusal', form: reading.form, code: 'INVALID_FILTER', status: 400 }; } @@ -263,13 +372,22 @@ export function booleanComparandDoorVerdict( * The refusal words * ──────────────────────────────────────────────────────────────────────────── */ -/** What is wrong with the comparand, per form — the clause after "which is not a boolean:". */ -const FORM_SENTENCE: Readonly> = { +/** + * What is wrong with the comparand, per form — the clause after "which is not + * a boolean:". [#21382] Each non-string clause states only what holds at every + * position: PostgreSQL's server error was measured at `where` alone (the + * engine evaluates the per-aggregation `filter` and `having` itself), so no + * clause names a backend. + */ +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.', + 'number': 'only the numbers 1 and 0 are read as a boolean; no other number names one.', + 'date': 'a Date is an instant, not a boolean; compare a Date with a date or datetime field.', + 'array': 'a list is not one boolean; to match either value use $in, each member a boolean.', }; /** The consequence and the remedy, after the load-bearing head. */ @@ -291,10 +409,10 @@ export interface BooleanComparandRefusalSite { 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. */ + /** The refused comparand — a string, a number, a `Date` or an array ({@link NonBooleanComparandForm}). */ readonly value: unknown; /** Why it is not a boolean — `door-refusal`'s `form`. */ - readonly form: NonBooleanStringForm; + readonly form: NonBooleanComparandForm; /** * `true` when `field` names an AGGREGATED-row column (`having`) rather than a * declared field of the object: the message then reads "a boolean aggregated @@ -314,7 +432,7 @@ export function booleanComparandRefusalMessage(site: BooleanComparandRefusalSite : `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]}` + + `${comparandPreview(site.value)} at ${site.path}, which is not a boolean: ${FORM_SENTENCE[site.form]}` + BOOLEAN_COMPARAND_REFUSAL_TAIL ); } @@ -364,8 +482,9 @@ export const BOOLEAN_COMPARAND_READING_CASES: readonly BooleanComparandReadingCa 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.'), + unread(2, 'A number other than 1 / 0 is no spelling; the verdict refuses it as a number.'), + unread(-1, 'A number other than 1 / 0 is no spelling; the verdict refuses it as a number.'), + unread(0.5, 'A fraction is no spelling; the verdict refuses it as a number.'), ]; /* ──────────────────────────────────────────────────────────────────────────── @@ -431,7 +550,7 @@ interface BooleanComparandDoorCaseBase { /** A case the door must refuse — before any driver runs. */ export interface BooleanComparandDoorRefusalCase extends BooleanComparandDoorCaseBase { readonly verdict: 'door-refusal'; - readonly form: NonBooleanStringForm; + readonly form: NonBooleanComparandForm; /** The ADR-0112 code the refusal must carry … */ readonly code: 'INVALID_FILTER'; /** … beside this status. */ @@ -479,9 +598,16 @@ function slotPosition(key: string, slot: Slot): string { 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); +/** + * A `{ $field }` reference, a `Date` and an array are mutable: every filter + * gets its own, so no suite can move another's. + */ +function freshComparand(comparand: unknown): unknown { + if (comparand instanceof Date) return new Date(comparand.getTime()); + if (Array.isArray(comparand)) return comparand.map(freshComparand); + if (typeof comparand === 'object' && comparand !== null) return { ...comparand }; + return comparand; +} function filterAt(key: string, slot: Slot, given: unknown): FilterCondition { const comparand = freshComparand(given); @@ -497,8 +623,8 @@ function isJudgedSlot(slot: Slot): boolean { 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'; +/** The five groups of {@link BOOLEAN_COMPARAND_DOOR_CASES}, which also prefix each case name. */ +type CaseGroup = 'census' | 'position' | 'reading' | 'unjudged' | 'value'; function caseFor( group: CaseGroup, @@ -512,7 +638,7 @@ function caseFor( 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}`, + name: `[${group}] ${position} = ${comparandPreview(comparand)} over ${declared} — ${verdict.verdict}`, key: field.name, declaredType: field.type, ...(field.returnType ? { returnType: field.returnType } : {}), @@ -528,7 +654,7 @@ function caseFor( form: verdict.form, code: verdict.code, status: verdict.status, - mustMention: [field.name, field.type, shapePreview(comparand), position], + mustMention: [field.name, field.type, comparandPreview(comparand), position], }; case 'narrows': return { ...base, verdict: 'narrows', value: verdict.value, expectedFilter: () => filterAt(field.name, slot, verdict.value) }; @@ -552,6 +678,19 @@ const JUDGED_SLOTS: readonly Slot[] = [ ]), ]; +/** The `Date` the `value` rows compare with — any instant; a filter holds a copy of it. */ +const VALUE_DATE = new Date(Date.UTC(2026, 0, 1)); + +/** The list the `value` rows put where one value belongs — itself a list of a legal member. */ +const VALUE_LIST: readonly boolean[] = [true]; + +/** + * The equality slots — implicit, `$eq`, `$ne` — where an array is the + * comparand-SHAPE door's refusal, one door before this one (module header). + */ +const isEqualitySlot = (slot: Slot): boolean => + slot.kind === 'implicit' || (slot.kind === 'scalar' && (slot.op === '$eq' || slot.op === '$ne')); + /** * The cases, derived rather than hand-kept: * @@ -566,6 +705,11 @@ const JUDGED_SLOTS: readonly Slot[] = [ * `$eq` on `f_boolean`. * 4. **The unjudged positions** — `$null`, `$exists`, `$empty` and a * `{ $field }` reference on `f_boolean` pass. + * 5. **The non-string comparands** ([#21382]) — `-1` at `$ne` on every judged + * field; `2` and a `Date` at every judged position on `f_boolean`, and an + * array at every one but the equality slots (the shape door's); all + * refused. Beside them, what passes: `null` as the null test, and a number + * or a `Date` against a field that is not boolean — not this door's subject. */ export const BOOLEAN_COMPARAND_DOOR_CASES: readonly BooleanComparandDoorCase[] = [ ...BOOLEAN_COMPARAND_DOOR_FIXTURE_FIELDS.flatMap((field) => [ @@ -584,4 +728,16 @@ export const BOOLEAN_COMPARAND_DOOR_CASES: readonly BooleanComparandDoorCase[] = 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' }), + ...BOOLEAN_COMPARAND_DOOR_FIXTURE_FIELDS + .filter((field) => booleanComparandFieldVerdict(field) === 'judged') + .map((field) => caseFor('value', field, { kind: 'scalar', op: '$ne' }, -1)), + ...JUDGED_SLOTS.flatMap((slot) => [ + caseFor('value', fixtureField('f_boolean'), slot, 2), + caseFor('value', fixtureField('f_boolean'), slot, VALUE_DATE), + ...(isEqualitySlot(slot) ? [] : [caseFor('value', fixtureField('f_boolean'), slot, VALUE_LIST)]), + ]), + caseFor('value', fixtureField('f_boolean'), { kind: 'implicit' }, null), + caseFor('value', fixtureField('f_boolean'), { kind: 'scalar', op: '$ne' }, null), + caseFor('value', fixtureField('f_text'), { kind: 'scalar', op: '$eq' }, 2), + caseFor('value', fixtureField('f_text'), { kind: 'scalar', op: '$ne' }, VALUE_DATE), ]; From 01892df8182459c1c81f413f12ae88a8ccc77a17 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 11:08:25 +0000 Subject: [PATCH 2/4] =?UTF-8?q?test(rest):=20the=20boolean=20comparand=20d?= =?UTF-8?q?oor=20cell=20=E2=80=94=20SQLite,=20and=20PostgreSQL=20/=20MySQL?= =?UTF-8?q?=20where=20provisioned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- .../src/data-boolean-comparand-door.test.ts | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 packages/rest/src/data-boolean-comparand-door.test.ts diff --git a/packages/rest/src/data-boolean-comparand-door.test.ts b/packages/rest/src/data-boolean-comparand-door.test.ts new file mode 100644 index 00000000000..5c9ee8d72a6 --- /dev/null +++ b/packages/rest/src/data-boolean-comparand-door.test.ts @@ -0,0 +1,270 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21382] A number other than `1` / `0`, a `Date` or an array compared + * against a declared boolean field is refused at the public door — + * `POST /api/v1/data/:object/query` and `engine.find` / `engine.aggregate` + * answer `400 INVALID_FILTER` at `where`, the per-aggregation `filter` and + * `having`, before any read — over a real `SqlDriver`, with `true`, `1` and + * `"true"` as the controls. + * + * Measured on the base (`69a12a0952`) through the engine, two rows (one + * `true`, one `false`); a `where` cell reads implicit, `$eq`, `$ne`, and a + * `$in` member beside `false`: + * + * | comparand, position | InMemoryDriver | SQLite | PostgreSQL 16 | + * |:--|:--|:--|:--| + * | `2`, `-1`, `0.5`, a `Date`, `where` | 200: no row, no row, both, the false row | the same | 500 `DATABASE_ERROR` at every slot | + * | the same, per-aggregation `filter` / `having` | count 0, 0, 2, 1 / the groups alike | the same | the same | + * | an array `[true]` as a `$in` member, `where` | 200, the false row | 400, the driver's own | 400, the driver's own | + * | the same, per-aggregation `filter` / `having` | count 1 / the false group | the same | the same | + * + * The door sits in the engine, in front of every driver, so one verdict holds + * on each cell; the engine-level pin that drives the contract's whole case + * table through a recording driver is `@objectstack/objectql`'s + * `engine-boolean-comparand-declared-type-door.test.ts`. InMemoryDriver's row + * is that pin's by construction: the door answers before a driver is resolved. + * + * ## The dialect axis of THIS file + * + * The SQLite cell always runs. The PostgreSQL and MySQL cells run where + * `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL` are set and are a named skip + * otherwise. ⚠️ No CI job provisions those variables for this package (the + * `Temporal Conformance (live PG + MySQL)` job runs `driver-sql`, + * `metadata-protocol` and one `runtime` file), so the live cells are + * red-capable and un-run in CI; the PR that landed this file carries their + * local PostgreSQL run. Each live cell owns one table, dropped before and + * after. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { EngineAggregateOptions, FilterCondition } from '@objectstack/spec/data'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; + +const OBJECT = 'rest_boolean_door_21382'; + +const TASKS = { + name: OBJECT, + label: 'Tasks 21382', + fields: { + label: { name: 'label', type: 'text' as const }, + done: { name: 'done', type: 'boolean' as const }, + }, +}; + +const ROWS = [ + { id: 'rt', label: 'a', done: true }, + { id: 'rf', label: 'b', done: false }, +]; + +interface Cell { + id: 'sqlite' | 'pg' | 'mysql'; + label: string; + env: string | null; + config: () => Record | null; +} + +const CELLS: readonly Cell[] = [ + { id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, + { + id: 'pg', + label: 'live postgres', + env: 'OS_TEST_POSTGRES_URL', + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), + }, + { + id: 'mysql', + label: 'live mysql', + env: 'OS_TEST_MYSQL_URL', + config: () => (process.env.OS_TEST_MYSQL_URL ? { client: 'mysql2', connection: process.env.OS_TEST_MYSQL_URL } : null), + }, +]; + +/** name · the constraint on `done` — what JSON carries: a number other than 1 / 0, or an array. */ +const REFUSED_OVER_REST: ReadonlyArray = [ + ['implicit 2 (the card)', 2], + ['$eq -1', { $eq: -1 }], + ['$ne 0.5', { $ne: 0.5 }], + ['a $in member 2', { $in: [false, 2] }], + ['a $nin member -1', { $nin: [-1] }], + ['a $in member [true] (the card)', { $in: [false, [true]] }], + ['$gt [true] (an array at a scalar slot)', { $gt: [true] }], +]; + +/** + * name · a factory for the constraint — what only an in-process caller (a + * flow, a hook, server code) can send: a `Date`, beside the number. + */ +const REFUSED_IN_PROCESS: ReadonlyArray unknown]> = [ + ['implicit 2', () => 2], + ['implicit Date', () => new Date(Date.UTC(2026, 0, 1))], + ['$ne Date', () => ({ $ne: new Date(Date.UTC(2026, 0, 1)) })], + ['a $in member [true]', () => ({ $in: [false, [true]] })], +]; + +/** name · the constraint · the ids `where` answers — the controls, unchanged. */ +const CONTROLS: ReadonlyArray = [ + ['implicit true', true, ['rt']], + ['implicit 1', 1, ['rt']], + ['implicit "true"', 'true', ['rt']], + ['$ne 1', { $ne: 1 }, ['rf']], + ['a $in member 1', { $in: [false, 1] }, ['rf', 'rt']], + ['$eq 0', { $eq: 0 }, ['rf']], +]; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +const perAggregation = (filter: FilterCondition): EngineAggregateOptions => ({ + aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter }], +}); + +/** + * `native`: SqlDriver aggregates and the engine applies `having` to its + * answer. `rows`: a filtered aggregation sends the engine to the rows path, + * where it aggregates itself and then applies `having`. + */ +const grouped = (path: 'native' | 'rows', having: FilterCondition): EngineAggregateOptions => ({ + groupBy: ['done'], + aggregations: [ + { function: 'count', alias: 'n' }, + ...(path === 'rows' ? [{ function: 'count' as const, alias: 'fb', filter: { label: { $ne: '' } } }] : []), + ], + having, +}); + +const refusalOf = async (p: Promise) => + p.then(() => null, (e: any) => e as Error & { code?: string; status?: number }); + +/** A stored boolean comes back as a boolean or as 1 / 0, per dialect. */ +const asBoolean = (v: unknown): boolean => v === true || v === 1 || v === '1'; + +for (const cell of CELLS) { + const config = cell.config(); + describe.skipIf(!config)( + `[#21382] a non-string comparand outside the accepted set against a boolean field at the public door — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + () => { + let engine: ObjectQL; + let driver: any; + const reads = { n: 0 }; + let query: (body: Record) => Promise<{ status: number; body: any }>; + + beforeAll(async () => { + driver = new SqlDriver(config as any); + if (cell.id !== 'sqlite') await driver.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(TASKS as any); + await engine.syncSchemas(); + for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); + + // Reads of THIS object — the protocol's own metadata traffic is not the question. + for (const verb of ['find', 'findOne', 'count', 'aggregate'] as const) { + const real = driver[verb].bind(driver); + driver[verb] = (o: string, ...rest: unknown[]) => { if (o === OBJECT) reads.n += 1; return real(o, ...rest); }; + } + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/query'); + expect(route).toBeDefined(); + query = async (body) => { + const res = makeRes(); + // What the wire carries: JSON. + await route!.handler({ params: { object: OBJECT }, body: JSON.parse(JSON.stringify(body)), query: {}, headers: {} } as any, res); + return { status: res._status ?? 200, body: res._json }; + }; + }); + + afterAll(async () => { + if (cell.id !== 'sqlite') await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + try { await engine?.destroy(); } catch { /* noop */ } + }); + + it('where: 400 INVALID_FILTER naming the field, through REST and engine.find — no read', async () => { + const before = reads.n; + for (const [name, spec] of REFUSED_OVER_REST) { + const where = { done: spec } as FilterCondition; + const res = await query({ where }); + expect(res.status, `REST, ${name}: ${JSON.stringify(res.body)}`).toBe(400); + expect(res.body.code, `REST, ${name}`).toBe('INVALID_FILTER'); + expect(res.body.error, `REST, ${name}`).toContain("filter on 'done' compares a declared boolean field"); + const err = await refusalOf(engine.find(OBJECT, { where })); + expect({ code: err?.code, status: err?.status }, `engine.find, ${name}`).toEqual({ code: 'INVALID_FILTER', status: 400 }); + } + for (const [name, spec] of REFUSED_IN_PROCESS) { + const err = await refusalOf(engine.find(OBJECT, { where: { done: spec() } as FilterCondition })); + expect({ code: err?.code, status: err?.status }, `engine.find, ${name}`).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err?.message, `engine.find, ${name}`).toContain("filter on 'done' compares a declared boolean field"); + } + expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); + }); + + it('the per-aggregation filter and having: 400 INVALID_FILTER at their own positions — no read', async () => { + const before = reads.n; + for (const [name, spec] of [['implicit 2', 2], ['a $in member [true]', { $in: [false, [true]] }]] as const) { + const filter = await query(perAggregation({ done: spec } as FilterCondition) as Record); + expect(filter.status, `filter, ${name}: ${JSON.stringify(filter.body)}`).toBe(400); + expect(filter.body.code, `filter, ${name}`).toBe('INVALID_FILTER'); + expect(filter.body.error, `filter, ${name}`).toContain('aggregations[1].filter.done'); + for (const path of ['native', 'rows'] as const) { + const having = await query(grouped(path, { done: spec } as FilterCondition) as Record); + expect(having.status, `having ${path}, ${name}: ${JSON.stringify(having.body)}`).toBe(400); + expect(having.body.code, `having ${path}, ${name}`).toBe('INVALID_FILTER'); + expect(having.body.error, `having ${path}, ${name}`).toContain("filter on 'done' compares a boolean aggregated column"); + } + } + for (const [name, spec] of REFUSED_IN_PROCESS) { + const filtered = await refusalOf(engine.aggregate(OBJECT, perAggregation({ done: spec() } as FilterCondition))); + expect({ code: filtered?.code, status: filtered?.status }, `filter, ${name}`).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(filtered?.message, `filter, ${name}`).toContain('aggregations[1].filter.done'); + for (const path of ['native', 'rows'] as const) { + const having = await refusalOf(engine.aggregate(OBJECT, grouped(path, { done: spec() } as FilterCondition))); + expect({ code: having?.code, status: having?.status }, `having ${path}, ${name}`).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(having?.message, `having ${path}, ${name}`).toContain('having.done'); + } + } + expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); + }); + + it('the controls: true, 1 and "true" answer the rows they name, at every position', async () => { + for (const [name, spec, ids] of CONTROLS) { + const res = await query({ where: { done: spec } }); + expect(res.status, `where, ${name}: ${JSON.stringify(res.body)}`).toBe(200); + expect(res.body.records.map((r: any) => r.id).sort(), `where, ${name}`).toEqual([...ids]); + const counted = await query(perAggregation({ done: spec } as FilterCondition) as Record); + expect(counted.status, `filter, ${name}: ${JSON.stringify(counted.body)}`).toBe(200); + expect(Number(counted.body.records[0]?.m), `filter, ${name}`).toBe(ids.length); + } + for (const path of ['native', 'rows'] as const) { + const groupsOf = async (having: FilterCondition) => { + const res = await query(grouped(path, having) as Record); + expect(res.status, `having ${path} ${JSON.stringify(having)}: ${JSON.stringify(res.body)}`).toBe(200); + return res.body.records.map((r: any) => asBoolean(r.done)).sort(); + }; + expect(await groupsOf({ done: true }), `having ${path}`).toEqual([true]); + expect(await groupsOf({ done: 1 }), `having ${path}`).toEqual([true]); + expect(await groupsOf({ done: { $ne: 'true' } }), `having ${path}`).toEqual([false]); + } + }); + }, + ); +} From 8c54edf79e8c6f57c02c108912328fc7412b5be3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 11:09:57 +0000 Subject: [PATCH 3/4] chore(spec): regenerate api-surface and export-origins for the three boolean value-form exports Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- packages/spec/api-surface/data.json | 3 +++ packages/spec/export-origins/data.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index ebcb2c7b3d3..1f7f04170c2 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -466,6 +466,7 @@ "MysqlConfigParsed (type)", "MysqlConfigSchema (const)", "NON_BOOLEAN_STRING_FORMS (const)", + "NON_BOOLEAN_VALUE_FORMS (const)", "NON_NUMERIC_STRING_FORMS (const)", "NON_NUMERIC_VALUE_FORMS (const)", "NON_TEXT_STORED_VALUE_TYPES (const)", @@ -501,7 +502,9 @@ "NoSQLQueryOptionsSchema (const)", "NoSQLTransactionOptions (type)", "NoSQLTransactionOptionsSchema (const)", + "NonBooleanComparandForm (type)", "NonBooleanStringForm (type)", + "NonBooleanValueForm (type)", "NonNumericComparandForm (type)", "NonNumericStringForm (type)", "NonNumericValueForm (type)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index ca4da9bce20..68e506aa24a 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -456,6 +456,7 @@ "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_BOOLEAN_VALUE_FORMS": "src/data/filter-boolean-comparand-declared-type.ts#NON_BOOLEAN_VALUE_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)", @@ -491,7 +492,9 @@ "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)", + "NonBooleanComparandForm": "src/data/filter-boolean-comparand-declared-type.ts#NonBooleanComparandForm (type)", "NonBooleanStringForm": "src/data/filter-boolean-comparand-declared-type.ts#NonBooleanStringForm (type)", + "NonBooleanValueForm": "src/data/filter-boolean-comparand-declared-type.ts#NonBooleanValueForm (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)", From 196afd75cd74fabf2b4e995ad1172540943c82bb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 11:10:52 +0000 Subject: [PATCH 4/4] chore(changeset): the boolean comparand non-string refusal, spec and objectql Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- ...2-objectql-boolean-comparand-non-string.md | 26 +++++++++++++++++++ ...21382-spec-boolean-comparand-non-string.md | 22 ++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 .changeset/21382-objectql-boolean-comparand-non-string.md create mode 100644 .changeset/21382-spec-boolean-comparand-non-string.md diff --git a/.changeset/21382-objectql-boolean-comparand-non-string.md b/.changeset/21382-objectql-boolean-comparand-non-string.md new file mode 100644 index 00000000000..5e7d4d0cb9d --- /dev/null +++ b/.changeset/21382-objectql-boolean-comparand-non-string.md @@ -0,0 +1,26 @@ +--- +"@objectstack/objectql": minor +--- + +fix(objectql)!: a number other than `1` / `0`, a `Date` or an array compared against a boolean field is refused with `INVALID_FILTER` / 400 at `where`, a per-aggregation `filter` and `having`, instead of a PostgreSQL 500 or an empty 200 + +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 of this package changes; the rule is `@objectstack/spec/data`'s `booleanComparandDoorVerdict`, whose own changeset lists what moved there. + +**What was answered before.** A non-string comparand outside the accepted set reached the driver as written. Measured on two rows (one `true`, one `false`) through `engine.find` / `engine.aggregate`, on InMemoryDriver, SqlDriver over SQLite and SqlDriver over PostgreSQL 16: + +| position | comparand | before: memory · SQLite · PostgreSQL | now, on all three | +|:--|:--|:--|:--| +| `where` | implicit / `$eq` `2`, `-1`, `0.5`, a `Date` | no row · no row · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 | +| `where` | `$ne` the same | both rows · both rows · 500 | `INVALID_FILTER` / 400 | +| `where` | a `$in` member `2` or a `Date` | the other members' rows · the same · 500 | `INVALID_FILTER` / 400 | +| `where` | a `$in` member `[true]` | the other members' rows (200) · a driver 400 · a driver 400 | `INVALID_FILTER` / 400, in one set of words | +| per-aggregation `filter` / `having` | any of the above | count 0 and no group (every row and group under `$ne`), a `$in` member ignored, on all three | `INVALID_FILTER` / 400 | +| all three positions | `true`, `1`, `"true"` (the controls) | the true row, count 1, the true group | the same | + +**The remedy.** Write `true` or `false` (or `1` / `0`). To match either value, use `$in`, each member a boolean. Compare a `Date` with a date or datetime field. + +**Unchanged.** `true` / `false` pass as written, the accepted spellings (`1` / `0`, `"1"` / `"0"`, `"true"` / `"false"`) narrow as before, and any other string is refused in the same words as before. `null` (the null test) and the flag operators answer as before. A value outside the accepted comparand types (`undefined`, a plain object, a `Map`) keeps the comparand-type door's own refusal and words. A filter on a `formula` field is still refused one step earlier. Driver-direct callers that never pass through the engine keep each driver's native binding. diff --git a/.changeset/21382-spec-boolean-comparand-non-string.md b/.changeset/21382-spec-boolean-comparand-non-string.md new file mode 100644 index 00000000000..0764408fefd --- /dev/null +++ b/.changeset/21382-spec-boolean-comparand-non-string.md @@ -0,0 +1,22 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec)!: the boolean-comparand verdict refuses a number other than `1` / `0`, a `Date` and an array compared against a boolean field, the same as a string that is not a boolean + +Clause-②: yes (narrowing) + + + +**BREAKING**: this narrows what a filter may compare a boolean field with. `booleanComparandDoorVerdict`, the published verdict the engine's boolean-comparand arm consumes, judged strings only; it now also answers `door-refusal` (`INVALID_FILTER` / 400) for a number other than `1` / `0`, a `Date` and an array, so the engine refuses them before any read, on every driver. It ships as `minor` under the launch-window convention for accept-set narrowings. The accepted set is unchanged: `true`, `false`, `1`, `0`, `"true"`, `"false"`, `"1"` and `"0"`, and `null` is still the null test. + +What moves in `@objectstack/spec/data`: + +- `booleanComparandDoorVerdict(field, comparand)` answers `door-refusal` with a new `form` for each non-string: `number`, `date` or `array`. `readBooleanComparand` reads a `bigint` as the number it names, so `1n` / `0n` narrow like `1` / `0` and any other `bigint` is refused as a number. That is the number the comparand-type door rewrites a `bigint` to, so the answer no longer depends on which door met it first. +- Three additive exports: `NON_BOOLEAN_VALUE_FORMS` (`number`, `date`, `array`) and the types `NonBooleanValueForm` and `NonBooleanComparandForm`. The refusal's `form` (on `BooleanComparandDoorVerdict`, `BooleanComparandRefusalSite` and `BooleanComparandDoorRefusalCase`) widens from `NonBooleanStringForm` to `NonBooleanComparandForm`, and the refusal site's `value` now carries a non-string. A consumer that switches over `form` exhaustively gains three cases. +- `booleanComparandRefusalMessage` gains one clause per non-string form, and renders a `Date` as `Date(ISO)` and a non-finite number by name instead of as JSON. +- `BOOLEAN_COMPARAND_DOOR_CASES` gains a `value` group: `-1` at `$ne` on every judged field, and `2`, a `Date` and an array at every judged position of `f_boolean` (no array at the equality slots, where the comparand-shape door refuses one first), plus the passing rows beside them. The `2` / `-1` reading rows, and a new `0.5` row, now derive refusals. + +FROM a number other than `1` / `0` (`2`, `-1`, `0.5`), a `Date`, or an array where one value belongs (a scalar operator's comparand, or a member of `$in` / `$nin` / `$between`), compared against a `boolean` or `toggle` field (or a groupBy / `min` / `max` column of one in `having`) → TO `INVALID_FILTER` / 400, naming the field, its declared type, the comparand, its position and what is wrong with it. The fix is one line: send `true` or `false`, or `1` / `0`; to match either value use `$in`, each member a boolean. + +**Unchanged.** Every string the verdict accepted or refused is answered as before, in the same words. A boolean, `null` and the flag operators (`$null`, `$exists`, `$empty`) pass. A value outside the accepted comparand types (`undefined`, a plain object, a `Map`) keeps the comparand-type door's own refusal and words, and a `{ $field }` reference is not judged. A comparand against a field that is not boolean is not this verdict's subject.