Skip to content

fix(data): sort / select / expand 指向不存在的字段时被拒绝,而不是静默丢弃 (#4226) - #4240

Merged
os-zhuang merged 3 commits into
mainfrom
claude/rest-list-field-validation-yyx9tk
Jul 31, 2026
Merged

fix(data): sort / select / expand 指向不存在的字段时被拒绝,而不是静默丢弃 (#4226)#4240
os-zhuang merged 3 commits into
mainfrom
claude/rest-list-field-validation-yyx9tk

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #4226

TL;DR

列表路径上有四条「调用方点名一个字段」的轴。filter 已经被 #4134 / #4164 / #4181 / #4121 收口;另外三条在名字写错时仍然一律 200

sort=no_such_field   -> 200  CAEBD          与「完全不排序」逐字节相同
select=no_such_field -> 200  <全部字段>      要一列,给了全部
expand=no_such_rel   -> 200  无该键          无关系,也无报错

三条轴现在都在共享的 normalizer 里被拒绝,于是 GET /data/:objectGET /data/:object/:idPOST /data/:object/query、export 路由和 runtime dispatcher 给出同一个答案,而不是五个。

三条轴分别怎么改的

按 issue 里的定性,各自的危害不同,所以消息也不同:

  • sort400 INVALID_SORT。结果集不变,所以不是 REST 列表:无法解析的 filter JSON 被静默忽略 —— 返回未过滤整页(#4134/#4164 家族第三员) #4181 那种「返回了全部」;但 sort + top 正是取「最新 N 条」的写法,排序被丢弃后拿到的是任意 N 条,且响应里没有任何线索。这就是 REST 列表:无法解析的 filter JSON 被静默忽略 —— 返回未过滤整页(#4134/#4164 家族第三员) #4181 在 export 路由 orderby 上修掉的那半,在 list 路由这半。INVALID_SORT 自写进标准 catalog 起就没有任何 emitter,这次把它用上了。

  • select400 INVALID_FIELD。这一轴的失败方向最反直觉:engine.find() 先丢弃未知列(刻意的 SELECT * / OData 容忍),再在投影被清空时退回 *,两段叠加成「?select=<拼错> 要一列,拿到全部列」——一个用途是返回更少的参数,失败方向是返回更多,与 FLS / 数据最小化正好相反。

    issue 里说部分未知那支「可以另议」,这里选择了一并拒绝,理由写在代码注释里:按 A filter with an operator outside VALID_AST_OPERATORS is silently dropped, not rejected — single-condition views return unfiltered results #3948,一个没生效的投影不该看起来像生效了;而且宽容那一读法必须解释,为什么在同一个端点、面对同一份字段表?status=<拼错> 是 400 而 ?select=<拼错> 不是。如果 review 认为部分未知那支应当保留宽容,改回去只需要动 assertProjectionFieldsExist 一个过滤条件。

  • expand400 INVALID_FIELD。最轻——行集和列都不变,只是关系没展开——但响应与「每一行的外键都是 null」无法区分,客户端就在该显示名字的地方渲染裸 id。三种原因给三条不同消息,因为修法不同:字段根本不存在(拼写错误)、字段存在但不是引用类型(?expand=title,误解了 expand 的作用)、引用类型但没声明 reference(bug 在对象上,不在请求上)。

顺带修好的两处「一直没生效」

这两处不是新增的严格性,而是本来就该生效却一直被静默丢弃的东西,不修的话「真实字段确实生效」的对照组就是假的:

  1. 两种 sort 拼写从未真正到达 driver。 client SDK 自己声明的 orderBy?: string | string[] | SortNode[] 里的 string[],以及 {field: direction} map —— 后者正是 GET /data/:object/exportGET /data/import/jobs 和 objectui calendar 三处发出的形状。两者都原样穿过 normalizer,然后被每个 driver 的 Array.isArray(query.orderBy) 守卫挡下:没有 ORDER BY 子句,没有报错。所以 GET /data/import/jobs 自写下起就一直在要 created_at desc、返回插入顺序。现在两者都折叠成 SortNode[]

  2. $expand 一个 tree 字段现在真的会展开。 REFERENCE_VALUE_TYPES(spec 自己的表)把 tree 列在「值指向另一条记录……展开形式为关联记录对象」里,objectui 的 EXPANDABLE_FIELD_TYPES 也请求它,但 expandRelatedRecords 用的是手抄的 !== 链,漏了 tree —— 层级字段一直返回裸 parent id。现在展开循环和新的 expand gate 读同一个 spec 集合,所以 gate 不可能放行一个引擎随后会跳过的字段。

边界与分层

  • 分层与 assertObjectRegistered 一致:registry + 字段表在 → 权威;没有 registry、没有字段表、或字段表是 legacy 数组形状 → 跳过(数组上 Object.keys 得到 '0','1','2'…,会把每个真实字段名都拒掉)。四条轴共用一次 resolveQueryFields 解析。
  • 引擎的容忍未被触碰:它保护的是内部调用方(hooks、flows、expand 子查询、registry-less 宿主),这些都不经过本 ingress —— 与既有的对象存在性 / 未知字段 gate 同一套分层。
  • 报错引用调用方真正写下的参数名:发 ?$orderby=… 的人不会被告知 'orderBy' 有问题(那个名字不在他的请求里)。
  • 未知对象仍然是 404 OBJECT_NOT_FOUND,任何一条轴的 gate 都不得把它变成 400。

测试

新增 packages/objectql/src/query-expression-conformance.test.ts —— issue 点名的那个文件,直接写成 bad → rejected 的形状(而不是 KNOWN GAP pin)。真实 ObjectQL 引擎 + 真 registry,5 行、插入顺序 C A E B D,就是 issue 的 transcript。

每条轴都带对照组:内存 driver 真的排序、真的投影、真的分页 —— 否则「真实字段确实生效」的断言会 vacuously 通过,pin 就什么也钉不住。另有:四条轴在一个请求上同时生效、单记录路由给出相同答案、legacy 数组字段表使 gate 整体降级、filter 轴各自的 code 未被新 gate 吞掉。

文档

content/docs/api/data-api.mdx 增加「Nor is a sort, a projection, or an expansion」一节(请求 → 结果对照表 + 为什么每条轴重要),参数表补上各自的拒绝码;odata.zod.ts$expand 的 describe 从 lookup/master_detail 更正为四种引用类型,并跑了 gen:docs 重新生成(只产生这一处 diff)。

对调用方的影响

sort / select / expand 中点名不存在字段的请求现在会显式失败,而不是收到一个未排序 / 被放宽 / 未展开的响应。所有点名真实字段的请求不受影响。


Generated by Claude Code

…, not dropped (#4226)

The list path has four axes on which a caller names a field. `filter` was
closed over #4134 / #4164 / #4181 / #4121; the other three still answered 200
when the name was wrong:

    sort=no_such_field   -> 200  CAEBD          identical to "no sort at all"
    select=no_such_field -> 200  <every field>   asked for one column, got all
    expand=no_such_rel   -> 200  <no such key>   no relation, no complaint

Each is now refused in the shared normalizer, so the list route, the
single-record route, `POST /data/:object/query`, the export route and the
runtime dispatcher give one answer instead of five.

- sort -> 400 INVALID_SORT. The row set is unchanged, so this is not #4181's
  "returned everything" — but sort + top is how a caller asks for "the latest
  N", and a dropped sort makes that an arbitrary N. INVALID_SORT had sat in
  the standard catalog with no emitter since it was written.
- select -> 400 INVALID_FIELD. `engine.find()` drops unknown columns and then
  falls back to `*` when that empties the projection, so `?select=<typo>` asked
  for ONE column and received EVERY column: a parameter that exists to return
  less, failing by returning more. The partially-unknown case is refused on the
  same terms.
- expand -> 400 INVALID_FIELD, with distinct messages for "no such field",
  "field holds no reference" and "reference field declares no target".

Two sort spellings that were silently never applied now are: the client SDK's
declared `orderBy: string[]`, and the `{field: direction}` map that the export
route, `GET /data/import/jobs` and objectui's calendar emit. Both fell through
the normalizer untouched and were then declined by every driver's
`Array.isArray(orderBy)` guard, so the import-job history has served insertion
order while asking for `created_at desc` since it was written.

`$expand` of a `tree` field also works now: `REFERENCE_VALUE_TYPES` lists it
among the types that expand, but `expandRelatedRecords` tested membership with
a hand-copied `!==` chain that omitted it. The loop and the new expand gate
both read the shared spec set, so the gate cannot admit a field the engine
then skips.

The engine's own tolerance is untouched — it guards internal callers (hooks,
flows, expand sub-reads, registry-less hosts) that never pass through this
ingress, the same tiering the object-existence and unknown-field gates use. A
legacy array-shaped field map now disables all four gates rather than
rejecting every real field name.

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

vercel Bot commented Jul 30, 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 Jul 30, 2026 9:15pm

Request Review

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling and removed size/xl labels Jul 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/spec.

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

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol, @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

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.

`query-syntax.mdx` and `queries.mdx` both taught `expand` as reaching
`lookup` / `master_detail` / `user`, which omitted `tree` — the omission the
engine's own `!==` chain had, now that the loop reads `REFERENCE_VALUE_TYPES`.

They also described the `orderBy` and projection failure modes purely as
engine behaviour. That stays true for internal callers and is left standing;
what is added is which of them the REST/protocol ingress now refuses, plus the
sort spellings it accepts. The dotted-path sort keeps its warning and gains the
reason it survives the gate: a dotted path is judged on its head segment, so it
reaches the driver backstop rather than a 400.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158f3QC22Lf2VfJrT9nQ9om
…d-validation-yyx9tk

# Conflicts:
#	packages/objectql/src/engine.ts
@os-zhuang
os-zhuang marked this pull request as ready for review July 31, 2026 00:40
@os-zhuang
os-zhuang merged commit bf478e1 into main Jul 31, 2026
18 checks passed
@os-zhuang
os-zhuang deleted the claude/rest-list-field-validation-yyx9tk branch July 31, 2026 00:40
os-zhuang pushed a commit that referenced this pull request Jul 31, 2026
…4121, #4181)

`?filter=` with an empty value must mean "no filter", not "an invalid one".
Four shapes express that and only two were pinned:

    filter: {}      pinned
    filter: ''      pinned
    filter: []      NOT pinned  <- #4121 exempts the empty array explicitly
    filter: '   '   NOT pinned  <- #4181 blanks with .trim(), only '' tested

Both unpinned shapes are decisions the code states in a comment and no test
held, and both sit one line from a rejection: #4121's array branch guards on
`length > 0`, and #4181's blank guard calls `.trim()`. Tightening either — the
natural direction for this family, which has moved from tolerate to reject four
times — would silently turn "I sent no filter" into a 400.

Mutation-checked rather than assumed: dropping `length > 0` fails 1 case,
replacing `.trim() === ''` with `=== ''` fails 2.

Found while reconciling #4231 against #4240, which landed the sort/select/
expand conformance independently; this is the part of that branch #4240 does
not already cover, so the rest is dropped rather than duplicated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKsxtrsCwSL3uAcxAdVj83
os-zhuang added a commit that referenced this pull request Jul 31, 2026
…4121, #4181) (#4231)

`?filter=` with an empty value must mean "no filter", not "an invalid one".
Four shapes express that; only `{}` and `''` were pinned. `[]` (which #4121
exempts explicitly) and `'   '` (which #4181 blanks via .trim(), never
exercised — only `''` was tested) were decisions stated in a comment and held
by nothing, each one line from a rejection: #4121's array branch guards on
`length > 0`, #4181's blank guard calls `.trim()`. This family has moved from
tolerate to reject five times, so the next tightening would silently turn "I
sent no filter" into a 400.

Mutation-checked: dropping `length > 0` fails 1 case, replacing
`.trim() === ''` with `=== ''` fails 2.

This branch originally carried a full query-expression conformance suite;
#4240 landed the sort/select/expand half independently under the same filename
while it waited, which both conflicted and made its KNOWN GAP pins assert
behavior that no longer exists. Only the part #4240 does not cover is kept.
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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

REST 列表:sort / select / expand 指向不存在的字段时被静默丢弃(filter 轴已收口,这三条轴还没有)

2 participants