Skip to content

Commit b057434

Browse files
fix(spec)!: a boolean, a Date or an array compared against a number field is refused like a non-numeric string (#20502) (#20545)
Fixes #20502 Clause-②: no (narrowing) ## What this changes One verdict, widened, as triage directed on the card: the published number-comparand contract now refuses any comparand compared against a declared numeric field that is neither a number nor a string the numeric grammar admits. The engine door that consumes it is unchanged in behaviour and carries no second rule. - **The verdict**, `packages/spec/src/data/filter-number-comparand-declared-type.ts`. `numberComparandDoorVerdict(field, comparand)` answers `door-refusal` (`INVALID_FILTER` / 400) for a boolean, a `Date` and an array at a judged position, beside the non-numeric string it already refused. The string rule is byte-for-byte what PR #20414 published. What still passes: a number, a `bigint` (the comparand-type door narrows it one call later), `null` (the null test), and every value outside the comparand-type door's accepted set (`undefined`, a plain object, a `{ $field }` reference, a `Map`), which that door already refuses on every field in its own words (see *Objects* below). - Three additive exports: `NON_NUMERIC_VALUE_FORMS` (`boolean`, `date`, `array`) and the types `NonNumericValueForm` / `NonNumericComparandForm`. The refusal's `form` and the refusal site's `value` widen to carry a non-string. - Three refusal clauses, each stating only what was measured; a `Date` renders as `Date(ISO)` so it does not read as the quoted string the grammar refuses. - The derived case table gains a `value` group: `false` at `$gt` on every judged field, `true` and a `Date` at every judged position of `f_number`, an array at every judged position except the equality slots (implicit, `$eq`, `$ne`, where the comparand-shape door refuses an array one door earlier), and four passing rows (the null test twice, a boolean on a `boolean` field, a `Date` on a `datetime` field). `filterAt` now gives each filter its own copy of a `Date` or an array. - One table-derivation fix the engine suite caught: the table's `unjudged` rows carry the flag operators' booleans (`$null: true`, `$exists: false`, `$empty: true`), which the door never hands to the verdict. The table now asks whether the slot is judged before consulting the verdict, so those rows stay `passes`. The engine never refused them; only the table's label would have been wrong. - **The door**, `packages/objectql/src/number-comparand-declared-type-door.ts`: the refusal site carries `value: comparand` instead of `comparand as string`, plus comments. No rule was added or changed; the compiled walk is the same. - **Tests**: the spec contract suite, the objectql door suite and the REST three-dialect suite (below). - **Generated**: `packages/spec/api-surface/data.json` and `export-origins/data.json`, three added lines each, regenerated by `gen:api-surface` / `gen:export-origins` after `check:generated` named exactly those two. - **Changeset**: `.changeset/20502-number-comparand-non-string.md`, `@objectstack/spec` and `@objectstack/objectql` `minor`, BREAKING banner, a FROM → TO line, the ADR-0087 disposition `not-required (no-migration-prescription)`. ## Before and after A declared `number` field, rows 5, 12 and 30. Measured through `engine.find` / `engine.aggregate` and `POST /api/v1/data/:object/query` (the two doors agree), on `InMemoryDriver`, `SqlDriver` on SQLite and `SqlDriver` on PostgreSQL 16. Before: base `4a1df19656`. After: this branch at `0dc69019da`. | position | comparand | before: memory · SQLite · PostgreSQL | after, all three | |:--|:--|:--|:--| | `where` | `$gt true` (the card) | no rows · every row · 500 `DATABASE_ERROR` | 400 `INVALID_FILTER` | | `where` | `$gt false` | no rows · every row · 500 | 400 | | `where` | `$eq true` / implicit `true` | no rows · no rows · 500 | 400 | | `where` | `$ne true` | every row · every row · 500 | 400 | | `where` | a `$in` member `true` | no rows · no rows · 500 | 400 | | `where` | `$between [true, 20]` | no rows · two rows · 500 | 400 | | `where` (in-process) | `$gt` / `$eq` / implicit / a `$in` member, a `Date` | no rows · no rows · 500 | 400 | | `where` | `$gt [1]`, `$lt [100]` | 400 in each driver's own words (SQL withholds the detail) | 400 in the contract's words | | `where` | a `$in` member `[1]` | no rows · driver 400 · driver 400 | 400 | | per-aggregation `filter` | `$gt true` / `$gt [1]` | count 3 on all three | 400 | | per-aggregation `filter` | `$gt` a `Date` | count 0 on all three | 400 | | `having` on `sum(amount)` | `$gt true` / `$gt [1]` | every group on all three | 400 | | `having` on `sum(amount)` | `$gt` a `Date` | no group on all three | 400 | | all three positions | `$gt 10` (the numeric control) | 2 rows / count 2 / both groups | the same | | `where` | `$gt {"a":1}`, a `$in` member `{"a":1}` | 400, the comparand-type door | the same, same words | | `where` | `$eq null` | no rows | the same | The per-aggregation `filter` and `having` rows are the engine's own evaluator on every driver, which is why they agree across drivers before the change: JS coercion read `true` and `[1]` as 1. ## Premise check, and the dispatch's hypotheses - **H1 holds.** The card's table reproduces at `4a1df19656` on all three drivers (rows above), through REST and `engine.find`. PostgreSQL ran on a private PostgreSQL 16 cluster in this container (the same major as the `Temporal Conformance (live PG + MySQL)` job's `postgres:16` service), started for this run and stopped after it. - **H2 holds: the door had no second rule.** Its only routing is the verdict (`judgeComparand` calls `numberComparandDoorVerdict` and turns the answer into an outcome); the one assumption it carried was the type cast `comparand as string`, erased at compile time. Widening the verdict alone reaches `where` (both spellings), the per-aggregation `filter` and `having`: the door file's diff is that cast plus comments, and the after-state above is uniform. - **H3, the producer census: no real producer.** `git grep` over `examples/**` and `packages/**` for a filter comparing with a boolean, a `Date` or an array (filter rules, `FilterArray` triples, `$op` literals), then each named field's declared type: every non-test hit names a boolean field (`active`, `is_active`, `archived`, ...) or a date field, or sits in driver-internal code. The one number-field hit, `amount: { $gt: new Date(0) }` in `driver-sql`'s `sql-driver-cross-field-reference.test.ts`, calls `SqlDriver` directly, beneath the engine door, and stays green (driver-direct callers keep native binding, as the changeset says). No fixture needed triage. ### Objects: served by the comparand-type door, deliberately Triage's list names objects. Measured on the base, an object comparand against the number field is already refused with `INVALID_FILTER` / 400, naming the path, on all three drivers and at all three positions, by the comparand-type door (`normalizeFilterComparandTypes`), and at the REST ingress with `VALIDATION_FAILED`. The widened verdict passes such a value instead of refusing it a second time, for one measured reason: the engine runs the two doors in a different order per position (this door first on the object spelling of `where` and on a per-aggregation `filter`; the comparand-type door first on the `FilterArray` spelling and on `having`). A second refusal here would answer one mistake with two sets of words depending on where it was written, and would change the comparand-type door's pinned words for every numeric field. The engine suite pins it: for a plain object, `undefined` and a `Map`, the engine's message equals the comparand-type door's own message, on both spellings. ## Tests (at `829106fd06`, the final head, unless noted) Every package run below is at `829106fd06` (this branch merged with `origin/main` `31d281d3b2` through `scripts/pm/os-regen-merge.sh`, whole workspace rebuilt: 72/72). Each run went through `scripts/pm/os-verify-lock.sh`; exit codes are the lock's `VERDICT command-exit` lines. - **`@objectstack/spec`**: `filter-number-comparand-declared-type.test.ts` 41/41. `--project local` 573 files, 16842 passed, 1 todo. `--project repo`: 39 files, 701 passed. - **`@objectstack/objectql`**: `engine-number-comparand-declared-type-door.test.ts` 34/34. `--project local` 332 files, 6641 passed. `--project repo` 1 file, 5 passed. - **`@objectstack/rest`**, with `OS_TEST_POSTGRES_URL` set: `data-number-comparand-door.test.ts` 8 passed, 4 skipped (the MySQL cell); the SQLite and PostgreSQL cells both ran. `--project local` 222 files, 4256 passed, 22 skipped. `--project repo` 1 file, 8 passed. - **`@objectstack/driver-sql`**, the whole suite under `TZ=America/New_York` against the live server set to `Asia/Shanghai` (the `Temporal Conformance` job's provisioning): 206 files, 3997 passed, 93 skipped (3 files skipped). - **`@objectstack/driver-memory`**: 59 files, 1419 passed. - **Typecheck**: `pnpm --filter @objectstack/spec typecheck`, `@objectstack/objectql` and `@objectstack/rest`: exit 0, and `check:test-typecheck` holds each ledger with no debt added (spec 53 files / 251 errors, objectql 40 / 234, rest 0 / 0). - **Lint, a proven narrowing** (the repo-wide `pnpm lint` is CI's): ① population from `eslint.config.mjs` itself: all 5 touched lintable files answer `isPathIgnored` false; the other 3 changed files are JSON / Markdown, outside its globs. ② `eslint --no-inline-config --format json` over them: 5 files, 0 errors, 0 warnings. ③ The config never enables type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot move any untouched file's verdict. - **Before/after table**: a scratch script against the built packages, not a committed test; the after column was taken at `0dc69019da` (this change before the later case-table fix, which changes test data only) and is re-pinned at the final head by the REST suite's SQLite and PostgreSQL cells and the objectql suite. ## Ablation (reverse verification) From the committed head `829106fd06`, with `scripts/ablation-replace.mjs` in wrap mode (the mutation lives only while the tool's trap is armed). The mutation removes the widening at its one routing line in the verdict: `if (form === null) return { verdict: 'passes' };` becomes the same line with `|| globalThis.ABLATION_20502 === undefined` added to its condition (spelled with a cast to a string-keyed record so the DTS pass type-checks it), so every non-string comparand passes again. The string rule is untouched. - **Mutated leg**: anchor 1 → 0, blob `fdb42a2a` → `55919fda`. After `pnpm --filter @objectstack/spec build`, `ablation-dist-preflight` found the marker in 4 built files (exit 0). Result: the spec suite had **5 failed, 36 passed**; the objectql door suite **4 failed, 30 passed**; the REST door suite **2 failed, 6 passed, 4 skipped**, one failure each for the SQLite and PostgreSQL cells. Every failure is a new non-string pin, or the objectql guard that counts the refused forms. Every string pin and every numeric control stayed green. - **Restore leg**: the blob is back to `fdb42a2a` (the HEAD blob), `git diff HEAD` is empty and `git status --porcelain` is clean. After a rebuild, `ablation-dist-preflight --absent` found the marker absent from all 224 built files and the tree clean. The three suites passed again: 41/41, 34/34, and 8 passed with 4 skipped. - **A first attempt that did not count.** It spelled the marker as a constant, `|| 'ABLATION_20502'`. The suites went red the same way, but `ablation-dist-preflight` answered exit 1 because the marker was absent from `dist/`: spec's tsup config runs with `treeshake: true`, and the bundler folded the constant condition away together with its marker. The mutation was therefore not proven to have reached the built artifact, so that reading is discarded. The redo above uses a `globalThis` property read, which the bundler cannot fold because the read could have side effects. I checked that it survives an esbuild bundle even with `minifySyntax`, and the preflight found it in `dist/`. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at **`829106fd06`** derives 88 commands (the 86 derived before the merge, plus `check:generated` and `check:pm-widening-tells`, which the merged gate map added). All 88 ran, and each exit code was captured before any pipe: **88 exited 0**. That includes `check:dual-build-cjs-loads` and `check:type-check-debt`, which answered exit 3 (PREREQUISITE NOT MET) before the whole workspace was built. Reconciliation with `dispatch-gates --ran` (recorded as `command :: exit N`): 88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN, and that zero is derived from the recorded codes. `check:adr-0087-registration` reads the changeset as `[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)`. `check-changeset-no-major`: no `major` bump. ## Acceptance notes - **InMemoryDriver.** Its cell is pinned by construction in the objectql door suite: the door answers before any driver is resolved, and the suite's recording driver asserts no read. Its live before/after rows above come from a scratch run against the built packages. A permanent real-`InMemoryDriver` cell would need a new dependency edge (`@objectstack/rest` or `@objectstack/objectql` on `@objectstack/driver-memory`), which this change did not add. - **PostgreSQL in CI.** No CI job provisions `OS_TEST_POSTGRES_URL` for `@objectstack/rest`, so the REST suite's PostgreSQL cell is red-capable and un-run in CI (the predecessor PR recorded the same). It ran locally here, against the private cluster. - **MySQL: NOT MEASURED.** No MySQL server in this container; the REST suite's MySQL cell is a named skip. - **`having` words.** A numeric aggregated column has no declared type; the door hands the verdict `number`, so the words read "compares a declared number field" for `having.total.$gt`. Inherited from the predecessor, unchanged. - **The flag operators.** `$null` / `$exists` / `$empty` take a boolean and are never judged; pinned in both the spec suite and, through the table, the engine suite. - **Out of scope, measured and handed to the seat, not filed here:** a plain object with no `$` key in a scalar field's value position, `where { amount: { "a": 1 } }`. This is filter STRUCTURE (a nested-relation / deep-equality condition), which no field-aware door descends into. On the base it answers 200 with no rows on InMemoryDriver and a driver 400 on SQLite and PostgreSQL, through `POST /api/v1/data/:object/query` and `engine.find`. It is not specific to numeric fields, and this change leaves it as it was. - **The save-time door, one family over.** No lint or save rule reads the number verdict, so a stored filter comparing a number field with a string, and now also with a boolean or an array, saves clean and is refused at query time. The predecessor's report already carries that family. This change widens its population, and the census above found no in-repo producer of the new shapes. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ba5927f commit b057434

8 files changed

Lines changed: 544 additions & 47 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
---
5+
6+
fix(spec)!: a boolean, a `Date` or an array compared against a number field is refused with `INVALID_FILTER` / 400 at `where`, a per-aggregation `filter` and `having`, the same as a non-numeric string
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a filter COMPARAND value at the engine's query door, decided by the published number-comparand verdict: no authorable key, spelling, export or stored shape moves (the verdict's function, its case table and its words keep their names; three exports are added), and no stored row is read or rewritten. What is refused is a boolean, a Date or an array compared against a declared numeric field or a numeric aggregated column; which number the caller meant is not something a ledger entry can decide (reading true as 1 is exactly the silent coercion this refuses). The other categories are closed on facts: both packages publish (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`). -->
11+
12+
**BREAKING**: this narrows what a filter may compare a number field with. `numberComparandDoorVerdict`, the published verdict the engine's number-comparand door consumes, judged strings only; it now also answers `door-refusal` for a boolean, 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 string rule is unchanged, and so are both packages' root exports except three additions to `@objectstack/spec/data`: `NON_NUMERIC_VALUE_FORMS` and the types `NonNumericValueForm` and `NonNumericComparandForm` (the refusal's `form` and the refusal site's `value` widen to carry a non-string).
13+
14+
FROM `true` / `false`, a `Date`, or an array where one value belongs (a scalar operator's comparand, or a member of `$in` / `$nin` / `$between`), compared against a `number`, `currency`, `percent`, `rating`, `slider`, `progress` or `summary` field (or a `count` / `sum` / `avg`, or a numeric `min` / `max` / groupBy column 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 the number the filter means (`12`, `-3.5`, `1e3`), or compare a `Date` with a date or datetime field.
15+
16+
Measured through `engine.find` / `engine.aggregate` and `POST /data/:object/query`, three rows (5, 12, 30) of a `number` field:
17+
18+
| position | comparand | before: memory · SQLite · PostgreSQL 16 | now, on all three |
19+
|:--|:--|:--|:--|
20+
| `where` | `$gt true` | no rows · every row · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 |
21+
| `where` | `$ne true` | every row · every row · 500 | `INVALID_FILTER` / 400 |
22+
| `where` | `$between [true, 20]` | no rows · two rows · 500 | `INVALID_FILTER` / 400 |
23+
| `where` | `$gt` a `Date` (in-process callers) | no rows · no rows · 500 | `INVALID_FILTER` / 400 |
24+
| `where` | `$gt [1]` | a driver's own 400, in each driver's words | `INVALID_FILTER` / 400, in one set of words |
25+
| `where` | a `$in` member `[1]` | no rows · a driver 400 · a driver 400 | `INVALID_FILTER` / 400 |
26+
| per-aggregation `filter` | `$gt true` / `$gt [1]` (`$gt` a `Date`) | count 3 (count 0), on all three | `INVALID_FILTER` / 400 |
27+
| `having` on `sum(amount)` | `$gt true` / `$gt [1]` (`$gt` a `Date`) | every group (no group), on all three | `INVALID_FILTER` / 400 |
28+
| all three positions | `$gt 10` (the numeric control) | 2 rows / count 2 / both groups | the same |
29+
30+
**Who is affected.** A caller that compares a number field with a boolean or an array through any door that reaches the engine (a `where`, `$filter` or `filter` body of `POST /data/:object/query`, or an in-process engine call), or with a `Date` in-process (a flow, a hook, server code). No example app, platform object or other in-repo producer compares a number field that way.
31+
32+
**Unchanged.** A number, a `bigint` (narrowed or refused by the comparand-type door, as before) and `null` (the null test) are answered 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. An array at an equality slot (implicit, `$eq`, `$ne`) keeps the comparand-shape door's refusal. A boolean or a `Date` compared against a boolean, date or datetime field is not this door's subject. Driver-direct callers that never pass through the engine keep each driver's native binding.

‎packages/objectql/src/engine-number-comparand-declared-type-door.test.ts‎

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ import { describe, it, expect, beforeEach } from 'vitest';
4545
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
4646
import {
4747
NON_NUMERIC_STRING_FORMS,
48+
NON_NUMERIC_VALUE_FORMS,
4849
NUMBER_COMPARAND_DOOR_CASES,
50+
normalizeFilterComparandTypes,
4951
NUMBER_COMPARAND_DOOR_FIXTURE,
5052
NUMBER_COMPARAND_DOOR_FIXTURE_OBJECT,
5153
NUMBER_COMPARAND_DOOR_LIST_OPERATORS,
@@ -157,8 +159,9 @@ describe('[#20351] the number-comparand declared-type door at the engine collect
157159
// The untyped formula is the table's only deferred row, and it is judged one door earlier.
158160
expect(DEFERRED).toHaveLength(0);
159161
expect(STAGED.map((c) => c.verdict)).toEqual(['passes']);
160-
// Every refused form the grammar names is driven, not just the card's "abc".
161-
expect(new Set(REFUSALS.map((c) => c.form))).toEqual(new Set(NON_NUMERIC_STRING_FORMS));
162+
// Every refused form the grammar names is driven, not just the card's "abc" —
163+
// and [#20502] every non-string form (a boolean, a Date, an array) beside them.
164+
expect(new Set(REFUSALS.map((c) => c.form))).toEqual(new Set([...NON_NUMERIC_STRING_FORMS, ...NON_NUMERIC_VALUE_FORMS]));
162165
// Every judged position is driven both ways.
163166
const positions = (cs: readonly NumberComparandDoorCase[]) =>
164167
new Set(cs.filter((c) => c.key === 'f_number').map((c) => c.position.replace(/\[\d\]$/, '')));
@@ -383,6 +386,110 @@ describe('[#20351] the number-comparand declared-type door at the engine collect
383386
expect(await groups({ id: { $ne: 'abc' } })).toEqual(['r1', 'r2', 'r3']);
384387
});
385388

389+
// ── [#20502] a boolean, a Date, an array: one refusal at every position ──
390+
391+
/** The non-string comparands the widened verdict refuses — each a value the card measured three ways. */
392+
const NON_STRING: ReadonlyArray<readonly [string, () => unknown, string]> = [
393+
['true', () => true, 'boolean'],
394+
['false', () => false, 'boolean'],
395+
['a Date', () => new Date(Date.UTC(2026, 0, 1)), 'date'],
396+
['an array', () => [10], 'array'],
397+
];
398+
399+
it('[#20502] refuses a boolean, a Date or an array in ONE aggregation\'s own filter, rooted at that position — no read', async () => {
400+
for (const [name, value, form] of NON_STRING) {
401+
for (const op of ['$gt', '$lte'] as const) {
402+
reads.length = 0;
403+
const err = await refusalOf(engine.aggregate(OBJECT, {
404+
aggregations: [
405+
{ function: 'count', alias: 'all' },
406+
{ function: 'count', alias: 'bad', filter: { f_number: { [op]: value() } } },
407+
],
408+
} as EngineAggregateOptions));
409+
expect(err, `${name} ${op}`).not.toBeNull();
410+
expect({ code: err!.code, status: err!.status }, `${name} ${op}`).toEqual({ code: 'INVALID_FILTER', status: 400 });
411+
expect(err!.message, `${name} ${op}`).toContain(`aggregations[1].filter.f_number.${op}`);
412+
expect(err!.message, `${name} ${op}`).toMatch(/^aggregate\('number_door_probe'\): filter on 'f_number' compares a declared number field/);
413+
expect(findNonNumericComparand(engine.registry.getObject(OBJECT), { f_number: { [op]: value() } }), name)
414+
.toMatchObject({ field: 'f_number', form });
415+
expect(reads, `${name} ${op}`).toHaveLength(0);
416+
}
417+
}
418+
});
419+
420+
it('[#20502] refuses a boolean, a Date or an array against a NUMERIC `having` column — count, sum and a numeric min alike', async () => {
421+
for (const [name, value] of NON_STRING) {
422+
for (const [fn, field] of [['count', undefined], ['sum', 'f_number'], ['min', 'f_currency']] as const) {
423+
reads.length = 0;
424+
const err = await refusalOf(engine.aggregate(OBJECT, {
425+
groupBy: ['f_text'],
426+
aggregations: [{ function: fn, ...(field ? { field } : {}), alias: 'total' }],
427+
having: { total: { $gt: value() } },
428+
} as EngineAggregateOptions));
429+
expect(err, `${name} ${fn}`).not.toBeNull();
430+
expect({ code: err!.code, status: err!.status }, `${name} ${fn}`).toEqual({ code: 'INVALID_FILTER', status: 400 });
431+
expect(err!.message, `${name} ${fn}`).toContain('having.total.$gt');
432+
expect(err!.message, `${name} ${fn}`).toContain("filter on 'total' compares a declared number field");
433+
expect(reads, `${name} ${fn}`).toHaveLength(0);
434+
}
435+
}
436+
});
437+
438+
it('[#20502] the numeric control at all three positions: a number reaches the driver and the evaluator as written', async () => {
439+
reads.length = 0;
440+
await engine.find(OBJECT, { where: { f_number: { $gt: 10 } } });
441+
expect(reads[0]?.ast?.where).toEqual({ f_number: { $gt: 10 } });
442+
const counted = await engine.aggregate(OBJECT, {
443+
aggregations: [{ function: 'count', alias: 'all' }, { function: 'count', alias: 'm', filter: { f_number: { $gt: 10 } } }],
444+
} as EngineAggregateOptions);
445+
expect(Number((counted[0] as Record<string, unknown>).m)).toBe(2);
446+
const groups = (await engine.aggregate(OBJECT, {
447+
groupBy: ['id'],
448+
aggregations: [{ function: 'sum', field: 'f_number', alias: 'total' }],
449+
having: { total: { $gt: 10 } },
450+
} as EngineAggregateOptions)).map((r) => (r as Record<string, unknown>).id).sort();
451+
expect(groups).toEqual(['r2', 'r3']);
452+
});
453+
454+
it('[#20502] the same mistake as FilterArray sugar and inside $and / $or / $not — one answer per mistake', async () => {
455+
const sugar = await refusalOf(
456+
engine.find(OBJECT, { where: [['f_number', '>', true]] } as unknown as EngineQueryOptions),
457+
);
458+
expect({ code: sugar!.code, status: sugar!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
459+
expect(sugar!.message).toContain("filter on 'f_number' compares a declared number field against true at where.f_number.$gt");
460+
for (const where of [
461+
{ $and: [{ f_text: 'a' }, { f_number: { $gt: true } }] },
462+
{ $or: [{ f_text: 'a' }, { f_currency: { $in: [1, new Date(0)] } }] },
463+
{ $not: { f_percent: { $between: [[1], 10] } } },
464+
]) {
465+
const err = await refusalOf(engine.find(OBJECT, { where: where as FilterCondition }));
466+
expect({ code: err?.code, status: err?.status }, JSON.stringify(where)).toEqual({ code: 'INVALID_FILTER', status: 400 });
467+
}
468+
expect(reads).toHaveLength(0);
469+
expect(engine.judgeFilter(OBJECT, { f_number: { $gt: new Date(0) } })).toMatchObject({ ok: false, code: 'INVALID_FILTER', status: 400 });
470+
});
471+
472+
it('[#20502] a value OUTSIDE the accepted comparand types is the comparand-TYPE door\'s refusal, in that door\'s words, on both spellings', async () => {
473+
const context = `find('${OBJECT}')`;
474+
for (const [name, value] of [['a plain object', { a: 1 }], ['undefined', undefined], ['a Map', new Map()]] as const) {
475+
const where = { f_number: { $gt: value } };
476+
// The verdict passes it, so THIS door has no second opinion …
477+
expect(findNonNumericComparand(engine.registry.getObject(OBJECT), where), name).toBeNull();
478+
// … and the engine answers exactly what the comparand-type door answers.
479+
let expected: Error | undefined;
480+
try { normalizeFilterComparandTypes(where, context); } catch (e) { expected = e as Error; }
481+
expect(expected, name).toBeDefined();
482+
const err = await refusalOf(engine.find(OBJECT, { where: where as FilterCondition }));
483+
expect({ code: err?.code, status: err?.status }, name).toEqual({ code: 'INVALID_FILTER', status: 400 });
484+
expect(err!.message, name).toBe(expected!.message);
485+
}
486+
const sugar = await refusalOf(engine.find(OBJECT, { where: [['f_number', '>', { a: 1 }]] } as unknown as EngineQueryOptions));
487+
let expected: Error | undefined;
488+
try { normalizeFilterComparandTypes({ f_number: { $gt: { a: 1 } } }, context); } catch (e) { expected = e as Error; }
489+
expect(sugar!.message).toBe(expected!.message);
490+
expect(reads).toHaveLength(0);
491+
});
492+
386493
it('GUARD the having walk narrows copy-on-write and judges only columns classed numeric', () => {
387494
const classes = new Map([['total', 'numeric' as const], ['label', 'text' as const], ['unknown', undefined]]);
388495
const having = { total: { $in: ['1', 2] }, label: { $eq: 'abc' }, unknown: { $eq: 'abc' } };

‎packages/objectql/src/number-comparand-declared-type-door.ts‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
* filter collection point: the fifth gate on the seam that already carries
66
* the #5869 comparand-shape gate, the #8296 unmaterializable-field gate, the
77
* #15661 text-operator declared-type gate and the #8690 temporal-comparand
8-
* gate. It answers a fifth question about the same predicate: *is this string
9-
* a number the column can be compared with.*
8+
* gate. It answers a fifth question about the same predicate: *is this
9+
* comparand a number the column can be compared with.* [#20502] widened the
10+
* question from strings to every comparand, in the spec's verdict alone: a
11+
* boolean, a `Date` and an array are refused beside a non-numeric string,
12+
* and this file changed only to carry a refused value that is not a string.
1013
*
1114
* ## The direction this implements (triage, recorded on #20336)
1215
*
@@ -45,9 +48,12 @@
4548
*
4649
* ## The door's two answers
4750
*
48-
* - **Refuse** a string the grammar does not read as a number: `INVALID_FILTER`
49-
* / 400, the existing filter envelope, in the contract's words, before any
50-
* driver is resolved. A `{placeholder}` is refused too, unresolved: every
51+
* - **Refuse** a string the grammar does not read as a number, and a boolean,
52+
* a `Date` or an array (#20502): `INVALID_FILTER` / 400, the existing filter
53+
* envelope, in the contract's words, before any driver is resolved. A value
54+
* outside the comparand-type door's accepted set (`undefined`, a plain
55+
* object, a `Map`) passes the verdict and is refused by that door, one call
56+
* later, in its own words. A `{placeholder}` is refused too, unresolved: every
5157
* filter token resolves to an id or a date, never a number (the contract
5258
* argues it), and this door runs before `resolveWhereTokens`, as its
5359
* temporal neighbour records it must.
@@ -178,7 +184,11 @@ function fieldMetaOf(def: unknown): NumberComparandDoorFieldMeta | null {
178184
return typeof returnType === 'string' ? { type, returnType } : { type };
179185
}
180186

181-
/** One comparand at a judged position: the spec's verdict, routed. */
187+
/**
188+
* One comparand at a judged position: the spec's verdict, routed. Whatever
189+
* the comparand is — a string, a boolean, a `Date`, an array (#20502) — the
190+
* verdict alone decides; this function only turns its answer into an outcome.
191+
*/
182192
function judgeComparand(
183193
meta: NumberComparandDoorFieldMeta,
184194
field: string,
@@ -195,8 +205,7 @@ function judgeComparand(
195205
declaredType: meta.type,
196206
...(meta.returnType === undefined ? {} : { returnType: meta.returnType }),
197207
path,
198-
// `door-refusal` is answered for a string comparand only.
199-
value: comparand as string,
208+
value: comparand,
200209
form: verdict.form,
201210
},
202211
};
@@ -292,7 +301,7 @@ function declaredMetaOf(schema: unknown): MetaOf | null {
292301
}
293302

294303
/**
295-
* Walk one `FilterCondition` and return the FIRST string a declared numeric
304+
* Walk one `FilterCondition` and return the FIRST comparand a declared numeric
296305
* field cannot be compared with, or `null`.
297306
*
298307
* Exported for the same reason the sibling walks are: a consumer that needs to
@@ -314,7 +323,7 @@ function refuse(context: string, refusal: NonNumericComparand): never {
314323
}
315324

316325
/**
317-
* Refuse every string a declared numeric field cannot be compared with, and
326+
* Refuse every comparand a declared numeric field cannot be compared with, and
318327
* narrow every numeric string to its number — `INVALID_FILTER` / 400, this
319328
* package's existing filter envelope, in the contract's words. No code is
320329
* minted.

0 commit comments

Comments
 (0)