Skip to content

Commit e398863

Browse files
claude[bot]claudezhuangjianguo
authored
feat(spec): refuse null members in list-comparand positions — $in/$nin members, $between bounds (#13357) (#13673)
* feat(spec): refuse null members in list-comparand positions ($in/$nin members, $between bounds) Implements the 2026-08-31 maintainer ruling on the null-list-comparand axis (option C): the contract refuses the shape at the validation entrance — both the runtime door (assertListComparandShapes, inside parseFilterAST and the engine seam) and the schema door (SetOperatorSchema / RangeOperatorSchema) — so the three backend camps' divergence over what a null member matches becomes constructively unreachable. The refusal text prescribes the ruling's explicit spelling for absence. Negative pins witness that no engine verb and no compile-face caller can hand the shape to a driver or the reference matcher. The empty list stays a declared predicate; only null is carved out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L * docs(spec): changeset + regenerated filter reference for the null list-member refusal Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Steedos <zhuangjianguo@steedos.com>
1 parent 4a17645 commit e398863

8 files changed

Lines changed: 471 additions & 18 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): refuse `null` in list-comparand positions — `$in` / `$nin` members and `$between` bounds (#13357, #13495)
6+
7+
**BREAKING** accept-set narrowing on the filter contract, shipped as `minor`
8+
under the repo's launch-window convention for breaking changes. Maintainer
9+
ruling 2026-08-31 (option C): the contract refuses the shape loudly at the
10+
validation entrance, and the cross-backend divergence it used to reach becomes
11+
constructively unreachable — ⛔ no cross-backend alignment (#5299 stays
12+
declined), and the reference matcher's own answers for these shapes are sealed
13+
behind the refusal, not repaired.
14+
15+
What is refused, and where:
16+
17+
- **Runtime door** (`assertListComparandShapes`, run inside `parseFilterAST`
18+
and at the engine seam on every verb): a `null` member of `$in` / `$nin`,
19+
and a `null` `$between` bound, are refused with the platform envelope
20+
(`INVALID_FILTER` / 400). Previously the shape reached the backends, where
21+
the SQL family, the mingo path and the reference matcher answered it three
22+
ways — the matcher even disagreed with itself across the two readings of
23+
"no value" (#13357's table).
24+
- **Schema door** (`SetOperatorSchema` / `FieldOperatorsSchema`): a `null`
25+
member is refused at parse time with a pointed message, the same
26+
check-not-type-change mechanism as the #7596 `{ $field }` member refusal.
27+
A `null` `$between` endpoint never parsed (the endpoint union is
28+
`number | Date | string`); it now gets the pointed message instead of zod's
29+
generic union text.
30+
31+
The refusal text prescribes the ruling's explicit spelling: "one of [] OR has
32+
no value" is `{"$or": [{"$in": […]}, {"$null": true}]}`, and `{"$null": false}`
33+
is the has-a-value half. The carve-out is null-shaped and nothing wider:
34+
`$in: []` / `$nin: []` stay the declared predicates they are, every non-null
35+
member type keeps parsing (#5041's and #5234's member questions stand
36+
untouched), and `$eq: null` is a separate surface (#13494, ruled separable).
37+
38+
**Migration.** A filter refused by the new checks was already answered
39+
inconsistently across backends, so it had no portable meaning to preserve.
40+
Spell the intent explicitly: `$or: [{$in: […]}, {$null: true}]` for
41+
"one of [] or empty", `{$null: false}` (or `$and` with it) for the
42+
has-a-value direction, and `$gte` / `$lte` for a half-open range.
43+
44+
<!-- 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 ($or with $null:true, $null:false, or a half-open range) is an authoring decision no migration entry can perform — and the ruling's evidence base measured zero authored occurrences of the refused shape. -->

content/docs/references/data/filter.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,8 @@ Type: `[FilterArray](#filterarray)[]`
123123

124124
| Property | Type | Required | Description |
125125
| :--- | :--- | :--- | :--- |
126-
| **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. |
127-
| **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. |
126+
| **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of [] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. |
127+
| **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of [] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. |
128128

129129

130130
---
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#13357] Ruling point 3's NEGATIVE pin, matcher side: a refused null list
5+
* member cannot reach this package's reference matcher.
6+
*
7+
* # What was ruled (2026-08-31, option C)
8+
*
9+
* #13357 measured that the reference matcher answers `$in: [null]` /
10+
* `$nin: [null]` differently across the two readings of "no value" (a stored
11+
* `null` vs an absent key) while `$null` / `$ne: null` agree — and that the
12+
* SQL family answers the same filters a third way. The ruling REFUSES the
13+
* shape at the contract's validation entrance (`@objectstack/spec`,
14+
* `assertListComparandShapes`, run inside `parseFilterAST` and at the engine
15+
* seam) instead of aligning the backends: the divergence becomes
16+
* constructively unreachable, ⛔ deliberately not repaired (「⛔ 不单独修一个
17+
* 到不了的路径」), so NOTHING in this file asserts what the matcher would
18+
* have answered. `memory-matcher-null-value-and-comparand.test.ts` keeps
19+
* those arms deliberately absent for the same reason.
20+
*
21+
* # What this file pins, and its honest boundary
22+
*
23+
* A direct caller of this driver — this repo's own conformance suites, an
24+
* embedder — compiles its filter with `parseFilterAST` and hands the result
25+
* over (`filter-comparand-shape.ts`'s #9228 section is the ruling that put
26+
* the gate on that face for exactly this caller). This file drives that
27+
* pipeline end to end and pins that for every refused shape it ABORTS at the
28+
* compile face, on BOTH fixture readings, before any row is consulted: the
29+
* evaluation step is provably never reached because the compile step throws.
30+
* The engine half (every verb, driver-call witness) is pinned in
31+
* `@objectstack/objectql`'s `engine-filter-array-lowering.test.ts`; the
32+
* wire/protocol face runs the same `parseFilterAST`.
33+
*
34+
* The boundary, stated rather than hidden: `match()` and
35+
* `InMemoryDriver.find()` remain plain library functions — a caller that
36+
* skips the compile face meets only this package's own
37+
* `assertFilterConditionShape`, which is deliberately NOT extended to the
38+
* null-member rule (⛔ 不做跨后端对齐工程). That boundary is the same one
39+
* every #5869 refusal has had since #9228, and it is not widened here.
40+
*/
41+
42+
import { describe, it, expect } from 'vitest';
43+
import { parseFilterAST } from '@objectstack/spec/data';
44+
45+
import { match } from './memory-matcher.js';
46+
47+
type Refusal = Error & { code?: string; status?: number };
48+
49+
/** The card's own fixture, in both readings of "no value" (#13357). */
50+
const NULLED_ROWS: Array<Record<string, unknown>> = [
51+
{ id: '1', name: 'a' },
52+
{ id: '3', name: null },
53+
];
54+
const MISSING_ROWS: Array<Record<string, unknown>> = [
55+
{ id: '1', name: 'a' },
56+
{ id: '3' },
57+
];
58+
59+
/**
60+
* The direct-caller pipeline, exactly as the module note describes it: compile
61+
* first, evaluate second. The refusal has to land in step one — if compile
62+
* returns, the matcher HAS been reached and the pin below fails on the
63+
* sentinel rather than on a missing throw.
64+
*/
65+
function compileThenMatch(rows: Array<Record<string, unknown>>, where: unknown): string[] {
66+
const condition = parseFilterAST(where);
67+
return rows.filter((row) => match(row, condition)).map((row) => String(row.id));
68+
}
69+
70+
const refusalOf = (run: () => unknown): Refusal => {
71+
try {
72+
run();
73+
} catch (e) {
74+
return e as Refusal;
75+
}
76+
throw new Error('expected the compile face to refuse this filter, but it returned');
77+
};
78+
79+
describe('[#13357] a refused null list member cannot reach the matcher (ruled 2026-08-31)', () => {
80+
it.each([
81+
['$in: [null]', { name: { $in: [null] } }],
82+
['$nin: [null]', { name: { $nin: [null] } }],
83+
['$between: [null, null]', { name: { $between: [null, null] } }],
84+
['$between: [null, max]', { name: { $between: [null, 'z'] } }],
85+
['$between: [min, null]', { name: { $between: ['a', null] } }],
86+
])('%s aborts at the compile face on BOTH readings of "no value"', (_label, where) => {
87+
// Record-independent by construction — the compile face never sees a row —
88+
// so the two readings that split the matcher (#13357's table) cannot even
89+
// be posed. Driving both anyway is the point of the pin: neither fixture
90+
// gets an answer, so there is no divergence left to observe.
91+
for (const rows of [NULLED_ROWS, MISSING_ROWS]) {
92+
const err = refusalOf(() => compileThenMatch(rows, where));
93+
expect(err.code, _label).toBe('INVALID_FILTER');
94+
expect(err.status, _label).toBe(400);
95+
}
96+
});
97+
98+
it('the pipeline itself is real — a legal list compiles and the matcher answers', () => {
99+
// Positive control: without it, the refusals above would also "pass" if
100+
// compileThenMatch were broken outright.
101+
expect(compileThenMatch(NULLED_ROWS, { name: { $in: ['a'] } })).toEqual(['1']);
102+
expect(compileThenMatch(MISSING_ROWS, { name: { $nin: ['a'] } })).toEqual(['3']);
103+
});
104+
105+
it('an EMPTY list still passes the same face — the refusal is null-shaped, not list-shaped', () => {
106+
// `$in: []` / `$nin: []` are declared predicates ("matches nothing" /
107+
// "matches everything") and PR #13630 pins them downstream; the carve-out
108+
// must not catch them.
109+
expect(compileThenMatch(NULLED_ROWS, { name: { $in: [] } })).toEqual([]);
110+
expect(compileThenMatch(NULLED_ROWS, { name: { $nin: [] } })).toEqual(['1', '3']);
111+
});
112+
});

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,69 @@ describe('Door 2 lowers FilterArray to FilterCondition before the driver (#5158)
483483
expect(err.message).toMatch(/where\.amount\.\$between/);
484484
});
485485

486+
// ── [#13357] the null carve-out, ruled 2026-08-31: refused at this seam, ──
487+
// ── so the drivers' three-way null-member divergence is UNREACHABLE ──────
488+
//
489+
// Ruling point 3's negative pin, engine half: the witness is the recording
490+
// driver's call log, not the thrown envelope alone — an envelope asserted
491+
// without the log would also pass if a DRIVER had thrown it, i.e. if the
492+
// refusal had not preceded the dispatch, which is the one thing these
493+
// assertions exist to prove. The compile-face half (`parseFilterAST`, both
494+
// input forms) is pinned in `@objectstack/spec`'s
495+
// `filter-comparand-shape.test.ts`; the matcher-side statement lives in
496+
// driver-memory's `memory-null-list-member-unreachable.test.ts`. ⛔ Nothing
497+
// here asserts what any backend WOULD have answered — the divergence is
498+
// sealed, not reconciled (#5299 stays declined).
499+
500+
it.each([
501+
['$in: [null]', { stage: { $in: [null] } }],
502+
['$nin: [null]', { stage: { $nin: [null] } }],
503+
['$between: [null, null]', { amount: { $between: [null, null] } }],
504+
['$between: [null, max]', { amount: { $between: [null, 20] } }],
505+
])('a null list member is refused on EVERY verb before any driver call — %s', async (_l, where) => {
506+
await expect(engine.find('deal', { where }))
507+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
508+
await expect(engine.findOne('deal', { where }))
509+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
510+
await expect(engine.count('deal', { where } as unknown as EngineCountOptions))
511+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
512+
await expect(engine.aggregate('deal', {
513+
where: where as unknown as EngineAggregateOptions['where'],
514+
groupBy: ['stage'],
515+
aggregations: [{ function: 'count', field: 'id', alias: 'n' }],
516+
})).rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
517+
await expect(engine.update('deal', { amount: 1 }, { where, multi: true } as any))
518+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
519+
await expect(engine.delete('deal', { where, multi: true } as any))
520+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
521+
// The negative half: refused BEFORE the store — no read, no write, no row
522+
// moved. (The count() control below adds its own read, so it runs after.)
523+
expect(reads).toHaveLength(0);
524+
expect(writes).toHaveLength(0);
525+
expect(await engine.count('deal')).toBe(3);
526+
});
527+
528+
it('the null-member refusal is not vacuous — the same list WITHOUT null reaches the driver', async () => {
529+
// Positive control for the zero-call reading above: one member removed,
530+
// same operator, same field, and the dispatch happens.
531+
const rows = await engine.find('deal', { where: { stage: { $in: ['won'] } } });
532+
expect(reads).toHaveLength(1);
533+
expect(lastWhere()).toEqual({ stage: { $in: ['won'] } });
534+
expect(rows.map((r: any) => r.id).sort()).toEqual(['d1', 'd3']);
535+
});
536+
537+
it('a nested null member is refused at its own path, engine prefix and all', async () => {
538+
const err = await engine.find(
539+
'deal',
540+
{ where: { $or: [{ stage: { $nin: [null] } }] } },
541+
).then(() => null, (e: any) => e);
542+
expect(err?.status).toBe(400);
543+
expect(err?.code).toBe('INVALID_FILTER');
544+
expect(err.message).toMatch(/^find\('deal'\): /);
545+
expect(err.message).toContain('where.$or[0].stage.$nin[0]');
546+
expect(reads).toHaveLength(0);
547+
});
548+
486549
// ── what must KEEP working: the declared list shapes ───────────────────
487550

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

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,68 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922
9898
expect(err.status).toBe(400);
9999
});
100100

