Skip to content

fix(objectql): 标量字段的写入载荷拒收算子对象 (#5922) - #6273

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-5922-text-operator-object-reject
Aug 7, 2026
Merged

fix(objectql): 标量字段的写入载荷拒收算子对象 (#5922)#6273
baozhoutao merged 1 commit into
mainfrom
claude/issue-5922-text-operator-object-reject

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #5922

前提复验(先证伪,再实现)

两条可证伪前提都在 origin/main @ 70f132c15 上重跑过。

P1 —— text 型今天确实放行算子对象:成立。 正文 PROBE 原样复现:

"title": "ADMIT: driver got [{\"title\":{\"$in\":[\"a\",\"b\"]}}]"
"n":     "REJECT: N must be a number"

P2 —— 既有算子形状判定存在且可达:部分不成立,如实报告。 仓内没有任何导出的「这是不是算子对象」谓词函数。存在的是五份互不相通的私有手抄(objectql/src/having-filter.ts:103driver-memorymemory-matcher.ts:114filter-refusal.ts:565driver-mongodb/src/mongodb-filter.ts:421driver-turso/src/remote-transport.ts:176),全部是 keys.some(k => k.startsWith('$')) 的变体,且全部在 driver 包里 —— @objectstack/objectql 不依赖任何 driver,导入即倒置分层。

可复用的是词表,不是函数,而词表恰好是 spec 明确声明的唯一真源:ALL_OPERATORS(= FILTER_OPERATORS + LOGICAL_OPERATORS)与 RETIRED_FILTER_OPERATORS,均从 @objectstack/spec/data 导出,record-validator 已在从该入口导入(无环)。所以本 PR 消费词表而不是写第六份 startsWith('$') —— 这正是 spec 自己在 FILTER_OPERATORS 的注释里点名称许的用法(driver-memorySUPPORTED_FIELD_OPERATORSservice-analytics 的覆盖测试「derive enforcement from it rather than restating it — which is the right design」)。协议新增算子当天即自动收口,手抄做不到这一点。

四型普查(分诊必答)

15 种声明类型,同一个 { $in: ['a','b'] },记录型 driver 驱动真实引擎:

类型 修复前 修复后
text / textarea 放行 —— driver 收到 {"title":{"$in":["a","b"]}} 拒收
select(声明 options) 放行 拒收
lookup(及 master_detail/user/tree 引用类) 放行 + 一条 ADR-0104 warn 拒收
number / percent 拒收(invalid_number) 拒收
boolean 拒收(invalid_boolean) 拒收
date / datetime / time 拒收(invalid_date / …) 拒收
select( options)/ url / email / phone 拒收 —— 但只是因为 String({ $in: […] })"[object Object]" 拒收
json 等结构化 JSON 类 放行 放行(刻意)

普查改变了裁决的形状:放行面远不止 text,而四种「拒收」里没有一种是真的在判断算子对象 —— 它们只是在一个被字符串化的对象上过不了正则或选项表。一条在 4 种类型上偶然成立、在另外 11 种上不成立的规则,作者无法从元数据预测。所以收口面按声明值是否为标量统一划线,而不是给 text 打一个补丁。

data.id:不在本 PR 收口,理由是分层而非遗漏。 实测确认 #5919 裁 A 之后它进了「不被校验」格:

PROBE data.id operator + multi:
  calls: [{"fn":"updateMany","args":[{"object":"probe_task"},{"id":{"$in":["a","b"]},"title":"x"}]}]

但这一格的裁定已经写在派发层:ENGINE_UPDATE_DISPATCH_CASES 明确列了 operator object in data.id WITH multi:true — the declared bulk intent is honoured (#5748)expect: 'multi',且 engine-update-dispatch.test.ts 用真实引擎逐条驱动它。在 record-validator 里拒收同一个调用,就是对同一个问题给出第二个答案 —— 正是 engine-update-dispatch.ts 这一族模块被抽出来防的事(#4550 / #4434)。按同族一致性它该收口,按「一个问题一个答案」它不该在这里收口,后者更重。已另立 #6262,给出 A(派发层剥离,倾向)/ B(响亮拒绝,需回退 #5748 裁 A 的一条 case)/ C(留给驱动,不建议)。

改动

packages/objectql/src/validation/record-validator.ts 一处,validateOne 在类型分支之前:声明值为标量的字段(即非多值、非结构化 JSON),值若携带已声明的 filter 算子键则拒收。

放在类型分支之前有两个理由,近的一个是必须的:下方 ADR-0104 引用/结构化 JSON 分支会在 return null 之前 warn 并向 onAdmittedValueShapeViolation 上报,而那个 sink 的语义是「本部署存下了一个不合规值」—— 为一次即将被拒绝的写入登记反例,会让部署凭空背上一条它并没有违反的 ADR-0104 记录(见 AdmittedValueShapeViolation 的 TSDoc:被拒绝的值 settles nothing)。

消息复用 ADR-0104 的 invalid_value_shape 文案(四语言均已本地化),wire code 仍是 invalid_type。没有新增消息 key:该 key 按句子编目,而「这个值的形状不适合该字段的声明类型」正是这句话,再加一条近义句是 catalog drift 而不是清晰度。渲染结果:

Title has an invalid text value: $in is a filter operator, not a value
  — a filter belongs in the query 'where', not in the write payload

反向验证(方向先写死,实测与预测一致)

肢 A = 去掉新判定。 预测:拒收用例翻红,且分两种原因 —— title/note/stage_free/owner 因为根本不抛(放行洞),stage/due/done/n 因为仍抛但落回各自偶然的旧 code;端到端用例翻红且脏对象落库;两组「刻意不动」与「合法标量/既有拒收」保持绿。

实测 15 failed | 9 passed,逐条对上:

× refuses { title: … }      AssertionError: expected [] to have a length of 1 but got +0
× refuses { stage: … }      AssertionError: expected 'invalid_option' to be 'invalid_type'
× refuses { due:   … }      AssertionError: expected 'invalid_date'   to be 'invalid_type'
× refuses { done:  … }      AssertionError: expected 'invalid_boolean' to be 'invalid_type'
× refuses { n:     … }      AssertionError: expected 'invalid_number'  to be 'invalid_type'
× refuses the issue's exact update PROBE, and the driver is never touched
    AssertionError: promise resolved "{ title: { '$in': [ 'a', 'b' ] }, …(1) }" instead of rejecting

最后一条就是「脏对象落库」的直接证据 —— 解析出来的行里躺着那个算子对象。保持绿的 9 条正好是两组刻意不动的用例,说明翻红来自新判定而不是用例本身。

测试

新增 packages/objectql/src/validation/operator-object-write-value.test.ts,24 条:每个收口类型一条拒收(断言点名字段/算子/声明类型)、复合算子、逻辑组合子、退役算子 $regex、insert 面;刻意不动面(json、多值 invalid_type_array、未声明的 $inn、普通对象);合法标量回归、Date 是 comparand、number 既有 invalid_number 回归(断言消息仍是 N must be a number)、boolean/select 既有 code 回归;写路径端到端 4 条(单行 update / insert / multi update 三个 validateRecord 入口各一条,断言 driver 一次未被调用且库里行未变,外加一条合法写仍然落库)。

pnpm --filter @objectstack/objectql test       → Test Files 138 passed, Tests 2273 passed
pnpm --filter @objectstack/objectql typecheck  → tsc --noEmit 通过,无输出
node scripts/check-engine-double-contract.mjs  → OK — 75 pinned, 133 DEBT, 2 exempt
node scripts/check-nul-bytes.mjs               → OK(5957 个文件,无裸控制字节)

消费半径普查:validateRecord 的运行时消费方只有 engine.ts 的三个入口(insert / update 单行 / update multi),三条都有端到端用例。跨包 fixture 用 \.(insert|update|create)\(…\$(in|gt|…) 扫过 rest / runtime / services / triggers / plugins / client / cli,命中全部落在 where 位(正确用法),无一在写入载荷位。另跑了两个最重的写路径消费方作为兜底:

packages/rest                  → Test Files 63 passed, Tests 861 passed
packages/services/service-automation → Test Files 66 passed, Tests 789 passed

#5591 是否触面:否

#5591engine.tsstripReadonlyFields 相对 beforeUpdate hook 的时序问题,实现在 rule-validator.ts(本 PR 未触),调用在 engine.ts(本 PR 未触)。文件面不相交;语义上也不相交:validateRecordreadonly/system 字段直接 continue(record-validator.ts:944 / :954),而 readonly 字段正是 #5591 唯一搬动的那一类,所以新规则在剥离前后都不会对它发火。

行为变化的存量风险(只测只答,未修)

已存进库的脏算子对象行,读路径今天不报错也不修复,原样交还:

READ findOne => {"id":"rec_1","title":{"$in":["a","b"]}}
typeof title => object
filter title=="x"      matches dirty row => false
filter $contains 'a'   matches dirty row => false
REPAIR write => {"err":null,"row":{"id":"rec_1","title":"repaired"}}

三点:

  1. 声明为 text 的列读回来是一个 JS 对象,凡是假定该列为字符串的消费方(模板渲染、display-name、CSV 导出、表单)拿到的都是对象,无错无警;
  2. 该列上的过滤一律不匹配,脏行从每个按该列过滤的面上静默消失 —— 正是正文说的「离原因很远」;
  3. 经 SQL 驱动往返后列里是序列化字符串 {"$in":["a","b"]},与内存后端保留对象不同,同一条脏数据在不同后端有两种事后形态。

本改动不会把存量脏行锁死:对脏行正常写入一个合法标量仍然成功(上面的 REPAIR write),存量可用普通 update 修复,无需迁移。是否需要一次扫描/迁移不在本单范围,未修。


Generated by Claude Code

写入载荷里的 `{ title: { $in: ['a','b'] } }` 此前在 `text` 型字段上零告警原样入库,
而同一个算子对象在 `number` 型上会立刻响亮拒绝 —— 同一个错误,两种命运,取决于字段类型。

实测(15 种字段类型,记录型 driver 驱动真实引擎)显示放行面远不止 `text`:`textarea`、
未声明 `options` 的 `select`、以及 `lookup` 等引用类(ADR-0104 warn-first)同样放行;
而 `select`(有 options)/ `url` / `email` / `phone` 之所以拒绝,只是因为
`String({ $in: […] })` 是 `"[object Object]"`,恰好过不了它们的正则或选项表 —— 一条在
4 种类型上偶然成立、在另外 11 种上不成立的规则。

现在只有一条规则:声明值是标量的字段一律不接受算子对象。判定复用 spec 已导出的算子词表
(`ALL_OPERATORS` + `RETIRED_FILTER_OPERATORS`),而不是仓内第六份手抄的
`startsWith('$')`(#5659 纪律);消息复用 ADR-0104 的 `invalid_value_shape` 同族文案,
点名字段、点名算子、点名声明类型。

刻意不动:`json` 等结构化 JSON 类继续放行(那是用户数据);多值字段保留既有的
`invalid_type_array`;`data.id` 归派发轴(#5748 / #5919 已裁 multi 意图照做),另立 #6262。

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 12:51pm

Request Review

@github-actions github-actions Bot added the size/m 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
@baozhoutao
baozhoutao marked this pull request as ready for review August 7, 2026 12:55
@baozhoutao
baozhoutao enabled auto-merge August 7, 2026 12:55
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit a682670 Aug 7, 2026
24 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-5922-text-operator-object-reject branch August 7, 2026 13:18
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

写入载荷里的算子对象:text 型字段不做类型校验,{ title: { $in: [...] } } 原样写进库(number 型会响亮拒绝)

2 participants