Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .changeset/mongodb-filter-shape-refusals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
"@objectstack/driver-mongodb": patch
---

fix(driver-mongodb): refuse malformed `$between`, undeclared node-level `$`-keys and `{ field: {} }` (#5346, #5376)

`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` (a pure function whose output *is* the document MongoDB
receives):

```
{ 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
(`filter.$or[1].score.$between`), through the same `unsupportedFilterError`
constructor this package's other filter refusals already used — no new envelope.

- **Malformed `$between`** — the emitter arm wrote both bounds inside
`if (Array.isArray(value) && value.length === 2)` and had no `else`, so a
malformed comparand dropped the whole range and normalised the field to `{}`.
The twin, down to the missing `else`, of the arm #5328 fixed on
`driver-memory`. The leading sentence is `driver-sql`'s verbatim — one
condition, one wording (#5240).

- **An undeclared `$`-key in a NODE position** — the severe one. The translator's
switch knows three combinators (`$and` / `$or` / `$not`); every other key took
the FIELD path, and a key 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; `$nor` is a real combinator
the Filter Protocol never declared. The emitter's field-level `default:` arm
has named exactly these spellings as its P0 reason for refusing them one level
down for two releases — that gate was only ever installed at the field
position. On the other backends the same input compiled to a column name and
returned zero rows (#5348 / cloud#1077, since refused) or was already refused
(#5324); only here was it evaluated.

- **`{ field: {} }`** — a field constrained by zero operators, ruled REFUSE on
#5240 and gated on `driver-sql` / `driver-sqlite-wasm` / `driver-memory` /
`formula` by #5327. This driver translated it to `{ field: {} }`, which MongoDB
reads as "the field is deep-equal to the empty document" — not the FALSE the
ruling declined to take, but a DIFFERENT filter that merely looks like FALSE
until a document actually stores `{}` there.

Each gate sits on the validating walk (`classifyFilterKey`), beside the existing
`$null` (#5347) and `$icontains` (#6520) gates, rather than in the emitter — the
emitter is skipped wholesale when a boolean identity settles the enclosing node,
so a gate there would fire or not depending on a shape's SIBLINGS. Measured
before the fix: `{ $or: [ {}, { $where: 'x' } ] }`,
`{ $or: [ {}, { score: { $between: 5 } } ] }` and `{ $or: [ { a: {} }, {} ] }`
all translated to `{}` — match-all. The `$between` emitter arm additionally
keeps a local check as defense for its own invariant, the dual-gate pattern the
`$null` arm documents; both sites call one constructor with one path spelling.

Every filter that translated before still translates byte-identically: this adds
refusals in front of the verdict, it does not reclassify any surviving shape.
Authored filters using these shapes were already not doing what they appeared to
do, and now say so instead of answering silently.
Original file line number Diff line number Diff line change
Expand Up @@ -274,13 +274,29 @@ describe('[#5239] translateFilter reduces empty combinators to their boolean ide
expect(translateFilter({ stage: 'won', limit: 5 })).toEqual({ stage: 'won' });
});

it('a field constrained by zero operators is still not ruled on (#5240)', () => {
// `{ stage: {} }` translates to an exact-match on an empty document, as it
// always did. Reducing it to TRUE would have decided #5240 from here.
expect(translateFilter({ stage: {} })).toEqual({ stage: {} });
expect(translateFilter({ $or: [{ stage: {} }, { owner: 'u1' }] })).toEqual({
$or: [{ stage: {} }, { owner: 'u1' }],
});
it('a field constrained by zero operators is now REFUSED (#5240 / #5376)', () => {
// This pin used to read "still not ruled on", and asserted that
// `{ stage: {} }` translated to an exact-match on an empty document as it
// always did — #5239 deliberately declined to decide #5240 from inside a
// reduction change, and recorded the non-decision here.
//
// #5376 is that decision arriving: #5240 ruled REFUSE, #5327 gated the
// four other backends, and this driver was the fifth and last still
// answering. What #5239 was careful about is still true and still pinned
// below — the VERDICT did not change. The key is classified `'clause'`
// exactly as before; a refusal was added in front of it, not a
// reclassification to TRUE, which is the one direction that would have
// turned the shape into match-all.
const err = refusalOf({ stage: {} });
expect(err.code).toBe('INVALID_FILTER');
expect(err.status).toBe(400);
expect(err.message).toContain('Field constraint at filter.stage carries zero operators');

// And inside a combinator, where the emitter would never have reached it.
const nested = refusalOf({ $or: [{ stage: {} }, { owner: 'u1' }] });
expect(nested.code).toBe('INVALID_FILTER');
expect(nested.status).toBe(400);
expect(nested.message).toContain('filter.$or[0].stage');
});

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