From 4a22e60d05d52b87aa691b78b8a59b23456ade64 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 06:46:47 +0000 Subject: [PATCH 1/5] wip(objectql): boolean-comparand arm of the declared-type door walk, and its spec contract Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- .../boolean-comparand-declared-type-door.ts | 111 ++++ packages/objectql/src/engine.ts | 13 +- .../number-comparand-declared-type-door.ts | 93 ++- .../filter-boolean-comparand-declared-type.ts | 582 ++++++++++++++++++ packages/spec/src/data/index.ts | 7 + 5 files changed, 792 insertions(+), 14 deletions(-) create mode 100644 packages/objectql/src/boolean-comparand-declared-type-door.ts create mode 100644 packages/spec/src/data/filter-boolean-comparand-declared-type.ts 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.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/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..6298dc0e853 --- /dev/null +++ b/packages/spec/src/data/filter-boolean-comparand-declared-type.ts @@ -0,0 +1,582 @@ +// 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, one field of each neighbouring class the + * door must pass (number, text, date), and a `formula` returning `boolean`, + * one returning `text` and one with none. Each is a legal `FieldSchema` input + * (pinned). + */ +export const BOOLEAN_COMPARAND_DOOR_FIXTURE_FIELDS: readonly BooleanComparandDoorFixtureField[] = [ + ...[...BOOLEAN_VALUE_TYPES].map((type) => ({ name: `f_${type}`, type })), + { name: 'f_number', type: 'number' }, + { name: 'f_text', type: 'text' }, + { name: 'f_date', type: 'date' }, + { 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}]`; +} + +function filterAt(key: string, slot: Slot, comparand: unknown): FilterCondition { + 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 From b8c59fde9be0f149edd70b468cf4bf2ad860bec9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 07:00:45 +0000 Subject: [PATCH 2/5] test(objectql,spec): pin the boolean-comparand arm and its contract Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- ...olean-comparand-declared-type-door.test.ts | 455 ++++++++++++++++++ ...umber-comparand-declared-type-door.test.ts | 41 +- ...er-boolean-comparand-declared-type.test.ts | 305 ++++++++++++ .../filter-boolean-comparand-declared-type.ts | 19 +- 4 files changed, 810 insertions(+), 10 deletions(-) create mode 100644 packages/objectql/src/engine-boolean-comparand-declared-type-door.test.ts create mode 100644 packages/spec/src/data/filter-boolean-comparand-declared-type.test.ts 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/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 index 6298dc0e853..011ce61eb83 100644 --- a/packages/spec/src/data/filter-boolean-comparand-declared-type.ts +++ b/packages/spec/src/data/filter-boolean-comparand-declared-type.ts @@ -386,16 +386,16 @@ export interface BooleanComparandDoorFixtureField { export const BOOLEAN_COMPARAND_DOOR_FIXTURE_OBJECT = 'boolean_door_probe'; /** - * The fixture: each judged type, one field of each neighbouring class the - * door must pass (number, text, date), and a `formula` returning `boolean`, - * one returning `text` and one with none. Each is a legal `FieldSchema` input - * (pinned). + * 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_number', type: 'number' }, { name: 'f_text', type: 'text' }, - { name: 'f_date', type: 'date' }, { 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' }, @@ -479,7 +479,12 @@ function slotPosition(key: string, slot: Slot): string { return `${key}.${slot.op}[${slot.index}]`; } -function filterAt(key: string, slot: Slot, comparand: unknown): FilterCondition { +/** 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]; From ab8f573809e2a655631608bbb39c048327b91f62 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 07:02:52 +0000 Subject: [PATCH 3/5] chore(spec): regenerate api-surface and export-origins for the boolean-comparand contract Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- packages/spec/api-surface/data.json | 26 ++++++++++++++++++++++++++ packages/spec/export-origins/data.json | 26 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) 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)", From c300dcc2c47b6503a021e9d5403ce74e22b17dbe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 07:03:57 +0000 Subject: [PATCH 4/5] chore(changeset): objectql boolean-comparand door and spec contract Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- .../21333-objectql-boolean-comparand-door.md | 31 +++++++++++++++++++ .../21333-spec-boolean-comparand-contract.md | 23 ++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 .changeset/21333-objectql-boolean-comparand-door.md create mode 100644 .changeset/21333-spec-boolean-comparand-contract.md diff --git a/.changeset/21333-objectql-boolean-comparand-door.md b/.changeset/21333-objectql-boolean-comparand-door.md new file mode 100644 index 00000000000..1f19f31755a --- /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-②: no (narrowing) + + + +**BREAKING**: this narrows what a filter may compare a declared `boolean` or `toggle` field with, at every filter position and through every door. 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 `POST /api/v1/data/:object/query` / `GET /api/v1/data/:object`: + +- `"true"` and `"false"` (implicit, `$eq`, `$in`, `?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"` 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. `?flag=true` and `?flag=1` now return the true row on every driver; +- 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..0782a680401 --- /dev/null +++ b/.changeset/21333-spec-boolean-comparand-contract.md @@ -0,0 +1,23 @@ +--- +"@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-②: no (narrowing) + + + +**BREAKING**: the module declares a narrowing of what a filter may compare a declared `boolean` or `toggle` field with; the engine door that enforces it is `@objectstack/objectql`'s. It ships as `minor` under the launch-window convention for accept-set narrowings. Every existing export is unchanged. + +**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 is refused.** 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`). Before, such a string was compared as written and matched no row (every row under `$ne`). + +**The remedy.** Write `true` or `false`, or in a querystring `true` / `false` or `1` / `0`. From 6f74eb444c1a4961aac2251b9733ee85e3f7a232 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 09:10:10 +0000 Subject: [PATCH 5/5] =?UTF-8?q?chore(changeset):=20Clause-=E2=91=A1=20yes?= =?UTF-8?q?=20(narrowing)=20for=20objectql=20and=20yes=20for=20spec;=20sco?= =?UTF-8?q?pe=20sentences=20to=20the=20measured=20drivers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The objectql changeset carries the PR's Clause-② line, yes (narrowing), and keeps its BREAKING banner. Its sentences are now scoped to what was measured (InMemoryDriver, SqlDriver over SQLite, through findData). The spec changeset reads Clause-② yes, with no banner, no arm and no ADR-0087 marker: the module is additive, and the narrowing is objectql's. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- .../21333-objectql-boolean-comparand-door.md | 14 +++++++------- .../21333-spec-boolean-comparand-contract.md | 10 +++------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/.changeset/21333-objectql-boolean-comparand-door.md b/.changeset/21333-objectql-boolean-comparand-door.md index 1f19f31755a..db91ee4fb6f 100644 --- a/.changeset/21333-objectql-boolean-comparand-door.md +++ b/.changeset/21333-objectql-boolean-comparand-door.md @@ -4,24 +4,24 @@ 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-②: no (narrowing) +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. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes. +**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 `POST /api/v1/data/:object/query` / `GET /api/v1/data/:object`: +**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"` and `"false"` (implicit, `$eq`, `$in`, `?filter=`, `?$filter=`, the filter AST, and the bare query parameter `?flag=true`) matched no row on either driver; +- `"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"` matched the right row on SQLite and no row on InMemoryDriver (`$ne 1` returned both rows there); +- `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. `?flag=true` and `?flag=1` now return the true row on every driver; +- `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. diff --git a/.changeset/21333-spec-boolean-comparand-contract.md b/.changeset/21333-spec-boolean-comparand-contract.md index 0782a680401..df894647417 100644 --- a/.changeset/21333-spec-boolean-comparand-contract.md +++ b/.changeset/21333-spec-boolean-comparand-contract.md @@ -4,11 +4,7 @@ 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-②: no (narrowing) - - - -**BREAKING**: the module declares a narrowing of what a filter may compare a declared `boolean` or `toggle` field with; the engine door that enforces it is `@objectstack/objectql`'s. It ships as `minor` under the launch-window convention for accept-set narrowings. Every existing export is unchanged. +Clause-②: yes **What it declares.** `filter-boolean-comparand-declared-type.ts`, the boolean twin of `filter-number-comparand-declared-type.ts`: @@ -18,6 +14,6 @@ Clause-②: no (narrowing) - `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 is refused.** 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`). Before, such a string was compared as written and matched no row (every row under `$ne`). +**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`). -**The remedy.** Write `true` or `false`, or in a querystring `true` / `false` or `1` / `0`. +**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.