101+
// ── the null carve-out, ruled 2026-08-31 (#13357; $between is #13495) ──
102+
103+
it.each([
104+
['$in, lowered array form', [['stage', 'in', [null]]]],
105+
['$in, object passthrough', { stage: { $in: [null] } }],
106+
['$nin, lowered array form', [['stage', 'not_in', [null]]]],
107+
['$nin, object passthrough', { stage: { $nin: [null] } }],
108+
['$in with a real neighbour', { stage: { $in: ['won', null] } }],
109+
])('refuses a null list MEMBER — %s', (_label, where) => {
110+
const err = refusalOf(() => parseFilterAST(where));
111+
expect(err.code).toBe(StandardErrorCode.enum.INVALID_FILTER);
112+
expect(err.status).toBe(400);
113+
});
114+
115+
it.each([
116+
['[null, null]', { at: { $between: [null, null] } }],
117+
['[null, max]', { at: { $between: [null, '2026-07-15'] } }],
118+
['[min, null]', { at: { $between: ['2026-07-01', null] } }],
119+
])('refuses a null $between BOUND — %s', (_label, where) => {
120+
const err = refusalOf(() => parseFilterAST(where));
121+
expect(err.code).toBe(StandardErrorCode.enum.INVALID_FILTER);
122+
expect(err.status).toBe(400);
123+
});
124+
125+
it('the null-member refusal prescribes the ruling\'s explicit spelling', () => {
126+
// 2026-08-31: 「等于 X 或为空」的合法拼法是显式的 $or + $null — the
127+
// refusal must spell it out, in both halves, and still name operator,
128+
// field, position and authoring spellings (the #5346/#5348 contract).
129+
const err = refusalOf(() => parseFilterAST({ stage: { $nin: [null] } }));
130+
expect(err.message).toMatch(/^Operator "\$nin" on field "stage"/);
131+
expect(err.message).toContain('where.stage.$nin[0]');
132+
expect(err.message).toContain('{"$or": [{"stage": {"$in": […]}}, {"stage": {"$null": true}}]}');
133+
expect(err.message).toContain('{"$null": false}');
134+
expect(err.message).toMatch(/Authoring spellings: nin, not_in, notin/);
135+
expect(err.message).toMatch(/UNFILTERED result set/);
136+
});
137+
138+
it('the null-bound refusal points at the offending index and the working alternatives', () => {
139+
const err = refusalOf(() => parseFilterAST({ at: { $between: ['2026-07-01', null] } }));
140+
expect(err.message).toMatch(/^Operator "\$between" on field "at" requires two non-null bounds/);
141+
expect(err.message).toContain('where.at.$between[1]');
142+
expect(err.message).toContain('"$gte"/"$lte"');
143+
expect(err.message).toContain('{"at": {"$null": true}}');
144+
expect(err.message).toMatch(/UNFILTERED result set/);
145+
});
146+
147+
it('a null member is refused at its own path inside $and / $or / $not too', () => {
148+
expect(refusalOf(() => parseFilterAST({ $not: { stage: { $in: [null] } } })).message)
149+
.toContain('where.$not.stage.$in[0]');
150+
expect(refusalOf(() => parseFilterAST({ $or: [{ stage: { $nin: [null] } }] })).message)
151+
.toContain('where.$or[0].stage.$nin[0]');
152+
});
153+
154+
it('refuses ONLY null — falsy and empty-ish members are values, not absence', () => {
155+
// The carve-out is null-shaped and nothing wider: #5041's and #5234's
156+
// member questions stand untouched, and every falsy VALUE keeps working.
157+
expect(parseFilterAST({ n: { $in: [0, false, ''] } })).toEqual({ n: { $in: [0, false, ''] } });
158+
expect(parseFilterAST({ n: { $nin: [0, false, ''] } })).toEqual({ n: { $nin: [0, false, ''] } });
159+
expect(parseFilterAST({ at: { $between: ['', ''] } })).toEqual({ at: { $between: ['', ''] } });
160+
expect(parseFilterAST({ n: { $between: [0, 0] } })).toEqual({ n: { $between: [0, 0] } });
161+
});
162+
101163
// ── the wording contract (#5346 / #5348), unchanged by the move ────────
102164

103165
it('names the operator, the field, what arrived, where, and the fix', () => {
@@ -134,6 +196,13 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922
134196
[['stage', 'not_in', 'won']],
135197
[['stage', 'in', 'won']],
136198
[['amount', 'between', 5]],
199+
// The 2026-08-31 null carve-out (#13357/#13495): the prescribed $or +
200+
// $null spelling makes these the LONGEST messages this door assembles,
201+
// so they live inside the same unrelaxed bound.
202+
{ stage: { $in: [null] } },
203+
{ stage: { $nin: [null] } },
204+
{ close_date: { $between: [null, null] } },
205+
{ close_date: { $between: ['2026-07-01', null] } },
137206
]) {
138207
const err = refusalOf(() => parseFilterAST(where, "find('deal')"));
139208
expect(err.message.length, JSON.stringify(where)).toBeLessThan(500);

0 commit comments

Comments
 (0)