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

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:103、driver-memory 的 memory-matcher.ts:114 与 filter-refusal.ts:565、driver-mongodb/src/mongodb-filter.ts:421、driver-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-memory 的 SUPPORTED_FIELD_OPERATORS 与 service-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 是否触面:否

#5591 是 engine.ts 里 stripReadonlyFields 相对 beforeUpdate hook 的时序问题,实现在 rule-validator.ts(本 PR 未触),调用在 engine.ts(本 PR 未触)。文件面不相交;语义上也不相交:validateRecord 对 readonly/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.

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