Skip to content

Commit 40a44b9

Browse files
os-salesclaude
andauthored
docs(spec,driver-memory): re-point the null-comparand prose at the 2026-09-01 ruling; position-safe undefined prescription (#15464)
The 2026-09-01 ruling (option A, PR #14425 under #14080) refused a null comparand of $gt / $gte / $lt / $lte at parseFilterAST. Three prose sites still described it as the one null-comparand position the contract accepts: - memory-matcher.ts: the #13553 guard's comment — the reason a no-value COMPARAND is excluded moves from "no ruling covers it" to "refused at the door; ruled unreachable". The exclusion and every operator arm stay as they are. - memory-matcher-null-value-and-comparand.test.ts: the header — the absence stays deliberate; its reason moves to "refused, ruled 2026-09-01", the wording the file already uses for the list positions. - filter-comparand-type.ts: undefinedComparandRefusal's prescription read "Write null for the null predicate, or omit the key" — position-agnostic advice that at an ordering position produced the newly refused shape. It now names the null predicate by its complete spellings, {"$eq": null} / {"$ne": null} (measured accepted at parseFilterAST; the pair the ruling names), or omit the key. No accept/refuse behaviour moves; same envelope. The spec pin that covered the sentence matched only /undefined/, /null/, /omit/ and quoted nothing, so it stays; a second pin holds the position-safe prescription at five positions and the 500-char bound. Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 Co-authored-by: Claude <noreply@anthropic.com>
1 parent a06faeb commit 40a44b9

5 files changed

Lines changed: 91 additions & 22 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): the `undefined` comparand refusal prescribes the null predicate by its ruled spellings (#14426)
6+
7+
`parseFilterAST`'s comparand-type door refuses an `undefined` comparand at every
8+
position. Its prescription read "Write null for the null predicate, or omit the
9+
key" — position-agnostic advice that, followed at `{ $gt: undefined }`, produced
10+
`{ $gt: null }`, which the 2026-09-01 ruling refuses one door over (and, at an
11+
`$in` / `$nin` / `$between` member, produced the list shapes refused on
12+
2026-08-31). Two loud refusals to reach one right answer.
13+
14+
The sentence now names the null predicate by its complete spellings —
15+
`{"$eq": null}` / `{"$ne": null}` — or omit the key, so following it never lands
16+
in a refusal at any position the sentence is emitted at. No accept/refuse
17+
behaviour changes: same envelope (`INVALID_FILTER` / 400), same path, same
18+
accepted-set and NOT-applied sentences.

packages/drivers/driver-memory/src/memory-matcher-null-value-and-comparand.test.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,18 @@
6060
* asserted here, now for the ruling's own reason — ⛔「不单独修一个到不了的
6161
* 路径」 — and they were measured byte-identical across this repair too.
6262
*
63-
* ⚠️ A null COMPARAND in an ORDERING position (`{$gte: null}`) is absent for
64-
* the ORIGINAL reason, and it is the one such position the contract still
65-
* ACCEPTS: the 2026-08-31 ruling refused the three siblings and #5332's
66-
* landing had already recorded this one in writing as a position "no ruling
67-
* covers". #13553's guard is scoped to leave those cells exactly where it
68-
* found them, so pinning them here — in either direction — would prejudge a
69-
* ruling nobody has made. The invariance is proven in the PR, not asserted
70-
* here.
63+
* ⚠️ A null COMPARAND in an ORDERING position (`{$gte: null}`) is likewise
64+
* ABSENT from this file. When #13553 landed it was the one such position the
65+
* contract still ACCEPTED (the 2026-08-31 ruling had refused the three
66+
* siblings, and #5332's landing had recorded this one in writing as "no
67+
* ruling covers"), so #13553's guard was scoped to leave those cells exactly
68+
* where it found them rather than prejudge a ruling. The maintainer ruled it
69+
* on 2026-09-01 (option A, #14080): the shape is now REFUSED at the
70+
* contract's validation entrance, the same door as the list positions, with
71+
* the negative pin in `memory-null-ordering-comparand-unreachable.test.ts`.
72+
* Its cells stay unasserted here, now for the ruling's own reason — ⛔「不单独修
73+
* matcher(死代码)」— and no ordering-vs-null semantics is defined anywhere,
74+
* so nothing here says what either face would have answered.
7175
*/
7276

7377
import { describe, it, expect, beforeAll, afterAll } from 'vitest';

packages/drivers/driver-memory/src/memory-matcher.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -337,16 +337,26 @@ function checkCondition(value: any, condition: any): boolean {
337337
// same reason — a rule spelled over "the value is null" alone would
338338
// reach arms whose no-value answer is ruled elsewhere.
339339
//
340-
// ⛔ A no-value COMPARAND is excluded from this guard, deliberately, so
341-
// those cells keep TODAY's answer rather than being decided here.
342-
// `$gt: null` is the one null-comparand position the contract still
343-
// ACCEPTS (measured at `parseFilterAST`): the 2026-08-31 ruling refused
344-
// the three siblings — `$in` / `$nin` null members and `$between`'s
345-
// null endpoints (#13357) — and #5332's landing had already recorded
346-
// this position in writing as one "no ruling covers". Deciding it in an
347-
// operator arm would pick a camp the platform declined to pick, and its
348-
// sibling was settled by REFUSING the shape rather than by answering
349-
// it.
340+
// ⛔ A no-value COMPARAND is excluded from this guard, deliberately.
341+
// When #13553 landed, `$gt: null` was the one null-comparand position
342+
// the contract still ACCEPTED (measured at `parseFilterAST`): the
343+
// 2026-08-31 ruling had refused the three siblings — `$in` / `$nin`
344+
// null members and `$between`'s null endpoints (#13357) — and #5332's
345+
// landing had recorded this one in writing as "no ruling covers", so
346+
// deciding it here would have picked a camp the platform declined to
347+
// pick. That reason is gone: ruled 2026-09-01 (option A, #14080), the
348+
// shape is REFUSED at the contract's validation entrance
349+
// (`assertListComparandShapes`, inside `parseFilterAST` and at the
350+
// engine seam), the same door and envelope as its siblings. These
351+
// cells are now constructively unreachable through the compile face,
352+
// and the exclusion stays for the ruling's own reason — ⛔「不单独修
353+
// matcher(死代码)」— with no ordering-vs-null semantics defined
354+
// anywhere. Negative pin:
355+
// `memory-null-ordering-comparand-unreachable.test.ts`. A direct
356+
// caller that skips the compile face meets only this package's own
357+
// `assertFilterConditionShape`, which deliberately does not carry the
358+
// rule (⛔ 不做跨后端对齐工程) — the honest boundary, not a cell for an
359+
// arm to decide.
350360
if (value === null && ORDERING_OPERATORS.has(op)
351361
&& target !== null && target !== undefined) {
352362
return false;

packages/spec/src/data/filter-comparand-type.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,34 @@ describe('refusals — the measured divergence rows die at the door', () => {
131131
expect(err?.message).toMatch(/omit/);
132132
});
133133

134+
it('the undefined prescription is position-safe: it names the null predicate by its ruled spellings, at every position it is emitted at (#14426)', () => {
135+
// "Write null for the null predicate" was position-agnostic advice: followed
136+
// at `$gt: undefined` it produced `$gt: null`, refused one door over since
137+
// the 2026-09-01 ruling; at an `$in` member it produced `$in: [null]`,
138+
// refused since 2026-08-31. The sentence names COMPLETE spellings instead —
139+
// the pair the ruling names — so following it never lands in a refusal.
140+
const positions: Array<[Record<string, unknown>, string]> = [
141+
[{ owner: undefined }, 'where.owner'],
142+
[{ owner: { $eq: undefined } }, 'where.owner.$eq'],
143+
[{ owner: { $gt: undefined } }, 'where.owner.$gt'],
144+
[{ owner: { $lte: undefined } }, 'where.owner.$lte'],
145+
[{ owner: { $in: [undefined] } }, 'where.owner.$in[0]'],
146+
];
147+
for (const [where, path] of positions) {
148+
const err = refusalOf(() => normalizeFilterComparandTypes(where));
149+
expect(err?.code, path).toBe('INVALID_FILTER');
150+
expect(err?.status, path).toBe(400);
151+
expect(err?.message, path).toContain(path);
152+
expect(err?.message, path).toContain('{"$eq": null}');
153+
expect(err?.message, path).toContain('{"$ne": null}');
154+
expect(err?.message, path).toMatch(/omit the key/);
155+
// The defect's own spelling: an instruction to write a bare null INTO the
156+
// position the sentence was emitted at.
157+
expect(err?.message, path).not.toMatch(/Write null\b/);
158+
expect(err?.message.length, path).toBeLessThan(500); // the client bound (#5423)
159+
}
160+
});
161+
134162
it('refuses a PLAIN OBJECT where a scalar operator comparand belongs — the SQL family already did', () => {
135163
const err = refusalOf(() => normalizeFilterComparandTypes({ qty: { $eq: { a: 1 } } }));
136164
expect(err?.code).toBe('INVALID_FILTER');

packages/spec/src/data/filter-comparand-type.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -255,15 +255,24 @@ function invalidComparandError(context: string | undefined, message: string): Er
255255
const NOT_APPLIED =
256256
'The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.';
257257

258-
/** `undefined` gets its own sentence — it is the one refused value that arrives by ACCIDENT. */
258+
/**
259+
* `undefined` gets its own sentence — it is the one refused value that arrives
260+
* by ACCIDENT. Its prescription names the null predicate by COMPLETE spellings
261+
* (`{"$eq": null}` / `{"$ne": null}`, the pair the 2026-09-01 ruling names),
262+
* never as "write null": this sentence is emitted at every comparand position,
263+
* and at `$gt` / `$gte` / `$lt` / `$lte` — or an `$in` / `$nin` / `$between`
264+
* member — "write null" produced exactly the null shapes refused one door over
265+
* (2026-08-31, 2026-09-01). Position-safe means following it never lands in a
266+
* refusal, whatever position it was emitted at (#14426).
267+
*/
259268
function undefinedComparandRefusal(context: string | undefined, path: string): Error {
260269
return invalidComparandError(
261270
context,
262271
`Filter comparand at ${path} is undefined. { key: undefined } cannot be told apart from an ` +
263272
`omitted key, yet the two mean OPPOSITE things (a predicate vs no constraint) — one ` +
264-
`backend even encoded it as MATCH EVERYTHING. Write null for the null predicate, or omit ` +
265-
`the key. A comparison value must be ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE}. ` +
266-
NOT_APPLIED,
273+
`backend even encoded it as MATCH EVERYTHING. Write the null predicate — {"$eq": null} / ` +
274+
`{"$ne": null} — or omit the key. A comparison value must be ` +
275+
`${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE}. ${NOT_APPLIED}`,
267276
);
268277
}
269278

0 commit comments

Comments
 (0)