diff --git a/.changeset/null-ordering-comparand-refused.md b/.changeset/null-ordering-comparand-refused.md new file mode 100644 index 0000000000..82afe1055e --- /dev/null +++ b/.changeset/null-ordering-comparand-refused.md @@ -0,0 +1,46 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): refuse a `null` comparand in the ordering positions — `$gt` / `$gte` / `$lt` / `$lte` (#14080) + +**BREAKING** accept-set narrowing on the filter contract, shipped as `minor` +under the repo's launch-window convention for breaking changes — the same +convention, the same door and the same envelope as the 2026-08-31 refusal of +`null` in the list-comparand positions (`$in` / `$nin` members, `$between` +bounds). Maintainer ruling 2026-09-01 (option A): the four ordering positions +were the last null-comparand positions the contract neither ruled on +(`$eq: null` / `$ne: null` ARE the null predicate) nor refused, and +`driver-memory`'s two faces answered them differently — the live path reads +two absences as equal, so `$gte: null` admits the no-value row; the reference +matcher compares through JS coercion, so `5 > null` is `5 > 0`. The contract +now refuses the shape loudly at the validation entrance, so that divergence is +constructively unreachable — ⛔ no ordering-vs-null semantics is defined +anywhere, ⛔ the matcher is not repaired, ⛔ no cross-backend alignment. + +What is refused, and where: + +- **Runtime door** (`assertListComparandShapes`, run inside `parseFilterAST` + and at the engine seam on every verb): `{ f: { $gt: null } }` and its three + siblings, in the object form and in every array/authoring spelling that + lowers to them (`>`, `gt`, `greater_than`, `after`, `before`, …), are refused + with the platform envelope (`INVALID_FILTER` / 400). Previously the shape + reached the backends unexamined. +- **Schema door** (`ComparisonOperatorSchema` / `FieldOperatorsSchema`): `null` + never parsed (the slot is `number | Date | string | { $field }`); it now gets + the pointed message instead of zod's generic union text, and the two copies + are built from one shared slot factory so they cannot drift. + +The refusal text prescribes the ruled spellings: `{"$eq": null}` is "has no +value", `{"$ne": null}` is "has a value". The carve-out is null-shaped and +nothing wider: every number, `Date`, string (`''` included) and `{ $field }` +comparand keeps parsing, `$eq: null` / `$ne: null` are untouched, and +`undefined` keeps the comparand-TYPE door's own message. + +**Migration.** A filter refused by the new check had no portable meaning to +preserve — the two in-memory faces already disagreed on it. Spell the intent +explicitly: `{ f: { $eq: null } }` for "has no value", `{ f: { $ne: null } }` +for "has a value", and `$or: [{ f: { $gte: X } }, { f: { $eq: null } }]` for +"at or above X OR has no value". + + diff --git a/packages/drivers/driver-memory/src/memory-null-ordering-comparand-unreachable.test.ts b/packages/drivers/driver-memory/src/memory-null-ordering-comparand-unreachable.test.ts new file mode 100644 index 0000000000..1711104ea8 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-null-ordering-comparand-unreachable.test.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14080] Ruling point 4's NEGATIVE pin, matcher side: a refused null + * ORDERING comparand cannot reach this package's reference matcher. + * + * # What was ruled (2026-09-01, option A) + * + * #14080 measured, on this package's two faces and the card's numeric + * fixture, that `{n: {$gt: null}}` / `{$gte: null}` / `{$lte: null}` answer + * DIFFERENTLY: the live (mingo) path reads two absences as EQUAL, so + * `$gte: null` admits the no-value row and `$gt: null` does not, while the + * reference matcher compares through JS coercion, so `5 > null` is `5 > 0`. + * It was the last null-comparand position the contract neither ruled on + * (`$eq: null` / `$ne: null` ARE the null predicate, #5332) nor refused (the + * 2026-08-31 ruling refused the `$in` / `$nin` members and the `$between` + * bounds, #13357). The ruling REFUSES the shape at the contract's validation + * entrance (`@objectstack/spec`, `assertListComparandShapes`, run inside + * `parseFilterAST` and at the engine seam) instead of defining the semantics: + * the divergence becomes constructively unreachable, ⛔ deliberately not + * repaired (「⛔ 不单独修 matcher(死代码)」) and ⛔ no ordering-vs-null rule is + * stated anywhere (「B(定义语义)排除」), so NOTHING in this file asserts what + * either face would have answered. `memory-matcher-null-value-and-comparand.test.ts` + * keeps those cells deliberately absent for the same reason. + * + * # What this file pins, and its honest boundary + * + * The same pipeline and the same boundary as + * `memory-null-list-member-unreachable.test.ts`: a direct caller of this + * driver compiles its filter with `parseFilterAST` and hands the result over, + * and this file drives that pipeline end to end, pinning that for every + * refused shape it ABORTS at the compile face, on BOTH readings of "no value", + * before any row is consulted. The engine half (every verb, driver-call + * witness) is pinned in `@objectstack/objectql`'s + * `engine-filter-array-lowering.test.ts`; the wire/protocol face runs the same + * `parseFilterAST`. `match()` and `InMemoryDriver.find()` remain plain library + * functions — a caller that skips the compile face meets only this package's + * own `assertFilterConditionShape`, which is deliberately NOT extended to the + * null-ordering rule (⛔ 不做跨后端对齐工程). Same boundary as every #5869 + * refusal since #9228; not widened here. + */ + +import { describe, it, expect } from 'vitest'; +import { parseFilterAST } from '@objectstack/spec/data'; + +import { match } from './memory-matcher.js'; + +type Refusal = Error & { code?: string; status?: number }; + +/** + * The card's own NUMERIC fixture, in both readings of "no value" — numeric + * because `null` coerces to `0` under a relational comparison, which is the + * coercion that split the two faces; a string fixture hides it (#13553). + */ +const NULLED_ROWS: Array> = [ + { id: '1', n: 5 }, + { id: '2', n: 0 }, + { id: '3', n: null }, +]; +const MISSING_ROWS: Array> = [ + { id: '1', n: 5 }, + { id: '2', n: 0 }, + { id: '4' }, +]; + +/** + * The direct-caller pipeline: compile first, evaluate second. The refusal has + * to land in step one — if compile returns, the matcher HAS been reached and + * the pin below fails on the sentinel rather than on a missing throw. + */ +function compileThenMatch(rows: Array>, where: unknown): string[] { + const condition = parseFilterAST(where); + return rows.filter((row) => match(row, condition)).map((row) => String(row.id)); +} + +const refusalOf = (run: () => unknown): Refusal => { + try { + run(); + } catch (e) { + return e as Refusal; + } + throw new Error('expected the compile face to refuse this filter, but it returned'); +}; + +describe('[#14080] a refused null ordering comparand cannot reach the matcher (ruled 2026-09-01)', () => { + it.each([ + ['$gt: null', { n: { $gt: null } }], + ['$gte: null', { n: { $gte: null } }], + ['$lt: null', { n: { $lt: null } }], + ['$lte: null', { n: { $lte: null } }], + ['lowered array form, ">="', [['n', '>=', null]]], + ['lowered array form, "before"', [['n', 'before', null]]], + ])('%s aborts at the compile face on BOTH readings of "no value"', (_label, where) => { + // Record-independent by construction — the compile face never sees a row — + // so the two readings that split the faces (the card's table) cannot even + // be posed. Driving both anyway is the point of the pin: neither fixture + // gets an answer, so there is no divergence left to observe. + for (const rows of [NULLED_ROWS, MISSING_ROWS]) { + const err = refusalOf(() => compileThenMatch(rows, where)); + expect(err.code, _label).toBe('INVALID_FILTER'); + expect(err.status, _label).toBe(400); + } + }); + + it('the pipeline itself is real — a legal ordering comparand compiles and the matcher answers', () => { + // Positive control: without it, the refusals above would also "pass" if + // compileThenMatch were broken outright. `0` is the discriminator the + // numeric fixture exists for — a VALUE, kept in, on every arm. + expect(compileThenMatch(NULLED_ROWS, { n: { $gt: 0 } })).toEqual(['1']); + expect(compileThenMatch(NULLED_ROWS, { n: { $gte: 0 } })).toEqual(['1', '2']); + expect(compileThenMatch(MISSING_ROWS, { n: { $lt: 5 } })).toEqual(['2']); + expect(compileThenMatch(MISSING_ROWS, [['n', '<=', 0]])).toEqual(['2']); + }); + + it('the null PREDICATE still passes the same face — the refusal is ordering-shaped, not null-shaped', () => { + // `$eq: null` IS the null predicate on both readings (#13494) and is the + // spelling the refusal prescribes; the carve-out must not catch it. + expect(compileThenMatch(NULLED_ROWS, { n: { $eq: null } })).toEqual(['3']); + expect(compileThenMatch(MISSING_ROWS, { n: { $eq: null } })).toEqual(['4']); + expect(compileThenMatch(NULLED_ROWS, { n: { $ne: null } })).toEqual(['1', '2']); + }); +}); diff --git a/packages/objectql/src/engine-filter-array-lowering.test.ts b/packages/objectql/src/engine-filter-array-lowering.test.ts index 45f6e2dd0e..58b4e44851 100644 --- a/packages/objectql/src/engine-filter-array-lowering.test.ts +++ b/packages/objectql/src/engine-filter-array-lowering.test.ts @@ -596,6 +596,79 @@ describe('Door 2 lowers FilterArray to FilterCondition before the driver (#5158) expect(reads).toHaveLength(0); }); + // ── [#14080] the ORDERING carve-out, ruled 2026-09-01: a null comparand ── + // ── of $gt/$gte/$lt/$lte is refused at this seam, so driver-memory's ───── + // ── two-face divergence on it is UNREACHABLE through the engine ───────── + // + // Ruling point 4's negative pin, engine half, in the exact shape of the + // #13357 block above: the witness is the recording driver's call log, not + // the thrown envelope alone. The compile-face half (`parseFilterAST`, both + // input forms) is pinned in `@objectstack/spec`'s + // `filter-comparand-shape.test.ts`; the matcher-side statement lives in + // driver-memory's `memory-null-ordering-comparand-unreachable.test.ts`. + // ⛔ Nothing here asserts what either face WOULD have answered, and no + // ordering-vs-null semantics is defined — the divergence is sealed. + + it.each([ + ['$gt: null', { amount: { $gt: null } }], + ['$gte: null', { amount: { $gte: null } }], + ['$lt: null', { amount: { $lt: null } }], + ['$lte: null', { amount: { $lte: null } }], + ['lowered array form, ">="', [['amount', '>=', null]]], + ])('a null ordering comparand is refused on EVERY verb before any driver call — %s', async (_l, where) => { + // `asFilterArrayQuery`: the array-form case makes `where` off-contract by + // declaration (see the helper's note), and the spelling names that. + await expect(engine.find('deal', asFilterArrayQuery(where))) + .rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' }); + await expect(engine.findOne('deal', asFilterArrayQuery(where))) + .rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' }); + await expect(engine.count('deal', { where } as unknown as EngineCountOptions)) + .rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' }); + await expect(engine.aggregate('deal', { + where: where as unknown as EngineAggregateOptions['where'], + groupBy: ['stage'], + aggregations: [{ function: 'count', field: 'id', alias: 'n' }], + })).rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' }); + await expect(engine.update('deal', { amount: 1 }, { where, multi: true } as any)) + .rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' }); + await expect(engine.delete('deal', { where, multi: true } as any)) + .rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' }); + // The negative half: refused BEFORE the store — no read, no write, no row + // moved. (The count() control below adds its own read, so it runs after.) + expect(reads).toHaveLength(0); + expect(writes).toHaveLength(0); + expect(await engine.count('deal')).toBe(3); + }); + + it('the null-ordering refusal is not vacuous — the same operator WITHOUT null reaches the driver', async () => { + // Positive control for the zero-call reading above: one comparand + // swapped for a value, same operator, same field, and the dispatch happens. + const rows = await engine.find('deal', { where: { amount: { $gt: 10 } } }); + expect(reads).toHaveLength(1); + expect(lastWhere()).toEqual({ amount: { $gt: 10 } }); + expect(rows.map((r: any) => r.id).sort()).toEqual(['d2', 'd3']); + }); + + it('the null PREDICATE still reaches the driver — the refusal is ordering-shaped, not null-shaped', async () => { + // `$eq: null` / `$ne: null` ARE the null predicate (#5332) and are the + // spellings the refusal prescribes; the seam must keep passing them. + await engine.find('deal', { where: { owner_id: { $ne: null } } }); + expect(reads).toHaveLength(1); + expect(lastWhere()).toEqual({ owner_id: { $ne: null } }); + }); + + it('a nested null ordering comparand is refused at its own path, engine prefix and all', async () => { + const err = await engine.find( + 'deal', + { where: { $or: [{ amount: { $lte: null } }] } }, + ).then(() => null, (e: any) => e); + expect(err?.status).toBe(400); + expect(err?.code).toBe('INVALID_FILTER'); + expect(err.message).toMatch(/^find\('deal'\): /); + expect(err.message).toContain('where.$or[0].amount.$lte'); + expect(reads).toHaveLength(0); + }); + // ── what must KEEP working: the declared list shapes ─────────────────── it('a proper list comparand still reaches the driver untouched', async () => { diff --git a/packages/spec/src/data/filter-comparand-shape.test.ts b/packages/spec/src/data/filter-comparand-shape.test.ts index 28b4c4665d..42db6129ca 100644 --- a/packages/spec/src/data/filter-comparand-shape.test.ts +++ b/packages/spec/src/data/filter-comparand-shape.test.ts @@ -160,6 +160,69 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922 expect(parseFilterAST({ n: { $between: [0, 0] } })).toEqual({ n: { $between: [0, 0] } }); }); + // ── the ordering carve-out, ruled 2026-09-01 (#14080) ────────────────── + + it.each([ + ['$gt, object passthrough', { n: { $gt: null } }], + ['$gte, object passthrough', { n: { $gte: null } }], + ['$lt, object passthrough', { n: { $lt: null } }], + ['$lte, object passthrough', { n: { $lte: null } }], + ['$gt, lowered array form (">")', [['n', '>', null]]], + ['$gte, lowered array form ("gte")', [['n', 'gte', null]]], + ['$lt, lowered array form ("before")', [['n', 'before', null]]], + ['$lte, lowered array form ("less_than_or_equal")', [['n', 'less_than_or_equal', null]]], + ['$gt with a real neighbour in the same bag', { n: { $gte: 0, $gt: null } }], + ])('refuses a null ORDERING comparand — %s', (_label, where) => { + const err = refusalOf(() => parseFilterAST(where)); + expect(err.code).toBe(StandardErrorCode.enum.INVALID_FILTER); + expect(err.status).toBe(400); + }); + + it('the null-ordering refusal prescribes the ruled null predicates', () => { + // 2026-09-01: 「拒绝信息点名可用拼法(`$eq: null` / `$ne: null` 是已裁的 + // null 谓词)」 — the refusal names both halves, and still names operator, + // field, position and authoring spellings (the #5346/#5348 contract). + const err = refusalOf(() => parseFilterAST({ close_date: { $gte: null } })); + expect(err.message) + .toMatch(/^Operator "\$gte" on field "close_date" does not accept a null comparand/); + expect(err.message).toContain('(at where.close_date.$gte)'); + expect(err.message).toContain('{"$eq": null} is "has no value"'); + expect(err.message).toContain('{"$ne": null} is "has a value"'); + expect(err.message) + .toMatch(/Authoring spellings: >=, gte, greater_than_or_equal, greaterthanorequal, greaterorequal\./); + expect(err.message).toMatch(/UNFILTERED result set/); + }); + + it('a null ordering comparand is refused at its own path inside $and / $or / $not too', () => { + expect(refusalOf(() => parseFilterAST({ $not: { n: { $lt: null } } })).message) + .toContain('where.$not.n.$lt'); + expect(refusalOf(() => parseFilterAST({ $or: [{ n: { $lte: null } }] })).message) + .toContain('where.$or[0].n.$lte'); + expect(refusalOf(() => parseFilterAST(['and', ['amount', '>', 5], ['n', '<=', null]])).message) + .toMatch(/where\.\$and\[1\]\.n\.\$lte/); + }); + + it('refuses ONLY null in the ordering slots — the null PREDICATES and every value keep passing', () => { + // `$eq: null` / `$ne: null` ARE the null predicate (#5332) and are the + // spellings the refusal prescribes; they must keep passing this face. + expect(parseFilterAST({ n: { $eq: null } })).toEqual({ n: { $eq: null } }); + expect(parseFilterAST({ n: { $ne: null } })).toEqual({ n: { $ne: null } }); + expect(parseFilterAST({ n: null })).toEqual({ n: null }); + // Every non-null comparand type the slots declare, and the { $field } + // reference (#5222) — the carve-out is null-shaped and nothing wider. + expect(parseFilterAST({ n: { $gt: 0 } })).toEqual({ n: { $gt: 0 } }); + expect(parseFilterAST({ n: { $gte: '' } })).toEqual({ n: { $gte: '' } }); + expect(parseFilterAST({ at: { $lt: '2026-07-01' } })).toEqual({ at: { $lt: '2026-07-01' } }); + expect(parseFilterAST({ a: { $lte: { $field: 'b' } } })).toEqual({ a: { $lte: { $field: 'b' } } }); + expect(parseFilterAST([['n', '>', 0]])).toEqual({ n: { $gt: 0 } }); + const day = new Date('2026-07-01T00:00:00.000Z'); + expect(parseFilterAST({ at: { $gt: day } })).toEqual({ at: { $gt: day } }); + // `undefined` stays the TYPE door's refusal, with that door's own sentence + // — strictly `null` here, so the two messages never compete for one input. + expect(refusalOf(() => parseFilterAST({ n: { $gt: undefined } })).message) + .toMatch(/^Filter comparand at where\.n\.\$gt is undefined/); + }); + // ── the wording contract (#5346 / #5348), unchanged by the move ──────── it('names the operator, the field, what arrived, where, and the fix', () => { @@ -203,6 +266,12 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922 { stage: { $nin: [null] } }, { close_date: { $between: [null, null] } }, { close_date: { $between: ['2026-07-01', null] } }, + // The 2026-09-01 ordering carve-out (#14080): `$gte` / `$lte` carry the + // longest spelling lists, so they are the tallest of the four. + { close_date: { $gte: null } }, + { close_date: { $lte: null } }, + { close_date: { $gt: null } }, + { close_date: { $lt: null } }, ]) { const err = refusalOf(() => parseFilterAST(where, "find('deal')")); expect(err.message.length, JSON.stringify(where)).toBeLessThan(500); @@ -246,6 +315,31 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922 .toEqual(['between']); }); + it('every AST spelling that lowers to an ORDERING operator is refused on null AND named', () => { + // The same reconciliation for the 2026-09-01 carve-out (#14080): the + // door's `ORDERING_COMPARAND_OPERATORS` spelling lists are hand-written + // for the same import-cycle reason, and this is what keeps them honest. + const ordering = [...VALID_AST_OPERATORS].filter((op) => { + const lowered = loweredOperatorOf(op); + return lowered === '$gt' || lowered === '$gte' || lowered === '$lt' || lowered === '$lte'; + }); + // Guards the loop from passing vacuously — twenty spellings, four operators. + expect(ordering.sort()).toEqual([ + '<', '<=', '>', '>=', 'after', 'before', + 'greater_than', 'greater_than_or_equal', 'greaterorequal', 'greaterthan', 'greaterthanorequal', + 'gt', 'gte', + 'less_than', 'less_than_or_equal', 'lessorequal', 'lessthan', 'lessthanorequal', + 'lt', 'lte', + ]); + for (const op of ordering) { + const err = refusalOf(() => parseFilterAST([['n', op, null]])); + expect(err.code, op).toBe(StandardErrorCode.enum.INVALID_FILTER); + expect(err.status, op).toBe(400); + expect(err.message, `"${op}" is refused but the refusal does not name it`) + .toContain(`${op}`); + } + }); + // ── what must KEEP working — the door is narrow, not merely present ───── it('lowers every legal list comparand untouched', () => { diff --git a/packages/spec/src/data/filter-comparand-shape.ts b/packages/spec/src/data/filter-comparand-shape.ts index beeac28599..9c274d23f3 100644 --- a/packages/spec/src/data/filter-comparand-shape.ts +++ b/packages/spec/src/data/filter-comparand-shape.ts @@ -104,6 +104,29 @@ * `$or: [{$in: […]}, {$null: true}]` — and the refusal text prescribes it. * #5041's question (ISO date strings as legitimate `$between` bounds) and * #5234's (object members on the `driver-sql` face) stand untouched. + * + * ## Refused BY RULING, 2026-09-01: a `null` ORDERING comparand (#14080) + * + * The same carve-out, one position over — and the last one. A `null` + * comparand of `$gt` / `$gte` / `$lt` / `$lte` was the only null-comparand + * position the contract neither RULED (`$eq: null` / `$ne: null` ARE the null + * predicate, #5332) nor REFUSED (the list positions above): #5332's landing + * had recorded it in writing as one "no ruling covers", and `driver-memory`'s + * two faces answered it differently — the live path reads two absences as + * EQUAL (so `$gte: null` admits the no-value row and `$gt: null` does not), + * the reference matcher compares through JS coercion (`5 > null` is + * `5 > 0`). Ruled 2026-09-01 (option A): refused at this door, same envelope, + * so the divergent cells are constructively unreachable — ⛔ the matcher is + * not repaired (dead code once refused), ⛔ no ordering-vs-null semantics is + * defined anywhere (the live path's reading needs a strictness rule, "two + * absences compare equal", that no ruling states), ⛔ no cross-backend + * alignment. `null` is not ordered; the refusal text prescribes the ruled + * spellings, `$eq: null` / `$ne: null`. Strictly `null`: `undefined` keeps the + * TYPE door's own sentence (`undefinedComparandRefusal`, one file over), every + * non-null comparand type and the `{ $field }` reference keep passing, and + * `$eq` / `$ne` are untouched. The door's NAME predates both null carve-outs; + * it stays, because the engine binds it by that name (`@objectstack/objectql`, + * a delegating wrapper) and there is still exactly one implementation. * - **A field spec with no `$` keys** (`{ author: { name: 'x' } }`) — a * deep-equality comparand to `driver-memory` and `driver-mongodb` alike. This * gate does not descend into one: a comparand is data, and a stricter reading @@ -141,6 +164,24 @@ const LIST_COMPARAND_OPERATORS: ReadonlyMap = new Map ['$between', ['between']], ]); +/** + * The ordering operators, with the authoring spellings that lower to each — + * the four positions of the 2026-09-01 null-comparand refusal (#14080). + * + * Same shape and same reason as {@link LIST_COMPARAND_OPERATORS}: the + * spellings serve the MESSAGE (an author writes `after` on a `ViewFilterRule` + * and never types `$gt`), they are the `AST_OPERATOR_MAP` keys that map to + * each operator, spelled here because `filter.zod.ts` imports this module, and + * `filter-comparand-shape.test.ts` reconciles the two sets so a new ordering + * spelling cannot land unnamed. + */ +const ORDERING_COMPARAND_OPERATORS: ReadonlyMap = new Map([ + ['$gt', ['>', 'gt', 'greater_than', 'greaterthan', 'after']], + ['$gte', ['>=', 'gte', 'greater_than_or_equal', 'greaterthanorequal', 'greaterorequal']], + ['$lt', ['<', 'lt', 'less_than', 'lessthan', 'before']], + ['$lte', ['<=', 'lte', 'less_than_or_equal', 'lessthanorequal', 'lessorequal']], +]); + /** What a caller most likely meant when they wrote a scalar. */ const SCALAR_ALTERNATIVE: ReadonlyMap = new Map([ ['$in', '"=" ($eq)'], @@ -330,16 +371,47 @@ function nullRangeBoundError( ); } +/** + * A `null` comparand of `$gt` / `$gte` / `$lt` / `$lte` — refused BY RULING, + * 2026-09-01 (#14080); see the module note's second "Refused BY RULING" + * section. + * + * The prescription is the ruling's own: `$eq: null` / `$ne: null` ARE the + * null predicates (#5332), so the refusal names both halves — an author who + * wrote `$gte: null` was reaching for one of them. Same #5346/#5348 wording + * contract as {@link nullListMemberError}: operator, field, position, + * corrected shape, authoring spellings, front-loaded and inside the 500-char + * client bound; the schema door's twin is `nullOrderingComparandMessage` + * (`./filter.zod.ts`), reconciled by pin. + */ +function nullOrderingComparandError( + context: string | undefined, + op: string, + field: string, + path: string, +): Error { + const spellings = ORDERING_COMPARAND_OPERATORS.get(op) ?? []; + return invalidFilterComparandError( + context, + `Operator "${op}" on field "${field}" does not accept a null comparand (at ${path}). ` + + `null is not ordered; no two evaluation faces agree on what it matches. State absence ` + + `with the null predicate: {"$eq": null} is "has no value", {"$ne": null} is "has a value". ` + + `Authoring spellings: ${spellings.join(', ')}. The filter was NOT applied, and an ` + + `unapplied filter would have returned the UNFILTERED result set.`, + ); +} + /** * Walk one `FilterCondition` and refuse every list-shaped operator whose - * comparand cannot be one. + * comparand cannot be one — and, since the two null rulings (2026-08-31, + * 2026-09-01), the null comparand positions those rulings carved out. * * 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. + * genuinely cannot agree on, 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 @@ -400,6 +472,15 @@ function assertFieldListComparands( // condition. Not descended into; see the module note. if (!keys.some((key) => key.startsWith('$'))) return; for (const op of keys) { + // 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. + if (ORDERING_COMPARAND_OPERATORS.has(op)) { + if (spec[op] === null) { + throw nullOrderingComparandError(context, op, field, `${path}.${op}`); + } + continue; + } if (!LIST_COMPARAND_OPERATORS.has(op)) continue; const comparand = spec[op]; if (op === '$between') { diff --git a/packages/spec/src/data/filter.test.ts b/packages/spec/src/data/filter.test.ts index cd072f8b8c..331e9e1ec2 100644 --- a/packages/spec/src/data/filter.test.ts +++ b/packages/spec/src/data/filter.test.ts @@ -148,6 +148,71 @@ describe('ComparisonOperatorSchema', () => { })).not.toThrow(); }); }); + + // ========================================================================== + // #14080 — a null comparand is refused with the POINTED message, on both + // copies. Ruled 2026-09-01 (option A). `null` never passed these unions (it + // is none of number | Date | string | { $field }); what the ruling adds at + // the SCHEMA door is the message, the same replace-only mechanism as the + // `$between` endpoint's (#13357). The RUNTIME door — `parseFilterAST`, which + // does not run this schema — gains the refusal itself, pinned in + // `filter-comparand-shape.test.ts`. + // ========================================================================== + + describe('null comparand (#14080)', () => { + const OPS = ['$gt', '$gte', '$lt', '$lte'] as const; + const messagesOf = (result: { error?: { issues: Array<{ message: string }> } }): string => + (result.error?.issues ?? []).map((i) => i.message).join('\n'); + + it('refuses null with the pointed message, not zod\'s generic union text', () => { + for (const op of OPS) { + const result = ComparisonOperatorSchema.safeParse({ [op]: null }); + expect(result.success, op).toBe(false); + const messages = messagesOf(result); + expect(messages, op).toContain(`null is not a valid ${op} comparand`); + expect(messages, op).toContain('{"$eq": null} is "has no value"'); + expect(messages, op).toContain('{"$ne": null} is "has a value"'); + expect(messages, op).not.toContain('Invalid input'); + } + }); + + it('is matched by the enforced copy — FieldOperatorsSchema, and through the normalized AST', () => { + for (const op of OPS) { + const result = FieldOperatorsSchema.safeParse({ [op]: null }); + expect(result.success, op).toBe(false); + expect(messagesOf(result), op).toContain(`null is not a valid ${op} comparand`); + } + // Through the normalized AST the `$and` member union reports its own + // member-shape sentence rather than the slot's, so the verdict is what is + // pinned here; the pointed message is pinned one assertion up, on the + // copy the AST validates against. + const nested = NormalizedFilterSchema.safeParse({ $and: [{ close_date: { $gte: null } }] }); + expect(nested.success).toBe(false); + }); + + it('the null PREDICATES are untouched — $eq: null / $ne: null keep parsing (#5332)', () => { + expect(FieldOperatorsSchema.safeParse({ $eq: null }).success).toBe(true); + expect(FieldOperatorsSchema.safeParse({ $ne: null }).success).toBe(true); + // The normalized AST is a logical group at its root (a bare field + // condition is an unrecognised key there), so the control is nested. + expect(NormalizedFilterSchema.safeParse({ $and: [{ close_date: { $eq: null } }] }).success).toBe(true); + expect(NormalizedFilterSchema.safeParse({ $and: [{ close_date: { $gte: 1 } }] }).success).toBe(true); + }); + + it('refuses ONLY null — the falsy VALUES the slots declare keep parsing', () => { + for (const op of OPS) { + expect(ComparisonOperatorSchema.safeParse({ [op]: 0 }).success, op).toBe(true); + expect(ComparisonOperatorSchema.safeParse({ [op]: '' }).success, op).toBe(true); + expect(FieldOperatorsSchema.safeParse({ [op]: 0 }).success, op).toBe(true); + expect(FieldOperatorsSchema.safeParse({ [op]: '' }).success, op).toBe(true); + } + // A comparand orderable at no backend keeps zod's own union verdict — + // the pointed message is for null and nothing else. + const other = ComparisonOperatorSchema.safeParse({ $gt: true }); + expect(other.success).toBe(false); + expect(messagesOf(other)).not.toContain('null is not a valid'); + }); + }); }); // ============================================================================ diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 5ff205b277..5ee9861d85 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -139,7 +139,9 @@ const ORDERING_COMPARAND_DESCRIPTION = + 'becomes the half-open next-day boundary). Ordering NON-temporal text is ' + 'permitted but NOT promised: the order is the backend collation\'s ' + '(byte-wise on SQLite, the database locale on Postgres, UTF-16 code units ' - + 'in the JS matchers), and those coincide only for ASCII.'; + + 'in the JS matchers), and those coincide only for ASCII. null is NOT a ' + + 'comparand: null is not ordered — state absence with the null predicate ' + + 'instead ($eq: null is "has no value", $ne: null is "has a value").'; /** * Ordering-comparison operators. @@ -226,22 +228,53 @@ const ORDERING_COMPARAND_DESCRIPTION = * backend agrees. Ordering arbitrary natural-language text is permitted, not * promised: it is the collation's answer, and it may differ per backend. */ +/** + * [#14080] The author-facing refusal for a `null` comparand of `$gt` / `$gte` + * / `$lt` / `$lte`. Ruled 2026-09-01 (option A), the sibling of the 2026-08-31 + * list-position ruling and the same replace-only mechanism as + * `rangeEndpointSchema`'s null endpoint: `null` never passed these unions (it + * is none of number | Date | string | { $field }), so what the ruling adds at + * the SCHEMA door is the POINTED message in place of zod's generic union text. + * The RUNTIME door — `parseFilterAST`, which does not run this schema — gains + * the refusal itself: `nullOrderingComparandError` + * (`./filter-comparand-shape.ts`), reconciled by pin. + */ +function nullOrderingComparandMessage(op: string): string { + return ( + `null is not a valid ${op} comparand. null is not ordered, and no two evaluation faces ` + + 'agree on what an ordering against it matches (driver-memory\'s live path reads two ' + + 'absences as equal; its reference matcher compares through JS coercion). State absence ' + + 'with the null predicate instead: {"$eq": null} is "has no value", {"$ne": null} is ' + + '"has a value". Ruled 2026-09-01: a null ordering comparand is refused at the validation ' + + 'entrance.' + ); +} + +/** + * One ordering slot, shared by the documentation copy + * ({@link ComparisonOperatorSchema}) and the enforced copy + * (`FieldOperatorsSchema`) — the two share the CODE rather than a description + * of it, the pairing `setMembershipSchema` gives the set slots, so they cannot + * drift (#5685 landed the string widening in one copy first and left the + * reachable surface still refusing the platform's own output). + */ +const orderingComparandSchema = (op: '$gt' | '$gte' | '$lt' | '$lte', label: string) => + z.union([z.number(), z.date(), z.string(), FieldReferenceSchema], { + error: (issue) => (issue.input === null ? nullOrderingComparandMessage(op) : undefined), + }).optional().describe(`${label}. ${ORDERING_COMPARAND_DESCRIPTION}`); + export const ComparisonOperatorSchema = lazySchema(() => z.object({ /** Greater than - SQL: > | MongoDB: $gt */ - $gt: z.union([z.number(), z.date(), z.string(), FieldReferenceSchema]).optional() - .describe(`Greater than. ${ORDERING_COMPARAND_DESCRIPTION}`), + $gt: orderingComparandSchema('$gt', 'Greater than'), /** Greater than or equal to - SQL: >= | MongoDB: $gte */ - $gte: z.union([z.number(), z.date(), z.string(), FieldReferenceSchema]).optional() - .describe(`Greater than or equal to. ${ORDERING_COMPARAND_DESCRIPTION}`), + $gte: orderingComparandSchema('$gte', 'Greater than or equal to'), /** Less than - SQL: < | MongoDB: $lt */ - $lt: z.union([z.number(), z.date(), z.string(), FieldReferenceSchema]).optional() - .describe(`Less than. ${ORDERING_COMPARAND_DESCRIPTION}`), + $lt: orderingComparandSchema('$lt', 'Less than'), /** Less than or equal to - SQL: <= | MongoDB: $lte */ - $lte: z.union([z.number(), z.date(), z.string(), FieldReferenceSchema]).optional() - .describe(`Less than or equal to. ${ORDERING_COMPARAND_DESCRIPTION}`), + $lte: orderingComparandSchema('$lte', 'Less than or equal to'), })); // ============================================================================ @@ -1019,12 +1052,13 @@ export const FieldOperatorsSchema = lazySchema(() => z.object({ // gives at length (#5685): the date-macro resolver and all three first-party // callers produce ISO/clock STRINGS in these slots and nothing else. This copy // is the ENFORCED one — `NormalizedFilterSchema` validates against it and the - // exported `FieldOperators` is inferred from it — so it must not drift from the - // documentation copy above. - $gt: z.union([z.number(), z.date(), z.string(), FieldReferenceSchema]).optional(), - $gte: z.union([z.number(), z.date(), z.string(), FieldReferenceSchema]).optional(), - $lt: z.union([z.number(), z.date(), z.string(), FieldReferenceSchema]).optional(), - $lte: z.union([z.number(), z.date(), z.string(), FieldReferenceSchema]).optional(), + // exported `FieldOperators` is inferred from it — so it is built from the same + // `orderingComparandSchema` factory the documentation copy uses (#14080): one + // union, one null-comparand message, no drift by construction. + $gt: orderingComparandSchema('$gt', 'Greater than'), + $gte: orderingComparandSchema('$gte', 'Greater than or equal to'), + $lt: orderingComparandSchema('$lt', 'Less than'), + $lte: orderingComparandSchema('$lte', 'Less than or equal to'), // Set. Members are open (`z.any()`) EXCEPT the one shape no backend resolves: // a `{ $field }` reference, ruled out by name in #7596. Built from the same