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
26 changes: 26 additions & 0 deletions .changeset/21382-objectql-boolean-comparand-non-string.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@objectstack/objectql": minor
---

fix(objectql)!: a number other than `1` / `0`, a `Date` or an array compared against a boolean field is refused with `INVALID_FILTER` / 400 at `where`, a per-aggregation `filter` and `having`, instead of a PostgreSQL 500 or an empty 200

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) a refusal of filter COMPARANDS at the engine's query door, against a declared boolean or toggle field or a boolean aggregated column. 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 answered a server error on PostgreSQL and an empty 200 on InMemoryDriver and SQLite, and which boolean a caller meant by 2 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 of this package changes; the rule is `@objectstack/spec/data`'s `booleanComparandDoorVerdict`, whose own changeset lists what moved there.

**What was answered before.** A non-string comparand outside the accepted set reached the driver as written. Measured on two rows (one `true`, one `false`) through `engine.find` / `engine.aggregate`, on InMemoryDriver, SqlDriver over SQLite and SqlDriver over PostgreSQL 16:

| position | comparand | before: memory · SQLite · PostgreSQL | now, on all three |
|:--|:--|:--|:--|
| `where` | implicit / `$eq` `2`, `-1`, `0.5`, a `Date` | no row · no row · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 |
| `where` | `$ne` the same | both rows · both rows · 500 | `INVALID_FILTER` / 400 |
| `where` | a `$in` member `2` or a `Date` | the other members' rows · the same · 500 | `INVALID_FILTER` / 400 |
| `where` | a `$in` member `[true]` | the other members' rows (200) · a driver 400 · a driver 400 | `INVALID_FILTER` / 400, in one set of words |
| per-aggregation `filter` / `having` | any of the above | count 0 and no group (every row and group under `$ne`), a `$in` member ignored, on all three | `INVALID_FILTER` / 400 |
| all three positions | `true`, `1`, `"true"` (the controls) | the true row, count 1, the true group | the same |

**The remedy.** Write `true` or `false` (or `1` / `0`). To match either value, use `$in`, each member a boolean. Compare a `Date` with a date or datetime field.

**Unchanged.** `true` / `false` pass as written, the accepted spellings (`1` / `0`, `"1"` / `"0"`, `"true"` / `"false"`) narrow as before, and any other string is refused in the same words as before. `null` (the null test) and the flag operators answer as before. A value outside the accepted comparand types (`undefined`, a plain object, a `Map`) keeps the comparand-type door's own refusal and words. A filter on a `formula` field is still refused one step earlier. Driver-direct callers that never pass through the engine keep each driver's native binding.
22 changes: 22 additions & 0 deletions .changeset/21382-spec-boolean-comparand-non-string.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@objectstack/spec": minor
---

