Skip to content

Commit 71f205d

Browse files
os-zhuangclaude
andauthored
fix(driver-mongodb): 空 $and/$or/$not 归约成布尔单位元,非 filter 节点先响亮拒收 (#5239) (#5323)
* fix(driver-mongodb): reduce empty $and/$or/$not to their boolean identity, refusing non-nodes first (#5239) `translateFilter` passed combinator arrays through verbatim, and MongoDB answers an empty one with neither TRUE nor FALSE but a third behaviour: it refuses the query (`$and/$or/$nor must be a nonempty array`). So `{$and: []}` and `{$or: []}` reached find/count/updateMany/deleteMany as a server error carrying no ADR-0112 code, while driver-sql (#5134), driver-memory and formula all answered them as identities. Replaced with the same STRUCTURAL three-valued reduction: reduce the whole tree to true/false/clause first, then emit. Empty `$and` becomes TRUE (no condition); empty `$or` becomes FALSE and emits a real zero-row condition (`{_id: {$in: []}}`) — emitting nothing would be `{}`, which find/updateMany/ deleteMany read as EVERY document, the opposite answer. Every `$and`/`$or` array emitted is therefore guaranteed non-empty. Shape rejection lands in the same change and runs BEFORE any identity: measured on main, `{$or: [new Date()]}` translated to `{$or: [{}]}` (every document) and `{$or: 'x'}` / `{$not: null}` translated to `{}` (every document). updateMany and deleteMany translate the same `where`, where that is data loss rather than a wrong row count. Non-nodes now raise INVALID_FILTER / 400 naming the position; the gate judges by PROTOTYPE, since Date/RegExp/class instances satisfy `typeof x === 'object'` while enumerating empty. spec is documentation only: FilterConditionSchema's contract TSDoc now states the NULL-safe `$not` semantics ruled in #5146, and filter-logic-conformance.ts records the measured matrix for the three ruled-but-not-yet-enrolled case families. The four FILTER_LOGIC_CASES rows #5239 asks for are deliberately NOT added: read-scope-sql and the analytics filter-normalizer, both enrolled backends, refuse empty combinators fail-closed by design and pinned test, which contradicts the identity ruling — escalated as #5322. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB * docs(spec): 同步轮散文校订 —— 对 main@cdfbee2f0 实测后落笔 (#5239) 本 PR 的 spec 半边是契约文档,机械合并会把 base 时代的论断带上 main; 逐条对当前 origin/main 实测后校订: - FilterConditionSchema 的 NULL-safe $not 合规段:read-scope-sql 已由 #5326 对齐(#5297 关闭)、filter-normalizer 已由 #5335 对齐(#5325 关闭),七个面全部一致 —— 「尚未合规、指向 #5297」改写为已闭合的事实。 - 「Deliberately NOT declared here」:空组合子单位元由「两立场对峙、 上交 #5322」改为「#5322 已拍板取单位元,实施在 #5365(排在本 PR 之后 合入);main 上两个 analytics 编译器今天仍拒收,故本 PR 仍不在此声明, 声明随 #5365 翻正」;{ field: {} } 由「无后端设闸」改为「#5327 已闸 四家,driver-mongodb 是唯一还在作答的后端(#5376)」。 - filter-logic-conformance.ts 族 2/3 状态行同步重测:族 2 的后端阻塞 已清零,唯余 fixture 工作;族 3 的四家闸门已落,阻塞改为表形扩展 + mongodb(#5376)。族 1 段落一字未动 —— 由 #5365 在其同步轮删除, 已约定分工。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB * chore(spec): gen:schema 前移 authorable-surface 锚点至合并后的 merge-base (cdfbee2) 合并 origin/main 后重建时由 gen:schema 写出(先 commit merge 再跑生成, #5370 的锚点倒退陷阱按序避开):baseRev 28ad90e → cdfbee2,随锚点带入 #5312 的 api/ApiEndpoint 键面。check:generated 9/9 up to date, check:authorable-surface 绿。非手改。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cdfbee2 commit 71f205d

7 files changed

Lines changed: 830 additions & 22 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/driver-mongodb": patch
3+
"@objectstack/spec": patch
4+
---
5+
6+
fix(driver-mongodb): 空 `$and` / `$or` / `$not` 按布尔单位元归约,非 filter 节点先响亮拒收 (#5239)
7+
8+
`translateFilter` 过去把组合子数组**原样透传**给 MongoDB。而 MongoDB 对空数组既不答
9+
TRUE 也不答 FALSE,是第三种行为:**直接拒绝整条查询**(`$and/$or/$nor must be a
10+
nonempty array`)。于是 `{ $and: [] }` 与 `{ $or: [] }` 一路走到 `find` /
11+
`countDocuments` / `updateMany` / `deleteMany`,变成一个不带 ADR-0112 错误码的服务端
12+
异常 —— 而 `driver-sql`(#5134 / PR #5243)、`driver-memory`、`formula` 三家早已按单位
13+
元作答。
14+
15+
改成与它们同一套**结构性三值归约**:先把整棵 filter 树判成 `true` / `false` /
16+
`clause`,再据此产出。空 `$and` 归约为 TRUE(不产出条件),空 `$or` 归约为 FALSE 并产出
17+
一个**真实的零行条件** `{ _id: { $in: [] } }` —— 关键在于「什么都不产出」等于 `{}`,而
18+
`find` / `updateMany` / `deleteMany` 把 `{}` 读作**全部文档**,方向正好相反。`{}` 作为
19+
`$or` 的分支仍是 TRUE 析取项,`{ $not: {} }` 仍是零行,这两条 MongoDB 本来就与布尔代数
20+
一致,所以归约按结构做而不是只判 `length === 0`。发出的每个 `$and` / `$or` 数组因此都保
21+
证非空。
22+
23+
**同一改动里的形状拒收**,顺序是先拒收后归约:单位元把「这个节点没有谓词」读作「匹配全部
24+
文档」,所以空节点必须只有一个成因。改前实测,本驱动这一格比 `driver-sql` 当年更糟 ——
25+
`{ $or: [new Date()] }` 译成 `{ $or: [{}] }`,即**每一份文档**;`{ $or: 'x' }` 与
26+
`{ $not: null }` 译成 `{}`,同样是每一份文档。`updateMany` / `deleteMany` 走的是同一个
27+
translate 层,在那里「放宽到全部文档」不是行数不对而是数据丢失。现在这类操作数按
28+
ADR-0112 以 `INVALID_FILTER` / `status: 400` 拒收,并在消息里点出位置
29+
(`filter.$or[0]`)。`Date` / `RegExp` / class 实例都满足 `typeof x === 'object'` 却枚举
30+
为空,故判定按**原型**而非 `typeof`。
31+
32+
`packages/spec` 侧只动文档:`FilterConditionSchema` 的契约 TSDoc 写明 `$not` 的
33+
**NULL-safe** 语义(#5146 维护者拍板 —— 被比较列为 NULL 的行不满足被否定的条件,应当被
34+
返回,即 `NOT (…) OR col IS NULL`),并在 `filter-logic-conformance.ts` 记下三族已裁定但
35+
**尚未进表**的 case 及其实测矩阵。无运行时行为变化,无 API 变化。

‎packages/plugins/driver-mongodb/src/mongodb-filter-boolean-identity.test.ts‎

Lines changed: 370 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/plugins/driver-mongodb/src/mongodb-filter.ts‎

Lines changed: 248 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,190 @@ import {
3232
type TemporalFieldKindResolver,
3333
} from './mongodb-temporal.js';
3434

35+
// ── [#5239] Boolean identities for the empty combinators ─────────────────────
36+
37+
/**
38+
* Keys that reach this translator inside a `where` object but describe the
39+
* QUERY rather than a predicate. The emitter has always skipped them; the
40+
* reduction below has to agree, or "what this node is worth as a boolean" and
41+
* "what this node emits" could disagree — which is the class of defect the
42+
* reduction exists to remove.
43+
*/
44+
const QUERY_LEVEL_KEYS = new Set(['limit', 'offset', 'fields', 'orderBy']);
45+
46+
/**
47+
* A MongoDB query document that matches NO document, for the `false` verdict.
48+
*
49+
* `$in: []` is empty-set membership — no value satisfies it, on every server
50+
* version, with no dependence on a collection's fields. `_id` is the one field
51+
* MongoDB guarantees exists. This is the shape #5239 names, and it is emitted
52+
* as a REAL condition: `$or: []` must reach `find` / `updateMany` /
53+
* `deleteMany` as "zero rows", never as the absent filter `{}`, which those
54+
* three read as "every document".
55+
*/
56+
function matchNothing(): Filter<any> {
57+
return { _id: { $in: [] } };
58+
}
59+
60+
/**
61+
* [#5239, mirroring #5134] What a filter node is worth as a boolean, decided
62+
* BEFORE any query document is built.
63+
*
64+
* - `'true'` — matches every document; the translator emits no condition.
65+
* - `'false'` — matches no document; the translator emits {@link matchNothing}.
66+
* - `'clause'` — carries at least one real predicate; translate it normally.
67+
*/
68+
type FilterVerdict = 'true' | 'false' | 'clause';
69+
70+
/**
71+
* [#5239] Is `value` a Filter Protocol NODE — the shape `FilterConditionSchema`
72+
* declares for every element of `$and`/`$or` and for the operand of `$not`?
73+
*
74+
* The PROTOTYPE check is the load-bearing half. The identity reduction turns
75+
* "this node has no predicates" into "matches every document", so any object
76+
* whose own enumerable keys are empty reads as TRUE. A `Date`, a `RegExp`, a
77+
* `Map` or a class instance all satisfy `typeof x === 'object' &&
78+
* !Array.isArray(x)` while enumerating to nothing — accepting them would
79+
* PROMOTE garbage from "silently mistranslated" to "matches all documents",
80+
* which on `deleteMany` is not a wrong row count but data loss. Measured on
81+
* `main` before this change: `{ $or: [new Date()] }` translated to
82+
* `{ $or: [{}] }`, i.e. every document, and `{ $or: 'x' }` / `{ $not: null }`
83+
* translated to `{}`, likewise every document.
84+
*/
85+
function isFilterNode(value: unknown): value is Record<string, unknown> {
86+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
87+
const proto = Object.getPrototypeOf(value);
88+
return proto === Object.prototype || proto === null;
89+
}
90+
91+
/**
92+
* [#5239] The gate that gives "this group translated to empty" exactly ONE
93+
* cause.
94+
*
95+
* Identity reduction is sound only once an empty group can mean "the author
96+
* wrote an empty group" and nothing else. Refusing non-nodes here — before any
97+
* identity is applied — is what makes the reduction safe rather than a
98+
* promotion of garbage to match-all. Same discipline as #5134 in `driver-sql`
99+
* and cloud#1073 in Turso's `RemoteTransport.buildWhereSQL`.
100+
*/
101+
function assertFilterNode(value: unknown, path: string): asserts value is Record<string, unknown> {
102+
if (isFilterNode(value)) return;
103+
throw unsupportedFilterError(
104+
`Filter node at ${path} is a ${describeFilterOperand(value)} (${safeShapePreview(value)}), not a filter ` +
105+
`condition object. Every element of "$and"/"$or" and the operand of "$not" must be a plain object of ` +
106+
`field constraints (e.g. { "status": "active" }) or nested combinators — @objectstack/spec ` +
107+
`FilterConditionSchema declares this position as a FilterCondition. It is refused rather than skipped ` +
108+
`because skipping it would silently change which documents match.`,
109+
);
110+
}
111+
112+
/** [#5239] `$and`/`$or` take a list; anything else is refused, never coerced. */
113+
function assertFilterNodeList(value: unknown, key: string, path: string): asserts value is unknown[] {
114+
if (Array.isArray(value)) return;
115+
throw unsupportedFilterError(
116+
`Filter combinator "${key}" at ${path} requires an array of filter conditions, but received a ` +
117+
`${describeFilterOperand(value)} (${safeShapePreview(value)}). @objectstack/spec FilterConditionSchema ` +
118+
`declares "${key}" as FilterCondition[].`,
119+
);
120+
}
121+
122+
/**
123+
* [#5239] Reduce one filter node to its boolean verdict, validating shapes on
124+
* the way down.
125+
*
126+
* A node is the AND of its entries, so FALSE dominates and a node with no
127+
* entries at all is TRUE (the empty conjunction) — which is why `{}` is a TRUE
128+
* disjunct inside `$or` and why `{ $not: {} }` is FALSE.
129+
*
130+
* The walk does NOT short-circuit: a `$or: []` sibling must not stop it from
131+
* reaching — and refusing — a malformed node further along, or the shape gate
132+
* would depend on key order.
133+
*
134+
* Deciding STRUCTURALLY, rather than translating and then asking whether the
135+
* emitted document came out empty, is the point. "Nothing was emitted" cannot
136+
* distinguish "the author wrote an empty group" from "something failed to
137+
* translate"; a structural verdict has no such blind spot.
138+
*/
139+
function reduceFilterNode(node: Record<string, unknown>, path: string): FilterVerdict {
140+
let sawFalse = false;
141+
let sawClause = false;
142+
for (const [key, value] of Object.entries(node)) {
143+
const verdict = reduceFilterKey(key, value, path);
144+
if (verdict === 'false') sawFalse = true;
145+
else if (verdict === 'clause') sawClause = true;
146+
}
147+
return sawFalse ? 'false' : sawClause ? 'clause' : 'true';
148+
}
149+
150+
/** [#5239] The verdict of ONE key of a filter node. */
151+
function reduceFilterKey(key: string, value: unknown, path: string): FilterVerdict {
152+
const here = path ? `${path}.${key}` : key;
153+
154+
if (key === '$and' || key === '$or') {
155+
assertFilterNodeList(value, key, here);
156+
let sawTrue = false;
157+
let sawFalse = false;
158+
let sawClause = false;
159+
value.forEach((element, index) => {
160+
const elementPath = `${here}[${index}]`;
161+
assertFilterNode(element, elementPath);
162+
const verdict = reduceFilterNode(element, elementPath);
163+
if (verdict === 'true') sawTrue = true;
164+
else if (verdict === 'false') sawFalse = true;
165+
else sawClause = true;
166+
});
167+
// `$and: []` → no FALSE, no clause → TRUE (the AND identity).
168+
if (key === '$and') return sawFalse ? 'false' : sawClause ? 'clause' : 'true';
169+
// `$or: []` → no TRUE, no clause → FALSE (the OR identity). MongoDB itself
170+
// answers neither: it rejects the empty array outright
171+
// (`$and/$or/$nor must be a nonempty array`), so this filter used to be a
172+
// 500-shaped throw rather than a verdict.
173+
return sawTrue ? 'true' : sawClause ? 'clause' : 'false';
174+
}
175+
176+
if (key === '$not') {
177+
assertFilterNode(value, here);
178+
const inner = reduceFilterNode(value, here);
179+
// NOT TRUE ≡ FALSE — so `{ $not: {} }` matches nothing.
180+
return inner === 'true' ? 'false' : inner === 'false' ? 'true' : 'clause';
181+
}
182+
183+
// Query-level keys carry no predicate; the emitter skips them and so does the
184+
// verdict, so the two never disagree about what this node is worth.
185+
if (QUERY_LEVEL_KEYS.has(key)) return 'true';
186+
187+
// [#5347] `$null`'s comparand is a boolean by declaration. Checked on THIS
188+
// walk rather than in the emitter's `$null` arm because the emitter is
189+
// skipped wholesale by a boolean identity: `{ $or: [ {}, { stage: { $null:
190+
// 'yes' } } ] }` reduces to TRUE on its first disjunct, so an emitter-side
191+
// gate would refuse the comparand or ignore it depending on its SIBLINGS —
192+
// the "gate conditional on evaluation order" #5368 placed driver-sql's and
193+
// driver-memory's gates on their walks to rule out. #5368 landed this
194+
// driver's gate in the emitter because no walk existed here yet; #5239's
195+
// reduction is that walk, so the gate moved with it. `hasOwnProperty` rather
196+
// than `'$null' in value` so an inherited key can never trip the gate.
197+
// `{ $null: undefined }` still counts: the key is own and enumerable, and
198+
// `undefined` is exactly one of the comparands #5347 measured a divergence
199+
// on.
200+
if (
201+
isFilterNode(value) &&
202+
Object.prototype.hasOwnProperty.call(value, '$null') &&
203+
typeof value.$null !== 'boolean'
204+
) {
205+
throw nonBooleanNullComparandError(key, value.$null, `${here}.$null`);
206+
}
207+
208+
// A field key always contributes a predicate. This stays `'clause'` even for
209+
// `{ field: {} }` (a field constrained by zero operators), which this
210+
// translator emits as `{ field: {} }` — an exact-match on an empty document.
211+
// That shape is a SEPARATE divergence, ruled REJECT in #5240 and since gated
212+
// on driver-sql / driver-sqlite-wasm / driver-memory / formula by #5327 —
213+
// this driver is now the one backend still answering it, tracked by #5376.
214+
// Classifying it as `'clause'` rather than `'true'` is precisely what keeps
215+
// this change from silently ruling on it.
216+
return 'clause';
217+
}
218+
35219
/**
36220
* [#5158] A `FilterArray` reached the driver unlowered — the twin of
37221
* `driver-sql`'s and `driver-memory`'s `filterArrayReachedDriverError`, word
@@ -150,11 +334,27 @@ export function translateFilter(
150334

151335
if (typeof where !== 'object') return {};
152336

153-
return translateCondition(where as Record<string, unknown>, temporalKind, 'filter');
337+
// [#5239] Shape gate + structural reduction FIRST, over the WHOLE tree. Only
338+
// a `'clause'` node reaches the emitter, so `translateCondition` never has to
339+
// ask whether what it built came out empty.
340+
const node = where as Record<string, unknown>;
341+
const verdict = reduceFilterNode(node, 'filter');
342+
if (verdict === 'true') return {};
343+
if (verdict === 'false') return matchNothing();
344+
345+
return translateCondition(node, temporalKind, 'filter');
154346
}
155347

156348
/**
157349
* Translate a FilterCondition object to a MongoDB filter.
350+
*
351+
* [#5239] Every combinator key is decided by {@link reduceFilterKey} BEFORE
352+
* anything is emitted, so an empty group is applied as its boolean IDENTITY
353+
* rather than dropped: a `'true'` key contributes nothing to the node's AND, a
354+
* `'false'` key contributes {@link matchNothing}, and only `'clause'` members
355+
* are translated. That is why every `$and`/`$or` array this function emits is
356+
* guaranteed non-empty — MongoDB rejects an empty one, and the old code handed
357+
* it straight through.
158358
*/
159359
function translateCondition(
160360
condition: Record<string, unknown>,
@@ -170,32 +370,50 @@ function translateCondition(
170370
for (const [key, value] of Object.entries(condition)) {
171371
switch (key) {
172372
case '$and':
173-
if (Array.isArray(value)) {
174-
andClauses.push({
175-
$and: value.map((sub, i) => translateCondition(sub as Record<string, unknown>, temporalKind, `${path}.$and[${i}]`)),
176-
});
177-
}
178-
break;
179-
180-
case '$or':
181-
if (Array.isArray(value)) {
182-
andClauses.push({
183-
$or: value.map((sub, i) => translateCondition(sub as Record<string, unknown>, temporalKind, `${path}.$or[${i}]`)),
184-
});
373+
case '$or': {
374+
const here = `${path}.${key}`;
375+
const keyVerdict = reduceFilterKey(key, value, path);
376+
// TRUE is the AND identity for the node — it adds no condition. FALSE
377+
// makes the node match nothing.
378+
if (keyVerdict === 'true') break;
379+
if (keyVerdict === 'false') {
380+
andClauses.push(matchNothing());
381+
break;
185382
}
383+
// `'clause'` guarantees at least one branch survives: a TRUE member
384+
// would have made a `$or` TRUE, a FALSE member would have made a `$and`
385+
// FALSE, and both were handled above. Dropping the identity members is
386+
// what makes `{ $or: [{ a: 'x' }, { $or: [] }] }` mean `a = x` rather
387+
// than an empty `$or` MongoDB refuses.
388+
const branches = (value as unknown[])
389+
.map((sub, index) => ({ sub: sub as Record<string, unknown>, index }))
390+
.filter(({ sub, index }) => reduceFilterNode(sub, `${here}[${index}]`) === 'clause')
391+
.map(({ sub, index }) => translateCondition(sub, temporalKind, `${here}[${index}]`));
392+
andClauses.push(key === '$and' ? { $and: branches } : { $or: branches });
186393
break;
394+
}
187395

188-
case '$not':
189-
if (value && typeof value === 'object') {
190-
const inner = translateCondition(value as Record<string, unknown>, temporalKind, `${path}.$not`);
191-
// MongoDB $not applies per-field; for top-level negation use $nor
192-
andClauses.push({ $nor: [inner] });
396+
case '$not': {
397+
const keyVerdict = reduceFilterKey(key, value, path);
398+
// NOT FALSE ≡ TRUE — no condition. NOT TRUE ≡ FALSE — zero documents.
399+
if (keyVerdict === 'true') break;
400+
if (keyVerdict === 'false') {
401+
andClauses.push(matchNothing());
402+
break;
193403
}
404+
const inner = translateCondition(
405+
value as Record<string, unknown>,
406+
temporalKind,
407+
`${path}.$not`,
408+
);
409+
// MongoDB $not applies per-field; for top-level negation use $nor
410+
andClauses.push({ $nor: [inner] });
194411
break;
412+
}
195413

196414
default:
197415
// Skip query-level keys that are not filter conditions
198-
if (['limit', 'offset', 'fields', 'orderBy'].includes(key)) continue;
416+
if (QUERY_LEVEL_KEYS.has(key)) continue;
199417

200418
if (value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
201419
// Check if this is an operator object (has $ keys)
@@ -323,6 +541,17 @@ function translateFieldOperators(
323541
// operator had three readings across four backends. Ruled REFUSED on
324542
// #5347: `FieldOperatorsSchema` declares `$null: z.boolean()`, and there
325543
// is no reading of a non-boolean here that is not a guess at intent.
544+
//
545+
// Since #5239's reduction, the LOAD-BEARING copy of this gate sits in
546+
// `reduceFilterKey`, on the validating walk: this emitter is skipped
547+
// wholesale whenever a boolean identity settles the enclosing node
548+
// (`{ $or: [ {}, { stage: { $null: 'yes' } } ] }` reduces to TRUE before
549+
// any arm here runs), so a gate only here would refuse or ignore the
550+
// comparand depending on the shape's SIBLINGS — the evaluation-order
551+
// dependence #5368 placed driver-sql's gates on its walk to rule out.
552+
// This arm keeps the check as local defense for its own invariant; both
553+
// call the one constructor with the same path spelling, so the wire
554+
// answer is identical whichever fires.
326555
case '$null':
327556
if (typeof value !== 'boolean') throw nonBooleanNullComparandError(field, value, `${path}.$null`);
328557
if (value === true) {

‎packages/plugins/driver-mongodb/src/mongodb-null-comparand-refusal.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,42 @@ describe('[#5347] driver-mongodb refuses a non-boolean $null comparand', () => {
9191
expect(refusalOf({ $not: { stage: { $null: 'x' } } }).message).toContain('filter.$not.stage.$null');
9292
});
9393

94+
/**
95+
* [#5239 x #5347] A boolean identity settling the enclosing node must not
96+
* skip this gate.
97+
*
98+
* #5368 landed this driver's gate in the emitter's `$null` arm — sound while
99+
* every node reached an emitter. #5239's structural reduction ended that: a
100+
* TRUE disjunct (`{}`) or a FALSE key (`$or: []`) settles the node before
101+
* any emitter runs, so an emitter-only gate would refuse or ignore the same
102+
* comparand depending on its SIBLINGS — the evaluation-order dependence
103+
* #5368 placed driver-sql's and driver-memory's gates on their validating
104+
* walks to rule out. The gate here moved to `reduceFilterKey` with the
105+
* merge of the two changes; these pins hold it there. Each fixture would
106+
* short-circuit to a verdict (match-all / match-nothing) if the walk did
107+
* not refuse first.
108+
*/
109+
it('is not skipped when a TRUE disjunct settles the $or (#5239 reduction)', () => {
110+
const err = refusalOf({ $or: [{}, { stage: { $null: 'yes' } }] });
111+
expect(err.code).toBe('INVALID_FILTER');
112+
expect(err.status).toBe(400);
113+
expect(err.message).toContain('filter.$or[1].stage.$null');
114+
});
115+
116+
it('is not skipped when a FALSE sibling key settles the node (#5239 reduction)', () => {
117+
const err = refusalOf({ $or: [], stage: { $null: 1 } });
118+
expect(err.code).toBe('INVALID_FILTER');
119+
expect(err.status).toBe(400);
120+
expect(err.message).toContain('filter.stage.$null');
121+
});
122+
123+
it('is not skipped inside a $not whose operand an identity settles (#5239 reduction)', () => {
124+
const err = refusalOf({ $not: { $or: [{}, { stage: { $null: 'x' } }] } });
125+
expect(err.code).toBe('INVALID_FILTER');
126+
expect(err.status).toBe(400);
127+
expect(err.message).toContain('filter.$not.$or[1].stage.$null');
128+
});
129+
94130
it('true and false translate exactly as before', () => {
95131
expect(translateFilter({ stage: { $null: true } })).toEqual({ stage: { $eq: null } });
96132
expect(translateFilter({ stage: { $null: false } })).toEqual({ stage: { $ne: null } });

0 commit comments

Comments
 (0)