Skip to content

Commit 100c394

Browse files
fix(spec)!: a list at a scalar operator is refused at the shared comparand-shape face, whatever the column type (#21448) (#21484)
Fixes #21448 Clause-②: no (narrowing) ## What this changes The shared comparand-shape face (`assertListComparandShapes`, `@objectstack/spec/data`) now refuses a LIST at every scalar operator, whatever the column type. That covers `$gt`, `$gte`, `$lt`, `$lte`, the text operators (`$contains`, `$notContains`, `$startsWith`, `$endsWith`, `$icontains`, `$like`, `$ilike`) and the flags (`$null`, `$exists`, `$empty`). `$eq` and `$ne` keep their own ruled arms. - **Envelope:** `INVALID_FILTER` / 400, before any read. - **Sentence:** one sentence naming the operator, the field, the list and the path. Its leading clause is `driver-memory`'s `arrayComparandError` for the same condition, word for word. - **Remedy:** one value; `$in` (authoring `in`) for "one of these values"; `$between` (authoring `between`) for a range. This implements triage's ruling (5958292323) as written. There is one verdict, at the shared face. It is not in the number or boolean declared-type verdicts. The lowering gains no second rule and no `values[0]` read of a list. ## Measured on `origin/main` `b94a2a727`, SQLite and PostgreSQL 16.14 alike Through `AnalyticsService.query` / `.queryDataset` (what `POST /api/v1/analytics/query` and `/api/v1/analytics/dataset/query` relay), on both faces, and through `engine.find` on the real `ObjectQL`: | filter | engine-aggregate face | native face | `engine.find` | |---|---|---|---| | `{ amount: { $gt: [10, 99] } }` (number) | **200, 2** (the driver got `$gt: 10`) | 400, the number verdict | 400, the number verdict | | `{ amount: { $gt: [10] } }` | **200, 2** | 400, the number verdict | 400, the number verdict | | `{ amount: { $lte: [12, 1] } }` | **200, 2** (`$lte: 12`) | 400, the number verdict | 400, the number verdict | | `{ note: { $gt: ['a', 'z'] } }` (text) | **200, 3** (`$gt: 'a'`) | **200, 3** (bound `'a'`) | 400, driver-sql's bind refusal | | `[['note', '>', ['a', 'z']]]` | **200, 3** | **200, 3** | 400, driver-sql's | | `{ note: { $eq: ['b'] } }`, `{ note: { $ne: ['b'] } }` | 400, the face | 400, the face | 400, the face | | `{ note: { $contains: ['b', 'm'] } }` | 400, this package's LIKE gate | 400, the same | 400, driver-sql's | | controls: `$in: ['b']`, `$nin: ['b']`, `$gt: 10` | 1 / 2 / 2 | the same | the same | **Triage's "measure first": the engine door's own answer for the text cell.** The engine door does NOT bind the first member. On `driver-sql` it refused 400 in the driver's own words ("…cannot be bound as a SQL parameter…"). So its answer was right and only its wording was per driver. The same verdict now answers it first, in the face's words (pinned: the `engine.find` text cell). One position over, `driver-memory` ANSWERS a list at a text operator (`memory-matcher-array-and-date-comparand.test.ts`). The face now refuses that before any driver runs. **Dispatch assumptions this measurement corrected:** - `$eq: [x]` / `$ne: [x]` already answered one 400 on both faces (#19757 / #19886's arms). The live defect was the ordering operators, plus a text column's native face. - The lowering reaches the face through `filter-normalizer.ts`'s `assertWhereComparandShapes` (#20010), not through `comparand-shape.ts`. ## Design - **The operator set is the spec's own:** `SCALAR_COMPARAND_OPERATORS`, the comparand-TYPE face's split, which `filter-comparand-type.test.ts` reconciles against `FieldOperatorsSchema`'s keys. - It moved verbatim from `filter-comparand-type.ts` into a new module outside the `data` barrel (`filter-comparand-operators.ts`). Both faces and the save door read ONE split, and nothing is published: `check:api-surface` is unchanged. - The face test also derives the arm's operators from the schema: every declared operator whose enforced slot refuses an array, which is all but `$in` / `$nin` / `$between`. - **No rule in the lowering.** `lowerAnalyticsWhere` already hands every field entry to the face before any leaf exists. So the arm reaches both analytics faces at every position (`where`, `runtimeFilter`, a dataset's scope, a measure's `filter`) with no code change there. `filter-normalizer.ts` and `comparand-shape.ts` change docblocks only; they state the invariant that only a list operator's array is spread into a leaf's `values`. - **The save door asks the same face** (`filter-save-door-refusals.ts`). - A stored dataset, measure, widget or report filter carrying the shape is refused on save, in the face's sentence less its location. The parity test's §2 requires a save-door sentence for every face arm. - The HTTP routes that Zod-parse a filter in their body therefore answer `VALIDATION_FAILED` / 400, located on the member, as for every other face arm. In-process callers get `INVALID_FILTER` / 400. Both layers are pinned. - **The native number arm (PR #21446).** `judgedComparands` lowers through `lowerAnalyticsWhere` first, so that arm's `array` refusal is no longer reached at a scalar operator from any native position. `native-sql-strategy.ts` is untouched; the now-unreachable branch is a note, not an edit. - **Flags.** A list at `$null` / `$exists` / `$empty` now reads in the shape sentence, because how many values comes before which value. A non-boolean scalar flag keeps the boolean rule's sentence. ## Pins - **New, `service-analytics`:** `list-at-scalar-operator-both-faces.test.ts`, run on SQLite and PostgreSQL. Each measured cell, plus `$gte` / `$lt: []` / `$contains` / `$startsWith`, `$or` / `$not`, and the FilterArray spelling, is checked at both doors. - Each cell answers one 400 on both faces, with the same message on each face and no raw statement, engine aggregate or driver read. - A registered dataset's scope and measure filter are refused the same way. `DatasetSchema` refuses the stored filter on save, in the sentence less its location. - `engine.find` refuses the text cell in the face's words, not the driver's. - Controls (`$in`, `$nin`, scalar `$gt`, `$between`) count alike on both faces. - **Both-faces pin:** PR #21446's native-only `$gt: [10]` cell is now a both-faces cell in `native-sql-number-comparand-door.test.ts`. - **New, `@objectstack/spec`:** a `filter-comparand-shape.test.ts` block covering the derived operator set; every list shape (pair, one member, strings, empty); nested paths; every AST spelling that carries a value; the message and remedy (`$in`, `$between`, both declared); flags; controls; the 500-char bound. - **Moved because the face now answers first** (each row left its old table and is pinned as the shape face's): - the declared-type corpora (`filter-number-` / `filter-boolean-comparand-declared-type.ts`) and their tests: list rows only at list members now; - `filter-save-door-face-parity.test.ts` (§1: every declared operator is face-judged; §2: new rows); - objectql's number, boolean and aggregate-flag doors; - REST's number and boolean data doors; - `analytics-filter-refusal-envelope.test.ts` (a new HTTP cell); - analytics' flag, `$empty` and type-face tests. ## Ablations Each leg was committed first, mutated through `scripts/ablation-replace.mjs` (WRAP, with the restore trapped), and its restore proven by blob == HEAD and an empty `git diff HEAD`. - **A: the spec arm deleted.** Rebuilt; `ablation-dist-preflight.mjs @objectstack/spec 'throw arrayScalarComparandError(' --absent` exit 0. - **Predicted:** each list-at-scalar cell goes back to a 200 (or to the native number verdict on a number column); `$eq` / `$ne` and the controls stay green. - **Observed:** analytics went 60 failed / 142 passed (30 cells × SQLite and PG): - the text and number cells were answered 200 on the engine-aggregate face; - the LIKE cells fell to the analytics LIKE gate's words; - `engine.find` answered in driver-sql's words; - the save door accepted the stored filter; - every `$eq` / `$ne` and control cell stayed green. - The face test's new block went 12 red. Restore leg: rebuilt, marker present in `dist/`, tree clean, 202 / 202 green. - **B: the lowering's consumption deleted** (`assertWhereComparandShapes`' face hand-over). The subject resolves from `src`, so no rebuild is owed. - **Predicted:** the object-spelling analytics cells go red; the FilterArray spelling, `$eq`, the save door and `engine.find` stay green. - **Observed:** 48 failed (24 cells × 2 drivers: the object-spelling cells, `$ne` included, and the registered scope and measure). 0 failures among the FilterArray, `$eq`, save-door and `engine.find` cells. ## Verification, at the merged head `2b9fd4f5e` (`origin/main` merged in) - **Full suites:** - `@objectstack/spec` test: 602 files / 17754 tests green. - `@objectstack/service-analytics` test, with PostgreSQL 16.14: 172 files green. One file's 4 live-PG cells need a UTC server; see the acceptance notes. - `@objectstack/objectql` test: 366 files green. One barrel-import test timed out at 5 s at load ~7, then 34 / 34 when run alone. - REST door pins: 4 files, 67 tests (MySQL cells are named skips). - **Typecheck:** spec, service-analytics, objectql and rest all green. - **Face importers, at the pre-merge head:** driver-memory, driver-mongodb, driver-turso, driver-sql (with a non-UTC PostgreSQL server), lint, metadata-core, metadata-protocol, plugin-security and plugin-sharing are all green. - **Gates:** `dispatch-gates.mjs --commands` at `2b9fd4f5e` derives 90 families. All 90 ran with recorded exit codes, all 0, including `check:dual-build-cjs-loads` (105 require entries across 66 packages load). `--ran` reconciles 90 run / 0 NOT MEASURED. - **Lint (narrowed, measured):** `eslint --no-inline-config --format json` over the 24 changed `.ts` files gives 24 files, 0 errors, 0 warnings, none ignored. - The population is read from eslint's own output. - Invariance: `eslint.config.mjs` has no type-aware linting (no `parserOptions.project` / `projectService`), so this diff cannot move an untouched file's verdict. - **Docs:** grepping `content/docs/**` (outside `releases/`) and `skills/**` for the comparand-shape rules and the filter operators found no sentence made false. ## Blast radius - **Shipped producers** writing a list at a scalar operator (object form, `[field, op, value]` and `{ field, operator, value }`, across `examples/`, `skills/`, `content/docs/`, `apps/` and `packages/**` non-test sources): none. The CEL lowering already refuses one (`cel-to-filter.ts`). - **NOT MEASURED:** objectui's console filter builder. `../objectui` is not checked out here, and `packages/console/dist` is not built. ## File surface against the claim The claim named `filter-normalizer.ts`, `comparand-shape.ts`, spec `filter-comparand-shape.ts` and its test, the pins and the changeset. Added, each a consequence of the narrowing inside the rule's consumer radius: - `filter-comparand-operators.ts` (new, internal); - `filter-comparand-type.ts` (the split's import, and one now-false sentence); - `filter-comparand-refusal-text.ts` (the shared sentence); - `filter-save-door-refusals.ts` (the save door's sentence); - the two declared-type corpora and the parity test; - the objectql, REST and analytics pins listed above. None of `native-sql-strategy.ts`, `objectql-strategy.ts`, `analytics-service.ts` or `preview-evaluator.ts` is touched. ## Acceptance notes - **Out of scope; reported to the seat, not filed here.** On PostgreSQL with the server TimeZone set to `Asia/Shanghai`, `objectql-face-order-limit.test.ts`'s live cells answer the newest month bucket of a `date` column holding `2026-06-01` as `2026-05` (2 rows). At UTC the answer is `2026-06` (1 row). The cell is the engine-aggregate face, since the native face declines granularity. Not touched by this diff. - The compilers' `values[0]` reads stay as they are. With the face's arm, no list reaches a scalar leaf through any analytics door. - PR #21452 (which held the analytics strategies) landed while this was open. It was merged in at `2b9fd4f5e` cleanly, with no overlap. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 48fa7a3 commit 100c394

25 files changed

Lines changed: 1117 additions & 100 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
A list at a scalar operator (`{ amount: { $gt: [10, 99] } }`) is refused at the shared comparand-shape face, whatever the column type, instead of being narrowed to its first member
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a LIST comparand at a scalar filter operator, at the shared comparand-shape face (assertListComparandShapes) and at the save door that asks it (FilterConditionSchema and the analytics carriers): no authorable key, spelling, export or type moves. FieldOperatorsSchema already refused a list at every one of these operator slots; the runtime face and the save door now refuse what that declaration refuses. No export is added or removed (the operator split moves to a module outside the data barrel), and no stored row is read or rewritten. Which one value the author meant by a refused list (its first member, a range, membership) is not something a ledger entry can decide, so there is nothing for objectstack migrate meta to rewrite. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a filter comparand's shape and this diff adds none (not registered / already-registered); and the change is runtime behaviour with no published interface or type changed (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING**: this narrows what the shared filter faces accept. FROM: a list at a scalar operator passed the comparand-shape face, and each consumer answered it alone. The analytics lowering bound the list's first member (`{ note: { $gt: ['a', 'z'] } }` answered 200 as `$gt 'a'` on both analytics faces, and the engine-aggregate face did the same on a number column), `driver-sql` refused it in its own words, and `driver-memory` answered one at a text operator. TO: `INVALID_FILTER` / 400 at the face, before any read, on every door that runs it, with one sentence naming the operator, the field, the list and where. It ships as `minor` under the launch-window convention for accept-set narrowings. No export, type or error code changes.
12+
13+
- **What changed.** `assertListComparandShapes` (`@objectstack/spec/data`) refuses a list under every scalar operator other than `$eq` / `$ne`, which keep their own refusals: `$gt`, `$gte`, `$lt`, `$lte`, the text operators (`$contains`, `$notContains`, `$startsWith`, `$endsWith`, `$icontains`, `$like`, `$ilike`) and the flags (`$null`, `$exists`, `$empty`). This covers every depth, both filter spellings (object and `[field, op, value]`), and the empty list.
14+
- The engine's `where`, per-aggregation `filter` and `having`, `parseFilterAST`, both analytics doors, the read-scope compiler and the RLS compiler all run the face, so all of them refuse it.
15+
- The save door asks the same face. A dataset, measure, dashboard-widget or report filter that carries one is refused on save, located on the member. The HTTP routes that parse a filter in their body (`POST /api/v1/data/:object/query`, `/api/v1/analytics/query`, `/api/v1/analytics/dataset/query`) answer `VALIDATION_FAILED` / 400 there, as for every other face refusal.
16+
- **What you may notice.** A filter that put a list under `$gt`, `$contains` or a flag now refuses instead of answering. Write one value; for "one of these values" use `$in` (authoring `in`), and for a range use `$between` (authoring `between`). A list at a flag reads in this sentence now, not the boolean-flag one.
17+
- **Unchanged.** A list at `$in` / `$nin` / `$between`, `$in: []` / `$nin: []`, every single value (`null`, a `Date` and a `{ $field }` reference included), a list nested inside `$in`, and an operator outside the declared vocabulary, which keeps its own refusal.

‎packages/objectql/src/engine-aggregate-flag-comparand-refusal.test.ts‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,9 @@ const NON_BOOLEANS: ReadonlyArray<readonly [string, unknown, string]> = [
154154
['"false" (truthy)', 'false', 'string ("false")'],
155155
['0', 0, 'number (0)'],
156156
['null', null, 'null (null)'],
157-
// Not one of the card's five, but reaching the same gate: no earlier door
158-
// refuses a list here, so it met the old `!!target` read like any other value.
159-
['[true] (a list)', [true], 'array ([true])'],
157+
// [#21448] `[true] (a list)` stood here, reaching this gate because no
158+
// earlier door refused a list at a flag. The shared comparand-shape face
159+
// does now, one door earlier, in its own words — pinned in the block below.
160160
];
161161

162162
describe('[#20981] a non-boolean $exists / $null — refused before any driver read, on both positions', () => {
@@ -184,6 +184,25 @@ describe('[#20981] a non-boolean $exists / $null — refused before any driver r
184184
}
185185
}
186186

187+
it('[#21448] a LIST flag is the shared comparand-shape face\'s refusal, one door earlier, at both positions — no read', async () => {
188+
for (const op of OPS) {
189+
const sentence = `Operator "${op}" on field "name" requires a single comparable value, but received an array ([true])`;
190+
const { engine, reads } = await makeEngine('rows', ROWS);
191+
const filtered = await refusalOf(() => engine.aggregate(OBJECT, filterQuery({ name: { [op]: [true] } })));
192+
expect({ code: filtered.code, status: filtered.status }, op).toEqual({ code: 'INVALID_FILTER', status: 400 });
193+
expect(filtered.message, op).toContain(`${sentence} at aggregations[1].filter.name.${op}.`);
194+
expect(filtered.message, op).not.toContain('requires a boolean comparand');
195+
expect(reads, `${op}: no row was read`).toEqual({ aggregate: 0, find: 0 });
196+
for (const path of ['native', 'rows'] as const) {
197+
const grouped = await makeEngine(path, ROWS);
198+
const having = await refusalOf(() => grouped.engine.aggregate(OBJECT, havingQuery(path, { name: { [op]: [true] } })));
199+
expect({ code: having.code, status: having.status }, `${op} ${path}`).toEqual({ code: 'INVALID_FILTER', status: 400 });
200+
expect(having.message, `${op} ${path}`).toContain(`${sentence} at having.name.${op}.`);
201+
expect(grouped.reads, `${op} ${path}: no row was read`).toEqual({ aggregate: 0, find: 0 });
202+
}
203+
}
204+
});
205+
187206
// Where the flag sits must not change the verdict: the walk is row-independent,
188207
// so a branch the per-row walk would short-circuit past is judged too.
189208
const POSITIONS: ReadonlyArray<readonly [string, (flag: Record<string, unknown>) => Record<string, unknown>, string]> = [

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,9 @@ describe('[#21333] the boolean-comparand arm at the engine collection point', ()
428428
['implicit Date', (v) => v, () => new Date(Date.UTC(2026, 0, 1)), 'date'],
429429
['a $nin member Date', (v) => ({ $nin: [v, true] }), () => new Date(Date.UTC(2026, 0, 1)), 'date'],
430430
['a $in member [true] (the card)', (v) => ({ $in: [false, v] }), () => [true], 'array'],
431-
['$gt [true]', (v) => ({ $gt: v }), () => [true], 'array'],
431+
// [#21448] `$gt [true]` left this table: a list at a scalar operator is the
432+
// shared comparand-shape face's refusal, one door before this arm, at every
433+
// position — pinned in its own block below.
432434
];
433435

434436
/** What the contract says is wrong, per non-string form — the clause after "which is not a boolean:". */
@@ -502,6 +504,34 @@ describe('[#21333] the boolean-comparand arm at the engine collection point', ()
502504
}
503505
});
504506

507+
it('[#21448] $gt [true] is the shared comparand-shape face\'s at all three positions — in its words, no read', async () => {
508+
const faceSentence = (field: string, path: string) =>
509+
`Operator "$gt" on field "${field}" requires a single comparable value, but received an array ([true]) at ${path}.`;
510+
reads.length = 0;
511+
const where = await refusalOf(engine.find(OBJECT, { where: { f_boolean: { $gt: [true] } } as FilterCondition }));
512+
expect({ code: where!.code, status: where!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
513+
expect(where!.message).toMatch(/^find\('boolean_door_probe'\): Operator /);
514+
expect(where!.message).toContain(faceSentence('f_boolean', 'where.f_boolean.$gt'));
515+
const filtered = await refusalOf(engine.aggregate(OBJECT, {
516+
aggregations: [
517+
{ function: 'count', alias: 'all' },
518+
{ function: 'count', alias: 'bad', filter: { f_boolean: { $gt: [true] } } },
519+
],
520+
} as EngineAggregateOptions));
521+
expect({ code: filtered!.code, status: filtered!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
522+
expect(filtered!.message).toContain(faceSentence('f_boolean', 'aggregations[1].filter.f_boolean.$gt'));
523+
const having = await refusalOf(engine.aggregate(OBJECT, {
524+
groupBy: ['f_boolean'], aggregations: [{ function: 'count', alias: 'n' }], having: { f_boolean: { $gt: [true] } },
525+
} as EngineAggregateOptions));
526+
expect({ code: having!.code, status: having!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
527+
expect(having!.message).toContain(faceSentence('f_boolean', 'having.f_boolean.$gt'));
528+
expect(reads).toHaveLength(0);
529+
// This arm's own walk still refuses the form when asked alone; no door
530+
// reaches it at a scalar operator any more.
531+
expect(() => narrowNumberComparands(OBJECT, 'find', engine.registry.getObject(OBJECT), { f_toggle: { $gt: [true] } }))
532+
.toThrow(/compares a declared toggle field/);
533+
});
534+
505535
it('[#21382] the controls at all three positions: true and 1 answer exactly what they answered before', async () => {
506536
for (const control of [true, 1]) {
507537
expect(await driverWhere({ f_boolean: control }), String(control)).toEqual(lowered({ f_boolean: true }));

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,9 @@ describe('[#20351] the number-comparand declared-type door at the engine collect
451451
['true', () => true, 'boolean'],
452452
['false', () => false, 'boolean'],
453453
['a Date', () => new Date(Date.UTC(2026, 0, 1)), 'date'],
454-
['an array', () => [10], 'array'],
454+
// [#21448] `[10]` left this table: a list at a scalar operator is the
455+
// shared comparand-shape face's refusal, one door before this one, at every
456+
// position — pinned in its own block below.
455457
];
456458

457459
it('[#20502] refuses a boolean, a Date or an array in ONE aggregation\'s own filter, rooted at that position — no read', async () => {
@@ -500,6 +502,39 @@ describe('[#20351] the number-comparand declared-type door at the engine collect
500502
}
501503
});
502504

505+
it('[#21448] an array at a scalar operator is the shared comparand-shape face\'s at all three positions — in its words, no read', async () => {
506+
const faceSentence = (op: string, field: string, path: string) =>
507+
`Operator "${op}" on field "${field}" requires a single comparable value, but received an array ([10]) at ${path}.`;
508+
reads.length = 0;
509+
const where = await refusalOf(engine.find(OBJECT, { where: { f_number: { $gt: [10] } } as FilterCondition }));
510+
expect({ code: where!.code, status: where!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
511+
expect(where!.message).toMatch(/^find\('number_door_probe'\): Operator /);
512+
expect(where!.message).toContain(faceSentence('$gt', 'f_number', 'where.f_number.$gt'));
513+
for (const op of ['$gt', '$lte'] as const) {
514+
const filtered = await refusalOf(engine.aggregate(OBJECT, {
515+
aggregations: [
516+
{ function: 'count', alias: 'all' },
517+
{ function: 'count', alias: 'bad', filter: { f_number: { [op]: [10] } } },
518+
],
519+
} as EngineAggregateOptions));
520+
expect({ code: filtered!.code, status: filtered!.status }, op).toEqual({ code: 'INVALID_FILTER', status: 400 });
521+
expect(filtered!.message, op).toMatch(/^aggregate\('number_door_probe'\): Operator /);
522+
expect(filtered!.message, op).toContain(faceSentence(op, 'f_number', `aggregations[1].filter.f_number.${op}`));
523+
}
524+
const having = await refusalOf(engine.aggregate(OBJECT, {
525+
groupBy: ['f_text'],
526+
aggregations: [{ function: 'count', alias: 'total' }],
527+
having: { total: { $gt: [10] } },
528+
} as EngineAggregateOptions));
529+
expect({ code: having!.code, status: having!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
530+
expect(having!.message).toContain(faceSentence('$gt', 'total', 'having.total.$gt'));
531+
expect(reads).toHaveLength(0);
532+
// This door's own walk still names the form when asked alone; no door
533+
// reaches that arm at a scalar operator any more.
534+
expect(findNonNumericComparand(engine.registry.getObject(OBJECT), { f_number: { $gt: [10] } }))
535+
.toMatchObject({ field: 'f_number', form: 'array' });
536+
});
537+
503538
it('[#20502] the numeric control at all three positions: a number reaches the driver and the evaluator as written', async () => {
504539
reads.length = 0;
505540
await engine.find(OBJECT, { where: { f_number: { $gt: 10 } } });

‎packages/rest/src/analytics-filter-refusal-envelope.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,16 @@ describe('[#17551] the structurally-malformed filter spellings are refused at th
292292
member: 'selection.runtimeFilter.stage.$in.1',
293293
sentence: /^Filter comparand is a plain object \(\{"a":1\}\), which no driver can compare\./,
294294
},
295+
{
296+
// [#21448] A list at a scalar operator, whatever the column type. Both
297+
// analytics faces bound its FIRST member on a text column (200, wrong
298+
// rows); the shared comparand-shape face now refuses it on query, and the
299+
// schema door asks that face, so it is refused here, located on the member.
300+
name: 'a list at a scalar operator',
301+
runtimeFilter: { stage: { $gt: ['a', 'z'] } },
302+
member: 'selection.runtimeFilter.stage.$gt',
303+
sentence: /^Operator "\$gt" on field "stage" requires a single comparable value, but received an array \(\["a","z"\]\)\. Write ONE value\./,
304+
},
295305
];
296306

297307
for (const c of AT_THE_DOOR) {

‎packages/rest/src/data-boolean-comparand-door.test.ts‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ const REFUSED_OVER_REST: ReadonlyArray<readonly [string, unknown]> = [
9191
['a $in member 2', { $in: [false, 2] }],
9292
['a $nin member -1', { $nin: [-1] }],
9393
['a $in member [true] (the card)', { $in: [false, [true]] }],
94-
['$gt [true] (an array at a scalar slot)', { $gt: [true] }],
94+
// [#21448] `$gt [true]` left this table: a list at a scalar operator is the
95+
// shared comparand-shape face's refusal, one door before this one — pinned
96+
// in its own block below. A list as a `$in` MEMBER is still this door's.
9597
];
9698

9799
/**
@@ -245,6 +247,40 @@ for (const cell of CELLS) {
245247
expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0);
246248
});
247249

250+
it('[#21448] $gt [true]: one 400 at every position, in the shared comparand-shape face\'s words — VALIDATION_FAILED at the wire, INVALID_FILTER in process — no read', async () => {
251+
const before = reads.n;
252+
const sentence = 'Operator "$gt" on field "done" requires a single comparable value, but received an array ([true])';
253+
const where = { done: { $gt: [true] } } as FilterCondition;
254+
// Over the wire the route parses its body first, and the schema door
255+
// asks the face (#20116): VALIDATION_FAILED, located on the member, in
256+
// the face's sentence less its location — before the engine runs.
257+
for (const [body, member] of [
258+
[{ where }, 'query.where.done.$gt'],
259+
[perAggregation(where), 'query.aggregations.1.filter.done.$gt'],
260+
[grouped('native', where), 'query.having.done.$gt'],
261+
] as const) {
262+
const res = await query(body as Record<string, unknown>);
263+
expect(res.status, JSON.stringify(res.body)).toBe(400);
264+
expect(res.body.code, member).toBe('VALIDATION_FAILED');
265+
const at = (res.body.fields as Array<{ field: string; message: string }>).filter((f) => f.field === member);
266+
expect(at, JSON.stringify(res.body.fields)).toHaveLength(1);
267+
expect(at[0]!.message.startsWith(`${sentence}. Write ONE value.`), at[0]!.message).toBe(true);
268+
}
269+
// In process the engine's seam runs the face itself: INVALID_FILTER, located.
270+
const err = await refusalOf(engine.find(OBJECT, { where }));
271+
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
272+
expect(err?.message).toContain(`${sentence} at where.done.$gt.`);
273+
const filtered = await refusalOf(engine.aggregate(OBJECT, perAggregation(where)));
274+
expect({ code: filtered?.code, status: filtered?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
275+
expect(filtered?.message).toContain(`${sentence} at aggregations[1].filter.done.$gt.`);
276+
for (const path of ['native', 'rows'] as const) {
277+
const having = await refusalOf(engine.aggregate(OBJECT, grouped(path, where)));
278+
expect({ code: having?.code, status: having?.status }, `having ${path}`).toEqual({ code: 'INVALID_FILTER', status: 400 });
279+
expect(having?.message, `having ${path}`).toContain(`${sentence} at having.done.$gt.`);
280+
}
281+
expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0);
282+
});
283+
248284
it('the controls: true, 1 and "true" answer the rows they name, at every position', async () => {
249285
for (const [name, spec, ids] of CONTROLS) {
250286
const res = await query({ where: { done: spec } });

0 commit comments

Comments
 (0)