fix(objectql)!: a no-operator object where a scalar field's value belongs is refused INVALID_FILTER / 400 on every driver (#20546) - #20744
Conversation
… under a scalar column (#20546) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…egation filter and having Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…ver SqlDriver (SQLite, live PostgreSQL/MySQL) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…e label Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 825d793287536190223a86bb4b2d8ea5f7560520 && git checkout 825d793287536190223a86bb4b2d8ea5f7560520
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 01e78dceeffb28477bcdbcab26f951b4cbef78ec b50627aca98d596a70d5f60c165ddca239ebdadf && git checkout -B drift-repro 01e78dceeffb28477bcdbcab26f951b4cbef78ec && git merge --no-ff b50627aca98d596a70d5f60c165ddca239ebdadf
node scripts/docs-audit/affected-docs.mjs --json 01e78dceeffb28477bcdbcab26f951b4cbef78ec
|
Contract reviewServed-tier: Inputs: card #20546 (body; triage 5882227960; claim 5901567907; dev report 5902224595), PR #20744 (body, the 8-file list, ① Derived judgmentsThe narrowing, cell by cell — what is refused now that was accepted, and where.
One walk, not a second traversal. RIGHT.
Public surface. None moves. Refusal envelope and words. Check-runs on Shipped prose, sentence by sentence. Changeset: the title, the ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 36656292008 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes #20546
Clause-②: no (narrowing)
What this changes
A plain object with no
$-operator key where a scalar field's value belongs, for examplewhere: { amount: { a: 1 } }on anumberfield, is now refused withINVALID_FILTER/ 400. The refusal names the field, its declared type, the object's keys (never its values) and the path. It runs before any driver is resolved, on every driver, at the three positions the engine judges:where(object form andFilterArraysugar, onfind/findOne/count/aggregate/update/deleteand the judge-onlyjudgeFilter),aggregations[i].filter, andhaving.Landing site: the number-comparand door's walk, as a second arm. It adds no second traversal. Triage said: "If the same walk is the natural site, it lands serially after that PR, in the same walk. ⛔ No second traversal of the filter." PR #20545's walk (
walkConditioninnumber-comparand-declared-type-door.ts) is the only filter walk the engine runs at all three positions with each column's declaration in hand. It already stood on the exact branch: a field spec with no$key, which it stepped past (return kept(spec)). It now asks one question per field key before the number arm runs:packages/objectql/src/no-operator-object-door.ts(new) holds the arm's classification (holdsScalarValues), its structure test (isNoOperatorObject) and its words. ⛔ Nothing in it walks a filter.number-comparand-declared-type-door.ts: the walk's per-key resolver now supplies two facts, the number arm's meta and the column's scalar-valued type. The first refusal the walk meets is either arm's.having-filter.ts:aggregatedRowColumnTypesreads each aggregated column's type off the query.aggregatedRowColumnClassesis now derived from it, so the class and the type are one reading of the query. Thehavingarm needs the type because thetextclass lumps ajsonorlookupgroupBy in with a real text column.engine.ts: thehavingcall passes the types; the other hunks are comments. PR fix(objectql,service-automation,runtime): the card's named warnings and endpoint hints state each decision in words instead of a tracker number #20738's warning-text region is untouched.Which columns are judged (H3): a closed definition from spec's classes.
SCALAR_FILTER_HEAD_TYPES(spec's published "stores one scalar value" set, derived from the ADR-0104 value classes; the #8371 dotted-head verdict reads the same set) with or withoutmultiple: true, plusMULTI_OPTION_TYPES. The accepted side is never judged: relation types (lookup,master_detail,user,tree, single or multiple), structured-JSON types, file and media types (the #8371 carve-out: a legacy stored value is an inline object),formula(refused one door earlier,INVALID_FIELD), undeclared keys, and unknown types.Before, measured on
origin/mainfbec216e2dThrough
engine.find/engine.aggregateandPOST /api/v1/data/:object/query(both doors answered alike). Three rows (amount5 / 12 / 30;owneru1 / u2 / u1 with u1 in region NA;meta{a:1}/{a:2}/{b:1}). InMemoryDriver, SqlDriver on SQLite (better-sqlite3), SqlDriver on a live PostgreSQL 16.13:where{ amount: { a: 1 } }(number, the card)INVALID_FILTER, the driver's words ("cannot be bound")where{ title: { a: 1 } }(text)wheresingle select, boolean, date, autonumber,multiple: trueselect,multiselect,tagswhere{ $not: { amount: { a: 1 } } }where{ $or: [{ amount: { a: 1 } }, { amount: 30 }] }wheresugar[['amount', '=', { a: 1 }]]where{ amount: {} }aggregations[1].filter{ amount: { a: 1 } },{ title: { a: 1 } },{ amount: {} }having{ total: { a: 1 } }(asum),{ title: { a: 1 } }(a groupBy),{ total: {} }where{ owner: { region: 'NA' } }(lookup;master_detailand a multiple lookup alike)where{ meta: { a: 1 } }(json)where{ amount: { $gt: { $field: 'cap' } } }After, the same run on this branch
Every non-control row above answers
400 INVALID_FILTERin the engine's words on all three drivers, at the path the object sits at (where.amount,where.$not.amount,where.$or[0].amount,aggregations[1].filter.amount,having.total). No read of the object runs. Every control answers exactly as before: the lookup, master-detail, multiple-lookup and JSON filters reach the driver as written, and so do the file field, the$fieldreference, the undeclared key and theidkey. Example of the words:Hypotheses (zone 2), which held
lowerWhereFilterArrayis the seam, andnarrowNumberComparandsis called there on both branches (the object branch and the lowered array branch). The number door's walk was number-specific only at its per-field gate (numberComparandFieldVerdict(meta) !== 'judged'), and itswhereresolver already returned every declared field's type. The text door and the temporal door each walk too, but neither runs athavingwith a column declaration, so neither covers every position. The number door's walk is the one walk that does. The arm rides it, and no traversal was added.whereanswered per driver, andaggregations[i].filterandhavinganswered a silent empty on every driver. Each is pinned.multiple: trueselect,multiselectandtagssplit exactly as a scalar field does (memory 200 no rows, SQL 400). That includes{ tags: { 0: 'x' } }, the spelling the [finding] The FILTER axis has no DOTTED-path verdict —where: { project_id.name: 'x' }rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371 multi-value carve-out exists for: the nested-object form does not reach an array member on InMemoryDriver. So they are judged. A multiple lookup stays on the relation side. A{ $field }reference carries a$key, so it is never this arm's (measured: served 2 rows on SQL, as before).git diff fbec216e2d HEAD -- packages/driversis empty). The SQL driver's ownINVALID_FILTERstays as defence in depth for driver-direct callers and for the columns this arm does not judge (the lookup and JSON controls above still meet it).Tests
All from
b50627aca9or from a commit whose non-test source is byte-identical to it (the last two commits touch only the changeset).pnpm --filter @objectstack/objectql test: 338 files / 6704 tests passed.test:repo: 1 file / 5 passed.pnpm --filter @objectstack/objectql typecheck: exit 0 (check:test-typecheckOK, the debt ledger held).pnpm --filter @objectstack/rest typecheck && pnpm --filter @objectstack/rest test: 229 files / 4391 passed / 63 skipped (the live-dialect cells, no URL set).packages/objectql/src/engine-no-operator-object-door.test.ts(17 tests). It uses a recording driver, which is InMemoryDriver's cell by construction because the arm answers before a driver is resolved. It covers every scalar class,{}, every verb and the judge,$and/$or/$notpaths, sugar, the three REST doors intofindData, the per-aggregation filter,having(sum, groupBy, max of a date, a month bucket), the accepted side at all three positions, aMapand the classification GUARD over everyFieldType.packages/rest/src/data-no-operator-object-door.test.ts: SQLite always, PostgreSQL and MySQL whereOS_TEST_POSTGRES_URL/OS_TEST_MYSQL_URLare set.whererefusals, the per-aggregation filter,having, and the two controls (a lookup nested-relation filter and a JSON object comparand: the driver is asked, and the answer is never the arm's). Local run with a live PostgreSQL 16.13: 8 passed (sqlite 4, live postgres 4) / 4 skipped (mysql, no URL).@objectstack/rest, so the live cells run only locally.@objectstack/objectql):service-analytics137 files / 3216 passed;plugin-security147 files / 3202 passed / 23 skipped. The other downstream consumers are declared to CI.Reverse verification (ablation), from the committed fix. It ran through
scripts/ablation-replace.mjs(WRAP mode, trap-restored). The anchorif (facts.scalarType !== null && isNoOperatorObject(value)) {was replaced byif (facts.scalarType === '__ablated_20546__' && …) {. On disk the anchor went 1 → 0 and the marker 0 → 1, with blob16151b29f6c1→0e8acf882100. Then objectql was rebuilt andablation-dist-preflightreported the marker present in 4 built files. Predicted direction: red. Observed: red. The objectql pin went 10 failed / 7 passed: every refusal case failed, and every control and GUARD stayed green. The rest pin went 4 failed / 4 passed / 4 skipped: thewhereand aggregate refusals failed on SQLite and live PostgreSQL, and the controls stayed green. Restore leg: blob equals HEAD (16151b29f6c1),git diff HEADempty, the whole-treegit status --porcelainempty, rebuilt,--absentpreflight (marker absent from all 14 built files), then both pins green again (17 / 17; 8 passed + 4 skipped).Gates
node scripts/pm/dispatch-gates.mjs --commandsatb50627aca9derived 65 commands. All were run onb50627aca9, and--ranreconciles them: 65 derived, 63 run, 2 NOT-MEASURED, 0 UNRUN. 63 exit 0, includingcheck:adr-0087-registration --base origin/main(not-required (no-migration-prescription)accepted),check:changeset-no-major,check:empty-changeset,check:doc-authoring,check:nul-bytes,check:engine-double-contract,check:where-matcher,check:driver-memory-census,check:cross-package-test-inputs,check:test-source-alias,check:type-check-coverageandcheck:query-options-erasure.check:dual-build-cjs-loadsandcheck:type-check-debt. Reason: each exits 3 (PREREQUISITE NOT MET) because it reads the built closure of every package, and this box built only the objectql/rest closure. CI'sLint & Repo Gatesbuilds that closure.fbec216e2d, a detached comparison worktree) and after (onb50627aca9):check:where-matcher: 440 matchers, 440 correct or loudly refusing, before and after.check:driver-memory-census: 12 bindings / 2 ruled consumers, before and after.check:engine-double-contract: pinned rows 825 → 825 and discovered files 953 → 953. Test files went 4231 → 4233 and production files 2997 → 2998, which are the two new tests and the new module. No new fake engine.pnpm exec eslint --no-inline-config --format jsonover the 7 changed.tsfiles, atb50627aca9, found 7 files, 0 errors, 0 warnings. The checked population comes from eslint's own config:calculateConfigForFileanswersisPathIgnored=falsefor all 7. The file count comes from the JSON output (7 results). Untouched files cannot change verdict:parserOptions.projectandprojectServicearenullfor every file, so type-aware linting is not enabled and this diff cannot move any untouched file's lint result.Changeset
.changeset/20546-no-operator-object-on-scalar.md:@objectstack/objectqlminor, a BREAKING banner,Clause-②: no (narrowing)and the ADR-0087 markernot-required (no-migration-prescription), following the #20501 / #20545 precedent. Its "Who is affected" section names a caller that sends the shape to the in-memory driver: a test suite, a local or embedded deployment onInMemoryDriver, or a flow or hook calling the engine in-process. No export or published type changes: the door modules are internal, and@objectstack/objectql's root and./coreexports are unchanged.Acceptance notes
{ owner: { region: 'NA' } }on alookupgives memory 200 with no rows and SQL 400.{ meta: { a: 1 } }on ajsonfield gives memory 200 with 1 row and SQL 400. So does the undeclaredidkey ({ id: { a: 1 } }: memory 200 no rows, SQL 400), because the registry's declared map carries noid. Spec'sFilterConditiondeclares the nested-relation form, but no data-path driver serves it. [finding] a plain object with no$key as a scalar field's filter value answers 200 with no rows on the memory driver andINVALID_FILTER400 on SQLite and PostgreSQL #20546 is not the card for that.where: { project_id.name: 'x' }rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371 carve-out and stay unjudged.{ photo: { url: 'x' } }answered memory 200 with no rows (on fresh rows) and SQL 400. A legacy inline value could still match on memory.where, a{}under a judged column is now answered in the engine's words instead of each driver's{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240 words, with the sameINVALID_FILTER/ 400 envelope. Under a column this arm does not judge,{}keeps the drivers' refusal.findNonNumericComparand(internal, tests only) still answers the number arm alone. When the walk's first refusal is the new arm's, it answersnull, and its docblock says so.Generated by Claude Code