Skip to content

Commit f7a60d9

Browse files
huangyiireneclaude
andauthored
fix(driver-mongodb): refuse malformed $between, undeclared node-level $-keys and { field: {} } (#5346, #5376) (#7528)
driver-mongodb was the last backend still ANSWERING three filter shapes every other backend refuses. All three failed the same way — the query ran, reported nothing, and returned a row set nobody asked for. Measured through translateFilter on origin/main @ 76d74ec: { score: { $between: 5 } } => {"score":{}} { $where: 'return true' } => {"$where":"return true"} { stage: {} } => {"stage":{}} All three now refuse with INVALID_FILTER / 400 (ADR-0112), naming the position, through the existing unsupportedFilterError constructor — no third envelope. - Malformed $between: the emitter arm wrote both bounds inside `if (Array.isArray(value) && value.length === 2)` with no else, so a malformed comparand dropped the range and normalised the field to {}. The twin, down to the missing else, of the arm #5328 fixed on driver-memory. - An undeclared $-key in a NODE position: the translator's switch knows three combinators; every other key took the FIELD path and, carrying no $-prefixed sub-keys, fell to implicit equality and was written into the outgoing document verbatim — where MongoDB EXECUTED it. $where is server-side JavaScript. The emitter's field-level default: arm has named these exact spellings as its P0 reason for refusing them one level down for two releases; the node position had no such gate. driver-sql / driver-sqlite-wasm compiled a column of that name (#5348, since refused), driver-memory refused (#5324) — only here was it evaluated. - { field: {} }: ruled REFUSE on #5240, gated on the four other backends by #5327. This driver translated it to { field: {} }, which MongoDB reads as "deep-equal to the empty document" — not the FALSE the ruling declined to take, but a DIFFERENT filter that looks like FALSE until a document stores {}. Each gate sits on the validating walk (classifyFilterKey), beside the existing $null (#5347) and $icontains (#6520) gates, not in the emitter — the emitter is skipped wholesale when a boolean identity settles the enclosing node. Measured before the fix, all three of `{ $or: [ {}, { $where: 'x' } ] }`, `{ $or: [ {}, { score: { $between: 5 } } ] }` and `{ $or: [ { a: {} }, {} ] }` translated to {} — match-all. The $between emitter arm keeps a local check as defense for its own invariant, the dual-gate pattern the $null arm documents. Wordings are driver-sql's / driver-memory's leading sentences verbatim — one condition, one wording (#5240) — with no package-name prefix (#3867). The verdict is unchanged: a field key still classifies as 'clause'. This adds refusals in front of it rather than reclassifying a surviving shape, so every filter that translated before translates byte-identically. Tests: 37 new cases in mongodb-filter-shape-refusal.test.ts, each asserting code and status alongside the pinned sibling wording, plus a walk-not-emitter block pinning the three sibling-dependence fixtures. The #5239 pin that recorded { field: {} } as "still not ruled on" is rewritten to record the ruling arriving. Package suite 269 passed / 143 skipped (live-mongod halves are skipIf-gated; mongodb-memory-server's binary is not downloadable here). check:driver-conformance OK. Claude-Session: https://claude.ai/code/session_018oM7XYyQ6AveqQs6eqxDdv Co-authored-by: Claude <noreply@anthropic.com>
1 parent b78016f commit f7a60d9

5 files changed

Lines changed: 631 additions & 24 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
"@objectstack/driver-mongodb": patch
3+
---
4+
5+
fix(driver-mongodb): refuse malformed `$between`, undeclared node-level `$`-keys and `{ field: {} }` (#5346, #5376)
6+
7+
`driver-mongodb` was the last backend still ANSWERING three filter shapes every
8+
other backend refuses. All three failed the same way — the query ran, reported
9+
nothing, and returned a row set nobody asked for. Measured through
10+
`translateFilter` (a pure function whose output *is* the document MongoDB
11+
receives):
12+
13+
```
14+
{ score: { $between: 5 } } => {"score":{}}
15+
{ $where: 'return true' } => {"$where":"return true"}
16+
{ stage: {} } => {"stage":{}}
17+
```
18+
19+
All three now refuse with `INVALID_FILTER` / 400 (ADR-0112), naming the position
20+
(`filter.$or[1].score.$between`), through the same `unsupportedFilterError`
21+
constructor this package's other filter refusals already used — no new envelope.
22+
23+
- **Malformed `$between`** — the emitter arm wrote both bounds inside
24+
`if (Array.isArray(value) && value.length === 2)` and had no `else`, so a
25+
malformed comparand dropped the whole range and normalised the field to `{}`.
26+
The twin, down to the missing `else`, of the arm #5328 fixed on
27+
`driver-memory`. The leading sentence is `driver-sql`'s verbatim — one
28+
condition, one wording (#5240).
29+
30+
- **An undeclared `$`-key in a NODE position** — the severe one. The translator's
31+
switch knows three combinators (`$and` / `$or` / `$not`); every other key took
32+
the FIELD path, and a key carrying no `$`-prefixed sub-keys fell to implicit
33+
equality and was written into the outgoing document verbatim, where **MongoDB
34+
executed it**. `$where` is server-side JavaScript; `$nor` is a real combinator
35+
the Filter Protocol never declared. The emitter's field-level `default:` arm
36+
has named exactly these spellings as its P0 reason for refusing them one level
37+
down for two releases — that gate was only ever installed at the field
38+
position. On the other backends the same input compiled to a column name and
39+
returned zero rows (#5348 / cloud#1077, since refused) or was already refused
40+
(#5324); only here was it evaluated.
41+
42+
- **`{ field: {} }`** — a field constrained by zero operators, ruled REFUSE on
43+
#5240 and gated on `driver-sql` / `driver-sqlite-wasm` / `driver-memory` /
44+
`formula` by #5327. This driver translated it to `{ field: {} }`, which MongoDB
45+
reads as "the field is deep-equal to the empty document" — not the FALSE the
46+
ruling declined to take, but a DIFFERENT filter that merely looks like FALSE
47+
until a document actually stores `{}` there.
48+
49+
Each gate sits on the validating walk (`classifyFilterKey`), beside the existing
50+
`$null` (#5347) and `$icontains` (#6520) gates, rather than in the emitter — the
51+
emitter is skipped wholesale when a boolean identity settles the enclosing node,
52+
so a gate there would fire or not depending on a shape's SIBLINGS. Measured
53+
before the fix: `{ $or: [ {}, { $where: 'x' } ] }`,
54+
`{ $or: [ {}, { score: { $between: 5 } } ] }` and `{ $or: [ { a: {} }, {} ] }`
55+
all translated to `{}` — match-all. The `$between` emitter arm additionally
56+
keeps a local check as defense for its own invariant, the dual-gate pattern the
57+
`$null` arm documents; both sites call one constructor with one path spelling.
58+
59+
Every filter that translated before still translates byte-identically: this adds
60+
refusals in front of the verdict, it does not reclassify any surviving shape.
61+
Authored filters using these shapes were already not doing what they appeared to
62+
do, and now say so instead of answering silently.

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

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -274,13 +274,29 @@ describe('[#5239] translateFilter reduces empty combinators to their boolean ide
274274
expect(translateFilter({ stage: 'won', limit: 5 })).toEqual({ stage: 'won' });
275275
});
276276

277-
it('a field constrained by zero operators is still not ruled on (#5240)', () => {
278-
// `{ stage: {} }` translates to an exact-match on an empty document, as it
279-
// always did. Reducing it to TRUE would have decided #5240 from here.
280-
expect(translateFilter({ stage: {} })).toEqual({ stage: {} });
281-
expect(translateFilter({ $or: [{ stage: {} }, { owner: 'u1' }] })).toEqual({
282-
$or: [{ stage: {} }, { owner: 'u1' }],
283-
});
277+
it('a field constrained by zero operators is now REFUSED (#5240 / #5376)', () => {
278+
// This pin used to read "still not ruled on", and asserted that
279+
// `{ stage: {} }` translated to an exact-match on an empty document as it
280+
// always did — #5239 deliberately declined to decide #5240 from inside a
281+
// reduction change, and recorded the non-decision here.
282+
//
283+
// #5376 is that decision arriving: #5240 ruled REFUSE, #5327 gated the
284+
// four other backends, and this driver was the fifth and last still
285+
// answering. What #5239 was careful about is still true and still pinned
286+
// below — the VERDICT did not change. The key is classified `'clause'`
287+
// exactly as before; a refusal was added in front of it, not a
288+
// reclassification to TRUE, which is the one direction that would have
289+
// turned the shape into match-all.
290+
const err = refusalOf({ stage: {} });
291+
expect(err.code).toBe('INVALID_FILTER');
292+
expect(err.status).toBe(400);
293+
expect(err.message).toContain('Field constraint at filter.stage carries zero operators');
294+
295+
// And inside a combinator, where the emitter would never have reached it.
296+
const nested = refusalOf({ $or: [{ stage: {} }, { owner: 'u1' }] });
297+
expect(nested.code).toBe('INVALID_FILTER');
298+
expect(nested.status).toBe(400);
299+
expect(nested.message).toContain('filter.$or[0].stage');
284300
});
285301

286302
it('an ARRAY where is outside the reduction entirely (#5158/#5329)', () => {

0 commit comments

Comments
 (0)