Skip to content

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

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-5346-mongodb-filter-refusals
Aug 11, 2026
Merged

fix(driver-mongodb): refuse malformed $between, undeclared node-level $-keys and { field: {} } (#5346, #5376)#7528
huangyiirene merged 1 commit into
mainfrom
claude/issue-5346-mongodb-filter-refusals

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes #5346
Fixes #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 first, on origin/main @ 76d74ecb4

Both cards were written 2026-08-05 against packages/plugins/driver-mongodb/** and every quoted line number has drifted; the file was structurally refactored by #5239/#5659/#5702 since. So every premise was re-measured through translateFilter (a pure function whose output is the document MongoDB receives) before anything was edited.

Still open — fixed here:

{ score: { $between: 5 } }        =>  {"score":{}}
{ score: { $between: [1] } }      =>  {"score":{}}
{ score: { $between: [1,2,3] } }  =>  {"score":{}}
{ $where: 'return true' }         =>  {"$where":"return true"}
{ $nor: [{ stage: 'won' }] }      =>  {"$nor":[{"stage":"won"}]}
{ stage: {} }                     =>  {"stage":{}}

Already converged by other lanes — zero diff, confirmed by measurement:

{ $and: 'nope' } / { $or: 42 } / { $not: 'nope' }  =>  INVALID_FILTER / 400
{ name: { $sounds_like: 'x' } }                    =>  INVALID_FILTER / 400, no [mongodb] prefix

Item 2 of #5346 (combinator shape gates) landed via #5239/#5323assertFilterNode / assertFilterNodeList now hang off the shared reduceFilterVerdict walk in @objectstack/spec. Item 3 (the default: arm's missing envelope) landed via #5702; the docblock at mongodb-filter.ts records the removal of the carve-out sentence that used to name this card. The PM's pre-dispatch hypothesis on both is confirmed — nothing was re-implemented.

What is fixed

All three refuse with INVALID_FILTER / 400 (ADR-0112), naming the position (filter.$or[1].score.$between), through the existing unsupportedFilterError constructor — no third envelope minted.

Gate position: the walk, not the emitter — and the measurement that settles it

Each gate sits in classifyFilterKey, the validating walk, beside the existing $null (#5347) and $icontains (#6520) gates. 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. Not a prediction — measured before the fix:

{ $or: [ {}, { $where: 'x' } ] }         =>  {}     ← match-all
{ $or: [ {}, { s: { $between: 5 } } ] }  =>  {}     ← match-all
{ $or: [ { a: {} }, {} ] }               =>  {}     ← 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, so the wire answer is identical whichever fires.

The verdict is unchanged. A field key still classifies 'clause', exactly as #5239 had it. This adds refusals in front of the verdict rather than reclassifying a surviving shape, so every filter that translated before translates byte-identically.

Wording

Leading sentences copied verbatim from the siblings — one condition, one wording (#5240) — with no package-name prefix (#3867):

condition leading sentence source
malformed $between driver-sql sql-driver.ts:8637 / driver-memory malformedBetweenError
{ field: {} } driver-sql / driver-memory emptyFieldConstraintError
undeclared node $-key driver-memory / driver-sql unknownLogicalOperatorError, verbatim through the vocabulary sentence

Only each message's closing clause differs, because only it describes what this driver used to do.

Multi-face declaration

The semantics gated here are already enforced elsewhere. Per face:

face status evidence
driver-mongodb fixed here mongodb-filter.ts classifyFilterKey; mongodb-filter-shape-refusal.test.ts
driver-sql already compliant sql-driver.ts:1921 ($-key, #5348), :1938 ({field:{}}, #5327), :8637 ($between); pinned in sql-driver-empty-field-constraint.test.ts, sql-driver-out-of-contract-filter-input.test.ts
driver-sqlite-wasm already compliant inherits SqlDriver; sqlite-wasm-empty-field-constraint.test.ts, sqlite-wasm-out-of-contract-filter-input.test.ts
driver-memory already compliant filter-refusal.ts:584 ($-key, #5324), :611 ({field:{}}), :382 ($between, #5328); memory-empty-field-constraint.test.ts, memory-filter-vocabulary-refusal.test.ts. Not touched — #5499 keeps this package frozen.
formula already compliant matches-filter.ts:143 ({field:{}}, #5327); matches-filter-empty-field-constraint.test.ts

Why a private test file rather than the shared table: FILTER_LOGIC_CASES has no expectRejection discriminant — its own docblock (filter-logic-conformance.ts:127-133) says so explicitly and names extending the shape as the spec lane's work, deliberately not invented there. FILTER_TEXT_CASES does carry expectRejection rows, but it is scoped to the $contains/$icontains text family. So these shapes have nowhere to hook, and the file follows mongodb-null-comparand-refusal.test.ts's precedent instead. Widening FilterConditionSchema stays with the spec lane.

Tests

37 new cases in mongodb-filter-shape-refusal.test.ts. Every refusal case asserts code and status, with the pinned sibling leading sentence asserted on top of the envelope, never instead of it. Includes a describe block pinning the three sibling-dependence fixtures above, so the gates cannot drift back into the emitter.

The #5239 pin that read "a field constrained by zero operators is still not ruled on" is rewritten, not deleted — it recorded a deliberate non-decision, and #5376 is that decision arriving. It now asserts the refusal while still pinning what #5239 was careful about: the verdict did not change.

One stale comment corrected in mongodb-null-comparand-refusal.test.ts, which described the [mongodb] prefix as one this package's other refusal "still carries (#5346)" — #5702 removed it and this PR closes the card.

Verification

Live-mongod halves did not run. mongodb-memory-server's ~123 MB binary is not downloadable in this container (proxy 403), and those suites are describe.skipIf-gated by #5517. All verification is at the translation layer, which for these three defects is the whole of the divergence: translateFilter's output is the query document. A skip is not a pass, so the server-free half carries the load — the same level and the same reasoning as mongodb-null-comparand-refusal.test.ts.


Generated by Claude Code

… $-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 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018oM7XYyQ6AveqQs6eqxDdv
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 5:00am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-mongodb.

5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via @objectstack/driver-mongodb)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-mongodb)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/driver-mongodb)
  • content/docs/plugins/packages.mdx (via @objectstack/driver-mongodb)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/driver-mongodb)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/driver-mongodb)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 11, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 11, 2026 05:24
@huangyiirene
huangyiirene added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit f7a60d9 Aug 11, 2026
26 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-5346-mongodb-filter-refusals branch August 11, 2026 05:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants