Skip to content

fix(service-analytics): 即席推断的 Cube 把 owner.region 当成关系穿越,不再铸成基表列 region (#5739) - #5923

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-5739-infer-cube-relation-join
Aug 6, 2026
Merged

fix(service-analytics): 即席推断的 Cube 把 owner.region 当成关系穿越,不再铸成基表列 region (#5739)#5923
hotlong merged 1 commit into
mainfrom
claude/issue-5739-infer-cube-relation-join

Conversation

@hotlong

@hotlong hotlong commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #5739

执行维护者 2026-08-06 的裁决(issue 08:28Z「方案 B1」与 10:37Z「裁决落地」两条评论):裁 B —— 原样铸造,即席推断路径支持关系穿越(JOIN)

缺陷与机制

inferCubeFromQuery 为「没有注册 Cube 的自由查询」即席合成一个 Cube。它的每个铸造点都先把成员过一遍 stripPrefix —— 一个把任何点号名首段剥掉的判定。这个判定把两件语义不同的事混成了一件:

写法 是什么 该不该剥
crm_account.industry cube 名限定符(getMeta 发出去的就是这个形状,调用方原样回传) 该剥
owner.region 关系穿越 不该剥

后者被剥成 dimensions.region = { sql: 'region' } —— 一个基表列的 dimension。下游 lookupMember 的「plain second-segment lookup (legacy behaviour)」那一档随即命中它,赶在「synthetic relation traversal」那一档把点号路径交给 JOIN 机制之前就返回了。关系穿越就此被基表列遮蔽。

实测:四个组合(测试双,crm_account 字段含 id/name/industry/region/owner —— region基表自己的列)

# 策略 / 请求键 修前(origin/main dc6abfd) 修后
ObjectQL / where executeAggregate 收到 {"region":"NA"} —— 静默筛基表列 响亮拒收 cannot evaluate a cross-object filter ("owner.region"),引擎一次都没跑
NativeSQL / where … FROM "crm_account" WHERE region = $1(无 JOIN) … LEFT JOIN "owner" ON "crm_account"."owner" = "owner"."id" WHERE "owner"."region" = $1
ObjectQL / dimensions groupBy: ["region"] FK-expand:基表按 owner 分组 → 按 id 读关联对象 ownerregion → 重新分桶,返回关联对象的值
NativeSQL / dimensions SELECT region AS "owner.region" … GROUP BY region —— 列名标着关系属性,值来自基表 SELECT "owner"."region" AS "owner.region" … LEFT JOIN … GROUP BY "owner"."region"

① 的修后答案与已注册 cube 上 ObjectQL 对同一成员的既有答案逐字一致;② 与已注册 cube 上 NativeSQL 的既有 JOIN 一致。裁决的公共下限「静默错列必须消失」在四个组合上都达成,且没有出现第三种更安静的答案。

改了什么

packages/services/service-analytics/src/analytics-service.tsinferCubeFromQuery:

  1. stripPrefixstripCubeQualifier:在首段等于 cube 名时剥,其余点号成员原样铸造(dimensions['owner.region'] = { sql: 'owner.region' })。原样铸造出来的 sql 恰好就是 lookupMember 的 synthetic 档本来要交给 qualifyAndRegisterJoin 的那个值,所以两条路径编出逐字相同的 SQL。
  2. 折叠 observation: inferCube 仍把数组 where 当「不是筛选」跳过 —— #5334 之后这个 !Array.isArray 守卫已经过时 #5353 (PR fix(service-analytics): lower the where before seeding an ad-hoc cube's dimensions (#5353) #5764) 留下的点号残留循环。此前 lowered 循环跳过点号键(if (key.includes('.')) continue;),再由一个读原始对象 where 的残留循环把它们按尾段补铸回去 —— 于是一个过滤器按写法给两种答案。现在点号键与裸键一起走同一个 lowered 循环,两种写法铸出同一个 cube、编出同一条语句fix(service-analytics): lower the where before seeding an ad-hoc cube's dimensions (#5353) #5764 在残留循环的注释里写的正是「Delete the loop (or fold it into the one above) when analytics 自动推断路径:inferCubeFromQuery 的 stripPrefix 把关系穿越 owner.region 铸成基表列 region —— 基表恰好有同名列时静默筛/分组错列(两个策略、两个请求键均如此) #5739 rules」。
  3. timeDimensions 同样按新判定铸造。这是同一函数里最安静的一支:每个对象都有 created_at,所以剥出来的尾段永远解析成一个真实基表列,任何闸门都不会响 —— 窗口就那样悄悄挪到了另一张表的时间戳上。

measures 循环刻意保持原样(实测后的决定,不是遗漏)。 lookupMember 的 synthetic 穿越档是 dimension-only 的(if (kind === 'dimension')),dotted measure 没有一个「已经正确的穿越答案」可以收敛过去 —— 裁 B 的依据(数组写法今天已正确)在 measures 上不成立。实测:若把 measures 也改成原样铸造,measures: ['total.sum'](一个 total_sum手滑拼写,不是穿越)会从 #4437400 INVALID_FIELD 退化成 ObjectQL 不带 code/status 的 cross-object measure 抛错 —— 更差的信封 + 错误的诊断。measure-source-field-gate.test.tsrefuses the dotted spelling the same way, naming what it stripped to 在我第一版里红了一次,正是它把这条量了出来。measures 侧自己的残留(实测 owner.region_count_distinct 静默聚合基表 region)另立 #5918,不在本 PR 修。

PM 机制假设:逐条复核(在合并后 origin/main dc6abfd 上实测)

假设 结论 证据
1. 原样铸造后 lookupMember 直接命中,qualifyAndRegisterJoin 走 IDENTIFIER_PATH,编出的 SQL 与数组写法 synthetic 档逐字相同 ✅ 成立 两种写法的 sqls 数组 toEqual 相等,且都含 LEFT JOIN "owner" ON "crm_account"."owner" = "owner"."id"
2. resolveMemberSourceBARE_IDENTIFIER 对带点 sql 判否 → source: null#5669 闸门自动保持 ✅ 成立 闸门代码一行未动;where-source-field-gate.test.ts 31 条全绿,裸名拼错仍答 400 INVALID_FIELD
3. ObjectQL 侧要么正确穿越、要么保持既有响亮拒收,不许出现第三种更安静的答案 ✅ 成立,且两种都出现了:where 走响亮拒收(与已注册 cube 逐字一致),dimensions 走 FK-expand 正确穿越 ③ 的断言是值级的:测试双让基表 region 返回 BASE-REGION、关联对象返回 NA/EMEA,断言结果行里出现后者、且 BASE-REGION 完全不出现

无 fork。假设 3 比预期更好一档(dimensions 侧不是拒收而是真穿越),已在测试注释里写明机制。

反向验证(方向在跑之前先预测)

stripCubeQualifier 换回无差别 stripPrefix(即 git checkout 掉实现文件,测试全留),预测:凡断言里出现穿越(JOIN / FK-expand / 点号 cube 键)的用例全红;只断言 cube 名限定符与既有拒收的用例全绿。这是普通方向,无反转 —— 断言的是「多出一个 JOIN」的语句和行,且每条都同时断言了基表列答案的缺席,所以不可能靠「两边都为空」蒙混过关。

实测:14 红 / 1042 绿,与预测逐条吻合:

  • infer-cube-relation-traversal.test.ts(新增,17 条):block 1(四个组合)4 红、block 2(两种写法收敛)4 红、block 4(timeDimensions)2 红 = 10 红;block 3(cube 名限定符仍剥 + 基表同名列仍可用)3 条、block 5(受保护的拒收面)4 条 = 7 绿,两个方向都绿,这正是它们的作用。
  • infer-cube-where-spelling-parity.test.ts:翻转的 3 条红,a nested relation object seeds its RELATION key, not the tail 绿(该形状不受裁决影响)。
  • where-source-field-gate.test.ts:翻转的 1 条红。

翻转的 pin(两处,均承重,受保护面未缩水)

  1. infer-cube-where-spelling-parity.test.ts 第 3 组(fix(service-analytics): lower the where before seeding an ad-hoc cube's dimensions (#5353) #5764 加的残留围栏,修前修后都绿 —— 它钉的是残留不是修复)。翻后不是删断言,而是换成承重断言:两种写法同一个 cube + 同一条语句,且语句里必须有那条 LEFT JOIN;另加一条「此前被 400 INVALID_FIELD 拒掉的那个查询现在跑通了」的判决级用例。该组唯一未翻的 a nested relation object seeds its RELATION key, not the tail 原样保留 —— 它守的是「不要改用 collectFilterLeaves 播种」,与本裁决无关。
  2. where-source-field-gate.test.tsanswers a dotted member on the INFERENCE path…(fix(service-analytics): where 源字段闸门 —— 点名不存在字段的筛选答 400 INVALID_FIELD 而非驱动 500 (#5669) #5740 钉的「两个请求键对同一个点号成员逐字同答」)。不变量本身没有翻:两个键仍然同答,只是答案从「INVALID_FIELD 点名剥出来的 region」变成「闸门站下、查询跑通」,而且是同时为两个键改的 —— 这正是把解析放进一个 resolveMemberSource 的目的。用例改成断言两键都不是 INVALID_FIELD 且都真的到达了驱动(aggregated 逐字比对),不是一句空洞的 not.toBe。该文件头部的反向验证计数(analytics: where 里点名不存在的字段仍然一路到驱动 —— #4437(measure)/ #5520(dimension)之后,filter 面是同一个缺陷剩下的第三个 param #5669 当时的 15 红 / 16 绿)也随之如实更新为 14 红 / 17 绿,并写明是本单移走了那一条。

全仓 pin 扫描:grep -rn "constrains field\|groups by field\|owner\.region\|account\.industry" 扫过 packages/(rest / runtime / services / spec / cli 各层)与 content/。同语义 pin 只有上面两处(均在 service-analytics);packages/rest/src/analytics-dataset-where-gate.test.ts:184dropped_columnpackages/rest/src/analytics-dataset-dimension-gate.test.ts:172bogus_dim 都是裸名,不受影响;packages/services/service-analytics/src/__tests__/dimension-source-field-gate.test.ts:486analytics-service.test.ts 里的点号用例跑在已注册 cube 上,不经过 inferCubeFromQuery@objectstack/rest 全量 824 条绿,确认无跨包 fixture 漏网。

必答项

验证(全部前台阻塞执行,持容器级 flock,heap 上限 4G,范围化 filter)

pnpm --filter '@objectstack/service-analytics^...' build          → 成功
pnpm --filter '@objectstack/service-analytics' test               → 58 files / 1056 tests 全绿
pnpm --filter '@objectstack/rest^...' build                       → 成功
pnpm --filter '@objectstack/rest' test                            → 58 files / 824 tests 全绿
turbo run typecheck --filter=@objectstack/service-analytics --filter=@objectstack/rest → 17/17 成功
eslint(四个改动文件,--no-inline-config)                          → 0 问题
pnpm check:nul-bytes / check:adr-anchors / check:engine-double-contract
  / check:error-code-casing / check:route-envelope                → 全 PASS
grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'(四个改动文件 + changeset) → 无命中

未触 packages/spec;git status 确认没有任何 spec 产物被重生成。本 PR 未新增 fake engine,故无 assertEngineDeleteDispatch 相关改动。

范围外发现


Generated by Claude Code

…on (#5739)

`inferCubeFromQuery` 的每个铸造点都先把成员过一遍 `stripPrefix` —— 一个把任何
点号名首段剥掉的判定。对 `<cube>.` 限定符这是对的;对关系穿越则不是:
`owner.region` 被铸成 `dimensions.region = { sql: 'region' }`,一个基表列,
`lookupMember` 的 plain second-segment 档随即命中它,赶在 synthetic relation
traversal 档把点号路径交给 JOIN 机制之前就返回 —— 穿越被基表列遮蔽。基表恰好
有同名列时,两个策略 × 两个请求键四个组合全部静默筛/分组错列。

维护者 2026-08-06 裁 B(原样铸造):只剥真正的 `<cube>.` 限定前缀(首段 == cube
名),其余点号 member 原样铸造,与数组 where 写法一直在走的 synthetic 档收敛到
同一条 LEFT JOIN 谓词 —— 两种写法逐字生成同一条语句。#5353 留在 `where` 上的
点号残留循环随之折叠进 lowered 循环,两种写法现在也铸出同一个 cube。

measures 循环刻意保持原样:`lookupMember` 的 synthetic 穿越档是 dimension-only,
dotted measure 没有可收敛的穿越答案,原样铸造只会把 #4437 的 400 INVALID_FIELD
换成 ObjectQL 不带 code/status 的 cross-object measure 抛错。其残留另立 #5918。

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

vercel Bot commented Aug 6, 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 6, 2026 11:43am

Request Review

@github-actions github-actions Bot added the size/l label Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics.

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

  • content/docs/api/data-api.mdx (via @objectstack/service-analytics)
  • content/docs/api/index.mdx (via @objectstack/service-analytics)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-analytics)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/service-analytics)
  • content/docs/plugins/packages.mdx (via @objectstack/service-analytics)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-analytics)
  • content/docs/releases/v17.mdx (via @objectstack/service-analytics)
  • content/docs/releases/v9.mdx (via @objectstack/service-analytics)

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 6, 2026
@hotlong
hotlong marked this pull request as ready for review August 6, 2026 11:56
@hotlong
hotlong added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit a6b3ee7 Aug 6, 2026
24 checks passed
@hotlong
hotlong deleted the claude/issue-5739-infer-cube-relation-join branch August 6, 2026 12:09
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/l tests tooling

Projects

None yet

2 participants