Skip to content

fix(objectql): 集合算子的标量比较值答 400 INVALID_FILTER 并点名期望形状,不再 500 (#5869) - #6209

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-5869-collection-operator-scalar-400
Aug 7, 2026
Merged

fix(objectql): 集合算子的标量比较值答 400 INVALID_FILTER 并点名期望形状,不再 500 (#5869)#6209
baozhoutao merged 2 commits into
mainfrom
claude/issue-5869-collection-operator-scalar-400

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #5869

前提复验(origin/main 80f7dc6a3)

单据前提成立,且复验中发现一处会决定落点成败的细节,先记在最前面。

探针实测(isFilterAST / parseFilterAST 直调):

not_in scalar | isFilterAST= true | lowered= {"stage":{"$nin":"won"}}
nin    scalar | isFilterAST= true | lowered= {"stage":{"$nin":"won"}}
notin  scalar | isFilterAST= true | lowered= {"stage":{"$nin":"won"}}
in     scalar | isFilterAST= true | lowered= {"stage":{"$in":"won"}}

isFilterAST 只看算子(VALID_AST_OPERATORS.has(filter[1])),不看比较值形状;parseFilterAST 照单下降。三种拼法同一条路径,与单据实测一致。

关键细节 —— 收口点不能只装在数组分支上。 分诊说的收口点(lowerWhereFilterArray)现行代码第一句是 if (!Array.isArray(where)) return bag;。但单据实测的那道门(HTTP)走不到数组分支:协议面 metadata-protocol/protocol.ts:5187 自己跑了一遍 isFilterASTparseFilterAST,交给引擎的已经是下降完的 FilterCondition 对象。所以只判数组分支的守卫,恰好漏掉本单实测的那道门。本 PR 因此把守卫装在下降后的 condition 上,两道门同一份判据。

为什么必须收在一处(实测的三家答案)

比较值 driver-sql driver-memory driver-mongodb
$in / $nin 标量 whereIn(f, scalar)500 DATABASE_ERROR 形状门放行 原样发射
$between 非二元组 拒收 400(arity) 拒收 400(#5328) 分支落空,不发射区间谓词

driver-memory 的形状门(filter-refusal.ts)实测逐格结果:

ACCEPT $in scalar           {"stage":{"$in":"won"}}
ACCEPT $nin scalar          {"stage":{"$nin":"won"}}
ACCEPT $in null             {"stage":{"$in":null}}
REFUSE $between scalar      | status= 400 code= INVALID_FILTER | Operator "$between" ...
ACCEPT $in ok (control)     {"stage":{"$in":["won"]}}

一条声明(FieldOperatorsSchema 写着 $in: z.array$nin: z.array$between: z.tuple),三家三个答案,且那份形状门只有 driver-memory 自己读 —— 这正是「收口点唯一」的论据本身。⛔ 未在任何 driver 侧加拒收(两族冻结,#5499)。

改动

⛔ 未改 packages/spec 这是本单最值得记的一条:期望形状早就声明了,缺的只是入口处的强制。ViewFilterRuleSchema.value 不按算子约束形状确实是真的,但修 500 不需要动它 —— 详见下方「扩测项」。

错误信息(实测,穿过 REST 后客户端收到的正文):

find('showcase_task'): Operator "$nin" on field "status" requires an ARRAY of values.
Received string ("done") at where.status.$nin. "$nin" tests membership of a list —
write ["done"] for a single value, or use "!=" ($ne) to compare against it.
Authoring spellings: nin, not_in, notin. The filter was NOT applied, and an unapplied
filter would have returned the UNFILTERED result set (#5869).

点名了算子(同时给作者实际书写的 not_in / nin / notin 拼法 —— 没人往 metadata 里写 $nin,只报下降后的名字会让作者去找一个他文件里没有的键)、字段、收到的值与位置、可直接粘贴的正确形状、以及标量比较该用的替代算子。

收益穿过 HTTP 边界后仍在(实测)

mapDataError 对声明了 4xx 的错误原样透传:

engine   : status=400 code=INVALID_FILTER msgLen=392
REST     : status=400 code=INVALID_FILTER

⚠️ 首版信息 633 字符,CLIENT_MESSAGE_MAX(500)截断,掉的正好是尾部那句「filter was NOT applied」—— 即这条拒收存在的理由本身。已把信息压到 392 / 372 / 355,并加了一条钉住该性质的用例(toBeLessThan(500) + 尾句仍在),因为那个上界在另一个包里。

反向验证(方向先写死,再跑)

预测 实测 一致
A:移除两处新判定 新用例翻红,#5158 既有用例保持绿 16 red / 30 green —— 恰为新增的 16 条
B:破坏信息组装(去掉算子名与 path) 只有点名类断言翻红,status/code 类保持绿 3 red / 43 green —— 恰为 3 条点名断言

肢 B 的意义:它证明点名断言绑的是点名,而不是顺带被「拒收发生了」满足 —— 否则「只把 500 改成 400 不给可操作信息」会照样绿。

肢 A 另有一条值得记的观察:测试替身的匹配器对 $in: 'won' 并不抛错(JS 里 'won'.includes('won') 为真),即无守卫时替身面的失败模式是静默错答,而 driver-sql 面才是 500。同一形状两种坏法,更说明判据该在引擎侧。

测试

新增 16 条,落在既有 engine-filter-array-lowering.test.ts(贴现有结构),含正反两侧:四种拼法的标量拒收、点名断言、对象门、null/数字/对象比较值、$and/$or/$not 嵌套走查(带 path)、六入口一致、$between 三格、REST 截断上界;以及必须保持工作的回归:合法数组形状原样下达、$in: [] / $nin: [] 仍是合法谓词、列表成员不复判、$eq 里形似算子图的深等值比较值不误伤、非集合算子标量不受影响(含 $gt 的 ISO 日期字符串)。

pnpm --filter @objectstack/objectql test
 Test Files  135 passed (135)
      Tests  2235 passed (2235)

pnpm --filter @objectstack/objectql typecheck
 > tsc --noEmit          (无输出 = 通过)

node scripts/check-engine-double-contract.mjs
 check-engine-double-contract: OK — 73 pinned, 133 in the DEBT ledger, 2 exempt.

node scripts/check-nul-bytes.mjs
 check-nul-bytes: OK (scanned 5920 tracked text file(s); ... no raw ASCII control bytes).

消费半径扫描(该判据对全仓每一次引擎查询生效,不能只测本包)。全仓 grep 标量 $in/$nin 命中三处夹具,均在 analytics read-scope 面(scope: / filter:,不是 where),且该面本就自带同族拒收(⑧ $in 需数组 / ⑨ $nin 需数组 / ⑩ $between[min,max])—— 与本 PR 无交叠,实测全绿:

@objectstack/service-analytics   Test Files 61 passed   Tests 1131 passed
@objectstack/metadata-protocol   Test Files 49 passed   Tests  502 passed
rest/analytics-read-scope-refusal-envelope.test.ts        8 passed
runtime/analytics-query-read-scope-withhold.test.ts      10 passed

顺带说明:本单未新增 fake engine 写动词,assertEngineDeleteDispatch 不适用;复用的是既有 makeRecordingDriver(driver 替身,非 engine double),check:engine-double-contract 已绿。

必答项

#5234(driver-sql 对象语法列表成员)—— 定价不变。 不同轴:本 PR 判「比较值整体是不是列表」,#5234 判「合法列表内部成员的类型」。{ status: { $in: ['a', { foo: 1 }] } } 是数组,本守卫原样放行(已加用例钉住这条边界)。LIKE 族的对象比较值也不在本守卫的算子集内。#5234 的落点(packages/drivers/driver-sql)、测量范围、文件面均不受影响;其分支 claude/issue-5234-silent-empty-predicates(85ef43d02)尚未并入 main,与本 PR 零文件交叠。唯一的间接影响是减负:经引擎路由的查询里非数组比较值不再抵达 driver-sql,但直调 driver 的路径仍在,所以接手者要做的事没有变少。

#5905(having-filter 第五求值面)—— 未触及。 如预期。lowerWhereFilterArray 只读 bag.where,having 是 aggregate AST 上的另一个键,不经过本守卫。且两者判据正交:#5905无值行的取值语义($ninundefined 该判真还是假),本守卫只问「比较值是不是列表」,即便跑在 having 面上也不会改动 #5905 的任何一格。engine-aggregate-having.test.ts / having-filter.test.ts 在全量里保持绿。

扩测项 between 的实测结论。 与 in/not_in 同族但不同格,如实报告:它不是 500 —— driver-sql(arr.length !== 2)与 driver-memory(isBetweenComparand,#5328)各自已经答 400 INVALID_FILTER,且措辞已经点名算子/字段/期望形状。但 driver-mongodbcase '$between'if (Array.isArray(value) && value.length === 2) {...}无 else,即不拒收也不发射区间谓词。所以本 PR 仍把它收进同一收口点:判据是纯 arity(与那两家逐字相同的条件,不发明更严的契约),首句措辞逐字保留,收在收口点后三家答案一致。

是否需要改 spec:否 —— 三格都不需要。 期望形状 FieldOperatorsSchema 已经声明($in/$ninz.array,$betweenz.tuple),缺的只是入口强制,所以本 PR 完全没进 packages/spec,无需转 spec 座位。

⚠️ 与此相关但不在本单范围的一条,留给 PM 判断是否另立:ViewFilterRuleSchema.value 不按算子约束形状仍然是事实 —— 本 PR 把它从「发布通过 → 运行时 500」改善成「发布通过 → 运行时可操作的 400」,但作者仍要到运行时才知道写错了。在发布期就按算子拒收domain:spec 的活,且是明显更大的一刀(要给每个算子定 value 形状并迁移存量 metadata),按纪律未做 rider、也未擅自立案。

偏差与风险

  • 唯一偏差:守卫装在下降后的 condition 上,而非分诊字面写的「数组分支」。理由即上文实测 —— 只判数组会漏掉本单实测的那道门。收口点、包、信封均未变。
  • 行为变化(500 → 400)对此前会崩的调用是纯改善;对此前恰好能跑的调用需确认无误伤,已由消费半径扫描 + 五个包全量覆盖。
  • $between 现在由引擎先答,driver 侧那两条拒收在经引擎路由时不再被触达(直调 driver 仍触达)。全仓仅 driver-memory 自己的用例断言该措辞,且为直调,保持绿;首句逐字保留也是为此。

Generated by Claude Code

…calar comparand (#5869)

`FieldOperatorsSchema` declares `$in`/`$nin` as arrays and `$between` as a
[min, max] tuple, but nothing enforced that on the way in: `isFilterAST`
checks only the OPERATOR and `parseFilterAST` lowers whatever comparand it
is handed, so `['status', 'not_in', 'done']` became
`{ status: { $nin: 'done' } }` and reached the driver, where `driver-sql`
handed a scalar to `whereIn()` and answered 500 DATABASE_ERROR -- a
server-fault code for a filter the caller can fix, naming neither the
operator nor the field.

The gate goes at the engine's single filter collection point rather than in
each driver: three backends answered three different ways for one declared
contract, and both driver families are under an investment freeze (#5499).

It runs on the LOWERED condition, which is what makes it cover the door the
defect was actually measured through: the protocol face runs its own
isFilterAST -> parseFilterAST and hands the engine a FilterCondition OBJECT,
so a guard on the array branch alone would have left the reported 500 exactly
where it was.

`$between` arity is hoisted to the same seam -- driver-sql and driver-memory
each already refuse it (wording kept verbatim), while driver-mongodb's arm
falls through without emitting a range predicate.

Not judged here: empty lists (declared predicates), list MEMBER types
(#5234, another face), and non-collection operators.

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

vercel Bot commented Aug 7, 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 7, 2026 11:15am

Request Review

@github-actions github-actions Bot added the size/l label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

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

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

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 7, 2026
… to `any`

`check:query-options-erasure` went red: "test surface grew 267 -> 289". The 22
new engine call sites in engine-filter-array-lowering.test.ts all carried a bare
`as any`, which the #4918 ratchet counts.

Both remedies the gate names are used, split by what `tsc` actually says about
each input rather than applied uniformly:

- `as unknown as EngineQueryOptions` (via the `asFilterArrayQuery` helper, and
  the `EngineCountOptions` / `EngineAggregateOptions` twins on count/aggregate)
  for the FilterArray inputs. Those are off-contract BY DECLARATION -- `where`
  is a FilterCondition / Record<string, unknown> that an array is not assignable
  to, because FilterArray is INPUT-ONLY sugar the spec excludes (#5285).

- The assertion simply DROPPED on the malformed-comparand cases
  (`{ stage: { $nin: 'won' } }`). Those type-check fine, because `where` is
  declared loosely on purpose -- which is precisely why the runtime gate this
  file pins has to exist. Erasing them would have hidden that they are
  type-legal.

The 23 pre-existing sites in this file are untouched, as are the baseline JSON
and eslint.config.mjs -- the ceiling is met by fixing the new sites, not by
raising the number.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
@baozhoutao
baozhoutao marked this pull request as ready for review August 7, 2026 11:16
@baozhoutao
baozhoutao enabled auto-merge August 7, 2026 11:16
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 10c4ea9 Aug 7, 2026
24 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-5869-collection-operator-scalar-400 branch August 7, 2026 11:48
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