fix(spec): lower equality triples with a $field comparand to {$eq: ref} (#7597) - #7672
Conversation
…f} (#7597) `parseFilterAST` lowered one authored intent two ways depending only on the operator spelling: `['amount', '>', { $field: 'budget' }]` kept its operator and worked on both evaluation paths, while `['amount', '=', ref]` — and its `==` / `equals` / `eq` spellings — dropped it and produced `{ amount: { $field: 'budget' } }`, a field spec whose only key is `$field`. No backend reads that as an equality: the in-memory evaluator dispatches `$field` to its operator switch, finds no arm, and returns its fail-closed `false`, so the filter silently matched no record on the very path that produced it. An equality triple whose comparand is a `FieldReferenceSchema` now lowers to the explicit `{ field: { $eq: ref } }` — the spelling both paths already implement (memory resolves the reference; driver-sql compiles it to a column-to-column comparison, #5222). Single-sink change per #5158. Unchanged, deliberately: a LITERAL comparand keeps implicit equality (the branch is on the comparand, not on the operator), a `$field` carrying a non-string is not a field reference on any path and keeps the literal lowering, and the evaluator's unknown-operator posture stays as #6520 left it — a hand-authored bare `{ field: { $field } }` FilterCondition keeps its current fate on every backend. Tests: the cross-field conformance corpus gains an AUTHORING arm entering through the sink instead of at the already-lowered object, run by both SQL drivers; `packages/spec` gains the lowering pins plus a vocabulary sweep that fails if ANY operator spelling lowers a reference comparand to a bare field spec. driver-sql's bare-form refusal pin is re-authored by hand, since the array sugar no longer reaches it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VkfGjiPTZjvhjE2fSuWdBW
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 108 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #7597
Executes the maintainer ruling recorded on #7597 (2026-08-11 08:08:19Z):
The defect, re-measured on
origin/main@cc3555eparseFilterASTlowered one authored intent two ways, depending only on how the operator was spelled:['amount', '>', { $field: 'budget' }]{"amount":{"$gt":{"$field":"budget"}}}["1"]['amount', '=', { $field: 'budget' }]{"amount":{"$field":"budget"}}[]← silent['amount', '==', ref]/'equals'/'eq'{"amount":{"$field":"budget"}}[]← silentAll four
$eqspellings dropped the operator, because a LITERAL comparand's implicit-equality form is{ field: value }— right for a literal, and for a field reference it yields a field spec whose only key is$field. Every consumer reads an all-$key set as an OPERATOR SPEC, and nothing implements$fieldas an operator.The full spelling set was measured, not assumed.
AST_OPERATOR_MAPfolds exactly=,==,equals,eqonto$eq, andconvertComparison's implicit-equality branch intercepts exactly those four — so the two lists coincide, and all four are fixed. A new test sweeps the whole exportedVALID_AST_OPERATORSvocabulary and fails if ANY spelling lowers a{ $field }comparand to a bare field spec, so a fifth$eqspelling entering the map cannot re-open this at a name the tests never heard of.What changed
One sink, one branch: in
convertComparison's equality shorthand, a comparand that is aFieldReferenceSchemalowers to{ field: { $eq: ref } }; everything else keeps the implicit form.Unchanged, deliberately:
['amount', '=', 5]→{ amount: 5 }. The branch is on the COMPARAND, never on the operator.$fieldcarrying a non-string. Not a field reference on any path (the predicate mirrorsdriver-sql'sfieldReferenceOf), so it keeps the literal lowering and is refused downstream as the uncompilable object it is.$icontains(driver-memory 两面 / driver-mongodb / objectqlhaving/ formula)—— SQL 族已实现,同一 filter 在内存 double 上抛错 #6520). A hand-authored bare{ field: { $field: … } }FilterConditionkeeps exactly its current fate — fail-closedfalsein memory,driver-sql's actionable refusal naming$eq. Only what the ARRAY sugar produces moved.Compile-surface enumeration
Every face of this predicate, with its conclusion. Measured on
origin/main@cc3555e, before and after.driver-sqlapplyFilterCondition(sql-driver.ts) — inherited bydriver-sqlite-wasmand local-modedriver-turso{ $eq: ref }to a column-to-column comparison. The lowered shape is what this PR routes to it, and the new authoring arm proves it row-for-row against the memory evaluator on both drivers. Docblock updated + one refusal pin re-authored (below).buildWhereSQL(remote-transport.ts){ $eq: ref }reachesserializeComparand, fails the bind allow-list, anduncompilableComparandrecognisesisFieldReferenceand refuses withINVALID_FILTERnaming cross-field comparison and the repair. The bare form was also loud there (unsupportedOperator). Loud before, loud after.compileScopedFilterToSql(read-scope-sql.ts)native-sql-strategy,objectql-strategy) pass it the RLS read SCOPE, which is compiled from policy/CEL and never passes throughparseFilterAST; no array sugar reaches it. Its own$fieldposture is #7598's card (#7604 was closed as its duplicate). Untouched here.lowerAnalyticsWhere+buildNode(filter-normalizer.ts)formulamatchesFilterCondition(matches-filter.ts)resolveValueresolves{ $field }against the record and$eq's arm answers it, NULLs included. Measured: the four spellings now return["3","6"]— the corpus's declared$eqrow set — where they returned[]before.having-filter.tsmatchesHavinghavingis never lowered through this sink.QueryASTSchema.having/DataEngine'shavingare declared as aFilterConditionOBJECT,metadata-protocolsays so in as many words (having— "aFilterConditionOBJECT — the engine has no AST lowering for it"), andengine.tshandsast.havingstraight toapplyHaving. Nothing this PR produces can arrive there; its current bare-$fieldrefusal (unknownOperator) is untouched. Stated explicitly because this face has no conformance-table coverage.driver-memory/driver-mongodbmemory-matcher.ts's$eqarm isvalue != target, so an object comparand answers false for every row — the same silent bucket$gtwith a reference has been in since #3948. The bare form was refused there by the shape gate, so this face sees the same loud→silent shift as face 4. Not edited: the freeze is explicit, and the fix is one decision for the whole$field-in-analytics/memory residue rather than a rider on this card.Face 4, measured (the one shift this PR causes)
assertCompilableComparandrefuses a$fieldonly in the LIKE-family and$in/$ninmember positions; a scalar operator's object comparand falls through totoSqlBindValue, which JSON-stringifies it (its own docblock: "any other object / array → JSON text. Not a meaningful comparison on any column"). So at this face the equality spelling moves from a loud refusal into the same silent bucket the ORDERING spellings have occupied all along — it is not a new defect class, it is one more spelling entering an existing one, and the shift is the price of making=behave like>everywhere else.It is reported rather than fixed here on the precedent this exact surface already set: #7604's triage records that #5222's dev seat declined to widen a security-relevant acceptance surface in a second subsystem as a side effect, and the PM called that the right call. Refusing only
$eqthere would create a fresh asymmetry inside the analytics face; refusing all six is #7598's card, whose open question 1 is whether these emitters implement$fieldor refuse by design. The measurement is being posted to #7598 so its executor has it.Re-pricing #7596 (asked explicitly by the dispatch)
Untouched — neither easier nor harder, and still necessary.
#7596 removes
FieldReferenceSchemafrom the$betweenendpoint unions and rules$in/$ninmembers out. This PR changes only the SCALAR equality position;parseFilterAST'sin/nin/betweenlowerings pass their comparand through exactly as before, so nothing this PR produces changes what arrives in a list position, and no test added here asserts anything about those unions.Two facts that help #7596 without changing its scope:
filter.zod.ts, in disjoint regions. This diff's two hunks start at:1350and:1376; [spec]FieldReferenceSchemais declared in the$betweenendpoints but NO backend resolves a$fieldinside a list #7596's unions at:319-320and:819-820are byte-identical and unshifted after this PR.FieldReferenceSchemais declared in the$betweenendpoints but NO backend resolves a$fieldinside a list #7596 draws gets cleaner. With equality moved into the supported set, the declared-but-unexecutable$fieldpositions remaining are exactly the list ones [spec]FieldReferenceSchemais declared in the$betweenendpoints but NO backend resolves a$fieldinside a list #7596 removes — so its removal argument reads as a complete boundary rather than one cut of several.Tests
packages/spec/src/data/filter-field-reference-lowering.test.ts(new) — the four spellings, case folding, the reference passing through unmodified (dotted paths included), literal comparands keeping implicit equality, a non-$fieldobject comparand, a non-string$field, the sugar's own nesting (and/or/ legacy flat),isFilterASTunmoved, and the vocabulary sweep.cross-field-conformance-cases.tsgainsCROSS_FIELD_AUTHORED_CASES— the corpus's AUTHORING arm, entering through the sink instead of at the already-lowered object: 4 spellings × 3 storage classes, a>control, and two sugar-structure cases. Each asserts the lowered SHAPE and then holds the lowered filter to the same both-paths-same-rows rule as every existing case. Run bydriver-sql(per dialect cell) anddriver-sqlite-wasm.sql-driver-cross-field-reference.test.ts— the bare-form refusal pin used to DERIVE its input fromparseFilterAST, which is precisely the route this PR closes. It is now hand-authored (the refusal itself is unchanged and still pinned), and a sibling case pins that the triple no longer lowers to it.Reverse verification
Direction predicted before running: removing the fix (via a patch file, restored with
git apply) should turn the AUTHORING arm and the new triple pin RED, and leave the hand-authored bare-form refusal GREEN — the point of having re-authored it. Measured, exactly that: 14 authoring cases +the equality TRIPLE no longer lowers to that bare spellingfailed withexpected { amount: { '$field': 'budget' } } to deeply equal { amount: { '$eq': … } }; the>control and the bare-form refusal stayed green.Gates run locally
@objectstack/specbuild + full suite@objectstack/driver-sqlfull suite@objectstack/driver-sqlite-wasmfull suite@objectstack/formulafull suitetypecheck(spec, driver-sql, driver-sqlite-wasm)pnpm check:driver-conformancepnpm check:merge-driver/check:adr-anchors/check:spec-parsed-aliaspnpm --filter @objectstack/spec check:authorable-surfacenode scripts/check-nul-bytes.mjsConsumer sweep: grepped every package and app for a test pinning the old equality-triple lowering (
'=' , { $field …, and the==/equals/eqspellings) — the only hits are the files in this diff.Changeset:
@objectstack/specminor,@objectstack/driver-sqlminor. Minor rather than patch on both counts honestly — a previously-silent authored shape gains working semantics and the sink's output object changes, matching the sibling precedent #7536 took for the same function;driver-sqlgains a public (test-only DATA) export.driver-sqlite-wasm's change is test-only, so it carries no bump.Generated by Claude Code