fix(data): sort / select / expand 指向不存在的字段时被拒绝,而不是静默丢弃 (#4226) - #4240
Merged
Conversation
…, 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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 109 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
`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
marked this pull request as ready for review
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
This was referenced Jul 31, 2026
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.
This was referenced Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4226
TL;DR
列表路径上有四条「调用方点名一个字段」的轴。
filter已经被 #4134 / #4164 / #4181 / #4121 收口;另外三条在名字写错时仍然一律200:三条轴现在都在共享的 normalizer 里被拒绝,于是
GET /data/:object、GET /data/:object/:id、POST /data/:object/query、export 路由和 runtime dispatcher 给出同一个答案,而不是五个。三条轴分别怎么改的
按 issue 里的定性,各自的危害不同,所以消息也不同:
sort→400 INVALID_SORT。结果集不变,所以不是 REST 列表:无法解析的filterJSON 被静默忽略 —— 返回未过滤整页(#4134/#4164 家族第三员) #4181 那种「返回了全部」;但sort+top正是取「最新 N 条」的写法,排序被丢弃后拿到的是任意 N 条,且响应里没有任何线索。这就是 REST 列表:无法解析的filterJSON 被静默忽略 —— 返回未过滤整页(#4134/#4164 家族第三员) #4181 在 export 路由orderby上修掉的那半,在 list 路由这半。INVALID_SORT自写进标准 catalog 起就没有任何 emitter,这次把它用上了。select→400 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一个过滤条件。expand→400 INVALID_FIELD。最轻——行集和列都不变,只是关系没展开——但响应与「每一行的外键都是 null」无法区分,客户端就在该显示名字的地方渲染裸 id。三种原因给三条不同消息,因为修法不同:字段根本不存在(拼写错误)、字段存在但不是引用类型(?expand=title,误解了 expand 的作用)、引用类型但没声明reference(bug 在对象上,不在请求上)。顺带修好的两处「一直没生效」
这两处不是新增的严格性,而是本来就该生效却一直被静默丢弃的东西,不修的话「真实字段确实生效」的对照组就是假的:
两种 sort 拼写从未真正到达 driver。 client SDK 自己声明的
orderBy?: string | string[] | SortNode[]里的string[],以及{field: direction}map —— 后者正是GET /data/:object/export、GET /data/import/jobs和 objectui calendar 三处发出的形状。两者都原样穿过 normalizer,然后被每个 driver 的Array.isArray(query.orderBy)守卫挡下:没有 ORDER BY 子句,没有报错。所以GET /data/import/jobs自写下起就一直在要created_at desc、返回插入顺序。现在两者都折叠成SortNode[]。$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解析。?$orderby=…的人不会被告知'orderBy'有问题(那个名字不在他的请求里)。404 OBJECT_NOT_FOUND,任何一条轴的 gate 都不得把它变成 400。测试
新增
packages/objectql/src/query-expression-conformance.test.ts—— issue 点名的那个文件,直接写成bad → rejected的形状(而不是KNOWN GAPpin)。真实ObjectQL引擎 + 真 registry,5 行、插入顺序C A E B D,就是 issue 的 transcript。每条轴都带对照组:内存 driver 真的排序、真的投影、真的分页 —— 否则「真实字段确实生效」的断言会 vacuously 通过,pin 就什么也钉不住。另有:四条轴在一个请求上同时生效、单记录路由给出相同答案、legacy 数组字段表使 gate 整体降级、filter 轴各自的 code 未被新 gate 吞掉。
pnpm test全仓 132/132 任务绿packages/objectql/src/protocol-data.test.ts改了一行:REST 列表:未知查询参数被静默当作字段过滤器 ——?pageSize=5返回 200 + 空列表 #4134 的「只建议端点真正接受的拼写」用例,其expand探针值从title换成owner_id(该对象的 lookup)—— 探针本来就按参数分形状(top/skip用数字、filter用对象),expand现在同样需要一个关系而非任意真实字段文档
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