fix(spec)!: the boolean-comparand verdict refuses a number other than `1` / `0`, a `Date` and an array compared against a boolean field, the same as a string that is not a boolean

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) a refusal of a filter COMPARAND value at the engine's query door, decided by the published boolean-comparand verdict: no authorable key, spelling or stored shape moves, and no export is removed or renamed (the verdict's function, its case table and its words keep their names; three exports are added). No stored row is read or rewritten. What is refused is a number other than 1 / 0, a Date or an array compared against a declared boolean field or a boolean aggregated column; which boolean the caller meant by 2 is not something a ledger entry can decide (reading 2 as true is exactly the silent coercion this refuses). The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a filter comparand (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 boolean field with. `booleanComparandDoorVerdict`, the published verdict the engine's boolean-comparand arm consumes, judged strings only; it now also answers `door-refusal` (`INVALID_FILTER` / 400) for a number other than `1` / `0`, a `Date` and an array, so the engine refuses them before any read, on every driver. It ships as `minor` under the launch-window convention for accept-set narrowings. The accepted set is unchanged: `true`, `false`, `1`, `0`, `"true"`, `"false"`, `"1"` and `"0"`, and `null` is still the null test.

What moves in `@objectstack/spec/data`:

- `booleanComparandDoorVerdict(field, comparand)` answers `door-refusal` with a new `form` for each non-string: `number`, `date` or `array`. `readBooleanComparand` reads a `bigint` as the number it names, so `1n` / `0n` narrow like `1` / `0` and any other `bigint` is refused as a number. That is the number the comparand-type door rewrites a `bigint` to, so the answer no longer depends on which door met it first.
- Three additive exports: `NON_BOOLEAN_VALUE_FORMS` (`number`, `date`, `array`) and the types `NonBooleanValueForm` and `NonBooleanComparandForm`. The refusal's `form` (on `BooleanComparandDoorVerdict`, `BooleanComparandRefusalSite` and `BooleanComparandDoorRefusalCase`) widens from `NonBooleanStringForm` to `NonBooleanComparandForm`, and the refusal site's `value` now carries a non-string. A consumer that switches over `form` exhaustively gains three cases.
- `booleanComparandRefusalMessage` gains one clause per non-string form, and renders a `Date` as `Date(ISO)` and a non-finite number by name instead of as JSON.
- `BOOLEAN_COMPARAND_DOOR_CASES` gains a `value` group: `-1` at `$ne` on every judged field, and `2`, a `Date` and an array at every judged position of `f_boolean` (no array at the equality slots, where the comparand-shape door refuses one first), plus the passing rows beside them. The `2` / `-1` reading rows, and a new `0.5` row, now derive refusals.

FROM a number other than `1` / `0` (`2`, `-1`, `0.5`), a `Date`, or an array where one value belongs (a scalar operator's comparand, or a member of `$in` / `$nin` / `$between`), compared against a `boolean` or `toggle` field (or a groupBy / `min` / `max` column of one in `having`) → TO `INVALID_FILTER` / 400, naming the field, its declared type, the comparand, its position and what is wrong with it. The fix is one line: send `true` or `false`, or `1` / `0`; to match either value use `$in`, each member a boolean.

**Unchanged.** Every string the verdict accepted or refused is answered as before, in the same words. A boolean, `null` and the flag operators (`$null`, `$exists`, `$empty`) pass. A value outside the accepted comparand types (`undefined`, a plain object, a `Map`) keeps the comparand-type door's own refusal and words, and a `{ $field }` reference is not judged. A comparand against a field that is not boolean is not this verdict's subject.
18 changes: 14 additions & 4 deletions packages/objectql/src/boolean-comparand-declared-type-door.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,15 @@
* `1` matched no row on InMemoryDriver;
* - any other string (`"yes"`, `"TRUE"`, `""`, a `{placeholder}`) is refused
* `INVALID_FILTER` / 400, naming the field and its declared type, before
* any driver is resolved.
* any driver is resolved;
* - [#21382] and so is a number other than `1` / `0`, a `Date` and an array
* (at a scalar slot or as a list member) — before, each reached the drivers
* as written: PostgreSQL answered `2` or a `Date` with a 500, the others
* with an empty 200, and an array `$in` member split 200 / 400 across
* drivers. A `bigint` is read as the number it names. The spec's verdict
* was widened; this file changed only in these words, because the arm
* already routed every comparand to it and carried the refused value as
* written.
*
* The contract — the accepted spellings, the pure verdict, the refusal words,
* the case table — is lane (1), `@objectstack/spec/data`'s
Expand Down Expand Up @@ -77,9 +85,11 @@ export function booleanArmFieldMeta(meta: BooleanComparandDoorFieldMeta | null):
}

/**
* One comparand at a judged position: the spec's verdict, routed. `aggregated`
* is the walk's site fact — `having`'s columns are the aggregated row's, not a
* declared field — and only ever written when true.
* One comparand at a judged position: the spec's verdict, routed. Whatever the
* comparand is — a string, a number, a `Date`, an array (#21382) — the verdict
* alone decides; this function only turns its answer into an outcome.
* `aggregated` is the walk's site fact — `having`'s columns are the aggregated
* row's, not a declared field — and only ever written when true.
*/
export function judgeBooleanComparand(
meta: BooleanComparandDoorFieldMeta,
Expand Down
Loading
Loading