diff --git a/.changeset/21448-list-at-scalar-operator.md b/.changeset/21448-list-at-scalar-operator.md new file mode 100644 index 00000000000..128939c2832 --- /dev/null +++ b/.changeset/21448-list-at-scalar-operator.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': minor +--- + +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 + +Clause-②: no (narrowing) + + + +**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. + +- **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. + - 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. + - 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. +- **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. +- **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. diff --git a/packages/objectql/src/engine-aggregate-flag-comparand-refusal.test.ts b/packages/objectql/src/engine-aggregate-flag-comparand-refusal.test.ts index 9b833b2b3cf..99bae398e01 100644 --- a/packages/objectql/src/engine-aggregate-flag-comparand-refusal.test.ts +++ b/packages/objectql/src/engine-aggregate-flag-comparand-refusal.test.ts @@ -154,9 +154,9 @@ const NON_BOOLEANS: ReadonlyArray = [ ['"false" (truthy)', 'false', 'string ("false")'], ['0', 0, 'number (0)'], ['null', null, 'null (null)'], - // Not one of the card's five, but reaching the same gate: no earlier door - // refuses a list here, so it met the old `!!target` read like any other value. - ['[true] (a list)', [true], 'array ([true])'], + // [#21448] `[true] (a list)` stood here, reaching this gate because no + // earlier door refused a list at a flag. The shared comparand-shape face + // does now, one door earlier, in its own words — pinned in the block below. ]; 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 } } + it('[#21448] a LIST flag is the shared comparand-shape face\'s refusal, one door earlier, at both positions — no read', async () => { + for (const op of OPS) { + const sentence = `Operator "${op}" on field "name" requires a single comparable value, but received an array ([true])`; + const { engine, reads } = await makeEngine('rows', ROWS); + const filtered = await refusalOf(() => engine.aggregate(OBJECT, filterQuery({ name: { [op]: [true] } }))); + expect({ code: filtered.code, status: filtered.status }, op).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(filtered.message, op).toContain(`${sentence} at aggregations[1].filter.name.${op}.`); + expect(filtered.message, op).not.toContain('requires a boolean comparand'); + expect(reads, `${op}: no row was read`).toEqual({ aggregate: 0, find: 0 }); + for (const path of ['native', 'rows'] as const) { + const grouped = await makeEngine(path, ROWS); + const having = await refusalOf(() => grouped.engine.aggregate(OBJECT, havingQuery(path, { name: { [op]: [true] } }))); + expect({ code: having.code, status: having.status }, `${op} ${path}`).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(having.message, `${op} ${path}`).toContain(`${sentence} at having.name.${op}.`); + expect(grouped.reads, `${op} ${path}: no row was read`).toEqual({ aggregate: 0, find: 0 }); + } + } + }); + // Where the flag sits must not change the verdict: the walk is row-independent, // so a branch the per-row walk would short-circuit past is judged too. const POSITIONS: ReadonlyArray) => Record, string]> = [ diff --git a/packages/objectql/src/engine-boolean-comparand-declared-type-door.test.ts b/packages/objectql/src/engine-boolean-comparand-declared-type-door.test.ts index 3687b26ea80..553e8516c79 100644 --- a/packages/objectql/src/engine-boolean-comparand-declared-type-door.test.ts +++ b/packages/objectql/src/engine-boolean-comparand-declared-type-door.test.ts @@ -428,7 +428,9 @@ describe('[#21333] the boolean-comparand arm at the engine collection point', () ['implicit Date', (v) => v, () => new Date(Date.UTC(2026, 0, 1)), 'date'], ['a $nin member Date', (v) => ({ $nin: [v, true] }), () => new Date(Date.UTC(2026, 0, 1)), 'date'], ['a $in member [true] (the card)', (v) => ({ $in: [false, v] }), () => [true], 'array'], - ['$gt [true]', (v) => ({ $gt: v }), () => [true], 'array'], + // [#21448] `$gt [true]` left this table: a list at a scalar operator is the + // shared comparand-shape face's refusal, one door before this arm, at every + // position — pinned in its own block below. ]; /** 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', () } }); + it('[#21448] $gt [true] is the shared comparand-shape face\'s at all three positions — in its words, no read', async () => { + const faceSentence = (field: string, path: string) => + `Operator "$gt" on field "${field}" requires a single comparable value, but received an array ([true]) at ${path}.`; + reads.length = 0; + const where = await refusalOf(engine.find(OBJECT, { where: { f_boolean: { $gt: [true] } } as FilterCondition })); + expect({ code: where!.code, status: where!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(where!.message).toMatch(/^find\('boolean_door_probe'\): Operator /); + expect(where!.message).toContain(faceSentence('f_boolean', 'where.f_boolean.$gt')); + const filtered = await refusalOf(engine.aggregate(OBJECT, { + aggregations: [ + { function: 'count', alias: 'all' }, + { function: 'count', alias: 'bad', filter: { f_boolean: { $gt: [true] } } }, + ], + } as EngineAggregateOptions)); + expect({ code: filtered!.code, status: filtered!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(filtered!.message).toContain(faceSentence('f_boolean', 'aggregations[1].filter.f_boolean.$gt')); + const having = await refusalOf(engine.aggregate(OBJECT, { + groupBy: ['f_boolean'], aggregations: [{ function: 'count', alias: 'n' }], having: { f_boolean: { $gt: [true] } }, + } as EngineAggregateOptions)); + expect({ code: having!.code, status: having!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(having!.message).toContain(faceSentence('f_boolean', 'having.f_boolean.$gt')); + expect(reads).toHaveLength(0); + // This arm's own walk still refuses the form when asked alone; no door + // reaches it at a scalar operator any more. + expect(() => narrowNumberComparands(OBJECT, 'find', engine.registry.getObject(OBJECT), { f_toggle: { $gt: [true] } })) + .toThrow(/compares a declared toggle field/); + }); + it('[#21382] the controls at all three positions: true and 1 answer exactly what they answered before', async () => { for (const control of [true, 1]) { expect(await driverWhere({ f_boolean: control }), String(control)).toEqual(lowered({ f_boolean: true })); diff --git a/packages/objectql/src/engine-number-comparand-declared-type-door.test.ts b/packages/objectql/src/engine-number-comparand-declared-type-door.test.ts index 0c07563285a..47b5c5d3222 100644 --- a/packages/objectql/src/engine-number-comparand-declared-type-door.test.ts +++ b/packages/objectql/src/engine-number-comparand-declared-type-door.test.ts @@ -451,7 +451,9 @@ describe('[#20351] the number-comparand declared-type door at the engine collect ['true', () => true, 'boolean'], ['false', () => false, 'boolean'], ['a Date', () => new Date(Date.UTC(2026, 0, 1)), 'date'], - ['an array', () => [10], 'array'], + // [#21448] `[10]` left this table: a list at a scalar operator is the + // shared comparand-shape face's refusal, one door before this one, at every + // position — pinned in its own block below. ]; 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 } }); + 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 () => { + const faceSentence = (op: string, field: string, path: string) => + `Operator "${op}" on field "${field}" requires a single comparable value, but received an array ([10]) at ${path}.`; + reads.length = 0; + const where = await refusalOf(engine.find(OBJECT, { where: { f_number: { $gt: [10] } } as FilterCondition })); + expect({ code: where!.code, status: where!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(where!.message).toMatch(/^find\('number_door_probe'\): Operator /); + expect(where!.message).toContain(faceSentence('$gt', 'f_number', 'where.f_number.$gt')); + for (const op of ['$gt', '$lte'] as const) { + const filtered = await refusalOf(engine.aggregate(OBJECT, { + aggregations: [ + { function: 'count', alias: 'all' }, + { function: 'count', alias: 'bad', filter: { f_number: { [op]: [10] } } }, + ], + } as EngineAggregateOptions)); + expect({ code: filtered!.code, status: filtered!.status }, op).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(filtered!.message, op).toMatch(/^aggregate\('number_door_probe'\): Operator /); + expect(filtered!.message, op).toContain(faceSentence(op, 'f_number', `aggregations[1].filter.f_number.${op}`)); + } + const having = await refusalOf(engine.aggregate(OBJECT, { + groupBy: ['f_text'], + aggregations: [{ function: 'count', alias: 'total' }], + having: { total: { $gt: [10] } }, + } as EngineAggregateOptions)); + expect({ code: having!.code, status: having!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(having!.message).toContain(faceSentence('$gt', 'total', 'having.total.$gt')); + expect(reads).toHaveLength(0); + // This door's own walk still names the form when asked alone; no door + // reaches that arm at a scalar operator any more. + expect(findNonNumericComparand(engine.registry.getObject(OBJECT), { f_number: { $gt: [10] } })) + .toMatchObject({ field: 'f_number', form: 'array' }); + }); + it('[#20502] the numeric control at all three positions: a number reaches the driver and the evaluator as written', async () => { reads.length = 0; await engine.find(OBJECT, { where: { f_number: { $gt: 10 } } }); diff --git a/packages/rest/src/analytics-filter-refusal-envelope.test.ts b/packages/rest/src/analytics-filter-refusal-envelope.test.ts index 83687c0501c..bbe0ae15bc6 100644 --- a/packages/rest/src/analytics-filter-refusal-envelope.test.ts +++ b/packages/rest/src/analytics-filter-refusal-envelope.test.ts @@ -292,6 +292,16 @@ describe('[#17551] the structurally-malformed filter spellings are refused at th member: 'selection.runtimeFilter.stage.$in.1', sentence: /^Filter comparand is a plain object \(\{"a":1\}\), which no driver can compare\./, }, + { + // [#21448] A list at a scalar operator, whatever the column type. Both + // analytics faces bound its FIRST member on a text column (200, wrong + // rows); the shared comparand-shape face now refuses it on query, and the + // schema door asks that face, so it is refused here, located on the member. + name: 'a list at a scalar operator', + runtimeFilter: { stage: { $gt: ['a', 'z'] } }, + member: 'selection.runtimeFilter.stage.$gt', + sentence: /^Operator "\$gt" on field "stage" requires a single comparable value, but received an array \(\["a","z"\]\)\. Write ONE value\./, + }, ]; for (const c of AT_THE_DOOR) { diff --git a/packages/rest/src/data-boolean-comparand-door.test.ts b/packages/rest/src/data-boolean-comparand-door.test.ts index 5c9ee8d72a6..6194e6782c5 100644 --- a/packages/rest/src/data-boolean-comparand-door.test.ts +++ b/packages/rest/src/data-boolean-comparand-door.test.ts @@ -91,7 +91,9 @@ const REFUSED_OVER_REST: ReadonlyArray = [ ['a $in member 2', { $in: [false, 2] }], ['a $nin member -1', { $nin: [-1] }], ['a $in member [true] (the card)', { $in: [false, [true]] }], - ['$gt [true] (an array at a scalar slot)', { $gt: [true] }], + // [#21448] `$gt [true]` left this table: a list at a scalar operator is the + // shared comparand-shape face's refusal, one door before this one — pinned + // in its own block below. A list as a `$in` MEMBER is still this door's. ]; /** @@ -245,6 +247,40 @@ for (const cell of CELLS) { expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); }); + 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 () => { + const before = reads.n; + const sentence = 'Operator "$gt" on field "done" requires a single comparable value, but received an array ([true])'; + const where = { done: { $gt: [true] } } as FilterCondition; + // Over the wire the route parses its body first, and the schema door + // asks the face (#20116): VALIDATION_FAILED, located on the member, in + // the face's sentence less its location — before the engine runs. + for (const [body, member] of [ + [{ where }, 'query.where.done.$gt'], + [perAggregation(where), 'query.aggregations.1.filter.done.$gt'], + [grouped('native', where), 'query.having.done.$gt'], + ] as const) { + const res = await query(body as Record); + expect(res.status, JSON.stringify(res.body)).toBe(400); + expect(res.body.code, member).toBe('VALIDATION_FAILED'); + const at = (res.body.fields as Array<{ field: string; message: string }>).filter((f) => f.field === member); + expect(at, JSON.stringify(res.body.fields)).toHaveLength(1); + expect(at[0]!.message.startsWith(`${sentence}. Write ONE value.`), at[0]!.message).toBe(true); + } + // In process the engine's seam runs the face itself: INVALID_FILTER, located. + const err = await refusalOf(engine.find(OBJECT, { where })); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err?.message).toContain(`${sentence} at where.done.$gt.`); + const filtered = await refusalOf(engine.aggregate(OBJECT, perAggregation(where))); + expect({ code: filtered?.code, status: filtered?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(filtered?.message).toContain(`${sentence} at aggregations[1].filter.done.$gt.`); + for (const path of ['native', 'rows'] as const) { + const having = await refusalOf(engine.aggregate(OBJECT, grouped(path, where))); + expect({ code: having?.code, status: having?.status }, `having ${path}`).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(having?.message, `having ${path}`).toContain(`${sentence} at having.done.$gt.`); + } + expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); + }); + it('the controls: true, 1 and "true" answer the rows they name, at every position', async () => { for (const [name, spec, ids] of CONTROLS) { const res = await query({ where: { done: spec } }); diff --git a/packages/rest/src/data-number-comparand-door.test.ts b/packages/rest/src/data-number-comparand-door.test.ts index a2ef2a81f3f..40708777f58 100644 --- a/packages/rest/src/data-number-comparand-door.test.ts +++ b/packages/rest/src/data-number-comparand-door.test.ts @@ -120,7 +120,9 @@ const NON_STRING_OVER_REST: ReadonlyArray = [ ['$ne true', { $ne: true }], ['a $in member true', { $in: [10, true] }], ['a $between bound true', { $between: [true, 20] }], - ['$gt [1] (an array)', { $gt: [1] }], + // [#21448] `$gt [1]` left this table: a list at a scalar operator is the + // shared comparand-shape face's refusal, one door before this one — pinned + // in its own block below. A list as a `$in` MEMBER is still this door's. ['a $in member [1]', { $in: [[1], 10] }], ]; @@ -134,7 +136,7 @@ const NON_STRING_OVER_REST: ReadonlyArray = [ const NON_STRING_IN_PROCESS: ReadonlyArray unknown]> = [ ['true', () => true], ['a Date', () => new Date(Date.UTC(2026, 0, 1))], - ['an array', () => [1]], + // [#21448] `[1]` left this table for the shared comparand-shape face's block below. ]; /** name · the constraint as a numeric string · the same as a number · `where` count. */ @@ -301,6 +303,41 @@ for (const cell of CELLS) { expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); }); + it('[#21448] a list at a scalar operator: 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 () => { + const before = reads.n; + const sentence = (field: string) => `Operator "$gt" on field "${field}" requires a single comparable value, but received an array ([1])`; + // Over the wire the route parses its body first, and the schema door + // asks the face (#20116): VALIDATION_FAILED, located on the member, in + // the face's sentence less its location — before the engine runs. + for (const [body, member, field] of [ + [{ where: { amount: { $gt: [1] } } }, 'query.where.amount.$gt', 'amount'], + [perAggregation({ amount: { $gt: [1] } } as FilterCondition), 'query.aggregations.1.filter.amount.$gt', 'amount'], + [grouped('native', { total: { $gt: [1] } } as FilterCondition), 'query.having.total.$gt', 'total'], + ] as const) { + const res = await query(body as Record); + expect(res.status, JSON.stringify(res.body)).toBe(400); + expect(res.body.code, member).toBe('VALIDATION_FAILED'); + const at = (res.body.fields as Array<{ field: string; message: string }>).filter((f) => f.field === member); + expect(at, JSON.stringify(res.body.fields)).toHaveLength(1); + expect(at[0]!.message.startsWith(`${sentence(field)}. Write ONE value.`), at[0]!.message).toBe(true); + } + // In process the engine's seam runs the face itself: INVALID_FILTER, located. + const found = await refusalOf(engine.find(OBJECT, { where: { amount: { $gt: [1] } } as FilterCondition })); + expect({ code: found?.code, status: found?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(found?.message).toContain(`${sentence('amount')} at where.amount.$gt.`); + const filtered = await refusalOf(engine.aggregate(OBJECT, perAggregation({ amount: { $gt: [1] } } as FilterCondition))); + expect({ code: filtered?.code, status: filtered?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(filtered?.message).toContain(`${sentence('amount')} at aggregations[1].filter.amount.$gt.`); + for (const path of ['native', 'rows'] as const) { + for (const column of ['total', 'top'] as const) { + const having = await refusalOf(engine.aggregate(OBJECT, grouped(path, { [column]: { $gt: [1] } } as FilterCondition))); + expect({ code: having?.code, status: having?.status }, `having ${path} ${column}`).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(having?.message, `having ${path} ${column}`).toContain(`${sentence(column)} at having.${column}.$gt.`); + } + } + expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); + }); + it('the control: a number is answered, and a numeric string counts what its number counts at every position', async () => { for (const [name, asString, asNumber, count] of NARROWED) { const s = await query({ where: { amount: asString } }); diff --git a/packages/services/service-analytics/src/__tests__/list-at-scalar-operator-both-faces.test.ts b/packages/services/service-analytics/src/__tests__/list-at-scalar-operator-both-faces.test.ts new file mode 100644 index 00000000000..472cf1a82b0 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/list-at-scalar-operator-both-faces.test.ts @@ -0,0 +1,354 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21448] A LIST at a scalar operator — `{ amount: { $gt: [10, 99] } }`, + * `{ note: { $gt: ['a', 'z'] } }` — answers ONE refusal on both analytics + * faces, at both analytics doors, on SQLite and PostgreSQL: `INVALID_FILTER` / + * 400, before any statement or engine read, in the words of the shared + * comparand-shape face (`assertListComparandShapes`, `@objectstack/spec/data`). + * + * ## Measured on `origin/main` `b94a2a727`, through these doors + * + * Three rows (`note` 'b' / 'm' / 'y', `amount` 5 / 12 / 30), counted by the + * cube read (`AnalyticsService.query`, what `POST /api/v1/analytics/query` + * relays) and the dataset door (`AnalyticsService.queryDataset`, what + * `POST /api/v1/analytics/dataset/query` relays, its `runtimeFilter` merged + * into the `where`), each on two faces of the plugin's own composition: the + * native strategy and the ObjectQL strategy (`engine.aggregate`). SQLite and + * PostgreSQL 16.14 answered alike: + * + * | filter | engine-aggregate face | native face | `engine.find` | + * |:--|:--|:--|:--| + * | `{ amount: { $gt: [10, 99] } }` | **200, 2** (the driver received `$gt: 10`) | 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'] } }` | **200, 3** (`$gt: 'a'`) | **200, 3** (bound `'a'`) | 400, `driver-sql`'s own bind refusal | + * | `[['note', '>', ['a', 'z']]]` (the FilterArray spelling) | **200, 3** | **200, 3** | 400, the driver'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 | the same | 400, the driver's | + * + * The shared analytics lowering carried the operator's whole list into one + * leaf, and the leaf compilers read its first member. The engine door did not + * bind the first member: on `driver-sql` it refused the text cell in the + * driver's own words, so the engine door's answer was right and only its + * wording was per driver. The face's new arm answers every row above, so each + * cell is one refusal, the same message on both faces. + * + * The lowering gained no rule: `lowerAnalyticsWhere` already hands every field + * entry of a `where` to the shared face (`assertWhereComparandShapes`, #20010) + * before any leaf exists, so the arm reaches it with no change here. + * + * The PostgreSQL cells run where `OS_TEST_POSTGRES_URL` is set and are a named + * skip otherwise; no CI step provisions that variable for this package. The + * live cell owns its table, dropped before and after. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { Cube } from '@objectstack/spec/data'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { AnalyticsService } from '../analytics-service.js'; +import { AnalyticsServicePlugin } from '../plugin.js'; + +const OBJECT = 'os21448_list_ledger'; + +const LEDGER = { + name: OBJECT, + label: 'List at a scalar operator ledger', + fields: { + note: { name: 'note', type: 'text' as const }, + amount: { name: 'amount', type: 'number' as const }, + }, +}; + +const ROWS = [ + { id: 'r5', note: 'b', amount: 5 }, + { id: 'r12', note: 'm', amount: 12 }, + { id: 'r30', note: 'y', amount: 30 }, +] as const; + +const CUBE: Cube = { + name: 'os21448_list_cube', + title: 'List at a scalar operator cube', + sql: OBJECT, + public: true, + measures: { row_count: { type: 'count', sql: '*', label: 'Rows' } }, + dimensions: { + note: { type: 'string', sql: 'note', label: 'Note' }, + amount: { type: 'number', sql: 'amount', label: 'Amount' }, + }, +} as Cube; + +/** The inline dataset the dataset door carries. */ +const INLINE = { + name: 'os21448_list_inline', + label: 'List at a scalar operator inline dataset', + object: OBJECT, + dimensions: [{ name: 'note', field: 'note', type: 'string' }], + measures: [{ name: 'row_count', aggregate: 'count' }], +}; + +/** A registered dataset: its own scope and its measure's filter are positions under test. */ +const registeredDataset = (name: string, filter: unknown, measureFilter: unknown) => ({ + name, + label: name, + object: OBJECT, + ...(filter === undefined ? {} : { filter }), + dimensions: [{ name: 'note', field: 'note', type: 'string' }], + measures: [ + { name: 'row_count', aggregate: 'count' }, + ...(measureFilter === undefined ? [] : [{ name: 'scoped_count', aggregate: 'count', filter: measureFilter }]), + ], +}); + +const REGISTERED_REFUSED = [ + registeredDataset('os21448_list_scope', { note: { $gt: ['a', 'z'] } }, undefined), + registeredDataset('os21448_list_measure', undefined, { amount: { $lt: [20, 1] } }), +] as const; + +type Refusal = { code: string; status: number; message: string }; +type Answer = number | Refusal; +type Face = 'native' | 'engine'; + +/** Each refused filter, and the operator and field the face must name. */ +const REFUSED_CELLS: ReadonlyArray = [ + // The card's cells. + [{ amount: { $gt: [10, 99] } }, '$gt', 'amount', 'a pair on a number column'], + [{ amount: { $gt: [10] } }, '$gt', 'amount', 'one member on a number column'], + [{ note: { $gt: ['a', 'z'] } }, '$gt', 'note', 'a pair on a text column'], + [{ note: { $eq: ['b'] } }, '$eq', 'note', '$eq on a text column (the equality arm, unchanged)'], + [{ note: { $ne: ['b'] } }, '$ne', 'note', '$ne on a text column (the $ne arm, unchanged)'], + // The rest of the scalar set, every column type. + [{ amount: { $lte: [12, 1] } }, '$lte', 'amount', '$lte on a number column'], + [{ note: { $gte: ['m'] } }, '$gte', 'note', '$gte on a text column'], + [{ note: { $lt: [] } }, '$lt', 'note', 'an EMPTY list — still a list in a one-value slot'], + [{ note: { $contains: ['b', 'm'] } }, '$contains', 'note', 'a text operator'], + [{ note: { $startsWith: ['b'] } }, '$startsWith', 'note', 'another text operator'], + // Every depth, and the FilterArray spelling. + [{ $or: [{ note: { $gt: ['a', 'z'] } }, { amount: 5 }] }, '$gt', 'note', 'under $or'], + [{ $not: { note: { $lt: ['m'] } } }, '$lt', 'note', 'under $not'], + [[['note', '>', ['a', 'z']]], '$gt', 'note', 'the FilterArray spelling, ">"'], + [[['amount', 'gte', [10, 99]]], '$gte', 'amount', 'the FilterArray spelling, "gte"'], +]; + +/** The controls the ruling names, and the scalar neighbours: each face counts alike. */ +const CONTROL_CELLS: ReadonlyArray = [ + [{ note: { $in: ['b'] } }, 1, 'a list at $in'], + [{ note: { $nin: ['b'] } }, 2, 'a list at $nin'], + [{ amount: { $gt: 10 } }, 2, 'a scalar at $gt'], + [{ note: { $gt: 'a' } }, 3, 'a scalar at $gt on a text column'], + [{ amount: { $between: [10, 40] } }, 2, 'a range'], +]; + +/** The leading sentence every one of these refusals carries — the face's, `driver-memory`'s for this condition. */ +const faceSentence = (op: string, field: string) => + `Operator "${op}" on field "${field}" requires a single comparable value, but received an array`; + +interface Cell { + id: 'sqlite' | 'pg'; + label: string; + env: string | null; + config: () => Record | null; +} + +const DRIVER_CELLS: readonly Cell[] = [ + { id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, + { + id: 'pg', + label: 'live postgres', + env: 'OS_TEST_POSTGRES_URL', + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), + }, +]; + +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +for (const cell of DRIVER_CELLS) { + const config = cell.config(); + describe.skipIf(!config)( + `[#21448] a list at a scalar operator answers one 400 on both analytics faces (${cell.label})${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + () => { + let driver: any; + let engine: ObjectQL; + /** Raw-SQL statements, engine aggregates and driver reads of THIS object. */ + const reads = { rawSql: 0, aggregate: 0, driver: 0 }; + const services: Partial> = {}; + + const dropTable = async () => { + if (cell.id !== 'pg') return; + await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + }; + + /** The total count a face answers, or its refusal envelope, and what read the object. */ + const ask = async (face: Face, run: (svc: AnalyticsService) => Promise<{ rows: unknown[] }>) => { + const before = { ...reads }; + const answer = await run(services[face]!).then( + (res) => (res.rows as Array>).reduce((sum, r) => sum + Number(r.row_count ?? 0), 0) as Answer, + (e: Error & { code?: string; status?: number }) => ({ code: String(e.code), status: Number(e.status), message: e.message }) as Answer, + ); + return { + answer, + rawSql: reads.rawSql - before.rawSql, + aggregate: reads.aggregate - before.aggregate, + driver: reads.driver - before.driver, + }; + }; + + const cubeRead = (where: unknown) => (svc: AnalyticsService) => + svc.query({ cube: CUBE.name, measures: ['row_count'], where: structuredClone(where) } as never) as Promise<{ rows: unknown[] }>; + const datasetRead = (runtimeFilter: unknown) => (svc: AnalyticsService) => + svc.queryDataset(INLINE as never, { measures: ['row_count'], dimensions: ['note'], runtimeFilter: structuredClone(runtimeFilter) } as never) as Promise<{ rows: unknown[] }>; + + /** Both faces at one door: one refusal, the same words on each, and nothing read. */ + const expectOneRefusal = async (door: typeof cubeRead, filter: unknown, op: string, field: string) => { + const messages: string[] = []; + for (const face of ['engine', 'native'] as const) { + const asked = await ask(face, door(filter)); + const answer = asked.answer as Refusal; + expect(typeof asked.answer, `${face}: refused, not answered`).toBe('object'); + expect({ code: answer.code, status: answer.status }, face).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(answer.message, face).toContain(faceSentence(op, field)); + expect(asked.rawSql, `${face}: no raw statement ran`).toBe(0); + expect(asked.aggregate, `${face}: no engine aggregate read the object`).toBe(0); + expect(asked.driver, `${face}: the driver read nothing`).toBe(0); + messages.push(answer.message); + } + // One verdict: the two faces answer the same words. + expect(messages[0], 'the engine face and the native face answer one message').toBe(messages[1]); + }; + + /** Both faces at one door, counting alike — and which strategy answered. */ + const expectBothCount = async (door: typeof cubeRead, filter: unknown, count: number) => { + const viaEngine = await ask('engine', door(filter)); + expect(viaEngine.answer, 'the engine face').toBe(count); + expect(viaEngine.rawSql, 'the engine face ran no raw statement').toBe(0); + const viaNative = await ask('native', door(filter)); + expect(viaNative.answer, 'the native face').toBe(count); + expect(viaNative.rawSql, 'NativeSQLStrategy answered').toBeGreaterThanOrEqual(1); + expect(viaNative.aggregate, 'no engine aggregate on the native face').toBe(0); + }; + + beforeAll(async () => { + driver = new SqlDriver(config as any); + await dropTable(); + engine = new ObjectQL({ logger: quiet } as any); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(LEDGER as any); + await engine.syncSchemas(); + for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); + + const realExecute = (engine as any).execute.bind(engine); + (engine as any).execute = (sql: unknown, opts?: { object?: string; args?: unknown[] }) => { + if (opts?.object === OBJECT) reads.rawSql += 1; + return realExecute(sql, opts); + }; + const realAggregate = engine.aggregate.bind(engine); + (engine as any).aggregate = (object: string, ...rest: unknown[]) => { + if (object === OBJECT) reads.aggregate += 1; + return (realAggregate as any)(object, ...rest); + }; + for (const verb of ['find', 'aggregate', 'count'] as const) { + const real = driver[verb].bind(driver); + driver[verb] = (object: string, ...rest: unknown[]) => { + if (object === OBJECT) reads.driver += 1; + return real(object, ...rest); + }; + } + + // The plugin's own composition over the real engine: both auto-bridges + // (`native`), and the same narrowed to the engine-aggregate path (`engine`). + for (const [face, caps] of [ + ['native', undefined], + ['engine', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })], + ] as const) { + const registered: Record = {}; + await new AnalyticsServicePlugin({ cubes: [CUBE], ...(caps ? { queryCapabilities: caps } : {}) } as any).init({ + getService: (name: string) => (name === 'data' ? engine : registered[name]), + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + hook: () => {}, + logger: quiet, + } as never); + services[face] = registered.analytics as AnalyticsService; + for (const dataset of REGISTERED_REFUSED) services[face]!.registerDataset(dataset as never); + } + }); + + afterAll(async () => { + await dropTable(); + try { await engine?.destroy(); } catch { /* noop */ } + }); + + describe('the cube read — the `where` of POST /analytics/query', () => { + for (const [filter, op, field, label] of REFUSED_CELLS) { + it(`${JSON.stringify(filter)} (${label}) is one 400 on both faces`, async () => { + await expectOneRefusal(cubeRead, filter, op, field); + }); + } + for (const [filter, count, label] of CONTROL_CELLS) { + it(`CONTROL ${JSON.stringify(filter)} (${label}) counts ${count} on both faces`, async () => { + await expectBothCount(cubeRead, filter, count); + }); + } + }); + + describe('the dataset door — the `runtimeFilter` of POST /api/v1/analytics/dataset/query', () => { + for (const [filter, op, field, label] of REFUSED_CELLS) { + it(`${JSON.stringify(filter)} (${label}) is one 400 on both faces`, async () => { + await expectOneRefusal(datasetRead, filter, op, field); + }); + } + for (const [filter, count, label] of CONTROL_CELLS) { + it(`CONTROL ${JSON.stringify(filter)} (${label}) counts ${count} on both faces`, async () => { + await expectBothCount(datasetRead, filter, count); + }); + } + }); + + describe("a registered dataset's own scope and measure filter, read by the cube door", () => { + for (const dataset of REGISTERED_REFUSED) { + it(`${dataset.name} is refused on both faces, before any read`, async () => { + for (const face of ['engine', 'native'] as const) { + const asked = await ask(face, (svc) => + svc.query({ cube: dataset.name, measures: dataset.measures.map((m) => m.name) } as never) as Promise<{ rows: unknown[] }>); + const answer = asked.answer as Refusal; + expect({ code: answer.code, status: answer.status }, face).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(answer.message, face).toMatch(/requires a single comparable value, but received an array/); + expect(asked.rawSql + asked.aggregate + asked.driver, `${face}: nothing read the object`).toBe(0); + } + }); + } + + it('…and the save door refuses the same stored filter, in the same sentence less its location', () => { + for (const dataset of REGISTERED_REFUSED) { + const parsed = DatasetSchema.safeParse(dataset); + expect(parsed.success, dataset.name).toBe(false); + const messages = parsed.error!.issues.map((i) => i.message); + expect(messages.some((m) => /requires a single comparable value, but received an array/.test(m)), dataset.name).toBe(true); + expect(messages.some((m) => m.includes(' at where.')), dataset.name).toBe(false); + } + }); + }); + + describe('the engine door, measured directly (`engine.find`, the real ObjectQL)', () => { + it('the text cell is refused in the face\'s words now, not the driver\'s — and the driver reads nothing', async () => { + const before = reads.driver; + const err = await engine.find(OBJECT, { where: { note: { $gt: ['a', 'z'] } } } as never).then( + () => null, + (e: Error & { code?: string; status?: number }) => e, + ); + expect(err, 'refused').not.toBeNull(); + expect({ code: err!.code, status: err!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err!.message).toContain(`find('${OBJECT}'): ${faceSentence('$gt', 'note')}`); + expect(reads.driver - before, 'the driver read nothing').toBe(0); + }); + + it('CONTROL: a list at $in and a scalar at $gt are served', async () => { + expect((await engine.find(OBJECT, { where: { note: { $in: ['b', 'y'] } } } as never)).length).toBe(2); + expect((await engine.find(OBJECT, { where: { note: { $gt: 'a' } } } as never)).length).toBe(3); + }); + }); + }, + ); +} diff --git a/packages/services/service-analytics/src/__tests__/native-sql-number-comparand-door.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-number-comparand-door.test.ts index 43766765815..07c49fe96fe 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-number-comparand-door.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-number-comparand-door.test.ts @@ -37,20 +37,23 @@ * with it, and which strategy answered; every narrowing cell asserts the * native statement bound the numbers the engine handed its driver. * - * ## Two cells the engine face does not answer with this verdict + * ## One cell the engine face does not answer with this verdict * - * Pinned on the native face alone ({@link NATIVE_ONLY_CELLS}), each measured - * on both faces: + * Pinned on the native face alone ({@link NATIVE_ONLY_CELLS}), measured on + * both faces: a relationship-path member (`account_credit`, the cube dimension + * over `account.credit`). The engine face refuses every cross-object filter + * (`INVALID_FIELD` / 400, "cannot evaluate a cross-object filter"), so it never + * reaches a comparand. The native face joins, and judges the member at the + * related object's declared column. * - * - A relationship-path member (`account_credit`, the cube dimension over - * `account.credit`): the engine face refuses every cross-object filter - * (`INVALID_FIELD` / 400, "cannot evaluate a cross-object filter"), so it - * never reaches a comparand. The native face joins, and judges the member at - * the related object's declared column. - * - An array at an ordering operator (`$gt: [10]`): the shared analytics - * lowering hands the engine the list's first member, so the engine door - * never sees the array (the engine face answered 200, 2). The spec's verdict - * refuses a list where one number belongs, and the native face answers it. + * [#21448] A second cell stood here: an array at an ordering operator + * (`$gt: [10]`), which the shared analytics lowering handed the engine as its + * first member (the engine face answered 200, 2) while the native face refused + * it with this verdict. The shared comparand-shape face now refuses a list at + * every scalar operator, whatever the column type, before either face's + * verdict runs, so that cell is a both-faces pin in {@link CELLS}, answered in + * the face's words, and this verdict's `array` arm is no longer reached at a + * scalar operator. * * Each filter handed in is deep-frozen, and so is each registered dataset's * own `filter` and its measures' `filter`s: narrowing is copy-on-write, so an @@ -188,6 +191,11 @@ const CELLS: ReadonlyArray = [ [{ account_credit: 'abc' }, REFUSED, null, 'a relationship path, judged at the related object\'s number column'], [{ account_credit: { $gt: '100' } }, 2, [100], 'a relationship path, narrowed at the related object\'s number column'], - [{ amount: { $gt: [10] } }, REFUSED, null, 'a list where one number belongs'], ]; /** Narrowing cells: the native statement binds exactly the numbers the engine handed its driver. */ @@ -301,7 +308,9 @@ for (const cell of DRIVER_CELLS) { } else { expect(viaNative.rawSql, 'refused before any statement ran').toBe(0); const message = (viaNative.answer as { message?: string }).message ?? ''; - expect(message).toContain(`'${member}'`); + // The number verdict quotes the member '…'; the shared comparand-shape + // face, which answers a list first (#21448), quotes it "…". + expect([`'${member}'`, `"${member}"`].some((quoted) => message.includes(quoted)), message).toBe(true); expect(message).toContain('where'); } return viaNative; diff --git a/packages/services/service-analytics/src/__tests__/where-boolean-flag-refusal.test.ts b/packages/services/service-analytics/src/__tests__/where-boolean-flag-refusal.test.ts index 7bebe8b9927..641bdc7a561 100644 --- a/packages/services/service-analytics/src/__tests__/where-boolean-flag-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-boolean-flag-refusal.test.ts @@ -88,7 +88,9 @@ const NON_BOOLEAN: Array<[string, unknown]> = [ ['0', 0], ['1', 1], ['null', null], - ['[true]', [true]], + // [#21448] `[true]` left this table: a LIST at a flag is the shared + // comparand-shape face's refusal now, one face before this gate, in that + // face's words (the precedence block below pins it). ['{ $field }', { $field: 'id' }], ['a Date', new Date('2026-01-01T00:00:00.000Z')], ['2n', 2n], @@ -192,6 +194,17 @@ describe('[#20040] existing refusals keep their order and their sentence', () => } }); + it('[#21448] a LIST at a flag is the shared comparand-shape face\'s, in its words — how many values before which', () => { + for (const op of FLAGS) { + const list = refusalOf(() => tree({ stage: { [op]: [true] } })); + expect(list.code, op).toBe('INVALID_FILTER'); + expect(list.status, op).toBe(400); + expect(list.message, op).not.toContain('requires a boolean comparand'); + expect(list.message.startsWith(`Operator "${op}" on field "stage" requires a single comparable value`), op).toBe(true); + expect(list.message, op).toContain(`at where.stage.${op}.`); + } + }); + it('a shape or type defect elsewhere in the same where is answered first, as the faces order it', () => { const shape = refusalOf(() => tree({ amt: { $in: 'x' }, stage: { $null: 'x' } })); expect(shape.code).toBe('INVALID_FILTER'); diff --git a/packages/services/service-analytics/src/__tests__/where-empty-operator.test.ts b/packages/services/service-analytics/src/__tests__/where-empty-operator.test.ts index d35a48ae62c..23a03bf1491 100644 --- a/packages/services/service-analytics/src/__tests__/where-empty-operator.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-empty-operator.test.ts @@ -118,7 +118,7 @@ describe('[#20445] normalizeAnalyticsFilterTree — `$empty` lowers to its own v }); }); - for (const flag of ['true', 0, null, [true], new Date(0)]) { + for (const flag of ['true', 0, null, new Date(0)]) { it(`a non-boolean flag (${String(flag)}) is refused INVALID_FILTER / 400, with the null flags`, async () => { const err = await refusalOf(() => normalizeAnalyticsFilterTree({ where: { name: { $empty: flag } } }, NO_DATETIME_COLUMNS)); expect(err.code).toBe('INVALID_FILTER'); @@ -126,6 +126,13 @@ describe('[#20445] normalizeAnalyticsFilterTree — `$empty` lowers to its own v expect(err.message).toContain('Operator "$empty" on field "name" requires a boolean comparand'); }); } + + it('[#21448] a LIST flag ([true]) is refused INVALID_FILTER / 400 by the shared comparand-shape face first, in its words', async () => { + const err = await refusalOf(() => normalizeAnalyticsFilterTree({ where: { name: { $empty: [true] } } }, NO_DATETIME_COLUMNS)); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toMatch(/^Operator "\$empty" on field "name" requires a single comparable value/); + }); }); describe('[#20445] the `where` face on SQLite — native execute, and the ObjectQL echo run on the same rows', () => { diff --git a/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts b/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts index 1ce16ad9c95..c751d4605ec 100644 --- a/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts @@ -213,23 +213,35 @@ describe('[#20035] what the type face does not judge keeps this door\'s own sent // The face steps around arrays outside the list operators, `{ $field }` // references and unknown operators, so those positions reach the door's own // gates (#6386's `undefined` sweep, #5234's member and LIKE checks) exactly - // as before. + // as before. [#21448] Except an array at a SCALAR operator: the shared + // comparand-SHAPE face, which runs before this one, refuses that list as the + // list, so only an operator outside the vocabulary still carries an array + // to #6386's sweep. it('an undefined inside an ARRAY comparand, or under an unknown operator — #6386', () => { for (const [where, path] of [ - [{ d: { $contains: ['a', undefined] } }, '"d".$contains[1]'], + [{ d: { $wat: ['a', undefined] } }, '"d".$wat[1]'], [{ d: { $wat: undefined } }, '"d".$wat'], ] as const) { const err = refusalOf(() => tree(where)); expectEnvelope(err); expect(err.message).toContain(`[analytics] comparand at ${path} is undefined`); } + // [#21448] Under a declared scalar operator, the list is diagnosed as the + // list, by the shape face, before any member is read. + const list = refusalOf(() => tree({ d: { $contains: ['a', undefined] } })); + expectEnvelope(list); + expect(list.message).toMatch(/^Operator "\$contains" on field "d" requires a single comparable value/); }); - it('an ARRAY or a { $field } member of $in, and the same as a LIKE comparand — #5234 / #7598', () => { + it('an ARRAY or a { $field } member of $in, and a { $field } LIKE comparand — #5234 / #7598', () => { expect(refusalOf(() => tree({ s: { $in: ['a', [1, 2]] } })).message).toContain('cannot be bound as a SQL parameter'); expect(refusalOf(() => tree({ s: { $in: [{ $field: 'other' }] } })).message).toContain('cannot be bound as a SQL parameter'); - expect(refusalOf(() => tree({ s: { $contains: ['a', 'b'] } })).message).toContain('StringOperatorSchema'); expect(refusalOf(() => tree({ s: { $contains: { $field: 'other' } } })).message).toContain('StringOperatorSchema'); + // [#21448] An ARRAY as a LIKE comparand is the shared comparand-shape + // face's now — a list at a scalar operator — so #5234's sentence no longer + // answers it from this door. + expect(refusalOf(() => tree({ s: { $contains: ['a', 'b'] } })).message) + .toMatch(/^Operator "\$contains" on field "s" requires a single comparable value/); }); }); diff --git a/packages/services/service-analytics/src/comparand-shape.ts b/packages/services/service-analytics/src/comparand-shape.ts index 4cc83d7bf44..0eee584e8c7 100644 --- a/packages/services/service-analytics/src/comparand-shape.ts +++ b/packages/services/service-analytics/src/comparand-shape.ts @@ -210,9 +210,11 @@ export function isBindableComparand(value: unknown): boolean { * type face now answers first, in its own sentence and at its own path, with * the same verdict and envelope. #6386's gate stays as `fieldLeaves`' * invariant and still answers the two positions the face steps around: an - * `undefined` inside an ARRAY comparand (`{d: {$contains: ['a', undefined]}}`) - * and the comparand of an operator outside the vocabulary (`{d: {$wat: - * undefined}}`). Either way, nothing reaches this predicate holding one. + * `undefined` inside an ARRAY comparand and the comparand of an operator + * outside the vocabulary (`{d: {$wat: undefined}}`). [#21448] The array arm is + * reached under an operator outside the vocabulary alone (`{d: {$wat: ['a', + * undefined]}}`): a list at a declared scalar operator is the shared + * comparand-SHAPE face's refusal first. Either way, nothing reaches this predicate holding one. * * So `comparand()`'s normalise-to-`null` is itself a deliberately-kept dead arm * (its own TSDoc says so, and says reopening it is #5526's call, not a @@ -530,7 +532,8 @@ export function shapePreview(value: unknown): string { * * [#20035] On the `where` door this sentence now answers only what the shared * comparand-TYPE face steps around — an ARRAY comparand and a `{ $field }` - * reference. A plain object, a `Map`, a binary or a class instance is refused + * reference. [#21448] And of those only the reference: an ARRAY there is a list + * at a scalar operator, which the shared comparand-SHAPE face refuses first. A plain object, a `Map`, a binary or a class instance is refused * there first by that face, in its own sentence. The read-scope lowering still * says this sentence for every object, because its own gates run before the * face (#20018). diff --git a/packages/services/service-analytics/src/strategies/filter-normalizer.ts b/packages/services/service-analytics/src/strategies/filter-normalizer.ts index 492d5ce73f1..cf47eb8f5ff 100644 --- a/packages/services/service-analytics/src/strategies/filter-normalizer.ts +++ b/packages/services/service-analytics/src/strategies/filter-normalizer.ts @@ -739,6 +739,9 @@ function andOf(children: NormalizedFilterNode[]): NormalizedFilterNode | null { * comparand position before this function runs (the #7872 ruling). From the * `where` door this gate now answers only what that face steps around — an * ARRAY and a `{ $field }` reference, as a list member or a LIKE comparand. + * [#21448] An ARRAY as a LIKE comparand is the shared comparand-SHAPE face's + * since, refused before this one as a list at a scalar operator; from the + * door, this gate's LIKE arm answers a `{ $field }` reference alone. * * ⚠️ [#7598, maintainer ruling 2026-08-12 Q1 = B] A THIRD arm briefly lived * here — a `{$field}` reference in the comparand of the six scalar comparison @@ -777,7 +780,9 @@ function assertCompilableComparand(opKey: string, field: string, value: unknown) // An array reaches this door as `values[0]` — i.e. every member after the // first is silently DROPPED — while `read-scope-sql` and `driver-sql` // stringify the whole array. That split is why an array is refused and not - // merely stringified consistently. + // merely stringified consistently. [#21448] From the `where` door the + // shared comparand-shape face refuses that list first (a list at a scalar + // operator), so this arm is `fieldLeaves`' invariant for it. if (!isRenderableTextComparand(value)) { throw invalidFilterError(`[analytics] ${unrenderableTextComparandMessage(opKey, field, value)}`); } @@ -940,8 +945,11 @@ function undefinedComparandError(field: string, path: string): Error { * operator's comparand, each `$in` / `$nin` member — in its own sentence and at * its own path (the #7872 ruling). This gate's sentence is reached only where * that face steps around: a member of an ARRAY comparand outside the list - * operators (`{d: {$contains: ['a', undefined]}}`) and the comparand of an - * operator outside the vocabulary (`{d: {$wat: undefined}}`). It stays as + * operators and the comparand of an operator outside the vocabulary + * (`{d: {$wat: undefined}}`). [#21448] The array arm is reached only under an + * operator outside the vocabulary (`{d: {$wat: ['a', undefined]}}`): the + * shared comparand-shape face refuses a list at every declared scalar + * operator first. It stays as * {@link fieldLeaves}' invariant, the same stance {@link assertCompilableComparand} * takes, and `where-type-face-refusal.test.ts` pins those two positions. * @@ -1297,6 +1305,11 @@ function fieldLeaves(key: string, raw: unknown): NormalizedFilterNode[] { // {@link assertCompilableComparand} for why this door and not the three // emitters downstream of it. assertCompilableComparand(opKey, key, v); + // [#21448] Only a LIST operator's array is spread into `values` here. + // The shared comparand-shape face refuses a list at every scalar + // operator, whatever the column type, and `lowerAnalyticsWhere` hands + // it every field entry before any leaf exists — so no scalar leaf is + // built from a list, and no compiler's `values[0]` read ever drops one. const values = Array.isArray(v) ? v.map(comparand) : [comparand(v)]; // [#5298] The operators that carry their own negation are NULL-safe, // here as everywhere else — see the module header's section on it. @@ -2038,9 +2051,12 @@ function assertNoListInEqualitySlot(node: unknown, path = 'where'): void { * * ⛔ Not moved: * - * - `$ne` with a list. The face does not judge it yet; #19886's stage 2 puts - * that refusal on the face, and this gate carries it the day it does, with no - * change here. + * - `$ne` with a list. The face did not judge it yet; #19886's stage 2 put + * that refusal on the face, and this gate carried it the day it did, with no + * change here. [#21448] The same held for a list at every other scalar + * operator (`$gt`, the text operators, the flags): the face's arm for it + * reached this door with no change here, so the lowering refuses the list + * before any leaf, and no scalar leaf is built from one. * - `$in: []` / `$nin: []`, every scalar ordering comparand, a `{ $field }` in * an ordering slot, and `null` in the equality slot (`{ f: null }`, * `$eq: null`, `$ne: null`, the null predicate) all compile as before. @@ -2277,7 +2293,10 @@ function assertBooleanNullFlags(node: unknown, path = 'where'): void { * positions the face does not judge: an array or a `{ $field }` reference as a * list member or a LIKE comparand (#5234 / #7598 wording), and an `undefined` * inside an array comparand or under an operator outside the vocabulary - * (#6386 wording). + * (#6386 wording). [#21448] Less one position: an ARRAY at a scalar operator — + * a LIKE comparand included — is the shape face's refusal now, so an array + * comparand reaches those gates only as a list member or under an operator + * outside the vocabulary. * * ## Binary is reconciled, not kept as a local extra * diff --git a/packages/spec/src/data/filter-boolean-comparand-declared-type.test.ts b/packages/spec/src/data/filter-boolean-comparand-declared-type.test.ts index 357caae74e2..edc2bed6319 100644 --- a/packages/spec/src/data/filter-boolean-comparand-declared-type.test.ts +++ b/packages/spec/src/data/filter-boolean-comparand-declared-type.test.ts @@ -343,9 +343,11 @@ describe('[#21333] BOOLEAN_COMPARAND_DOOR_CASES', () => { ...BOOLEAN_COMPARAND_DOOR_LIST_OPERATORS.flatMap((op) => [`f_boolean.${op}[0]`, `f_boolean.${op}[1]`])]; expect([...positionsOf('number')].sort()).toEqual([...judged].sort()); expect([...positionsOf('date')].sort()).toEqual([...judged].sort()); - // The array rows skip the equality slots, where the comparand-shape door speaks first. - const equality = new Set(['f_boolean', 'f_boolean.$eq', 'f_boolean.$ne']); - expect([...positionsOf('array')].sort()).toEqual(judged.filter((p) => !equality.has(p)).sort()); + // The array rows skip every one-value slot, where the comparand-shape door + // speaks first: the equality slots since #19757 / #19886, and [#21448] every + // other scalar operator since. They sit at the list members alone. + const listMember = (p: string) => /\[\d\]$/.test(p); + expect([...positionsOf('array')].sort()).toEqual(judged.filter(listMember).sort()); // Every judged field is refused a number; null and the non-boolean field's rows pass. expect(new Set(refused.filter((c) => c.comparand === -1).map((c) => c.key))) .toEqual(new Set(['f_boolean', 'f_toggle', 'f_formula_boolean'])); diff --git a/packages/spec/src/data/filter-boolean-comparand-declared-type.ts b/packages/spec/src/data/filter-boolean-comparand-declared-type.ts index 41a2437312d..303216bf8ad 100644 --- a/packages/spec/src/data/filter-boolean-comparand-declared-type.ts +++ b/packages/spec/src/data/filter-boolean-comparand-declared-type.ts @@ -107,7 +107,9 @@ * at an EQUALITY slot (implicit, `$eq`, `$ne`) is refused one door earlier * still, by the comparand-shape door, whose remedy is the one for that slot; * the verdict answers `door-refusal` for it too, and the case table places its - * array rows where the shape door does not speak. An object is the + * array rows where the shape door does not speak. [#21448] That door now + * refuses a list at every other scalar operator as well, whatever the column + * type, so the rows sit at the list members alone. An object is the * comparand-type door's refusal, as above: refused before this change, and * still refused, in that door's words. * @@ -685,11 +687,12 @@ const VALUE_DATE = new Date(Date.UTC(2026, 0, 1)); const VALUE_LIST: readonly boolean[] = [true]; /** - * The equality slots — implicit, `$eq`, `$ne` — where an array is the - * comparand-SHAPE door's refusal, one door before this one (module header). + * The one-value slots — implicit and every scalar operator — where an array is + * the comparand-SHAPE door's refusal, one door before this one (module + * header). [#21448] It was the equality slots alone until that door refused a + * list at every other scalar operator too. */ -const isEqualitySlot = (slot: Slot): boolean => - slot.kind === 'implicit' || (slot.kind === 'scalar' && (slot.op === '$eq' || slot.op === '$ne')); +const isOneValueSlot = (slot: Slot): boolean => slot.kind === 'implicit' || slot.kind === 'scalar'; /** * The cases, derived rather than hand-kept: @@ -707,7 +710,7 @@ const isEqualitySlot = (slot: Slot): boolean => * `{ $field }` reference on `f_boolean` pass. * 5. **The non-string comparands** ([#21382]) — `-1` at `$ne` on every judged * field; `2` and a `Date` at every judged position on `f_boolean`, and an - * array at every one but the equality slots (the shape door's); all + * array at every list member (a one-value slot is the shape door's); all * refused. Beside them, what passes: `null` as the null test, and a number * or a `Date` against a field that is not boolean — not this door's subject. */ @@ -734,7 +737,7 @@ export const BOOLEAN_COMPARAND_DOOR_CASES: readonly BooleanComparandDoorCase[] = ...JUDGED_SLOTS.flatMap((slot) => [ caseFor('value', fixtureField('f_boolean'), slot, 2), caseFor('value', fixtureField('f_boolean'), slot, VALUE_DATE), - ...(isEqualitySlot(slot) ? [] : [caseFor('value', fixtureField('f_boolean'), slot, VALUE_LIST)]), + ...(isOneValueSlot(slot) ? [] : [caseFor('value', fixtureField('f_boolean'), slot, VALUE_LIST)]), ]), caseFor('value', fixtureField('f_boolean'), { kind: 'implicit' }, null), caseFor('value', fixtureField('f_boolean'), { kind: 'scalar', op: '$ne' }, null), diff --git a/packages/spec/src/data/filter-comparand-operators.ts b/packages/spec/src/data/filter-comparand-operators.ts new file mode 100644 index 00000000000..76341f6a45e --- /dev/null +++ b/packages/spec/src/data/filter-comparand-operators.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The declared field-operator vocabulary, split by what the comparand IS: ONE + * literal, or a LIST. One split, read by every face that judges a comparand by + * its operator: + * + * - the comparand-TYPE face (`./filter-comparand-type.ts`, #7872) judges a + * scalar operator's comparand as one literal, and each member of a list + * operator's array as a literal in its own right; + * - [#21448] the comparand-SHAPE face (`./filter-comparand-shape.ts`) refuses a + * LIST at a scalar operator, whatever the column type, because one value + * belongs there; + * - the save door (`./filter-save-door-refusals.ts`) words that refusal. + * + * The split lived in the type face alone until the shape face needed it too. + * It moved here so the faces read ONE split rather than two copies of it. + * `filter-comparand-type.test.ts` reconciles the union of the two sets against + * `FieldOperatorsSchema`'s own keys, so an operator added to the schema cannot + * silently skip either face. `filter-comparand-shape.test.ts` holds the scalar + * set to the schema's other half: every declared operator whose enforced slot + * refuses an array. + * + * ## Why it is NOT in the `data` barrel + * + * Nothing here is a contract a consumer calls; the faces are, and they are + * already published (`normalizeFilterComparandTypes`, + * `assertListComparandShapes`). Exporting the split would widen + * `@objectstack/spec/data` for no reader, so this file stays out of + * `./index.ts`, as `./filter-comparand-refusal-text.ts` does. It imports + * nothing, so no face can reach a cycle through it. + */ + +/** The operators whose declared comparand is ONE literal. */ +export const SCALAR_COMPARAND_OPERATORS: ReadonlySet = new Set([ + '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', + '$contains', '$notContains', '$startsWith', '$endsWith', '$icontains', + '$like', '$ilike', + '$null', '$exists', '$empty', +]); + +/** The operators whose declared comparand is a LIST. */ +export const LIST_COMPARAND_OPERATORS: ReadonlySet = new Set([ + '$in', '$nin', '$between', +]); diff --git a/packages/spec/src/data/filter-comparand-refusal-text.ts b/packages/spec/src/data/filter-comparand-refusal-text.ts index bbf0ced3b08..aa83e1fafe7 100644 --- a/packages/spec/src/data/filter-comparand-refusal-text.ts +++ b/packages/spec/src/data/filter-comparand-refusal-text.ts @@ -21,6 +21,10 @@ * `FieldOperatorsSchema.$ne` with "one remedy text", so that sentence is * assembled here too, by {@link arrayInequalityComparandMessage}. * + * [#21448] Every other scalar slot is the third, by + * {@link arrayScalarComparandMessage}: the face refuses a list there and the + * save door words the same refusal, so both import it from here. + * * ## Why it is NOT in the `data` barrel * * 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']; */ export const NIN_OPERATOR_SPELLINGS: readonly string[] = ['nin', 'not_in', 'notin']; +/** + * [#21448] The authoring spellings that lower to `$between`. + * + * `$between` is the range operator the scalar-slot refusal prescribes beside + * `$in`. So, as with {@link IN_OPERATOR_SPELLINGS}, the face's + * `LIST_COMPARAND_OPERATORS` reads its `$between` row from here, and the + * refusal of a malformed `$between` and the scalar-slot refusal name one + * spelling list. `filter-comparand-shape.test.ts` reconciles the list against + * `AST_OPERATOR_MAP`. + */ +export const BETWEEN_OPERATOR_SPELLINGS: readonly string[] = ['between']; + /** * [#19757] The ARRAY-CONTAINMENT operator the equality-slot refusal prescribes, * with its authoring spelling: `$contains`, which `FieldOperatorsSchema` @@ -212,16 +228,69 @@ export function arrayInequalityComparandMessage( ); } +/** + * [#21448] The ONE remedy for a list at any other scalar operator — an ordering + * operator, a text operator or a flag: write the one value, and the two list + * operators an author holding a list may have meant, by their spec spellings + * and their authoring spellings. Then the sentence a caller cannot infer from a + * status code. + * + * - `$in` — "one of these values" (authoring spelling `in`). + * - `$between` — a range (authoring spelling `between`), what a two-member + * list under an ordering operator most often stands for. + * + * Written like {@link ARRAY_EQUALITY_COMPARAND_REMEDY}: no field wrapper and + * `…` for the value, because the field is the subject of the sentence in front + * of this one, and the 500-char client bound buys more as prescription. + */ +export const ARRAY_SCALAR_COMPARAND_REMEDY: string = + 'Write ONE value. ' + + `For "one of these values" use {"$in": […]} (authoring: ${IN_OPERATOR_SPELLINGS.join(', ')}); ` + + `for a range, {"$between": [min, max]} (authoring: ${BETWEEN_OPERATOR_SPELLINGS.join(', ')}). ` + + `The filter was NOT applied, and an unapplied filter would have returned the ` + + `UNFILTERED result set.`; + +/** + * [#21448] Where the refused list sits, as far as the calling door can see. The + * operator is always known: this refusal exists only under an operator. The + * face passes `field` and `path`; the save door passes `field` alone, because + * its issue carries the location as its own `path`. + */ +export type ArrayScalarComparandSite = { readonly field?: string; readonly path?: string }; + +/** + * [#21448] The refusal of a LIST at a scalar operator other than `$eq` / `$ne` + * (each of which has its own sentence and remedy, above), as the doors print + * it — the shared comparand-shape face and the save door. + * + * The leading sentence is `driver-memory`'s `arrayComparandError` for the same + * operator, word for word, so one condition keeps one wording across packages + * (#5240's rule). Then the received list, bounded by {@link shapePreview}, the + * location when the door knows it, and {@link ARRAY_SCALAR_COMPARAND_REMEDY}. + */ +export function arrayScalarComparandMessage( + op: string, + value: unknown, + site: ArrayScalarComparandSite, +): string { + return singleValueSlotMessage( + operatorPosition(op, site.field), + value, + site.path, + ARRAY_SCALAR_COMPARAND_REMEDY, + ); +} + /** `Operator "$op"`, plus ` on field "f"` when the door can see the field. */ function operatorPosition(op: string, field: string | undefined): string { return `Operator "${op}"${field === undefined ? '' : ` on field "${field}"`}`; } /** - * The sentence both single-value-slot refusals share: the position, the words + * The sentence every single-value-slot refusal shares: the position, the words * `driver-memory` uses for this condition, the received list, the location and - * the slot's own remedy. One template, so the `$eq` and `$ne` refusals cannot - * drift apart in their first sentence. + * the slot's own remedy. One template, so the `$eq`, `$ne` and [#21448] other + * scalar-slot refusals cannot drift apart in their first sentence. */ function singleValueSlotMessage( position: string, diff --git a/packages/spec/src/data/filter-comparand-shape.test.ts b/packages/spec/src/data/filter-comparand-shape.test.ts index 380c6bbc7be..e7a147d7f33 100644 --- a/packages/spec/src/data/filter-comparand-shape.test.ts +++ b/packages/spec/src/data/filter-comparand-shape.test.ts @@ -47,16 +47,27 @@ const refusalOf = (run: () => unknown): Refusal => { * trips the door it is used to find. The EQUALITY spellings do refuse it, since * the 2026-09-23 arm (#19757) — and they answer `undefined` either way: before * that arm they lowered it to the implicit form, which carries no `$` key. - * [#19886] The `$ne` spellings refuse it too, since ruling A, so they answer - * `undefined` here now; the `$ne` pins below find their spellings with a - * SCALAR probe instead. + * [#19886] The `$ne` spellings refuse it too, since ruling A; the `$ne` pins + * below find their spellings with a SCALAR probe instead. + * [#21448] So does every other scalar spelling now, ordering and text alike. + * A spelling that refuses the list probe is therefore asked again with ONE + * value, which every scalar operator accepts and every list operator refuses, + * so each spelling still lowers through exactly one of the two probes. The + * equality spellings still answer `undefined`: one value lowers them to the + * implicit form, which carries no `$` key. */ const loweredOperatorOf = (op: string): string | undefined => { + const lowerWith = (probe: unknown): Record | undefined => + parseFilterAST([['probe', op, probe]]) as Record | undefined; let lowered: Record | undefined; try { - lowered = parseFilterAST([['probe', op, ['a', 'b']]]) as Record | undefined; + lowered = lowerWith(['a', 'b']); } catch { - return undefined; + try { + lowered = lowerWith('a'); + } catch { + return undefined; + } } const spec = lowered?.probe; if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return undefined; @@ -916,3 +927,158 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922 expect(parseFilterAST(where)).toBe(where); }); }); + +// ── the one-value arm for every other scalar operator (#21448) ───────────── +// +// The triage ruling (record 5958292323): "A list at a scalar operator (`$gt`, +// `$gte`, `$lt`, `$lte`, `$eq`, `$ne` and the rest of the scalar set) is a +// shape error, whatever the column type." `$eq` and `$ne` keep their own arms +// and remedies (above). Before this arm every row below RETURNED from this +// face (measured on `origin/main` `b94a2a727`, and again by this PR's +// ablation), and the analytics lowering bound the list's first member. + +describe('[#21448] a LIST at every other scalar operator is refused — whatever the column type', () => { + /** The declared vocabulary, read off the enforced operator schema. */ + const declared = Object.keys(FieldOperatorsSchema.shape); + /** Its list half: the operators whose enforced slot ACCEPTS an array. */ + const arrayValued = declared.filter((op) => FieldOperatorsSchema.safeParse({ [op]: ['a', 'b'] }).success); + /** The arm's operators: the one-value half, less the equality pair and its own two arms. */ + const oneValueOperators = declared.filter((op) => !arrayValued.includes(op) && op !== '$eq' && op !== '$ne'); + + it('the judged operators are the schema\'s one-value half, derived — a new scalar operator joins by itself', () => { + // Guards the loops below from passing vacuously, and names what the + // derivation finds today: the ordering, text and flag operators. + expect(arrayValued.sort()).toEqual(['$between', '$in', '$nin']); + expect([...oneValueOperators].sort()).toEqual([ + '$contains', '$empty', '$endsWith', '$exists', '$gt', '$gte', '$icontains', '$ilike', + '$like', '$lt', '$lte', '$notContains', '$null', '$startsWith', + ]); + }); + + it.each([ + ['a pair', [10, 99]], + ['one member', [10]], + ['two strings', ['a', 'z']], + ['an EMPTY list — still a list in a one-value slot', []], + ])('refuses %s at every one of them, in the envelope, naming the operator and the path', (_label, list) => { + for (const op of oneValueOperators) { + const err = refusalOf(() => assertListComparandShapes({ f: { [op]: list } })); + // ADR-0112 class 1, both halves. + expect(err.code, op).toBe(StandardErrorCode.enum.INVALID_FILTER); + expect(err.status, op).toBe(400); + expect(err.message, op).toMatch( + new RegExp(`^Operator "\\${op}" on field "f" requires a single comparable value, but received an array `), + ); + expect(err.message, op).toContain(`at where.f.${op}.`); + } + }); + + it.each([ + ['nested under $and', { $and: [{ g: 1 }, { f: { $gt: [1] } }] }, 'where.$and[1].f.$gt'], + ['nested under $or', { $or: [{ g: 1 }, { f: { $contains: ['a'] } }] }, 'where.$or[1].f.$contains'], + ['nested under $not', { $not: { f: { $lte: [1, 2] } } }, 'where.$not.f.$lte'], + ['beside a legal operator on the same field', { f: { $gte: 1, $lt: [9] } }, 'where.f.$lt'], + ])('refuses it at its own path — %s', (_label, where, path) => { + const err = refusalOf(() => parseFilterAST(where)); + expect(err.code).toBe(StandardErrorCode.enum.INVALID_FILTER); + expect(err.status).toBe(400); + expect(err.message).toContain(`at ${path}.`); + }); + + it('every AST spelling that carries its value to one of these operators refuses a list — the FilterArray spelling', () => { + // Derived by LOWERING one value: a spelling carries its value when + // `[f, op, 'a']` lowers to `{ f: { $op: 'a' } }` with `$op` in the arm's + // set. (`is_null` and friends lower to a hard-coded flag and carry no value, + // so they are not this loop's.) + const carrying = [...VALID_AST_OPERATORS].filter((op) => { + try { + const spec = (parseFilterAST([['f', op, 'a']]) as Record | undefined)?.f; + if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return false; + const [key, value] = Object.entries(spec as Record)[0] ?? []; + return key !== undefined && oneValueOperators.includes(key) && value === 'a'; + } catch { + return false; + } + }); + // Guards the loop: the twenty ordering spellings and the text spellings. + expect(carrying).toEqual(expect.arrayContaining(['>', 'gt', 'after', '<=', 'before', 'contains', 'starts_with', 'like'])); + for (const op of carrying) { + const err = refusalOf(() => parseFilterAST([['f', op, ['a', 'z']]])); + expect(err.code, op).toBe(StandardErrorCode.enum.INVALID_FILTER); + expect(err.status, op).toBe(400); + expect(err.message, op).toMatch(/^Operator "\$[A-Za-z]+" on field "f" requires a single comparable value/); + } + }); + + it('names the list it received, and prescribes ONE value, $in and $between by their spec and authoring spellings', () => { + const err = refusalOf(() => parseFilterAST({ amount: { $gt: [10, 99] } })); + // The leading sentence is driver-memory's own for this condition. + expect(err.message).toMatch( + /^Operator "\$gt" on field "amount" requires a single comparable value, but received an array \(\[10,99\]\) at where\.amount\.\$gt\. Write ONE value\. /, + ); + expect(err.message).toContain('"one of these values" use {"$in": […]} (authoring: in)'); + expect(err.message).toContain('for a range, {"$between": [min, max]} (authoring: between)'); + expect(err.message).toMatch(/The filter was NOT applied, .*UNFILTERED result set\.$/); + // The two prescribed operators are DECLARED, and each authoring spelling + // lowers to its `$` spelling through the one AST table. + expect(declared).toEqual(expect.arrayContaining(['$in', '$between'])); + expect(loweredOperatorOf('in')).toBe('$in'); + expect(loweredOperatorOf('between')).toBe('$between'); + // A caller-supplied context keeps its prefix, as on every sibling arm. + expect(refusalOf(() => assertListComparandShapes({ amount: { $gt: [10] } }, "find('deal')")).message) + .toMatch(/^find\('deal'\): Operator "\$gt" on field "amount"/); + }); + + it('a list at a FLAG is this arm\'s too; a non-boolean scalar flag is still not this face\'s', () => { + // How many values comes before which value: the boolean rule (#5347 / + // #5369) is downstream of this face on every door and keeps judging a + // scalar flag. + expect(refusalOf(() => parseFilterAST({ deleted_at: { $null: [true] } })).message) + .toMatch(/^Operator "\$null" on field "deleted_at" requires a single comparable value/); + expect(parseFilterAST({ deleted_at: { $null: 'x' } })).toEqual({ deleted_at: { $null: 'x' } }); + }); + + it('LIT CONTROL — the list operators keep their lists, one value passes, and the other arms keep their words', () => { + // The controls the ruling names: a list at `$in` / `$nin`, a scalar at `$gt`. + expect(parseFilterAST({ s: { $in: ['a', 'b'] } })).toEqual({ s: { $in: ['a', 'b'] } }); + expect(parseFilterAST({ s: { $nin: ['a'] } })).toEqual({ s: { $nin: ['a'] } }); + expect(parseFilterAST({ s: { $in: [] } })).toEqual({ s: { $in: [] } }); + expect(parseFilterAST({ n: { $between: [1, 5] } })).toEqual({ n: { $between: [1, 5] } }); + expect(parseFilterAST({ n: { $gt: 10 } })).toEqual({ n: { $gt: 10 } }); + // Every one-value comparand the vocabulary declares keeps passing. + const day = new Date('2026-07-01T00:00:00.000Z'); + for (const where of [ + { n: { $gte: 'a' } }, { n: { $lt: day } }, { n: { $lte: { $field: 'm' } } }, + { s: { $contains: 'a' } }, { s: { $like: 'a%' } }, { s: { $exists: false } }, { s: { $empty: true } }, + ]) { + expect(parseFilterAST(where), JSON.stringify(where)).toEqual(where); + } + // The older arms answer first, in their own words. + expect(refusalOf(() => parseFilterAST({ n: { $gt: null } })).message) + .toContain('does not accept a null comparand'); + expect(refusalOf(() => parseFilterAST({ s: { $eq: ['a'] } })).message).toContain('{"$contains": "…"}'); + expect(refusalOf(() => parseFilterAST({ s: { $ne: ['a'] } })).message).toContain('{"$nin": […]}'); + // Not judged here: an operator outside the vocabulary (refused downstream, + // by name), a nested list inside `$in`, and a no-`$`-key field spec. + expect(parseFilterAST({ s: { $wat: ['a'] } })).toEqual({ s: { $wat: ['a'] } }); + expect(parseFilterAST({ s: { $in: [['a']] } })).toEqual({ s: { $in: [['a']] } }); + expect(parseFilterAST({ acct: { amount: { $gt: [1] } } })).toEqual({ acct: { amount: { $gt: [1] } } }); + }); + + it('the whole refusal fits under the 500-char client bound (#5423)', () => { + // The sibling arms' bound test above, on this arm: one prescription pair + // plus the received list, the long list cut at the shared 60-char preview + // bound, at every one of its operators, under the same context prefix. + for (const op of oneValueOperators) { + for (const where of [ + { close_date: { [op]: ['a'] } }, + { close_date: { [op]: ['aaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccc'] } }, + { $not: { $or: [{ close_date: { [op]: [] } }] } }, + ]) { + const err = refusalOf(() => parseFilterAST(where, "find('deal')")); + expect(err.message.length, `${op} ${JSON.stringify(where)}`).toBeLessThan(500); + expect(err.message, `${op} ${JSON.stringify(where)}`).toMatch(/UNFILTERED result set\.$/); + } + } + }); +}); diff --git a/packages/spec/src/data/filter-comparand-shape.ts b/packages/spec/src/data/filter-comparand-shape.ts index 2c903708ac5..00f107262ab 100644 --- a/packages/spec/src/data/filter-comparand-shape.ts +++ b/packages/spec/src/data/filter-comparand-shape.ts @@ -12,8 +12,13 @@ * is the 2026-09-23 ruling's, recorded in its own section below. * * [#19886] And the same question for equality's negation: whether a `$ne` - * comparand received ONE value rather than a list. Ruling A's, recorded in the - * last "Refused BY RULING" section. + * comparand received ONE value rather than a list. Ruling A's, recorded in its + * own "Refused BY RULING" section. + * + * [#21448] And the same question for every other scalar operator — the + * ordering, text and flag operators: whether the comparand is ONE value rather + * than a list, whatever the column type. The 2026-10-02 ruling's, recorded in + * the last "Refused BY RULING" section. * * `FieldOperatorsSchema` (`./filter.zod.ts`) declares three operators whose * comparand is a LIST rather than a scalar: @@ -74,7 +79,7 @@ * delegating wrapper that supplies the engine's `find('deal')` context prefix; * there is exactly one implementation of "a list operator takes a list". * - * That sentence is true of THREE slots, and each is named because it was once + * That sentence is true of FOUR slots, and each is named because it was once * true of fewer: * * - **The list-operator slot** (`$in` / `$nin` / `$between`) — a scalar where a @@ -87,7 +92,12 @@ * array equality. That is the arm's section below. * - **The inequality slot** (`$ne`) — a list where one value belongs, since * ruling A on #19886 (2026-09-24). Until then this face passed it and each - * backend answered it alone. That is the last "Refused BY RULING" section. + * backend answered it alone. That is the sixth "Refused BY RULING" section. + * - **Every other scalar slot** (`$gt` / `$gte` / `$lt` / `$lte`, the text + * operators, the flags) — a list where one value belongs, since the + * 2026-10-02 ruling (#21448). Until then this face passed it, and the + * analytics lowering bound the list's FIRST member while the drivers each + * answered it alone. That is the last "Refused BY RULING" section. * * ## Both engine doors, because only one of them carries an array * @@ -268,9 +278,10 @@ * this ruling names implicit and explicit equality. It measured the same * split (refused on the SQL family and `driver-memory`, answered by * `driver-mongodb`), so it went to its own card and got its own ruling — - * the last section below, with its own remedy. The other scalar operators + * the sixth section, with its own remedy. The other scalar operators * (`$gt`, `$contains`, …) carrying an array are not this ruling's, and not - * that one's either. + * that one's either: they are the 2026-10-02 ruling's (#21448), the last + * section below, with a remedy of their own. * - **The list operators keep their lists**, `$in: []` / `$nin: []` included, * and every scalar — `null` above all, since `{ field: null }` and * `$eq: null` ARE the has-no-value predicate (#5332) — keeps passing. @@ -340,7 +351,10 @@ * reconciles the spellings against `FieldOperatorsSchema` and the AST table. * - ⛔ **Not the ordering operators, a nested array inside `$in`, or a * `{ $field }` referent to a multi-valued field.** Those are the same class, - * are recorded on the card, and are scoped separately. + * are recorded on the card, and are scoped separately. [#21448] The + * ordering operators are now the last section's, with every other scalar + * operator; a nested array inside `$in` and a `{ $field }` referent are still + * not judged here. * - **The leading sentence is `driver-memory`'s** `arrayComparandError` for * `$ne`, word for word, so one condition keeps one wording across packages. * @@ -350,6 +364,65 @@ * is assembled in `./filter-comparand-refusal-text.ts`, which both doors * import. * + * ## Refused BY RULING, 2026-10-02: a LIST at every other scalar operator (#21448) + * + * The triage ruling (record 5958292323): "A list at a scalar operator (`$gt`, + * `$gte`, `$lt`, `$lte`, `$eq`, `$ne` and the rest of the scalar set) is a shape + * error, whatever the column type." `$eq` and `$ne` had their arms already + * (the two sections above); this arm is the rest. It does not live in the + * number or boolean declared-type verdicts, because the column type does not + * decide it. + * + * The governing declaration is `FieldOperatorsSchema`: of the declared + * operators, only `$in`, `$nin` and `$between` accept an array at the schema + * door, and every other slot refuses one. This face passed the shape, and each + * consumer answered it alone. Measured on `b94a2a727`, SQLite and PostgreSQL + * 16.14 alike, through `POST /api/v1/analytics/query` and + * `/api/v1/analytics/dataset/query`, and at the engine's own `find`: + * + * | filter | analytics, engine-aggregate face | analytics, native face | `engine.find` | + * |:--|:--|:--|:--| + * | `{ amount: { $gt: [10, 99] } }` (number) | 200, the driver received `$gt: 10` | 400 (the number verdict) | 400 (the number verdict) | + * | `{ note: { $gt: ['a', 'z'] } }` (text) | 200, the driver received `$gt: 'a'` | 200, bound `'a'` | 400 (`driver-sql`'s own bind refusal) | + * + * The analytics lowering carried the operator's whole array into one leaf, and + * the leaf compilers read its FIRST member: the predicate the caller wrote was + * narrowed without a word, and the answer was a 200 with wrong rows. One + * position over, `driver-memory` refuses a list at the six comparisons but + * ANSWERS one at a text operator, while `driver-sql` refuses both in its own + * words. Refusing here makes every one of those cells unreachable through a + * platform door, and the analytics `where` door needs no rule of its own: it + * already hands every field entry to this face before any leaf exists. + * + * The scope: + * + * - **Every operator of the declared scalar half** (`SCALAR_COMPARAND_OPERATORS`, + * `./filter-comparand-operators.ts`, the split the comparand-TYPE face reads) + * other than `$eq` / `$ne`, whose arms answer first with their own remedies: + * the four ordering operators, the text operators (`$contains`, + * `$notContains`, `$startsWith`, `$endsWith`, `$icontains`, `$like`, + * `$ilike`) and the three flags. At every depth, the EMPTY list too: `[]` is a + * list in a one-value slot, and only `$in: []` / `$nin: []` are declared + * predicates. + * - **The flags** (`$null`, `$exists`, `$empty`). A list there was already + * refused on every query face by the boolean rule (#5347 / #5369), in that + * rule's words; it is now refused here first, in this arm's, because HOW MANY + * values comes before WHICH value. A non-boolean scalar flag keeps the + * boolean rule's sentence. + * - **Not judged:** the list operators keep their lists; every scalar, a `Date` + * and a `{ $field }` reference pass as before (a `null` ordering comparand + * keeps its own arm above); an operator outside the vocabulary (`$wat`, + * retired `$regex`) is not in the set and keeps its own refusal downstream. + * - **The remedy** is ONE value, or the two list operators an author holding a + * list may have meant, by their spec and authoring spellings: `$in` for "one + * of these values" and `$between` for a range. The leading sentence is + * `driver-memory`'s `arrayComparandError`, word for word. + * + * The sentence is assembled in `./filter-comparand-refusal-text.ts`, and the + * save door (`./filter-save-door-refusals.ts`) prints it without the location, + * so a stored filter carrying the shape is refused on save the day this face + * refuses it on query. + * * ## Refusal envelope * * Every refusal carries `code: 'INVALID_FILTER'` and `status: 400` (ADR-0112 @@ -363,15 +436,19 @@ * @see https://github.com/objectstack-ai/objectstack/issues/9228 (the move) * @see https://github.com/objectstack-ai/objectstack/issues/19757 (the equality-slot arm) * @see https://github.com/objectstack-ai/objectstack/issues/19886 (the `$ne` arm) + * @see https://github.com/objectstack-ai/objectstack/issues/21448 (the other scalar slots' arm) */ import { + BETWEEN_OPERATOR_SPELLINGS, IN_OPERATOR_SPELLINGS, NIN_OPERATOR_SPELLINGS, arrayEqualityComparandMessage, arrayInequalityComparandMessage, + arrayScalarComparandMessage, shapePreview, } from './filter-comparand-refusal-text'; +import { SCALAR_COMPARAND_OPERATORS } from './filter-comparand-operators'; /** * The operators whose comparand `FieldOperatorsSchema` declares as a list, with @@ -392,7 +469,8 @@ const LIST_COMPARAND_OPERATORS: ReadonlyMap = new Map ['$in', IN_OPERATOR_SPELLINGS], // [#19886] The `$nin` row likewise, for the operator the `$ne` refusal prescribes. ['$nin', NIN_OPERATOR_SPELLINGS], - ['$between', ['between']], + // [#21448] And the `$between` row, for the range the scalar-slot refusal prescribes. + ['$between', BETWEEN_OPERATOR_SPELLINGS], ]); /** @@ -768,21 +846,45 @@ function arrayInequalityComparandError( return invalidFilterComparandError(context, arrayInequalityComparandMessage(value, { field, path })); } +/** + * A LIST at any other scalar operator — refused BY RULING, 2026-10-02 (#21448); + * see the module note's seventh "Refused BY RULING" section. + * + * The sentence is `arrayScalarComparandMessage` + * (`./filter-comparand-refusal-text.ts`), which the save door prints too. Its + * leading sentence is `driver-memory`'s `arrayComparandError` for the same + * operator, word for word, and its remedy is ONE value, or the two list + * operators an author holding a list may have meant (`$in`, `$between`), with + * their authoring spellings. As with {@link arrayEqualityComparandError}, this + * door adds only its `at ` location and its envelope. + */ +function arrayScalarComparandError( + context: string | undefined, + op: string, + field: string, + value: unknown, + path: string, +): Error { + return invalidFilterComparandError(context, arrayScalarComparandMessage(op, value, { field, path })); +} + /** * Walk one `FilterCondition` and refuse every list-shaped operator whose * comparand cannot be one — and, since the two null rulings (2026-08-31, * 2026-09-01), the null comparand positions those rulings carved out; and, * since the 2026-09-23 ruling (#19757), every EQUALITY comparand (implicit or * `$eq`) that IS a list; and, since ruling A on #19886, every `$ne` comparand - * that is a list. + * that is a list; and, since the 2026-10-02 ruling (#21448), every other + * scalar operator's comparand that is a list. * * Read-only and allocation-free on the overwhelmingly common path (a filter * with no list operator walks its own keys and returns). Runs on every engine * read and write and inside every {@link parseFilterAST} call, so it stays a * walk rather than a schema parse — that cost is now the whole reason, and this * gate deliberately enforces only the three list declarations the drivers - * genuinely cannot agree on, the equality slot's mirror of them, plus the null - * carve-outs ruled onto the same door. + * genuinely cannot agree on, their mirror in every scalar slot (equality since + * #19757, `$ne` since #19886, the rest since #21448), plus the null carve-outs + * ruled onto the same door. * * @param node the LOWERED `FilterCondition` — never the authoring array. * @param context optional caller prefix (`find('deal')`), the engine's #5346 @@ -869,6 +971,14 @@ function assertFieldListComparands( } continue; } + // Every other scalar operator (#21448): a list where ONE value belongs, + // whatever the column type. The set is the declared vocabulary's scalar + // half — the same split the comparand-TYPE face reads — so an operator + // outside the vocabulary (`$wat`, retired `$regex`) keeps its own refusal + // downstream. Before the null-ordering arm, which a list can never reach. + if (SCALAR_COMPARAND_OPERATORS.has(op) && Array.isArray(spec[op])) { + throw arrayScalarComparandError(context, op, field, spec[op], `${path}.${op}`); + } // The ordering carve-out (2026-09-01 ruling, #14080). Strictly `null`: // `undefined` keeps the TYPE door's own sentence, and every other // comparand type in these slots is that door's question, not this one's. diff --git a/packages/spec/src/data/filter-comparand-type.ts b/packages/spec/src/data/filter-comparand-type.ts index c859a9cf3b9..b053eff02d3 100644 --- a/packages/spec/src/data/filter-comparand-type.ts +++ b/packages/spec/src/data/filter-comparand-type.ts @@ -92,7 +92,11 @@ * implicit and `$eq` — the layer that answers it is now the comparand-SHAPE * door one file over (ruled 2026-09-23), which runs first inside * `parseFilterAST` and at the engine seam, so no array reaches this walk - * there; `$ne` and the other scalar operators stay per driver. + * there. [#19886] `$ne` followed (ruling A). [#21448] And so did every + * other scalar operator: that door refuses a list at any of them, whatever + * the column type, so from a door that runs it first no array reaches this + * walk at a scalar position. This walk still steps around one, for a caller + * that asks it alone. * - **An operator outside the declared vocabulary** (`$wat`, retired `$regex`): * the unknown-/retired-operator refusals downstream carry the specific * prescriptions (`RETIRED_FILTER_OPERATORS`), which a generic type refusal @@ -116,6 +120,8 @@ * @see https://github.com/objectstack-ai/objectstack/issues/7956 (the matrix) */ +import { LIST_COMPARAND_OPERATORS, SCALAR_COMPARAND_OPERATORS } from './filter-comparand-operators'; + /** * The accepted comparand-type set, as type names. The order is the SQL * family's established message order, which {@link ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} @@ -174,22 +180,12 @@ export function isAcceptedFilterComparand(value: unknown): boolean { } } -/** - * The declared field-operator vocabulary, split by what the comparand IS. - * `filter-comparand-type.test.ts` reconciles the union of these two sets - * against `FieldOperatorsSchema`'s own keys, so an operator added to the - * schema cannot silently skip the door. - */ -const SCALAR_COMPARAND_OPERATORS: ReadonlySet = new Set([ - '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', - '$contains', '$notContains', '$startsWith', '$endsWith', '$icontains', - '$like', '$ilike', - '$null', '$exists', '$empty', -]); - -const LIST_COMPARAND_OPERATORS: ReadonlySet = new Set([ - '$in', '$nin', '$between', -]); +// The declared field-operator vocabulary, split by what the comparand IS, is +// `SCALAR_COMPARAND_OPERATORS` / `LIST_COMPARAND_OPERATORS`, imported above. +// [#21448] It lives in `./filter-comparand-operators.ts` since the +// comparand-SHAPE face began reading it too. `filter-comparand-type.test.ts` +// still reconciles the union of the two sets against `FieldOperatorsSchema`'s +// own keys, so an operator added to the schema cannot silently skip the door. /** * Filter STRUCTURE rather than a comparand: a PLAIN object — prototype diff --git a/packages/spec/src/data/filter-number-comparand-declared-type.test.ts b/packages/spec/src/data/filter-number-comparand-declared-type.test.ts index 0fd726de8f5..1a65829f904 100644 --- a/packages/spec/src/data/filter-number-comparand-declared-type.test.ts +++ b/packages/spec/src/data/filter-number-comparand-declared-type.test.ts @@ -502,8 +502,11 @@ describe('[#20336] NUMBER_COMPARAND_DOOR_CASES', () => { const judged = new Set(NUMBER_COMPARAND_DOOR_CASES.filter((c) => c.name.startsWith('[position]')).map((c) => c.position)); for (const p of judged) { const forms = onNumber.filter((c) => c.position === p && isRefusal(c)).map((c) => (c as NumberComparandDoorRefusalCase).form); - const equality = /^f_number(?:\.\$(?:eq|ne))?$/.test(p); - expect(sorted(forms), p).toEqual(sorted(equality ? ['boolean', 'date'] : ['array', 'boolean', 'date'])); + // [#21448] An array is the comparand-shape door's refusal at every + // one-value slot (implicit and each scalar operator), so the array rows + // sit at the list members alone. + const listMember = /\[\d\]$/.test(p); + expect(sorted(forms), p).toEqual(sorted(listMember ? ['array', 'boolean', 'date'] : ['boolean', 'date'])); } }); diff --git a/packages/spec/src/data/filter-number-comparand-declared-type.ts b/packages/spec/src/data/filter-number-comparand-declared-type.ts index def484ea18c..e5f33fdc841 100644 --- a/packages/spec/src/data/filter-number-comparand-declared-type.ts +++ b/packages/spec/src/data/filter-number-comparand-declared-type.ts @@ -172,7 +172,9 @@ * earlier still, by the comparand-shape door, whose remedy (`$in` / * `$contains` / `$nin`) is the one for that slot; the verdict still answers * `door-refusal` for it, and the case table places its array rows where the - * shape door does not speak (the ordering operators and the list members). + * shape door does not speak. [#21448] That door now refuses a list at every + * other scalar operator too, the ordering operators included, whatever the + * column type, so the rows sit at the list members alone. * * ## A `{placeholder}` against a number field is refused, not stepped around * @@ -912,11 +914,12 @@ const JUDGED_SLOTS: readonly Slot[] = [ const VALUE_DATE = new Date(Date.UTC(2026, 0, 1)); /** - * The equality slots — implicit, `$eq`, `$ne` — where an array is the - * comparand-SHAPE door's refusal, one door before this one (module header). + * The one-value slots — implicit and every scalar operator — where an array is + * the comparand-SHAPE door's refusal, one door before this one (module + * header). [#21448] It was the equality slots alone until that door refused a + * list at every other scalar operator too. */ -const isEqualitySlot = (slot: Slot): boolean => - slot.kind === 'implicit' || (slot.kind === 'scalar' && (slot.op === '$eq' || slot.op === '$ne')); +const isOneValueSlot = (slot: Slot): boolean => slot.kind === 'implicit' || slot.kind === 'scalar'; /** * The cases, derived rather than hand-kept: @@ -932,8 +935,8 @@ const isEqualitySlot = (slot: Slot): boolean => * `{ $field }` reference on `f_number` pass. * 5. **The non-string comparands** ([#20502]) — `false` at `$gt` on every * judged field; `true` and a `Date` at every judged position on - * `f_number`, and an array at every one but the equality slots (the shape - * door's); all refused. Beside them, what passes: `null` as the null test, + * `f_number`, and an array at every list member (a one-value slot is the + * shape door's); all refused. Beside them, what passes: `null` as the null test, * and a boolean or a `Date` against the field classes that hold one (a * `boolean` field, a `datetime` field) — not this door's subject. */ @@ -964,7 +967,7 @@ export const NUMBER_COMPARAND_DOOR_CASES: readonly NumberComparandDoorCase[] = [ 'The card: SQLite read true as 1, PostgreSQL answered a server error, the in-process evaluator coerced it.'), caseFor('value', fixtureField('f_number'), slot, VALUE_DATE, 'The card: no rows on memory and SQLite, a server error on PostgreSQL.'), - ...(isEqualitySlot(slot) ? [] : [caseFor('value', fixtureField('f_number'), slot, [LIST_NEIGHBOUR], + ...(isOneValueSlot(slot) ? [] : [caseFor('value', fixtureField('f_number'), slot, [LIST_NEIGHBOUR], 'A list where one value belongs; the in-process evaluator read [10] as 10.')]), ]), caseFor('value', fixtureField('f_number'), { kind: 'implicit' }, null, diff --git a/packages/spec/src/data/filter-save-door-face-parity.test.ts b/packages/spec/src/data/filter-save-door-face-parity.test.ts index 504abb22608..fe3efaaca26 100644 --- a/packages/spec/src/data/filter-save-door-face-parity.test.ts +++ b/packages/spec/src/data/filter-save-door-face-parity.test.ts @@ -216,7 +216,11 @@ describe('#20116 §1 — the enumeration: the save door refuses exactly what the // Every operator the face judges today refuses at least one battery shape — // the guard against a battery that silently stopped reaching an arm. const faceJudged = OPERATORS.filter((op) => BATTERY.some(([, c]) => faceRefusal({ f: { [op]: c } }))); - expect(faceJudged.sort()).toEqual(['$between', '$eq', '$gt', '$gte', '$in', '$lt', '$lte', '$ne', '$nin']); + // [#21448] Every declared operator: the list operators by their own arms, + // and every scalar operator by the one-value arm (a battery list), the text + // operators and the flags included. Until then the face judged only the + // list operators, the equality pair and the four ordering slots. + expect(faceJudged.sort()).toEqual([...OPERATORS].sort()); // [stage 2] The TYPE face judges every declared operator — its own test // reconciles its scalar/list split against this same vocabulary — so every // one of them must refuse some battery shape here, as a scalar comparand or @@ -303,6 +307,11 @@ describe('#20116 §2 — each refusal: issue code, path and the prescription', ( ['an empty $ne list', { stage: { $ne: [] } }, 'stage.$ne', 'where.stage.$ne'], ['an implicit list (#19889, unchanged)', { stage: ['won'] }, 'stage', 'where.stage'], ['a $eq list (#19889, unchanged)', { stage: { $eq: ['won'] } }, 'stage.$eq', 'where.stage.$eq'], + // [#21448] A list at every other scalar operator: ordering, text and flag. + ['a $gt list (#21448)', { amount: { $gt: [10, 99] } }, 'amount.$gt', 'where.amount.$gt'], + ['an empty $lte list (#21448)', { amount: { $lte: [] } }, 'amount.$lte', 'where.amount.$lte'], + ['a $contains list (#21448)', { stage: { $contains: ['won', 'lost'] } }, 'stage.$contains', 'where.stage.$contains'], + ['a $null list — a flag (#21448)', { stage: { $null: [true] } }, 'stage.$null', 'where.stage.$null'], ])('%s — the face\'s sentence, less its location', (_label, where, issuePath, facePath) => { const issue = issueAt(FilterConditionSchema.safeParse(where), issuePath); expect(issue.code).toBe('custom'); @@ -359,7 +368,9 @@ describe('#20116 §2 — each refusal: issue code, path and the prescription', ( ['$exists: "false" — the string, truthy', { stage: { $exists: 'false' } }, 'stage.$exists', 'a string ("false")'], ['$null: null', { stage: { $null: null } }, 'stage.$null', 'null'], ['$exists: 1', { stage: { $exists: 1 } }, 'stage.$exists', 'a number (1)'], - ['$null: an array', { stage: { $null: [true] } }, 'stage.$null', 'an array ([true])'], + // [#21448] `$null: [true]` left this table: a list at a flag is the + // comparand-shape face's refusal now (how many values comes before which + // value), and it reads in that face's words — the §2 rows above. ])('a non-boolean flag, %s — the query faces\' sentence and prescription', (_label, where, issuePath, received) => { const op = issuePath.split('.').pop()!; const issue = issueAt(FilterConditionSchema.safeParse(where), issuePath); diff --git a/packages/spec/src/data/filter-save-door-refusals.ts b/packages/spec/src/data/filter-save-door-refusals.ts index 5059d3ccdc1..77251499755 100644 --- a/packages/spec/src/data/filter-save-door-refusals.ts +++ b/packages/spec/src/data/filter-save-door-refusals.ts @@ -25,7 +25,8 @@ * The comparand-shape face (`assertListComparandShapes`, * `./filter-comparand-shape.ts`) is called read-only on a one-slot node, so the * save door refuses exactly the cells the query door refuses: an array in the - * equality or `$ne` slot, a `null` ordering comparand, a non-list `$in` / + * equality or `$ne` slot or [#21448] at any other scalar operator, a `null` + * ordering comparand, a non-list `$in` / * `$nin`, a malformed `$between`, a `null` list member or endpoint, and a blank * or `{ $field }` endpoint — and passes what the face passes (the null * predicate, a `{ $field }` reference as a whole comparand, `$in: []`, a @@ -55,8 +56,9 @@ * * ## The words * - * - The equality and `$ne` slots: the face's own sentence, from the builders - * both doors import (`./filter-comparand-refusal-text.ts`). + * - The equality and `$ne` slots, and [#21448] a list at any other scalar + * operator: the face's own sentence, from the builders both doors import + * (`./filter-comparand-refusal-text.ts`). * - A `null` ordering comparand, a `null` list member or endpoint, a blank or * `{ $field }` endpoint: the sentence the enforced operator slot * (`FieldOperatorsSchema`) prints for the same comparand, read off that slot @@ -87,11 +89,13 @@ import type { z } from 'zod'; import { assertListComparandShapes } from './filter-comparand-shape'; import { normalizeFilterComparandTypes } from './filter-comparand-type'; +import { SCALAR_COMPARAND_OPERATORS } from './filter-comparand-operators'; import { IN_OPERATOR_SPELLINGS, NIN_OPERATOR_SPELLINGS, arrayEqualityComparandMessage, arrayInequalityComparandMessage, + arrayScalarComparandMessage, shapePreview, } from './filter-comparand-refusal-text'; @@ -235,6 +239,10 @@ function comparandShapeRefusalAtSave( refusal = { at: [], message: arrayEqualityComparandMessage(comparand, { op, field }) }; } else if (op === '$ne') { refusal = { at: [], message: arrayInequalityComparandMessage(comparand, { field }) }; + } else if (Array.isArray(comparand) && SCALAR_COMPARAND_OPERATORS.has(op)) { + // [#21448] A list at any other scalar operator: the face's own sentence, + // from the builder both doors import, less its location. + refusal = { at: [], message: arrayScalarComparandMessage(op, comparand, { field }) }; } else if (comparand === null && ORDERING_OPERATORS.has(op)) { refusal = fromSlot([]); } else if (op === '$in' || op === '$nin') {