diff --git a/.changeset/undefined-comparand-prescription-position-safe.md b/.changeset/undefined-comparand-prescription-position-safe.md new file mode 100644 index 0000000000..25e5aaf86b --- /dev/null +++ b/.changeset/undefined-comparand-prescription-position-safe.md @@ -0,0 +1,18 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): the `undefined` comparand refusal prescribes the null predicate by its ruled spellings (#14426) + +`parseFilterAST`'s comparand-type door refuses an `undefined` comparand at every +position. Its prescription read "Write null for the null predicate, or omit the +key" — position-agnostic advice that, followed at `{ $gt: undefined }`, produced +`{ $gt: null }`, which the 2026-09-01 ruling refuses one door over (and, at an +`$in` / `$nin` / `$between` member, produced the list shapes refused on +2026-08-31). Two loud refusals to reach one right answer. + +The sentence now names the null predicate by its complete spellings — +`{"$eq": null}` / `{"$ne": null}` — or omit the key, so following it never lands +in a refusal at any position the sentence is emitted at. No accept/refuse +behaviour changes: same envelope (`INVALID_FILTER` / 400), same path, same +accepted-set and NOT-applied sentences. diff --git a/packages/drivers/driver-memory/src/memory-matcher-null-value-and-comparand.test.ts b/packages/drivers/driver-memory/src/memory-matcher-null-value-and-comparand.test.ts index c55b71bfbb..dfb86f4517 100644 --- a/packages/drivers/driver-memory/src/memory-matcher-null-value-and-comparand.test.ts +++ b/packages/drivers/driver-memory/src/memory-matcher-null-value-and-comparand.test.ts @@ -60,14 +60,18 @@ * asserted here, now for the ruling's own reason — ⛔「不单独修一个到不了的 * 路径」 — and they were measured byte-identical across this repair too. * - * ⚠️ A null COMPARAND in an ORDERING position (`{$gte: null}`) is absent for - * the ORIGINAL reason, and it is the one such position the contract still - * ACCEPTS: the 2026-08-31 ruling refused the three siblings and #5332's - * landing had already recorded this one in writing as a position "no ruling - * covers". #13553's guard is scoped to leave those cells exactly where it - * found them, so pinning them here — in either direction — would prejudge a - * ruling nobody has made. The invariance is proven in the PR, not asserted - * here. + * ⚠️ A null COMPARAND in an ORDERING position (`{$gte: null}`) is likewise + * ABSENT from this file. When #13553 landed it was the one such position the + * contract still ACCEPTED (the 2026-08-31 ruling had refused the three + * siblings, and #5332's landing had recorded this one in writing as "no + * ruling covers"), so #13553's guard was scoped to leave those cells exactly + * where it found them rather than prejudge a ruling. The maintainer ruled it + * on 2026-09-01 (option A, #14080): the shape is now REFUSED at the + * contract's validation entrance, the same door as the list positions, with + * the negative pin in `memory-null-ordering-comparand-unreachable.test.ts`. + * Its cells stay unasserted here, now for the ruling's own reason — ⛔「不单独修 + * matcher(死代码)」— and no ordering-vs-null semantics is defined anywhere, + * so nothing here says what either face would have answered. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; diff --git a/packages/drivers/driver-memory/src/memory-matcher.ts b/packages/drivers/driver-memory/src/memory-matcher.ts index b347fa8da1..eb0cabd7b1 100644 --- a/packages/drivers/driver-memory/src/memory-matcher.ts +++ b/packages/drivers/driver-memory/src/memory-matcher.ts @@ -337,16 +337,26 @@ function checkCondition(value: any, condition: any): boolean { // same reason — a rule spelled over "the value is null" alone would // reach arms whose no-value answer is ruled elsewhere. // - // ⛔ A no-value COMPARAND is excluded from this guard, deliberately, so - // those cells keep TODAY's answer rather than being decided here. - // `$gt: null` is the one null-comparand position the contract still - // ACCEPTS (measured at `parseFilterAST`): the 2026-08-31 ruling refused - // the three siblings — `$in` / `$nin` null members and `$between`'s - // null endpoints (#13357) — and #5332's landing had already recorded - // this position in writing as one "no ruling covers". Deciding it in an - // operator arm would pick a camp the platform declined to pick, and its - // sibling was settled by REFUSING the shape rather than by answering - // it. + // ⛔ A no-value COMPARAND is excluded from this guard, deliberately. + // When #13553 landed, `$gt: null` was the one null-comparand position + // the contract still ACCEPTED (measured at `parseFilterAST`): the + // 2026-08-31 ruling had refused the three siblings — `$in` / `$nin` + // null members and `$between`'s null endpoints (#13357) — and #5332's + // landing had recorded this one in writing as "no ruling covers", so + // deciding it here would have picked a camp the platform declined to + // pick. That reason is gone: ruled 2026-09-01 (option A, #14080), the + // shape is REFUSED at the contract's validation entrance + // (`assertListComparandShapes`, inside `parseFilterAST` and at the + // engine seam), the same door and envelope as its siblings. These + // cells are now constructively unreachable through the compile face, + // and the exclusion stays for the ruling's own reason — ⛔「不单独修 + // matcher(死代码)」— with no ordering-vs-null semantics defined + // anywhere. Negative pin: + // `memory-null-ordering-comparand-unreachable.test.ts`. A direct + // caller that skips the compile face meets only this package's own + // `assertFilterConditionShape`, which deliberately does not carry the + // rule (⛔ 不做跨后端对齐工程) — the honest boundary, not a cell for an + // arm to decide. if (value === null && ORDERING_OPERATORS.has(op) && target !== null && target !== undefined) { return false; diff --git a/packages/spec/src/data/filter-comparand-type.test.ts b/packages/spec/src/data/filter-comparand-type.test.ts index f3227fd3af..b1ecae1a61 100644 --- a/packages/spec/src/data/filter-comparand-type.test.ts +++ b/packages/spec/src/data/filter-comparand-type.test.ts @@ -131,6 +131,34 @@ describe('refusals — the measured divergence rows die at the door', () => { expect(err?.message).toMatch(/omit/); }); + it('the undefined prescription is position-safe: it names the null predicate by its ruled spellings, at every position it is emitted at (#14426)', () => { + // "Write null for the null predicate" was position-agnostic advice: followed + // at `$gt: undefined` it produced `$gt: null`, refused one door over since + // the 2026-09-01 ruling; at an `$in` member it produced `$in: [null]`, + // refused since 2026-08-31. The sentence names COMPLETE spellings instead — + // the pair the ruling names — so following it never lands in a refusal. + const positions: Array<[Record, string]> = [ + [{ owner: undefined }, 'where.owner'], + [{ owner: { $eq: undefined } }, 'where.owner.$eq'], + [{ owner: { $gt: undefined } }, 'where.owner.$gt'], + [{ owner: { $lte: undefined } }, 'where.owner.$lte'], + [{ owner: { $in: [undefined] } }, 'where.owner.$in[0]'], + ]; + for (const [where, path] of positions) { + const err = refusalOf(() => normalizeFilterComparandTypes(where)); + expect(err?.code, path).toBe('INVALID_FILTER'); + expect(err?.status, path).toBe(400); + expect(err?.message, path).toContain(path); + expect(err?.message, path).toContain('{"$eq": null}'); + expect(err?.message, path).toContain('{"$ne": null}'); + expect(err?.message, path).toMatch(/omit the key/); + // The defect's own spelling: an instruction to write a bare null INTO the + // position the sentence was emitted at. + expect(err?.message, path).not.toMatch(/Write null\b/); + expect(err?.message.length, path).toBeLessThan(500); // the client bound (#5423) + } + }); + it('refuses a PLAIN OBJECT where a scalar operator comparand belongs — the SQL family already did', () => { const err = refusalOf(() => normalizeFilterComparandTypes({ qty: { $eq: { a: 1 } } })); expect(err?.code).toBe('INVALID_FILTER'); diff --git a/packages/spec/src/data/filter-comparand-type.ts b/packages/spec/src/data/filter-comparand-type.ts index a1fc08d7f9..f449a25074 100644 --- a/packages/spec/src/data/filter-comparand-type.ts +++ b/packages/spec/src/data/filter-comparand-type.ts @@ -255,15 +255,24 @@ function invalidComparandError(context: string | undefined, message: string): Er const NOT_APPLIED = 'The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.'; -/** `undefined` gets its own sentence — it is the one refused value that arrives by ACCIDENT. */ +/** + * `undefined` gets its own sentence — it is the one refused value that arrives + * by ACCIDENT. Its prescription names the null predicate by COMPLETE spellings + * (`{"$eq": null}` / `{"$ne": null}`, the pair the 2026-09-01 ruling names), + * never as "write null": this sentence is emitted at every comparand position, + * and at `$gt` / `$gte` / `$lt` / `$lte` — or an `$in` / `$nin` / `$between` + * member — "write null" produced exactly the null shapes refused one door over + * (2026-08-31, 2026-09-01). Position-safe means following it never lands in a + * refusal, whatever position it was emitted at (#14426). + */ function undefinedComparandRefusal(context: string | undefined, path: string): Error { return invalidComparandError( context, `Filter comparand at ${path} is undefined. { key: undefined } cannot be told apart from an ` + `omitted key, yet the two mean OPPOSITE things (a predicate vs no constraint) — one ` + - `backend even encoded it as MATCH EVERYTHING. Write null for the null predicate, or omit ` + - `the key. A comparison value must be ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE}. ` + - NOT_APPLIED, + `backend even encoded it as MATCH EVERYTHING. Write the null predicate — {"$eq": null} / ` + + `{"$ne": null} — or omit the key. A comparison value must be ` + + `${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE}. ${NOT_APPLIED}`, ); }