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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/21333-objectql-boolean-comparand-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@objectstack/objectql": minor
---

fix(objectql)!: a comparand against a declared boolean field is narrowed to its boolean at the engine's filter door, and any string other than "true" / "false" / "1" / "0" is refused with `INVALID_FILTER` / 400

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) a refusal and a narrowing of filter COMPARANDS at the engine's query door, against a declared boolean or toggle field. No authorable key, spelling, export or stored shape moves: every query shape, every FilterCondition and every object definition parse as before, @objectstack/objectql exports nothing new and nothing less, and no stored row is read or rewritten. What is refused is a comparand that matched no row (and every row under $ne) on InMemoryDriver and on SqlDriver over SQLite (PostgreSQL and MySQL not measured), and which boolean a caller meant by "yes" is not something a ledger entry can decide. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a filter comparand, and this diff adds none (not registered / already-registered); and the change is runtime behaviour, not a declaration (not runtime-interface-only / type-surface-only). -->

**BREAKING**: this narrows what a filter may compare a declared `boolean` or `toggle` field with, at every filter position and through every door that reaches the engine's filter walk (`engine.find` / `findOne` / `count` / `aggregate` / `update` / `delete`, and every spelling the data API hands it). It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.

**What was accepted before.** A string compared with a boolean field was neither refused nor read as a boolean: the engine handed it to the driver as written, and every answer was a 200. Measured on two rows (one `true`, one `false`) on InMemoryDriver and on SqlDriver over SQLite, through `engine.find`, `engine.aggregate` and the protocol's `findData` with each spelling the `POST /api/v1/data/:object/query` and `GET /api/v1/data/:object` routes hand it:

- `"true"` (implicit, `$eq`, `$in`) and `"false"` (implicit), and both through `?filter=`, `?$filter=`, the filter AST and the bare query parameter (`?flag=true`), matched no row on either driver;
- `$ne "true"` and `$nin ["true"]` returned both rows, the true row included;
- `"yes"` matched no row, and `$ne "yes"` both rows;
- `1`, `"1"`, `0` and `"0"` at `where` (and `"1"` / `"0"` through every spelling above) matched the right row on SQLite and no row on InMemoryDriver (`$ne 1` returned both rows there);
- the per-aggregation `filter` and `having` (the engine's own evaluator) answered `"true"` with no row and no group, and `$ne "true"` with every one.

**What is answered now.** At `where` (both spellings), the per-aggregation `filter` and `having`, on every verb that collects a filter, before any driver is asked for a row:

- `true` / `false` are handed to the driver as written;
- `1` / `0`, `"1"` / `"0"` and `"true"` / `"false"` are narrowed to `true` / `false`, so every driver receives the one boolean each names. Measured on InMemoryDriver and on SqlDriver over SQLite, `?flag=true` and `?flag=1` now return the true row; any other driver receives the same narrowed boolean by mechanism (PostgreSQL and MySQL not measured);
- any other string, a different letter case (`"TRUE"`), surrounding whitespace, a blank and a `{placeholder}` included, is refused `INVALID_FILTER` / 400. The message names the field, its declared type, the comparand and its position, and says what is wrong with it.

The accepted set is the one the record validator already admits when a boolean field is WRITTEN. The rule lives in `@objectstack/spec/data`'s `filter-boolean-comparand-declared-type.ts`, and the engine applies it in the same walk that judges number comparands.

**The remedy.** Write `true` or `false`. In a querystring, where every value is a string, write `true` / `false` or `1` / `0`.

**Unchanged.** A boolean comparand, `null` (the null test) and the flag operators (`$null`, `$exists`, `$empty`) answer as before, and so does every comparand against a field that is not boolean. A number other than `1` / `0` against a boolean field is still handed to the driver as written. A filter on a `formula` field is still refused one step earlier, as before.
19 changes: 19 additions & 0 deletions .changeset/21333-spec-boolean-comparand-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@objectstack/spec": minor
---

feat(spec): the boolean-comparand declared-type contract in `@objectstack/spec/data` — the comparands a declared boolean field accepts in a filter, the boolean each narrows to, and the refusal words

Clause-②: yes

**What it declares.** `filter-boolean-comparand-declared-type.ts`, the boolean twin of `filter-number-comparand-declared-type.ts`:

- `BOOLEAN_COMPARAND_SPELLINGS`: the accepted non-boolean spellings, `1` / `0`, `"1"` / `"0"` and `"true"` / `"false"`, each with the boolean it narrows to. This is the set the record validator admits when a boolean field is written. `readBooleanComparand` reads a comparand by it, and names why a string is not one (`NON_BOOLEAN_STRING_FORMS`: `empty`, `padded`, `letter-case`, `placeholder`, `not-a-boolean`).
- `BOOLEAN_COMPARAND_DOOR_JUDGED_TYPES` (`BOOLEAN_VALUE_TYPES` itself), and the judged positions, which are the number door's lists by identity.
- `booleanComparandFieldVerdict` and `booleanComparandDoorVerdict`, the pure verdict: `narrows`, `door-refusal` (`INVALID_FILTER` / 400), `passes` or `deferred`.
- `booleanComparandRefusalMessage`: the refusal words, inside the 500-character client bound.
- `BOOLEAN_COMPARAND_READING_CASES`, `BOOLEAN_COMPARAND_DOOR_FIXTURE` and the derived `BOOLEAN_COMPARAND_DOOR_CASES`, for a door's suite to drive.

**What the verdict answers `door-refusal` for.** A string other than the four accepted ones, compared with a declared boolean field, at the value positions of a filter (the implicit comparand, `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte`, and each member of `$in` / `$nin` / `$between`).

**What moves for consumers.** Nothing in this package refuses or narrows a filter, and every existing export is unchanged. The door that applies the verdict ships in the same release in `@objectstack/objectql`, whose changeset states what changes for a caller.
111 changes: 111 additions & 0 deletions packages/objectql/src/boolean-comparand-declared-type-door.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading