Skip to content

Commit 1b3e042

Browse files
committed
wip(spec): a list at any scalar operator is refused at the comparand-shape face (#21448)
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent b94a2a7 commit 1b3e042

5 files changed

Lines changed: 262 additions & 34 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The declared field-operator vocabulary, split by what the comparand IS: ONE
5+
* literal, or a LIST. One split, read by every face that judges a comparand by
6+
* its operator:
7+
*
8+
* - the comparand-TYPE face (`./filter-comparand-type.ts`, #7872) judges a
9+
* scalar operator's comparand as one literal, and each member of a list
10+
* operator's array as a literal in its own right;
11+
* - [#21448] the comparand-SHAPE face (`./filter-comparand-shape.ts`) refuses a
12+
* LIST at a scalar operator, whatever the column type, because one value
13+
* belongs there;
14+
* - the save door (`./filter-save-door-refusals.ts`) words that refusal.
15+
*
16+
* The split lived in the type face alone until the shape face needed it too.
17+
* It moved here so the faces read ONE split rather than two copies of it.
18+
* `filter-comparand-type.test.ts` reconciles the union of the two sets against
19+
* `FieldOperatorsSchema`'s own keys, so an operator added to the schema cannot
20+
* silently skip either face. `filter-comparand-shape.test.ts` holds the scalar
21+
* set to the schema's other half: every declared operator whose enforced slot
22+
* refuses an array.
23+
*
24+
* ## Why it is NOT in the `data` barrel
25+
*
26+
* Nothing here is a contract a consumer calls; the faces are, and they are
27+
* already published (`normalizeFilterComparandTypes`,
28+
* `assertListComparandShapes`). Exporting the split would widen
29+
* `@objectstack/spec/data` for no reader, so this file stays out of
30+
* `./index.ts`, as `./filter-comparand-refusal-text.ts` does. It imports
31+
* nothing, so no face can reach a cycle through it.
32+
*/
33+
34+
/** The operators whose declared comparand is ONE literal. */
35+
export const SCALAR_COMPARAND_OPERATORS: ReadonlySet<string> = new Set([
36+
'$eq', '$ne', '$gt', '$gte', '$lt', '$lte',
37+
'$contains', '$notContains', '$startsWith', '$endsWith', '$icontains',
38+
'$like', '$ilike',
39+
'$null', '$exists', '$empty',
40+
]);
41+
42+
/** The operators whose declared comparand is a LIST. */
43+
export const LIST_COMPARAND_OPERATORS: ReadonlySet<string> = new Set([
44+
'$in', '$nin', '$between',
45+
]);

‎packages/spec/src/data/filter-comparand-refusal-text.ts‎

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
* `FieldOperatorsSchema.$ne` with "one remedy text", so that sentence is
2222
* assembled here too, by {@link arrayInequalityComparandMessage}.
2323
*
24+
* [#21448] Every other scalar slot is the third, by
25+
* {@link arrayScalarComparandMessage}: the face refuses a list there and the
26+
* save door words the same refusal, so both import it from here.
27+
*
2428
* ## Why it is NOT in the `data` barrel
2529
*
2630
* Nothing here is a contract a consumer calls; the two refusals are, and they
@@ -54,6 +58,18 @@ export const IN_OPERATOR_SPELLINGS: readonly string[] = ['in'];
5458
*/
5559
export const NIN_OPERATOR_SPELLINGS: readonly string[] = ['nin', 'not_in', 'notin'];
5660

61+
/**
62+
* [#21448] The authoring spellings that lower to `$between`.
63+
*
64+
* `$between` is the range operator the scalar-slot refusal prescribes beside
65+
* `$in`. So, as with {@link IN_OPERATOR_SPELLINGS}, the face's
66+
* `LIST_COMPARAND_OPERATORS` reads its `$between` row from here, and the
67+
* refusal of a malformed `$between` and the scalar-slot refusal name one
68+
* spelling list. `filter-comparand-shape.test.ts` reconciles the list against
69+
* `AST_OPERATOR_MAP`.
70+
*/
71+
export const BETWEEN_OPERATOR_SPELLINGS: readonly string[] = ['between'];
72+
5773
/**
5874
* [#19757] The ARRAY-CONTAINMENT operator the equality-slot refusal prescribes,
5975
* with its authoring spelling: `$contains`, which `FieldOperatorsSchema`
@@ -212,16 +228,69 @@ export function arrayInequalityComparandMessage(
212228
);
213229
}
214230

231+
/**
232+
* [#21448] The ONE remedy for a list at any other scalar operator — an ordering
233+
* operator, a text operator or a flag: write the one value, and the two list
234+
* operators an author holding a list may have meant, by their spec spellings
235+
* and their authoring spellings. Then the sentence a caller cannot infer from a
236+
* status code.
237+
*
238+
* - `$in` — "one of these values" (authoring spelling `in`).
239+
* - `$between` — a range (authoring spelling `between`), what a two-member
240+
* list under an ordering operator most often stands for.
241+
*
242+
* Written like {@link ARRAY_EQUALITY_COMPARAND_REMEDY}: no field wrapper and
243+
* `…` for the value, because the field is the subject of the sentence in front
244+
* of this one, and the 500-char client bound buys more as prescription.
245+
*/
246+
export const ARRAY_SCALAR_COMPARAND_REMEDY: string =
247+
'Write ONE value. ' +
248+
`For "one of these values" use {"$in": […]} (authoring: ${IN_OPERATOR_SPELLINGS.join(', ')}); ` +
249+
`for a range, {"$between": [min, max]} (authoring: ${BETWEEN_OPERATOR_SPELLINGS.join(', ')}). ` +
250+
`The filter was NOT applied, and an unapplied filter would have returned the ` +
251+
`UNFILTERED result set.`;
252+
253+
/**
254+
* [#21448] Where the refused list sits, as far as the calling door can see. The
255+
* operator is always known: this refusal exists only under an operator. The
256+
* face passes `field` and `path`; the save door passes `field` alone, because
257+
* its issue carries the location as its own `path`.
258+
*/
259+
export type ArrayScalarComparandSite = { readonly field?: string; readonly path?: string };
260+
261+
/**
262+
* [#21448] The refusal of a LIST at a scalar operator other than `$eq` / `$ne`
263+
* (each of which has its own sentence and remedy, above), as the doors print
264+
* it — the shared comparand-shape face and the save door.
265+
*
266+
* The leading sentence is `driver-memory`'s `arrayComparandError` for the same
267+
* operator, word for word, so one condition keeps one wording across packages
268+
* (#5240's rule). Then the received list, bounded by {@link shapePreview}, the
269+
* location when the door knows it, and {@link ARRAY_SCALAR_COMPARAND_REMEDY}.
270+
*/
271+
export function arrayScalarComparandMessage(
272+
op: string,
273+
value: unknown,
274+
site: ArrayScalarComparandSite,
275+
): string {
276+
return singleValueSlotMessage(
277+
operatorPosition(op, site.field),
278+
value,
279+
site.path,
280+
ARRAY_SCALAR_COMPARAND_REMEDY,
281+
);
282+
}
283+
215284
/** `Operator "$op"`, plus ` on field "f"` when the door can see the field. */
216285
function operatorPosition(op: string, field: string | undefined): string {
217286
return `Operator "${op}"${field === undefined ? '' : ` on field "${field}"`}`;
218287
}
219288

220289
/**
221-
* The sentence both single-value-slot refusals share: the position, the words
290+
* The sentence every single-value-slot refusal shares: the position, the words
222291
* `driver-memory` uses for this condition, the received list, the location and
223-
* the slot's own remedy. One template, so the `$eq` and `$ne` refusals cannot
224-
* drift apart in their first sentence.
292+
* the slot's own remedy. One template, so the `$eq`, `$ne` and [#21448] other
293+
* scalar-slot refusals cannot drift apart in their first sentence.
225294
*/
226295
function singleValueSlotMessage(
227296
position: string,

‎packages/spec/src/data/filter-comparand-shape.ts‎

Lines changed: 121 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,13 @@
1212
* is the 2026-09-23 ruling's, recorded in its own section below.
1313
*
1414
* [#19886] And the same question for equality's negation: whether a `$ne`
15-
* comparand received ONE value rather than a list. Ruling A's, recorded in the
16-
* last "Refused BY RULING" section.
15+
* comparand received ONE value rather than a list. Ruling A's, recorded in its
16+
* own "Refused BY RULING" section.
17+
*
18+
* [#21448] And the same question for every other scalar operator — the
19+
* ordering, text and flag operators: whether the comparand is ONE value rather
20+
* than a list, whatever the column type. The 2026-10-02 ruling's, recorded in
21+
* the last "Refused BY RULING" section.
1722
*
1823
* `FieldOperatorsSchema` (`./filter.zod.ts`) declares three operators whose
1924
* comparand is a LIST rather than a scalar:
@@ -74,7 +79,7 @@
7479
* delegating wrapper that supplies the engine's `find('deal')` context prefix;
7580
* there is exactly one implementation of "a list operator takes a list".
7681
*
77-
* That sentence is true of THREE slots, and each is named because it was once
82+
* That sentence is true of FOUR slots, and each is named because it was once
7883
* true of fewer:
7984
*
8085
* - **The list-operator slot** (`$in` / `$nin` / `$between`) — a scalar where a
@@ -87,7 +92,12 @@
8792
* array equality. That is the arm's section below.
8893
* - **The inequality slot** (`$ne`) — a list where one value belongs, since
8994
* ruling A on #19886 (2026-09-24). Until then this face passed it and each
90-
* backend answered it alone. That is the last "Refused BY RULING" section.
95+
* backend answered it alone. That is the sixth "Refused BY RULING" section.
96+
* - **Every other scalar slot** (`$gt` / `$gte` / `$lt` / `$lte`, the text
97+
* operators, the flags) — a list where one value belongs, since the
98+
* 2026-10-02 ruling (#21448). Until then this face passed it, and the
99+
* analytics lowering bound the list's FIRST member while the drivers each
100+
* answered it alone. That is the last "Refused BY RULING" section.
91101
*
92102
* ## Both engine doors, because only one of them carries an array
93103
*
@@ -268,9 +278,10 @@
268278
* this ruling names implicit and explicit equality. It measured the same
269279
* split (refused on the SQL family and `driver-memory`, answered by
270280
* `driver-mongodb`), so it went to its own card and got its own ruling —
271-
* the last section below, with its own remedy. The other scalar operators
281+
* the sixth section, with its own remedy. The other scalar operators
272282
* (`$gt`, `$contains`, …) carrying an array are not this ruling's, and not
273-
* that one's either.
283+
* that one's either: they are the 2026-10-02 ruling's (#21448), the last
284+
* section below, with a remedy of their own.
274285
* - **The list operators keep their lists**, `$in: []` / `$nin: []` included,
275286
* and every scalar — `null` above all, since `{ field: null }` and
276287
* `$eq: null` ARE the has-no-value predicate (#5332) — keeps passing.
@@ -340,7 +351,10 @@
340351
* reconciles the spellings against `FieldOperatorsSchema` and the AST table.
341352
* - ⛔ **Not the ordering operators, a nested array inside `$in`, or a
342353
* `{ $field }` referent to a multi-valued field.** Those are the same class,
343-
* are recorded on the card, and are scoped separately.
354+
* are recorded on the card, and are scoped separately. [#21448] The
355+
* ordering operators are now the last section's, with every other scalar
356+
* operator; a nested array inside `$in` and a `{ $field }` referent are still
357+
* not judged here.
344358
* - **The leading sentence is `driver-memory`'s** `arrayComparandError` for
345359
* `$ne`, word for word, so one condition keeps one wording across packages.
346360
*
@@ -350,6 +364,65 @@
350364
* is assembled in `./filter-comparand-refusal-text.ts`, which both doors
351365
* import.
352366
*
367+
* ## Refused BY RULING, 2026-10-02: a LIST at every other scalar operator (#21448)
368+
*
369+
* The triage ruling (record 5958292323): "A list at a scalar operator (`$gt`,
370+
* `$gte`, `$lt`, `$lte`, `$eq`, `$ne` and the rest of the scalar set) is a shape
371+
* error, whatever the column type." `$eq` and `$ne` had their arms already
372+
* (the two sections above); this arm is the rest. It does not live in the
373+
* number or boolean declared-type verdicts, because the column type does not
374+
* decide it.
375+
*
376+
* The governing declaration is `FieldOperatorsSchema`: of the declared
377+
* operators, only `$in`, `$nin` and `$between` accept an array at the schema
378+
* door, and every other slot refuses one. This face passed the shape, and each
379+
* consumer answered it alone. Measured on `b94a2a727`, SQLite and PostgreSQL
380+
* 16.14 alike, through `POST /api/v1/analytics/query` and
381+
* `/api/v1/analytics/dataset/query`, and at the engine's own `find`:
382+
*
383+
* | filter | analytics, engine-aggregate face | analytics, native face | `engine.find` |
384+
* |:--|:--|:--|:--|
385+
* | `{ amount: { $gt: [10, 99] } }` (number) | 200, the driver received `$gt: 10` | 400 (the number verdict) | 400 (the number verdict) |
386+
* | `{ note: { $gt: ['a', 'z'] } }` (text) | 200, the driver received `$gt: 'a'` | 200, bound `'a'` | 400 (`driver-sql`'s own bind refusal) |
387+
*
388+
* The analytics lowering carried the operator's whole array into one leaf, and
389+
* the leaf compilers read its FIRST member: the predicate the caller wrote was
390+
* narrowed without a word, and the answer was a 200 with wrong rows. One
391+
* position over, `driver-memory` refuses a list at the six comparisons but
392+
* ANSWERS one at a text operator, while `driver-sql` refuses both in its own
393+
* words. Refusing here makes every one of those cells unreachable through a
394+
* platform door, and the analytics `where` door needs no rule of its own: it
395+
* already hands every field entry to this face before any leaf exists.
396+
*
397+
* The scope:
398+
*
399+
* - **Every operator of the declared scalar half** (`SCALAR_COMPARAND_OPERATORS`,
400+
* `./filter-comparand-operators.ts`, the split the comparand-TYPE face reads)
401+
* other than `$eq` / `$ne`, whose arms answer first with their own remedies:
402+
* the four ordering operators, the text operators (`$contains`,
403+
* `$notContains`, `$startsWith`, `$endsWith`, `$icontains`, `$like`,
404+
* `$ilike`) and the three flags. At every depth, the EMPTY list too: `[]` is a
405+
* list in a one-value slot, and only `$in: []` / `$nin: []` are declared
406+
* predicates.
407+
* - **The flags** (`$null`, `$exists`, `$empty`). A list there was already
408+
* refused on every query face by the boolean rule (#5347 / #5369), in that
409+
* rule's words; it is now refused here first, in this arm's, because HOW MANY
410+
* values comes before WHICH value. A non-boolean scalar flag keeps the
411+
* boolean rule's sentence.
412+
* - **Not judged:** the list operators keep their lists; every scalar, a `Date`
413+
* and a `{ $field }` reference pass as before (a `null` ordering comparand
414+
* keeps its own arm above); an operator outside the vocabulary (`$wat`,
415+
* retired `$regex`) is not in the set and keeps its own refusal downstream.
416+
* - **The remedy** is ONE value, or the two list operators an author holding a
417+
* list may have meant, by their spec and authoring spellings: `$in` for "one
418+
* of these values" and `$between` for a range. The leading sentence is
419+
* `driver-memory`'s `arrayComparandError`, word for word.
420+
*
421+
* The sentence is assembled in `./filter-comparand-refusal-text.ts`, and the
422+
* save door (`./filter-save-door-refusals.ts`) prints it without the location,
423+
* so a stored filter carrying the shape is refused on save the day this face
424+
* refuses it on query.
425+
*
353426
* ## Refusal envelope
354427
*
355428
* Every refusal carries `code: 'INVALID_FILTER'` and `status: 400` (ADR-0112
@@ -363,15 +436,19 @@
363436
* @see https://github.com/objectstack-ai/objectstack/issues/9228 (the move)
364437
* @see https://github.com/objectstack-ai/objectstack/issues/19757 (the equality-slot arm)
365438
* @see https://github.com/objectstack-ai/objectstack/issues/19886 (the `$ne` arm)
439+
* @see https://github.com/objectstack-ai/objectstack/issues/21448 (the other scalar slots' arm)
366440
*/
367441

368442
import {
443+
BETWEEN_OPERATOR_SPELLINGS,
369444
IN_OPERATOR_SPELLINGS,
370445
NIN_OPERATOR_SPELLINGS,
371446
arrayEqualityComparandMessage,
372447
arrayInequalityComparandMessage,
448+
arrayScalarComparandMessage,
373449
shapePreview,
374450
} from './filter-comparand-refusal-text';
451+
import { SCALAR_COMPARAND_OPERATORS } from './filter-comparand-operators';
375452

376453
/**
377454
* The operators whose comparand `FieldOperatorsSchema` declares as a list, with
@@ -392,7 +469,8 @@ const LIST_COMPARAND_OPERATORS: ReadonlyMap<string, readonly string[]> = new Map
392469
['$in', IN_OPERATOR_SPELLINGS],
393470
// [#19886] The `$nin` row likewise, for the operator the `$ne` refusal prescribes.
394471
['$nin', NIN_OPERATOR_SPELLINGS],
395-
['$between', ['between']],
472+
// [#21448] And the `$between` row, for the range the scalar-slot refusal prescribes.
473+
['$between', BETWEEN_OPERATOR_SPELLINGS],
396474
]);
397475

398476
/**
@@ -768,21 +846,45 @@ function arrayInequalityComparandError(
768846
return invalidFilterComparandError(context, arrayInequalityComparandMessage(value, { field, path }));
769847
}
770848

849+
/**
850+
* A LIST at any other scalar operator — refused BY RULING, 2026-10-02 (#21448);
851+
* see the module note's seventh "Refused BY RULING" section.
852+
*
853+
* The sentence is `arrayScalarComparandMessage`
854+
* (`./filter-comparand-refusal-text.ts`), which the save door prints too. Its
855+
* leading sentence is `driver-memory`'s `arrayComparandError` for the same
856+
* operator, word for word, and its remedy is ONE value, or the two list
857+
* operators an author holding a list may have meant (`$in`, `$between`), with
858+
* their authoring spellings. As with {@link arrayEqualityComparandError}, this
859+
* door adds only its `at <path>` location and its envelope.
860+
*/
861+
function arrayScalarComparandError(
862+
context: string | undefined,
863+
op: string,
864+
field: string,
865+
value: unknown,
866+
path: string,
867+
): Error {
868+
return invalidFilterComparandError(context, arrayScalarComparandMessage(op, value, { field, path }));
869+
}
870+
771871
/**
772872
* Walk one `FilterCondition` and refuse every list-shaped operator whose
773873
* comparand cannot be one — and, since the two null rulings (2026-08-31,
774874
* 2026-09-01), the null comparand positions those rulings carved out; and,
775875
* since the 2026-09-23 ruling (#19757), every EQUALITY comparand (implicit or
776876
* `$eq`) that IS a list; and, since ruling A on #19886, every `$ne` comparand
777-
* that is a list.
877+
* that is a list; and, since the 2026-10-02 ruling (#21448), every other
878+
* scalar operator's comparand that is a list.
778879
*
779880
* Read-only and allocation-free on the overwhelmingly common path (a filter
780881
* with no list operator walks its own keys and returns). Runs on every engine
781882
* read and write and inside every {@link parseFilterAST} call, so it stays a
782883
* walk rather than a schema parse — that cost is now the whole reason, and this
783884
* gate deliberately enforces only the three list declarations the drivers
784-
* genuinely cannot agree on, the equality slot's mirror of them, plus the null
785-
* carve-outs ruled onto the same door.
885+
* genuinely cannot agree on, their mirror in every scalar slot (equality since
886+
* #19757, `$ne` since #19886, the rest since #21448), plus the null carve-outs
887+
* ruled onto the same door.
786888
*
787889
* @param node the LOWERED `FilterCondition` — never the authoring array.
788890
* @param context optional caller prefix (`find('deal')`), the engine's #5346
@@ -869,6 +971,14 @@ function assertFieldListComparands(
869971
}
870972
continue;
871973
}
974+
// Every other scalar operator (#21448): a list where ONE value belongs,
975+
// whatever the column type. The set is the declared vocabulary's scalar
976+
// half — the same split the comparand-TYPE face reads — so an operator
977+
// outside the vocabulary (`$wat`, retired `$regex`) keeps its own refusal
978+
// downstream. Before the null-ordering arm, which a list can never reach.
979+
if (SCALAR_COMPARAND_OPERATORS.has(op) && Array.isArray(spec[op])) {
980+
throw arrayScalarComparandError(context, op, field, spec[op], `${path}.${op}`);
981+
}
872982
// The ordering carve-out (2026-09-01 ruling, #14080). Strictly `null`:
873983
// `undefined` keeps the TYPE door's own sentence, and every other
874984
// comparand type in these slots is that door's question, not this one's.

0 commit comments

Comments
 (0)