Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/undefined-comparand-prescription-position-safe.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
30 changes: 20 additions & 10 deletions packages/drivers/driver-memory/src/memory-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 28 additions & 0 deletions packages/spec/src/data/filter-comparand-type.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>, 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');
Expand Down
17 changes: 13 additions & 4 deletions packages/spec/src/data/filter-comparand-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
);
}

Expand Down
Loading