fix(driver-mongodb): refuse malformed $between, undeclared node-level $-keys and { field: {} } (#5346, #5376) - #7528
Conversation
… $-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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #5346
Fixes #5376
driver-mongodbwas 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@76d74ecb4Both 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 throughtranslateFilter(a pure function whose output is the document MongoDB receives) before anything was edited.Still open — fixed here:
Already converged by other lanes — zero diff, confirmed by measurement:
Item 2 of #5346 (combinator shape gates) landed via #5239/#5323 —
assertFilterNode/assertFilterNodeListnow hang off the sharedreduceFilterVerdictwalk in@objectstack/spec. Item 3 (thedefault:arm's missing envelope) landed via #5702; the docblock atmongodb-filter.tsrecords 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 existingunsupportedFilterErrorconstructor — no third envelope minted.Malformed
$between. The emitter arm wrote both bounds insideif (Array.isArray(value) && value.length === 2)and had noelse, so a malformed comparand dropped the whole range and normalised the field to{}. The twin, down to the missingelse, of the arm driver-memory 对形状错误的$between静默不发谓词(匹配 0 行),driver-sql 同一过滤器抛错 —— 一个过滤器两个答案 #5328 fixed ondriver-memory.An undeclared
$-key in a NODE position — the severe one, and the P0-adjacent item from driver-mongodb 带着 #5328 的同一条缝:形状错误的$between/ 非数组$and、$or/ 非对象$not全被静默吞掉,且它的算子拒收没有 ADR-0112 信封 #5346 comment 5186668033. The translator's switch knows three combinators; 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.$whereis server-side JavaScript;$noris a real combinator the Filter Protocol never declared. The emitter's field-leveldefault: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.{ field: {} }(driver-mongodb 是{ field: {} }唯一还在作答的后端:#5240 拍板拒收、#5327 已闸四家,mongodb 仍译成「字段深等于空文档」 #5376). Ruled REFUSE on{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240, gated on four backends by fix(driver-sql,driver-memory,formula)!:{ field: {} }四个后端一律拒收 —— 零个操作符的字段约束不再有三个答案 (#5240) #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.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:The
$betweenemitter arm additionally keeps a local check as defense for its own invariant — the dual-gate pattern the$nullarm 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):
$betweendriver-sqlsql-driver.ts:8637/driver-memorymalformedBetweenError{ field: {} }driver-sql/driver-memoryemptyFieldConstraintError$-keydriver-memory/driver-sqlunknownLogicalOperatorError, verbatim through the vocabulary sentenceOnly 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:
mongodb-filter.tsclassifyFilterKey;mongodb-filter-shape-refusal.test.tssql-driver.ts:1921($-key, #5348),:1938({field:{}}, #5327),:8637($between); pinned insql-driver-empty-field-constraint.test.ts,sql-driver-out-of-contract-filter-input.test.tsSqlDriver;sqlite-wasm-empty-field-constraint.test.ts,sqlite-wasm-out-of-contract-filter-input.test.tsfilter-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.matches-filter.ts:143({field:{}}, #5327);matches-filter-empty-field-constraint.test.tsWhy a private test file rather than the shared table:
FILTER_LOGIC_CASEShas noexpectRejectiondiscriminant — 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_CASESdoes carryexpectRejectionrows, but it is scoped to the$contains/$icontainstext family. So these shapes have nowhere to hook, and the file followsmongodb-null-comparand-refusal.test.ts's precedent instead. WideningFilterConditionSchemastays with the spec lane.Tests
37 new cases in
mongodb-filter-shape-refusal.test.ts. Every refusal case assertscodeandstatus, with the pinned sibling leading sentence asserted on top of the envelope, never instead of it. Includes adescribeblock 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
driver-mongodbpackage suite: 269 passed / 143 skipped, up from 232 passed.check:driver-conformance: OK — 36 covered cells, 4 in the DEBT ledger, 0 exempt. The twodriver-mongodbDEBT rows are drivers(memory, mongodb): the$containsfamily still folds case — the last two backends left on the wrong side of #4706 Q2 = A #6682 / [drivers] driver-memory / driver-mongodb disagree with the SQL family oncount_distinct— the two AGGREGATION_CASES cells #6409 left open #6814 and are untouched by this change.tsc --noEmitclean; build closure (spec→types→metadata-core→core→driver-mongodb) builds.Live-mongod halves did not run.
mongodb-memory-server's ~123 MB binary is not downloadable in this container (proxy 403), and those suites aredescribe.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 asmongodb-null-comparand-refusal.test.ts.Generated by Claude Code