Repository navigation
driver-sql: a declared operator on a multiple: true (JSON array) column silently answers wrong — $in/$eq always zero rows, $nin returns the rows it was asked to EXCLUDE #7398
Description
Activity
Claim: PM loop, drivers lane — queue-jumped on the maintainer's direct instruction (2026-08-10 ~09:4xZ:「7498 插队」).
Session:
session_01Hg9Pkg5nDedCRihRsdeCdX
Branch:claude/issue-7398-multiple-column-operator-refusal
Worktree:objectstack-issue-7398(cloud session — own container, Opus)On the number, stated openly so a correction can land: the instruction said 7498; no issue or PR 7498 exists in
objectstack,objectui, orcloud(three 404s), and this repo's counter stood at 7398 when the instruction arrived. This card — filed by the maintainer minutes earlier,driver-sql, this lane — is the unique plausible referent, so I am reading 7498 as a typo for 7398. If that reading is wrong, say so and I will re-route; nothing here forecloses that.This jumps ahead of the normal order while #7332's dev is still in flight — the maintainer's instruction overrides this lane's one-card cadence. Both cards touch
sql-driver.tsbut in disjoint regions (#7332: index introspection:6781-6864; this card: filter lowering:7838-8460); the merge queue serializes whatever lands second.Premises verified on
origin/main@d53bd0ba9— with one delta the dev must know:- The lowering is where the card says:
applyNormalizedComparison:7838; itslistarm:7870(case 'in': case '$in': return list('in')at:7895;case 'nin': case 'not_in': case 'notin': case '$nin': return list('not in')), with no column-type consultation. Bare equality routes through:8030.⚠️ There is a second, plain-column lowering family —:8370const mIn = logicalOp === 'or' ? 'orWhereIn' : 'whereIn'— so the fix must enumerate all lowering sites, not just the one the card names. - The type information is present at the point of lowering, as the card claims:
this.jsonFieldsper-table (:2369, populated:5361/:5442),isJsonField:9052. - The refusal precedent is in-file:
unsupportedFilterError:547, thrown from the unknown-operator default arm:8445. - ⭐ The card's "minor" note is ALREADY FIXED on main. The message at
:8445now lists$icontains, not$regex, and[#5702]routes retired spellings to a prescription (retiredFilterOperatorError). The card measured17.0.0-rc.5. Verify and report; do not "fix" it again.
Scope ruling for this dispatch — ask 1 only. The card offers two asks and itself says ask 1 suffices ("If the ruling is '1 only', that is completely fine… a loud refusal is all the downstream app needs"). Ask 2 (
$overlaps/$containsAny) would open the closed 15-operatorFILTER_OPERATORSspec set — a cross-lane spec change that needs its own maintainer ruling, not a rider on a driver fix. ⛔ Do not implement ask 2; if the measurement suggests it is warranted, file it as a finding for the decision box.The one behaviour that must NOT change:
$contains(and$or-of-$contains) on these same columns — it is the downstream app's only working spelling, and the card's own tables depend on it. The refusal must be surgical: the operators that are meaningless against a JSON-array column, not the column itself.
Generated by Claude Code
- The lowering is where the card says:
os-dev report
Ask 1 implemented in
driver-sql. Branchclaude/issue-7398-multiple-column-operator-refusal, draft PR #7415 (3db8a3e,4acc2cd). Ask 2 not implemented — see the findings at the end.Lowering sites enumerated
Every filter this driver compiles passes through one funnel —
applyFilters→applyFilterCondition— reached byfind/findOne/count/aggregate/distinct/updateMany/deleteMany/findWithWindowFunctions/analyzeQuery. Inside it there are four emitters, not one:# emitter reached when what it compiles A applyFilterConditionoperator switch —$in→whereIn/orWhereIn,$nin→orWhereNotIn,$eq/$ne/ordering →where(field, op, v)a { field: { $op: v } }node on an ordinary columnthe card's measured memberscaseB applyNormalizedComparison—whereRaw, thelist/binaryarmsfilterColumnExpr(table, field, col)returns non-nulla JSON column that is ALSO an un-canonicalised datetime/time column C applyFiltersplain-map loop{ field: value }when no key of the filter carries an operatorbare equality D applyFilterConditionbare-value branch{ field: value }when some key carries an operatorbare equality The gate is installed at the three entry points (A/B share one, placed ahead of both), so all four are covered:
applyFiltersplain-map loop, besideassertCompilableComparand→ covers CapplyFilterConditionoperator branch, besideassertCompilableComparand, before the calendar-day rewrites and beforeapplyNormalizedComparison→ covers A and BapplyFilterConditionbare-value branch → covers D
Two other
whereInsites in the file are driver-internal, not caller filters, and are deliberately untouched: the id-list builder (getBuilder(...).whereIn('id', ids)) and the tenant-scope predicate.Positive control on the enumeration
⚠️ The card and the claim comment both nameapplyNormalizedComparison:7838/ itslistarm as the site that lowers the measured$in. It is not that site.filterColumnExprreturns non-null only for a SQLite column needing legacy datetime/time repair; for a managedmultiple: truelookup it returnsnull, so the card'smembersfixture is served entirely by family A. Verified by callingfilterColumnExpr('team','members','members')→null. The claim comment's "second plain-column family at:8370" is in fact the primary family, and the only one the card's own table exercises.Family B is nevertheless real and reachable, and that is what makes it a control rather than a footnote.
registerExternalObjectpopulatesdatetimeFieldsbut never runsbackfillCanonicalDatetimes, socanonicalDatetimeFieldsstays empty andneedsLegacyDatetimeRepairstays true. Measured onmain, an external object with amultiple: truedatetime column:filterColumnExpr: (case when typeof(??) in ('integer','real') then strftime(…) … end) ← non-null | $in | rows=0 | | $nin | rows=1 | | $eq | rows=0 | | bare | rows=0 |Identical defect, different emitter. The new test file carries this cell, and it asserts its own premise (
filterColumnExpris non-null,isJsonColumnis true) so it cannot silently degrade into a copy of the family-A block. See "Reverse verification" for what happens when the gate is placed in only one family.The predicate, and why
Chosen: the existing
jsonFieldsregistry — i.e.isJsonField(type, field)=JSON_COLUMN_TYPES.has(type) || !!field.multiple. Not scoped tomultiple, and not a new list.- The mechanism is the storage, not the array-ness. A structured-JSON column shows the identical defect, including the fail-open half. Measured on this fixture before the change:
{address:{$eq:'Beijing'}}→ 0 rows;{address:{$nin:['Beijing']}}→ the row it was asked to exclude. Scoping tomultiplewould leave that live at a different field type. - The ordering comparisons are worse than empty on both.
$lteonmembersmatched, because["usr_…"sorts belowusr_…on the leading[. A lexicographic verdict over a serialization is a wrong answer, and it is no less wrong for{"city":…}. $eqon a structured-JSON column has no contract to preserve. It could only ever "work" by byte-exact serialization match, which is key-order dependent — not something the platform declares.- Single source.
JSON_COLUMN_TYPEScarries a header calling itself the single source for the DDL switch andisJsonField"so the two can't drift". A filter-side copy of "which columns are JSON" would be a third list to keep in step.
Deliberately absent from the refused set: the
LIKEfamily ($contains,$notContains,$startsWith,$endsWith,$icontains) and the null predicates ($null,$exists) — presence is a well-formed question whatever the column holds.Deliberately conservative: a table with no
jsonFieldsentry answersfalse. A table the driver was never told about (raw knex, as the conformance sweeps use) has no field types to consult, and inventing a refusal from an absent registry would refuse filters on ordinary columns.One addition beyond the card's stated minimum:
$between. It is>= AND <=, and this driver decomposes a calendar-day$betweeninto$gte/$lta few lines above the emitter. Refusing the halves and compiling the compound would leave the same wrong answer at one more spelling. Flagging it explicitly since the card's minimum set stopped at the four ordering comparisons.Operators refused, all spellings either emitter answers:
$eq/=/==,$ne/!=/<>,$gt/>,$gte/>=,$lt/<,$lte/<=,$in/in,$nin/nin/not_in/notin,$between/between, plus the bare{ field: value }spelling.The card's table, before → after
Fixture: one row,
members=["usr_1111","usr_2222"], stored as'["usr_1111","usr_2222"]'(asserted in the test).filter before ( main)after {members:{$in:[U1]}}200, 0 rows400 INVALID_FILTER{members:{$in:[U1,U2]}}200, 0 rows400 INVALID_FILTER{members:{$eq:U1}}200, 0 rows400 INVALID_FILTER{members: U1}(bare)200, 0 rows400 INVALID_FILTER{members:{$contains:U1}}200, 1 row200, 1 row — unchanged{members:{$contains:U2}}200, 1 row200, 1 row — unchanged{$or:[{…$contains U1},{…$contains U2}]}200, 1 row200, 1 row — unchanged{members:{$nin:[U1]}}200, 1 row — the excluded row⚠️ 400 INVALID_FILTER{members:{$overlaps:[U1]}}400unknown operator400unknown operator — unchanged{owner:{$in:[U1]}}(control)200, 1 row200, 1 row — unchangedRows the card did not have, measured on the same fixture:
filter before note {members:{$ne:U1}}200, 1 rowthe same fail-open inversion as $nin, unlisted in the card{members:{$lte:U1}}200, 1 rowwrong, not empty — lexicographic on the leading [{members:{$gt:U1}}200, 0 rows{members:{$between:[U1,U2]}}200, 0 rows{address:{$eq:'Beijing'}}200, 0 rowsstructured JSON, not multiple{address:{$nin:['Beijing']}}200, 1 row⚠️ structured JSON — same inversion All of the above are
400 INVALID_FILTERafter. Every reproduction ran on all offind/count/aggregate, with identical answers per row.Faces covered (#6203)
The gate sits in the shared funnel, and the sweep asserts it per face rather than trusting that:
find,findOne,count,aggregate,distinct,updateMany,deleteMany— each × each refused operator × bare equality, plus a$containspositive on each. The write faces additionally assert that a refusal left nothing mutated, and their$containspositive is measured on the row count against a scratch row (a where-clause reaching no row would satisfy a bare "it resolved" and prove nothing). Depth is covered too: the refusal holds inside$or,$andand$not.Conformance grid — checked, no cell affected
FILTER_LOGIC_CASES/FILTER_LOGIC_ROWS(packages/spec/src/data/filter-logic-conformance.ts): the fixture is ninet.string()columns (id,a,b,c,d,owner,status,parent_object,parent_id) created through raw knex, with noinitObjects.jsonFieldsis therefore empty for that table and the gate is structurally inert there. No case exercises any operator on a multi-value column.FILTER_TEXT_CASES: text/case folding on string columns. Unaffected.pnpm check:driver-conformance(the registry gate, 分页读取在没有 orderBy 时同样不确定:tie-breaker 只覆盖了「排了序的翻页」 #4363) — green. No case-set added, no cell turned red.- All five conformance suites green: driver-sql's
sql-driver-or-filtersweep,sqlite-wasm,turso(local + remote);driver-memoryanddriver-mongodbuntouched and green in the full run.
No STOP condition triggered.
Reverse verification (predicted, then measured — both directions)
Predicted before running:
- Zero existing driver-sql tests go red. Basis: only four test files declare a
multiple: trueor JSON-typed field (sql-driver-array-fields,sql-driver-bulk-json,sql-driver-schema,sql-driver-deferred-ddl, plusnumeric-fidelity/external-remote-name), and every one of them filters onid/nameonly — none filters on the JSON column. - Possible cross-package reds where a platform object declares
multiple: trueand something filters it (objectql,rest,runtime, services, plugins).
Measured:
- Confirmed — 0 reds in driver-sql (82 files / 1285 tests green).
- Did not appear — 0 reds repo-wide (135/135 turbo tasks). Per the dispatch's rule I suspected the fixture first rather than declaring the absence a result: the new tests demonstrably do fire (129 pass; 95 of 129 fail when the gate is removed), so the gate is live and simply nothing in the repo was filtering a JSON column with a scalar operator.
Removing the fix (all three gate calls deleted): 95 / 129 red, 34 green — the 34 being the
$containspins, the scalar-column controls and the$overlapsunknown-operator pin, exactly the set that should survive.Positive control on the site enumeration — the gate moved one line later, after
applyNormalizedComparisoninstead of before it: exactly 6 red, 123 green. The six are the external multi-value-datetime cells; every managed-column cell stays green. That is the measurement behind the claim that family B is a separate site and not a restatement of family A.Existing tests asserting the OLD silent behaviour: none. Nothing was deleted, loosened, skipped or retried. The only edit to an existing gate's input was my own new test file (see the ratchet note below).
Gates, per CI job
job result CI / Test Core ( turbo run test)135 / 135 tasks green. driver-sql 82 files / 1285 tests (was 81 / 1156 — +1 file, +129 tests) CI / Temporal Conformance (live PG + MySQL) not runnable here (no servers provisioned). The gate throws before any SQL is built, so it is dialect-independent; the new test pins better-sqlite3explicitly, like its siblingsql-driver-filter-refusal-envelope.test.tsLint & Type Check / ESLint pnpm lintcleanLint & Type Check / TypeScript pnpm typecheck126 / 126 greenLint & Type Check / ratchets all 43 check:*gates inlint.ymlpass.⚠️ One went red on the first pass and is fixed in4acc2cd:check:query-options-erasurecounted anas anyat the aggregate face's options position (test surface 249 → 250). The cast was gratuitous —aggregationsis onDriverQuery— so it is typed rather than laundered; back at 249changeset gates check-empty-changesetandcheck-adr-0087-registrationgreen.patch, not declared-breaking, so no ADR-0087 marker is owedOne flake, unrelated and not caused by this change:
@objectstack/lint > runtime-lazy-deps.test.tstimed out at 30 s under a saturated runner (two full suites overlapping). It passes in isolation (70 files / 1852 tests), and the test's own comment records that a busy runner false-reds it.The card's "minor" note — verified, no change
Confirmed stale, exactly as the claim comment said. On
mainthe unknown-operator message at thedefaultarm lists… $startsWith, $endsWith, $icontains, $null, $exists.— no$regex— and[#5702]'sretiredFilterOperatorErrorroutes retired spellings to a prescription before the vocabulary list is ever reached, pinned bysql-driver-icontains-and-retired-operators.test.ts. Nothing done.Anything else the card got wrong
- The named lowering site is not the one the card measured — detailed above. This is the substantive correction; a fix installed only where the card pointed would have covered family B and missed the fixture in the card's own table, and a fix installed only in family A would have left the external case silently wrong.
FILTER_OPERATORSis 16, not 15. The card says "a closed set of 15";$icontainsjoined it in drivers:$regex响亮拒收 +$icontains各后端实现(#4706 裁决 B 案 · 驱动半边) #5702. Immaterial to the ask, but the closed-world sweep reads the export rather than a number, so it cannot drift either way.- Everything else in the card reproduces byte-for-byte, including the raw-SQL two-liner.
Findings recorded, not acted on
$overlaps/$containsAny(the card's ask 2) — not implemented, per the dispatch. Worth noting from the measurement side that the refusal makes the gap visible rather than closing it: an author who wants "any of these values" is now told to write an$orof N$contains, which is O(N) LIKE predicates over a serialization. That is a workable spelling, not a good one. Maintainer's call.driver-memory/matchesFilterhas the same gap, and worse — ⛔ FROZEN under [裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499, untouched. Readingpackages/formula/src/matches-filter.ts:$inisv.some(x => looseEq(actual, x))andlooseEqbottoms out ata === b, so an arrayactualequals no scalar member →$in→ no match,$nin→ matches (the same fail-open inversion). And$containsthere is guarded bytypeof actual === 'string', so on an array field it is simplyfalse— meaning driver-memory has no working membership spelling at all, not even the one this refusal prescribes. Not measured against a live driver-memory fixture (out of scope); recorded as a finding candidate for the decision box.- Cross-driver divergence is now explicit, and worth a ruling before anyone generalises this refusal: on
driver-mongodb,$inagainst a real array column is meaningful (Mongo matches any element). So the same filter is a correct query there, a refused query here, and a silently inverted query ondriver-memory. This change fixes the third case into the second; whether the first and second should converge — and in which direction — is a Filter Protocol question, not a driver one.
Not relocated to the engine
packages/objectqldoes have a chokepoint where a column-type gate could live — the comparand-shape gate landed there (collection-operator-scalar-comparand-400). It was not used, per the dispatch. The trade-off, stated so the decision is on the record: the engine sees the field metadata and would fix every driver at once, but "this operator cannot apply to this column" is not backend-independent here — it is true becausedriver-sqlserialises the value to text, and it is false ondriver-mongodb, where$inover an array column is the operator working as designed. Lifting the rule to the engine would refuse, on every backend, a query one backend answers correctly. It belongs in the driver whose storage decision makes it wrong.
Generated by Claude Code
PM step-7 gate: ACCEPTED — with the ready-flip held until one infra-red CI job reruns green (details at the end; it never executed a test and is not this diff's doing).
PR #7415 @
4acc2cd9d. Verified independently off the branch and main, not off the report:Claim Independent check filterColumnExprreturns non-null ONLY for legacy datetime/time repair — so the card's fixture never reachesapplyNormalizedComparison✅ read verbatim at sql-driver.ts:7848-7866: twoneedsLegacy*Repairbranches, thenreturn null. The dev's falsification of BOTH the card's site attribution AND my claim comment's is correct — the family I called "second" is the primary, and the family the card named serves only the external-object datetime cellRefusal envelope is ADR-0112 class 1 ✅ gate → jsonColumnOperatorError→unsupportedFilterError(:547), which setscode: INVALID_FILTER+status: 400— the same envelope as the unknown-operator refusalTests assert code AND status, never bare toThrow(#6144)✅ expectJsonColumnRefusalassertserr.code === 'INVALID_FILTER'ANDerr.status === 400(test:92-93), applied across all 129 cases; zero baretoThrow()in the file$containsfamily pinned unchanged✅ 21 $containsreferences in the new file, positive pins per face; the LIKE family and$null/$existsdeliberately absent from the refused setNo existing test touched ✅ diff is pure addition — 697+/0− across exactly 3 files (gate + new test file + changeset); the unlisted-flip reject-condition is structurally impossible No docs/adr/**in the path surface✅ path check clean (new-doctrine ACCEPT fork not triggered) CI @ 4acc2cd9d24 of 25 green (2 justified paths-filter skips); 1 red = infra, see below What earns the accept, beyond the checklist:
- The enumeration was measured, not asserted, and it has a positive control. Moving the gate one line later — after
applyNormalizedComparisoninstead of before — reds exactly the 6 external-datetime cells and nothing else. That is the measurement standing behind "two emitters, both wrong the same way", and it is what separates this from a fix that happened to land in the right place. A fix installed only where the card pointed would have missed the card's own fixture; only where I pointed, and it would have missed the external case. Both of us were falsified by measurement, and the falsification is the eighth and ninth time this lane's devs have corrected a stated premise — correct both times. - The predicate widening is evidence-backed. Scoping to
multiple: truealone would have left{address: {$nin: ['Beijing']}}— structured JSON, same fail-open inversion, measured on this fixture — silently wrong. Reusing thejsonFieldsregistry (whose own header declares itself the single source "so the two can't drift") instead of minting a filter-side copy is the right ownership call. $betweenwas caught by reasoning about the decomposition (calendar-day$betweenlowers to the very$gte/$lthalves being refused) — a gap in the card's minimum set that would have been a one-spelling bypass.- The closed-world sweep future-proofs the partition: every
FILTER_OPERATORSmember must be refused or on the keep-working list, asserted whole — a newly declared operator cannot join the silent set without turning the file red. And it reads the export, not the card's "15" (which is stale —$icontainsmade it 16 in drivers:$regex响亮拒收 +$icontains各后端实现(#4706 裁决 B 案 · 驱动半边) #5702, the dev's third correction of the card). - The engine-relocation refusal is the right call for the right reason, on the record: the refusal is true because driver-sql serialises to text and false on driver-mongodb, where
$in-over-array is the operator working as designed. Lifting it to the engine would refuse, on every backend, a query one backend answers correctly.
Findings noted for triage/decision-box, not acted on here (correctly recorded, not smuggled in): ask 2's visibility trade-off (
$or-of-$containsis O(N) LIKEs — workable, not good);driver-memoryhas the same inversion AND no working membership spelling at all (frozen under #5499); the now-explicit three-way cross-driver divergence ($in-on-array: correct on mongodb, refused on sql, was-inverted on memory) is a Filter Protocol question awaiting a ruling.The one red, diagnosed per #4859 before any conclusion:
Temporal Conformance (live PG + MySQL)failed in 57 seconds — the job log showsdocker pull postgres:16failing 3× withcontext deadline exceededagainst Docker Hub, dying in service-container setup before checkout, zero tests executed. Pure runner-side registry outage, unreachable-by-construction from this diff. Rerun triggered (same head — valid for infra retries). Ready + auto-merge follow once that job's conclusion issuccess; on any re-red that actually executes tests, I re-diagnose from the job log — live-dialect coverage is exactly the surface this PR must clear, since the dev could not run PG/MySQL locally.
Generated by Claude Code
- The enumeration was measured, not asserted, and it has a positive control. Moving the gate one line later — after
- added 3 commits that reference this issue
on Aug 17, 2026 - added 4 commits that reference this issue
on Sep 17, 2026 - added a commit that references this issue
on Sep 28, 2026 - added a commit that references this issue
on Oct 7, 2026
Summary
A declared filter operator applied to a column the operator cannot mean anything for is compiled to SQL and executed anyway, returning a wrong answer with a 200. No error, no warning, nothing for a type checker to catch.
Concretely, on a field declared
multiple: true(whichdriver-sqlstores as a JSON text column):$in/$eq/ bare equality → always zero rows (fail-closed)$nin→ returns the rows it was asked to EXCLUDE (fail-open)The second one is the reason I am filing this rather than logging it as a footgun.
$ninon an array column inverts the result — this is the dangerous half{ members: { $nin: [U1] } }returned exactly the record whosememberscontains U1.The mechanism is one line of SQL: the stored column value is the text
["U1","U2"], somembers not in ('U1')is true — the text genuinely is not equal to that uuid. "Exclude these"therefore compiles to "return everything".
Why this matters more than the
$incase:$infails closed — the caller sees fewer rows than exist. Bad, silent, but narrowing.$ninfails open — the caller sees rows it explicitly asked to remove. Any exclusion built onit (a row-level visibility narrowing, a de-duplication pass, an "everything except the ones already
handled" sweep) silently stops filtering, and the failure direction is widening.
That is the same class A filter with an operator outside VALID_AST_OPERATORS is silently dropped, not rejected — single-condition views return unfiltered results #3948 was rated on: a dropped/inverted predicate does not degrade a feature,
it widens a result set.
Measured
Environment:
@objectstack/*17.0.0-rc.5,SqlDriver(better-sqlite3), single-tenant dev runtime,queries issued over the REST list face (
GET /api/v1/data/:object?filter={json}).Fixture: one record in the table. Its multi-value lookup column
membersholds["U1","U2"](shown here as
U1/U2; they are ordinary record ids).Multi-value (
multiple: true) column —members{members:{$in:[U1]}}{members:{$in:[U1,U2]}}{members:{$eq:U1}}{members: U1}(bare equality){members:{$contains:U1}}{members:{$contains:U2}}{$or:[{members:{$contains:U1}},{members:{$contains:U2}}]}{members:{$nin:[U1]}}{members:{$overlaps:[U1]}}{members:{$containsAny:[U1]}}{members:{$in:[[U1,U2]]}}DATABASE_ERROR(this is the #5234 face, not this one)Control — the same object's single-value lookup column
owner{owner:{$in:[U1]}}So
$initself is fine. What is broken is$inmeeting an array column.Raw SQL, same table, same row
Reading the column and querying it directly, with no platform in the path:
That is the whole bug in two lines.
driver-sqllowers$instraight tocol in (?, ?, …)(the
list('in')arm ofapplyNormalizedComparison) without consulting the column's type,and a JSON text column is never equal to any single member it contains.
$contains"works" onlybecause it lowers to
LIKE '%value%'and the JSON serialization happens to contain the substring.Why I read this as a defect and not as documented behaviour
The platform already chose "refuse" on every adjacent seam — this is the one square on the grid
with no gate:
$in: 'done', a scalar where an array is declared) → the unreleasedcollection-operator-scalar-comparand-400changeset moves this from a 500 to a 400INVALID_FILTER, and its wording explicitly states the filter was not applied.$in/$nin的非$field对象成员,与 LIKE 族的对象比较值(String 成[object Object]) #5234, filed, same "silently zero rows" reasoning.fields, fix(objectql): 标量字段的写入载荷拒收算子对象 (#5922) #6273 refuses operator objects on scalar fields.
So writes check the field's type against the payload; reads do not check the field's type against
the operator. Given
isJsonField(type, field) { return JSON_COLUMN_TYPES.has(type) || !!field.multiple }already exists in
driver-sql, the information needed for the check is present at the point of lowering.And the failure mode is the worst available one:
200+ an empty array is byte-identical to asuccessful query that legitimately matched nothing. There is no signal for a caller to key on.
What it cost downstream (why I bothered to measure it)
In a downstream business app, a delete-guard was written as:
The rule it implements is "refuse to delete a team while any of its members is still referenced by a
plan". Because the predicate matched nothing, the guard never fired once since the feature shipped.
It threw no error, logged nothing, and type-checked. It was found only when someone happened to test
the guard's positive path by hand. A full sweep of that codebase then had to be run to prove there
was no second occurrence.
The general shape: this bug class does not produce incidents, it produces rules that quietly do not
exist. That is why "the caller should know better" is a weak answer here — there is nothing for the
caller to notice.
Asks (either would fix it; the first is the smaller change)
400 INVALID_FILTERenvelope that already covers unknown operators and malformed comparands —naming the operator, the field, and the fact that the filter was not applied. Minimum viable
set:
$in/$nin/$eq/ bare equality / the ordering comparisons, againstfield.multiple(and the other
JSON_COLUMN_TYPES) columns.$containsis declared as a stringoperator (
$contains: z.ZodString, inStringOperatorSchema) and works on array columns purelybecause the JSON serialization is text and
LIKE '%v%'happens to hit. That is incidental, notdesigned — it is substring matching over a serialization, so it is also sensitive to how the
column is serialized. There is likewise no way to express "any of these values" in one query;
the only spelling is an
$orof N$contains, sinceFILTER_OPERATORSis a closed set of 15with no
$overlaps/$containsAny.If the ruling is "1 only", that is completely fine for me downstream — a loud refusal is all the
downstream app needs, and
$or+$containsis a perfectly workable spelling. The current state(silently wrong, and inverted for
$nin) is the only outcome that cannot be worked with.Refs
$in/$nin的非$field对象成员,与 LIKE 族的对象比较值(String 成[object Object]) #5234 — closest sibling:$in/$ninlist members that are objects, also silently zero rows.Different face (comparand vs column), same family.
collection-operator-scalar-comparand-400— comparand shape; its own textnotes member typing is "another face (driver-sql:两类无意义比较对象仍编译成「静默空谓词」——
$in/$nin的非$field对象成员,与 LIKE 族的对象比较值(String 成[object Object]) #5234)". Column typing is a third face, covered by neither.filterJSON 被静默忽略 —— 返回未过滤整页(#4134/#4164 家族第三员) #4181 / fix(data): a filter the server cannot apply is rejected, not silently ignored (#4181) #4209, ObjectQL silently drops unsupported predicate keys;findOnethen returns the first row #4419,$regexon driver-sql is not a regex — it compiles to a substring LIKE, so it both over-matches and silently matches nothing #4706 — the "silently wrong filter answer" family, and the reasoningthat a widening failure outranks a narrowing one.
Minor, same code path, mentioned only so it is not lost
The refusal message for an unknown operator advertises
$regexas supported:$regexis inRETIRED_FILTER_OPERATORSper the #4706 ruling, so the error text is pointing authorsat a retired operator. Not worth its own issue — just noting it since it is emitted from the same area.