Repository navigation
[Decision] v18:查询能否直接按关联记录的字段筛选(例:「客户行业 = 科技」的商机) #20802
Description
Ruled: 5907789183 · letter A — v18 serves { relation: { field: value } } at the #5930 seam (one level, forward, as the caller, a loud cap); this card carries the work · 2026-09-30T08:59Z
Unblocked: #5930 step 2 (the engine seam) landed as cfa931535 on 2026-09-30T08:00Z; released to pm:queue by domain:engine#1, record 5907891818.
Blocked-by: #20887
Filed by the triage seat (objectstack-wide, seat post #6015, session_01AavokzJ5DndAwitDXvKy4U) at the maintainer's request 「开一张 v18 的决策卡」. ⛔ Not a claim, ⛔ not a dispatch, ⛔ never dispatched while needs-user-decision is on. Graded: enhancement · priority:p3 · domain:engine · area:api · target:v18.
一句话问题
作者想查「客户行业是科技的商机」,最自然的写法是把条件写在关联字段下面。平台的类型接受这个写法,但数据查询会报 400;而同样的写法放到分析看板里却能查出结果。v18 要真的支持它,还是把这个写法从协议里去掉?
背景
- [finding] a no-operator object under a lookup, master_detail or json field answers per driver: the declared nested-relation filter returns no rows on memory and a 400 on SQL, and a json object comparand deep-equals on memory and is refused on SQL #20745 实测:
{ account: { industry: 'tech' } }这类「关联字段下挂无运算符对象」的写法,内存驱动静默返回 0 行,SQL 驱动返回 400。 - PR fix(objectql)!: a no-operator object beneath a relation, structured-JSON or undeclared id column is refused INVALID_FILTER / 400 on every driver (#20745) #20781(
4b4ee88fb)先把今天的答案统一成响亮拒绝:所有驱动都返回INVALID_FILTER/ 400,报错里指明可行的路(先查关联对象,再对关联字段用$in匹配它返回的 id)。文档里教这个写法的示例也删了。 - 当时 triage 把「要不要真正支持」留给维护者([finding] a no-operator object under a lookup, master_detail or json field answers per driver: the declared nested-relation filter returns no rows on memory and a 400 on SQL, and a json object comparand deep-equals on memory and is refused on SQL #20745 的定级
5902573237;[finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 上的指针5902585544)。[finding] a no-operator object under a lookup, master_detail or json field answers per driver: the declared nested-relation filter returns no rows on memory and a 400 on SQL, and a json object comparand deep-equals on memory and is refused on SQL #20745 已关,这个问题没有自然的挂靠处,所以单开此卡。
Governing text
- spec 声明:
packages/spec/src/data/filter.zod.ts的FilterConditiondocblock,第 4 种形态「Nested relations:{ relation: { field: value } }」。它写明类型和 schema 接受、查询引擎拒绝。
re-check:git grep -n "4. Nested relations" origin/main -- packages/spec/src/data/filter.zod.ts→ 1 - 裁决基准(pm-dispatch 技能的「基本裁决原则」):spec 声明 > 实现 > 文档面;声明了却不兑现是实现缺口,只能补实现或退役。所以「一直维持现状」不是选项,只是等裁决期间的过渡状态。
- seam 架构(ADR-0053 修订第 5 条,即 [finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 裁决的 D4 (b)):驱动收到的是已经在 seam 下译好的过滤条件,驱动本身不改。
协议声明、是否改协议
- A 改的是 docblock 的描述:从「引擎拒绝」改成「引擎服务」。类型不收窄,属于扩大,
Clause-②: no。 - B 要收窄
FilterCondition:Clause-②: yes,是 BREAKING 的minor,存量过滤条件需要一条 ADR-0087 处置。
前提(每条带 re-check,均已在 origin/main 96e724475c 跑过)
- CRUD 数据口今天拒绝这个写法,点号路径(
'owner.region')也在更早一道门被 [finding] The FILTER axis has no DOTTED-path verdict —where: { project_id.name: 'x' }rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371 拒掉。
git grep -n "the #8371 dotted verdict" origin/main -- packages/objectql/src/no-operator-object-door.ts→ 1 - 分析看板那一面今天是支持的:cube 过滤把它拍平成点号成员(
profile.verified),靠 cube 声明的 join 解析。
git grep -n "Nested relation (e.g." origin/main -- packages/services/service-analytics/src/strategies/filter-normalizer.ts→ 1 - 分析的读权限范围那一面拒绝它。
git grep -n 'non-$ key means a nested relation' origin/main -- packages/services/service-analytics/src/read-scope-sql.ts→ 1 - 引擎里已有同形的机制:
expand批量加载关联记录,用的就是对关联 id 的$in查询。
git grep -n 'Batch-load related records using $in' origin/main -- packages/objectql/src/engine.ts→ 1 - 业务拉动(实测):hotcrm 有 4 个 hook 手写同一段两步查询:先查成员行拿到线索 id,再对线索用
id: { $in: leadIds },而且第一步手挑了top: 5000,超过 5000 条会静默截断。
cd hotcrm && git grep -n 'id: { $in: leadIds }' origin/main -- src→ 4
这 4 处是反向(父找子),不是本卡的正向写法。hotcrm 的 271 行filter/where中,按正则扫描,正向嵌套写法为 0。
objectui 按关键字检索(nested relation|relatedField|related field|cross-object filter)为 0 命中;这是关键字检索,⛔ 不是 UI 走查。
具体问题
v18 的平台契约里,「按关联记录的字段筛选」要么是服务的能力(A),要么不存在(B)。不裁时,今天的响亮拒绝继续有效,不影响 17.x 发版。
选项 × 真实代价
| 选项 | 做什么 | 客户可感知的后果 |
|---|---|---|
| A 支持(v18,排在 #5930 收敛的 seam 之后) | 引擎在一个 seam 上把 { 关联: { 字段: 值 } } 下译成「以调用者身份查关联对象 → 对关联字段 $in 这些 id」,驱动不改(D4 (b))。首版只做一层、只做正向(子 → 父)。多值关联按「任一匹配」处理。 |
列表视图、API、AI 写的查询都能直接写「客户行业 = 科技」。CRUD 与看板对同一写法给同一个答案。多一次内部查询;关联集合很大时按上限响亮拒绝,⛔ 不截断。 |
| B 退役(v18 收窄类型) | FilterCondition 不再接受这个写法,在发布、保存时就报错,只剩「两步 + $in」一条路。分析看板的嵌套写法同时退役,点号成员写法保留。 |
作者和 AI 在保存时就被拦下,不用等到运行时。但这个能力要靠手写两步查询,像 hotcrm 那 4 个 hook 一样,会重复写、会截断。 |
业务含义直译
- A = 像 Salesforce 那样,列表筛选可以直接写「客户.行业 = 科技」。平台替你做两步查询,并且按你的权限做。
- B = 平台明说「不能跨对象筛选」,想做就在代码里自己先查一遍。像早期只支持单表筛选的轻量表格工具。
四轴论证(从业务立场)
- 项目长远合理性:两年后,一个元数据 CRM 平台应当能在列表和查询里按父记录字段筛选。主流做法都是服务它:Salesforce SOQL 的父关系字段 WHERE(
Account.Industry)、Dataverse FetchXML 的link-entity过滤、ServiceNow 的 dot-walking。A 把它落在一个 seam 上,和 [finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 的收敛方向(一处下译,驱动不改)一致,并让 CRUD 与看板对齐。B 让契约更小,但这个能力大概率会以新拼写回来,届时要再迁一次。 - 实际业务拉动:正向写法今天零作者(hotcrm 为 0,objectui 关键字检索为 0)。相邻的需求是实测存在的:hotcrm 4 处手写两步查询,而且带静默截断。这说明「跨对象筛选」被需要,但需要的是反向,A 的首版不覆盖。
- 防 AI 犯错:AI 最自然写出的正是这个嵌套写法。
- A:写出来就对,而且以调用者权限执行。出错面在权限:若内部查询不套关联对象的读权限,就成了静默泄露(能按看不见的字段值筛出记录),所以这条是 A 的硬约束。
- B:保存时响亮拒绝,结构上最难写错,但 AI 会转去手写两步查询,把截断之类的错误带进应用代码。
- 创业阶段不扩散:B 最省,删一个声明面,不留永久义务。A 新增一个要长期维护的能力,含权限、上限、多值语义。A 的成本被 [finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 的 seam 摊薄,但仍是新义务。
os-decision-facets
- ① 项目长远合理性:A 与主流平台和 [finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 的单点下译一致,并消除 CRUD 与看板的分叉;B 缩小契约,但能力大概率日后以新拼写回归。
- ② 实际业务拉动:正向写法实测零作者;hotcrm 有 4 处反向两步查询(带静默截断),A 首版不覆盖。
- ③ 防 AI 犯错:B 在保存时响亮拒绝,结构最硬;A 让自然写法直接可用,但必须以调用者读权限执行,否则是静默泄露。
- ④ 创业阶段不扩散:B 删除声明面,零后续义务;A 新增权限、上限、多值三项长期义务。
Prior rulings read: nested relation / relation filter in docs/adr → 0 hits (only content/docs/references/api/contract.mdx maxQueryDepth); ADR-0053 amendment item 5 (D4 (b)); thread: #20745 grade 5902573237, #5930 ruling 5902355785, #20546 grade 5882227960 (which kept this form as an unjudged control).
推荐
推荐 A:v18 支持,排在 #5930 收敛落地 seam 之后;首版一层、正向、以调用者读权限执行、超上限响亮拒绝。
- 回退:B。 如果维护者按 ②④ 从紧(零正向拉动 → 不扩散),B 是干净的另一端。
- 自检: 只看①选 A;②③④ 是否翻转:否。② 的零正向拉动和 ④ 的从紧只调时序与范围(排在 seam 之后,限一层、正向),不翻字母。
- 置信缺口: 看不到租户库里存量视图和过滤条件是否带这个写法(自 PR fix(objectql)!: a no-operator object beneath a relation, structured-JSON or undeclared id column is refused INVALID_FILTER / 400 on every driver (#20745) #20781 起它们都会 400,但没有计数);大关联集合下「先取 id 再
$in」的实际开销没测过;objectui 没做 UI 走查。
裁后执行(维护者只裁方向)
- A → triage 开一张
domain:engine·target:v18卡,Blocked-by:[finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 落地 seam 的那一步。卡上写明:- 下译位置在引擎 seam,驱动不改;
- 内部查询以调用者身份执行,套用关联对象的行权限和字段权限;对调用者不可读字段的条件响亮拒绝,⛔ 不静默返回空;
- id 集合设上限,超限响亮拒绝,⛔ 不截断;
- 多值关联按「任一匹配」;
- docblock 第 4 条改为「服务」,恢复
data-engine.mdx示例,skills/objectstack-query([finding]skills/objectstack-queryteaches the nested relation filter{ relation: { field: value } }as a working form; no data-path driver serves it, and PR #20781 makes the engine refuse it #20782)同步; - pins:[finding] a no-operator object under a lookup, master_detail or json field answers per driver: the declared nested-relation filter returns no rows on memory and a 400 on SQL, and a json object comparand deep-equals on memory and is refused on SQL #20745 的表在 memory / SQLite / PostgreSQL 上翻成返回
d1、d3,外加一条权限 pin 和一条上限 pin。 - 反向(父找子,hotcrm 那 4 处)另议,⛔ 不并入首版。
- B → triage 开一张
domain:spec卡:收窄FilterCondition,Clause-②: yes,BREAKINGminor;存量过滤条件按 ADR-0087 处置,带指引拒绝;分析看板的嵌套写法同时退役,点号成员写法保留;[finding]skills/objectstack-queryteaches the nested relation filter{ relation: { field: value } }as a working form; no data-path driver serves it, and PR #20781 makes the engine refuse it #20782 的 skill 文案改成「这是唯一的路」。 - 不裁 → 维持 PR fix(objectql)!: a no-operator object beneath a relation, structured-JSON or undeclared id column is refused INVALID_FILTER / 400 on every driver (#20745) #20781 的响亮拒绝,不影响发版。
相关单与 PR
#20745(已关,PR #20781)· #20546 / PR #20744 · #5930(v18 过滤器收敛,裁决 5902355785)· #20782(skill 教 $in)· #8371(点号路径拒绝)· ADR-0053 修订第 5 条 · ADR-0049 · ADR-0087
Activity