Skip to content

Commit d16df74

Browse files
os-muskclaude
andauthored
feat(spec): refuse a null comparand in the ordering positions — $gt / $gte / $lt / $lte (#14080) (#14425)
* feat(spec): refuse a null comparand in the ordering positions ($gt/$gte/$lt/$lte) Implements the 2026-09-01 maintainer ruling (option A) on the last null-comparand position the filter contract neither ruled on nor refused: `{ f: { $gt: null } }` and its three siblings are refused at the same validation entrance as the 2026-08-31 list-position refusal — the runtime door (`assertListComparandShapes`, inside `parseFilterAST` and at the engine seam) with the `INVALID_FILTER` / 400 envelope, and the schema door (`ComparisonOperatorSchema` / `FieldOperatorsSchema`, now one shared slot factory) with the pointed message in place of zod's generic union text. The refusal prescribes the ruled null predicates, `$eq: null` / `$ne: null`. driver-memory's two-face divergence on the shape becomes constructively unreachable; the matcher is not repaired and no ordering-vs-null semantics is defined anywhere. Negative pins on the compile face, the engine seam (every verb, zero driver calls) and the matcher pipeline; the carve-out is strictly null — every value, `{ $field }`, `$eq` / `$ne` and `undefined` keep the answers they had. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * test(objectql): spell the off-contract array-form query with asFilterArrayQuery in the #14080 pin The query-options-erasure ratchet counts a bare `as any` on a query-options position; the file's own helper names the contract being bypassed and is the spelling the ratchet asks for. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 940c128 commit d16df74

7 files changed

Lines changed: 532 additions & 17 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): refuse a `null` comparand in the ordering positions — `$gt` / `$gte` / `$lt` / `$lte` (#14080)
6+
7+
**BREAKING** accept-set narrowing on the filter contract, shipped as `minor`
8+
under the repo's launch-window convention for breaking changes — the same
9+
convention, the same door and the same envelope as the 2026-08-31 refusal of
10+
`null` in the list-comparand positions (`$in` / `$nin` members, `$between`
11+
bounds). Maintainer ruling 2026-09-01 (option A): the four ordering positions
12+
were the last null-comparand positions the contract neither ruled on
13+
(`$eq: null` / `$ne: null` ARE the null predicate) nor refused, and
14+
`driver-memory`'s two faces answered them differently — the live path reads
15+
two absences as equal, so `$gte: null` admits the no-value row; the reference
16+
matcher compares through JS coercion, so `5 > null` is `5 > 0`. The contract
17+
now refuses the shape loudly at the validation entrance, so that divergence is
18+
constructively unreachable — ⛔ no ordering-vs-null semantics is defined
19+
anywhere, ⛔ the matcher is not repaired, ⛔ no cross-backend alignment.
20+
21+
What is refused, and where:
22+
23+
- **Runtime door** (`assertListComparandShapes`, run inside `parseFilterAST`
24+
and at the engine seam on every verb): `{ f: { $gt: null } }` and its three
25+
siblings, in the object form and in every array/authoring spelling that
26+
lowers to them (`>`, `gt`, `greater_than`, `after`, `before`, …), are refused
27+
with the platform envelope (`INVALID_FILTER` / 400). Previously the shape
28+
reached the backends unexamined.
29+
- **Schema door** (`ComparisonOperatorSchema` / `FieldOperatorsSchema`): `null`
30+
never parsed (the slot is `number | Date | string | { $field }`); it now gets
31+
the pointed message instead of zod's generic union text, and the two copies
32+
are built from one shared slot factory so they cannot drift.
33+
34+
The refusal text prescribes the ruled spellings: `{"$eq": null}` is "has no
35+
value", `{"$ne": null}` is "has a value". The carve-out is null-shaped and
36+
nothing wider: every number, `Date`, string (`''` included) and `{ $field }`
37+
comparand keeps parsing, `$eq: null` / `$ne: null` are untouched, and
38+
`undefined` keeps the comparand-TYPE door's own message.
39+
40+
**Migration.** A filter refused by the new check had no portable meaning to
41+
preserve — the two in-memory faces already disagreed on it. Spell the intent
42+
explicitly: `{ f: { $eq: null } }` for "has no value", `{ f: { $ne: null } }`
43+
for "has a value", and `$or: [{ f: { $gte: X } }, { f: { $eq: null } }]` for
44+
"at or above X OR has no value".
45+
46+
<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing over existing keys: no key is removed, renamed or re-shaped, so there is no tombstone and nothing mechanical for `objectstack migrate meta` to rewrite. The refusal reaches an affected author at the parse/query site carrying the remedy; which explicit spelling matches the author's intent ($eq: null, $ne: null, or $or with one of them) is an authoring decision no migration entry can perform — and the ruling's precondition census measured zero authored occurrences of the refused shape. -->
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#14080] Ruling point 4's NEGATIVE pin, matcher side: a refused null
5+
* ORDERING comparand cannot reach this package's reference matcher.
6+
*
7+
* # What was ruled (2026-09-01, option A)
8+
*
9+
* #14080 measured, on this package's two faces and the card's numeric
10+
* fixture, that `{n: {$gt: null}}` / `{$gte: null}` / `{$lte: null}` answer
11+
* DIFFERENTLY: the live (mingo) path reads two absences as EQUAL, so
12+
* `$gte: null` admits the no-value row and `$gt: null` does not, while the
13+
* reference matcher compares through JS coercion, so `5 > null` is `5 > 0`.
14+
* It was the last null-comparand position the contract neither ruled on
15+
* (`$eq: null` / `$ne: null` ARE the null predicate, #5332) nor refused (the
16+
* 2026-08-31 ruling refused the `$in` / `$nin` members and the `$between`
17+
* bounds, #13357). The ruling REFUSES the shape at the contract's validation
18+
* entrance (`@objectstack/spec`, `assertListComparandShapes`, run inside
19+
* `parseFilterAST` and at the engine seam) instead of defining the semantics:
20+
* the divergence becomes constructively unreachable, ⛔ deliberately not
21+
* repaired (「⛔ 不单独修 matcher(死代码)」) and ⛔ no ordering-vs-null rule is
22+
* stated anywhere (「B(定义语义)排除」), so NOTHING in this file asserts what
23+
* either face would have answered. `memory-matcher-null-value-and-comparand.test.ts`
24+
* keeps those cells deliberately absent for the same reason.
25+
*
26+
* # What this file pins, and its honest boundary
27+
*
28+
* The same pipeline and the same boundary as
29+
* `memory-null-list-member-unreachable.test.ts`: a direct caller of this
30+
* driver compiles its filter with `parseFilterAST` and hands the result over,
31+
* and this file drives that pipeline end to end, pinning that for every
32+
* refused shape it ABORTS at the compile face, on BOTH readings of "no value",
33+
* before any row is consulted. The engine half (every verb, driver-call
34+
* witness) is pinned in `@objectstack/objectql`'s
35+
* `engine-filter-array-lowering.test.ts`; the wire/protocol face runs the same
36+
* `parseFilterAST`. `match()` and `InMemoryDriver.find()` remain plain library
37+
* functions — a caller that skips the compile face meets only this package's
38+
* own `assertFilterConditionShape`, which is deliberately NOT extended to the
39+
* null-ordering rule (⛔ 不做跨后端对齐工程). Same boundary as every #5869
40+
* refusal since #9228; not widened here.
41+
*/
42+
43+
import { describe, it, expect } from 'vitest';
44+
import { parseFilterAST } from '@objectstack/spec/data';
45+
46+
import { match } from './memory-matcher.js';
47+
48+
type Refusal = Error & { code?: string; status?: number };
49+
50+
/**
51+
* The card's own NUMERIC fixture, in both readings of "no value" — numeric
52+
* because `null` coerces to `0` under a relational comparison, which is the
53+
* coercion that split the two faces; a string fixture hides it (#13553).
54+
*/
55+
const NULLED_ROWS: Array<Record<string, unknown>> = [
56+
{ id: '1', n: 5 },
57+
{ id: '2', n: 0 },
58+
{ id: '3', n: null },
59+
];
60+
const MISSING_ROWS: Array<Record<string, unknown>> = [
61+
{ id: '1', n: 5 },
62+
{ id: '2', n: 0 },
63+
{ id: '4' },
64+
];
65+
66+
/**
67+
* The direct-caller pipeline: compile first, evaluate second. The refusal has
68+
* to land in step one — if compile returns, the matcher HAS been reached and
69+
* the pin below fails on the sentinel rather than on a missing throw.
70+
*/
71+
function compileThenMatch(rows: Array<Record<string, unknown>>, where: unknown): string[] {
72+
const condition = parseFilterAST(where);
73+
return rows.filter((row) => match(row, condition)).map((row) => String(row.id));
74+
}
75+
76+
const refusalOf = (run: () => unknown): Refusal => {
77+
try {
78+
run();
79+
} catch (e) {
80+
return e as Refusal;
81+
}
82+
throw new Error('expected the compile face to refuse this filter, but it returned');
83+
};
84+
85+
describe('[#14080] a refused null ordering comparand cannot reach the matcher (ruled 2026-09-01)', () => {
86+
it.each([
87+
['$gt: null', { n: { $gt: null } }],
88+
['$gte: null', { n: { $gte: null } }],
89+
['$lt: null', { n: { $lt: null } }],
90+
['$lte: null', { n: { $lte: null } }],
91+
['lowered array form, ">="', [['n', '>=', null]]],
92+
['lowered array form, "before"', [['n', 'before', null]]],
93+
])('%s aborts at the compile face on BOTH readings of "no value"', (_label, where) => {
94+
// Record-independent by construction — the compile face never sees a row —
95+
// so the two readings that split the faces (the card's table) cannot even
96+
// be posed. Driving both anyway is the point of the pin: neither fixture
97+
// gets an answer, so there is no divergence left to observe.
98+
for (const rows of [NULLED_ROWS, MISSING_ROWS]) {
99+
const err = refusalOf(() => compileThenMatch(rows, where));
100+
expect(err.code, _label).toBe('INVALID_FILTER');
101+
expect(err.status, _label).toBe(400);
102+
}
103+
});
104+
105+
it('the pipeline itself is real — a legal ordering comparand compiles and the matcher answers', () => {
106+
// Positive control: without it, the refusals above would also "pass" if
107+
// compileThenMatch were broken outright. `0` is the discriminator the
108+
// numeric fixture exists for — a VALUE, kept in, on every arm.
109+
expect(compileThenMatch(NULLED_ROWS, { n: { $gt: 0 } })).toEqual(['1']);
110+
expect(compileThenMatch(NULLED_ROWS, { n: { $gte: 0 } })).toEqual(['1', '2']);
111+
expect(compileThenMatch(MISSING_ROWS, { n: { $lt: 5 } })).toEqual(['2']);
112+
expect(compileThenMatch(MISSING_ROWS, [['n', '<=', 0]])).toEqual(['2']);
113+
});
114+
115+
it('the null PREDICATE still passes the same face — the refusal is ordering-shaped, not null-shaped', () => {
116+
// `$eq: null` IS the null predicate on both readings (#13494) and is the
117+
// spelling the refusal prescribes; the carve-out must not catch it.
118+
expect(compileThenMatch(NULLED_ROWS, { n: { $eq: null } })).toEqual(['3']);
119+
expect(compileThenMatch(MISSING_ROWS, { n: { $eq: null } })).toEqual(['4']);
120+
expect(compileThenMatch(NULLED_ROWS, { n: { $ne: null } })).toEqual(['1', '2']);
121+
});
122+
});

packages/objectql/src/engine-filter-array-lowering.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,79 @@ describe('Door 2 lowers FilterArray to FilterCondition before the driver (#5158)
596596
expect(reads).toHaveLength(0);
597597
});
598598

599+
// ── [#14080] the ORDERING carve-out, ruled 2026-09-01: a null comparand ──
600+
// ── of $gt/$gte/$lt/$lte is refused at this seam, so driver-memory's ─────
601+
// ── two-face divergence on it is UNREACHABLE through the engine ─────────
602+
//
603+
// Ruling point 4's negative pin, engine half, in the exact shape of the
604+
// #13357 block above: the witness is the recording driver's call log, not
605+
// the thrown envelope alone. The compile-face half (`parseFilterAST`, both
606+
// input forms) is pinned in `@objectstack/spec`'s
607+
// `filter-comparand-shape.test.ts`; the matcher-side statement lives in
608+
// driver-memory's `memory-null-ordering-comparand-unreachable.test.ts`.
609+
// ⛔ Nothing here asserts what either face WOULD have answered, and no
610+
// ordering-vs-null semantics is defined — the divergence is sealed.
611+
612+
it.each([
613+
['$gt: null', { amount: { $gt: null } }],
614+
['$gte: null', { amount: { $gte: null } }],
615+
['$lt: null', { amount: { $lt: null } }],
616+
['$lte: null', { amount: { $lte: null } }],
617+
['lowered array form, ">="', [['amount', '>=', null]]],
618+
])('a null ordering comparand is refused on EVERY verb before any driver call — %s', async (_l, where) => {
619+
// `asFilterArrayQuery`: the array-form case makes `where` off-contract by
620+
// declaration (see the helper's note), and the spelling names that.
621+
await expect(engine.find('deal', asFilterArrayQuery(where)))
622+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
623+
await expect(engine.findOne('deal', asFilterArrayQuery(where)))
624+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
625+
await expect(engine.count('deal', { where } as unknown as EngineCountOptions))
626+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
627+
await expect(engine.aggregate('deal', {
628+
where: where as unknown as EngineAggregateOptions['where'],
629+
groupBy: ['stage'],
630+
aggregations: [{ function: 'count', field: 'id', alias: 'n' }],
631+
})).rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
632+
await expect(engine.update('deal', { amount: 1 }, { where, multi: true } as any))
633+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
634+
await expect(engine.delete('deal', { where, multi: true } as any))
635+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
636+
// The negative half: refused BEFORE the store — no read, no write, no row
637+
// moved. (The count() control below adds its own read, so it runs after.)
638+
expect(reads).toHaveLength(0);
639+
expect(writes).toHaveLength(0);
640+
expect(await engine.count('deal')).toBe(3);
641+
});
642+
643+
it('the null-ordering refusal is not vacuous — the same operator WITHOUT null reaches the driver', async () => {
644+
// Positive control for the zero-call reading above: one comparand
645+
// swapped for a value, same operator, same field, and the dispatch happens.
646+
const rows = await engine.find('deal', { where: { amount: { $gt: 10 } } });
647+
expect(reads).toHaveLength(1);
648+
expect(lastWhere()).toEqual({ amount: { $gt: 10 } });
649+
expect(rows.map((r: any) => r.id).sort()).toEqual(['d2', 'd3']);
650+
});
651+
652+
it('the null PREDICATE still reaches the driver — the refusal is ordering-shaped, not null-shaped', async () => {
653+
// `$eq: null` / `$ne: null` ARE the null predicate (#5332) and are the
654+
// spellings the refusal prescribes; the seam must keep passing them.
655+
await engine.find('deal', { where: { owner_id: { $ne: null } } });
656+
expect(reads).toHaveLength(1);
657+
expect(lastWhere()).toEqual({ owner_id: { $ne: null } });
658+
});
659+
660+
it('a nested null ordering comparand is refused at its own path, engine prefix and all', async () => {
661+
const err = await engine.find(
662+
'deal',
663+
{ where: { $or: [{ amount: { $lte: null } }] } },
664+
).then(() => null, (e: any) => e);
665+
expect(err?.status).toBe(400);
666+
expect(err?.code).toBe('INVALID_FILTER');
667+
expect(err.message).toMatch(/^find\('deal'\): /);
668+
expect(err.message).toContain('where.$or[0].amount.$lte');
669+
expect(reads).toHaveLength(0);
670+
});
671+
599672
// ── what must KEEP working: the declared list shapes ───────────────────
600673

601674
it('a proper list comparand still reaches the driver untouched', async () => {

0 commit comments

Comments
 (0)