fix(service-analytics): 即席推断的 Cube 把 owner.region 当成关系穿越,不再铸成基表列 region (#5739) - #5923
Merged
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
hotlong
marked this pull request as ready for review
August 6, 2026 11:56
This was referenced Aug 6, 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.
Fixes #5739
执行维护者 2026-08-06 的裁决(issue 08:28Z「方案 B1」与 10:37Z「裁决落地」两条评论):裁 B —— 原样铸造,即席推断路径支持关系穿越(JOIN)。
缺陷与机制
inferCubeFromQuery为「没有注册 Cube 的自由查询」即席合成一个 Cube。它的每个铸造点都先把成员过一遍stripPrefix—— 一个把任何点号名首段剥掉的判定。这个判定把两件语义不同的事混成了一件:crm_account.industrygetMeta发出去的就是这个形状,调用方原样回传)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/maindc6abfd)whereexecuteAggregate收到{"region":"NA"}—— 静默筛基表列cannot evaluate a cross-object filter ("owner.region"),引擎一次都没跑where… FROM "crm_account" WHERE region = $1(无 JOIN)… LEFT JOIN "owner" ON "crm_account"."owner" = "owner"."id" WHERE "owner"."region" = $1dimensionsgroupBy: ["region"]owner分组 → 按 id 读关联对象owner的region→ 重新分桶,返回关联对象的值dimensionsSELECT 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.ts的inferCubeFromQuery:stripPrefix→stripCubeQualifier:只在首段等于 cube 名时剥,其余点号成员原样铸造(dimensions['owner.region'] = { sql: 'owner.region' })。原样铸造出来的sql恰好就是lookupMember的 synthetic 档本来要交给qualifyAndRegisterJoin的那个值,所以两条路径编出逐字相同的 SQL。inferCube仍把数组where当「不是筛选」跳过 —— #5334 之后这个!Array.isArray守卫已经过时 #5353 (PR fix(service-analytics): lower thewherebefore seeding an ad-hoc cube's dimensions (#5353) #5764) 留下的点号残留循环。此前 lowered 循环跳过点号键(if (key.includes('.')) continue;),再由一个读原始对象 where 的残留循环把它们按尾段补铸回去 —— 于是一个过滤器按写法给两种答案。现在点号键与裸键一起走同一个 lowered 循环,两种写法铸出同一个 cube、编出同一条语句。fix(service-analytics): lower thewherebefore 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」。timeDimensions同样按新判定铸造。这是同一函数里最安静的一支:每个对象都有created_at,所以剥出来的尾段永远解析成一个真实基表列,任何闸门都不会响 —— 窗口就那样悄悄挪到了另一张表的时间戳上。measures循环刻意保持原样(实测后的决定,不是遗漏)。lookupMember的 synthetic 穿越档是 dimension-only 的(if (kind === 'dimension')),dotted measure 没有一个「已经正确的穿越答案」可以收敛过去 —— 裁 B 的依据(数组写法今天已正确)在 measures 上不成立。实测:若把 measures 也改成原样铸造,measures: ['total.sum'](一个total_sum的手滑拼写,不是穿越)会从 #4437 的400 INVALID_FIELD退化成 ObjectQL 不带 code/status 的cross-object measure抛错 —— 更差的信封 + 错误的诊断。measure-source-field-gate.test.ts的refuses the dotted spelling the same way, naming what it stripped to在我第一版里红了一次,正是它把这条量了出来。measures 侧自己的残留(实测owner.region_count_distinct静默聚合基表region)另立 #5918,不在本 PR 修。PM 机制假设:逐条复核(在合并后
origin/maindc6abfd 上实测)lookupMember直接命中,qualifyAndRegisterJoin走 IDENTIFIER_PATH,编出的 SQL 与数组写法 synthetic 档逐字相同sqls数组toEqual相等,且都含LEFT JOIN "owner" ON "crm_account"."owner" = "owner"."id"resolveMemberSource的BARE_IDENTIFIER对带点sql判否 →source: null→ #5669 闸门自动保持where-source-field-gate.test.ts31 条全绿,裸名拼错仍答400 INVALID_FIELDwhere走响亮拒收(与已注册 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(两处,均承重,受保护面未缩水)
infer-cube-where-spelling-parity.test.ts第 3 组(fix(service-analytics): lower thewherebefore 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播种」,与本裁决无关。where-source-field-gate.test.ts的answers 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:184的dropped_column与packages/rest/src/analytics-dataset-dimension-gate.test.ts:172的bogus_dim都是裸名,不受影响;packages/services/service-analytics/src/__tests__/dimension-source-field-gate.test.ts:486与analytics-service.test.ts里的点号用例跑在已注册 cube 上,不经过inferCubeFromQuery。@objectstack/rest全量 824 条绿,确认无跨包 fixture 漏网。必答项
dataset-compiler.ts/dataset-executor.ts/native-sql-strategy.ts,本 PR 一处未触;dataset 路由会编译并注册 cube,根本不走inferCubeFromQuery。一条相邻事实已单独留痕在 analytics dataset 路由:另有九处「作者/调用方形状」的 dataset 拒收仍答 500 —— 它们从来没进过 #5352 的正则名单,所以 #5367 的信封化也没覆盖到 #5716 的评论里(不改这里的判断):planCrossObject的跨对象拒收同样不带 code/status,它在main上已经可达(已注册 cube、以及即席路径的数组写法),本 PR 让它在对象写法上也可达 —— 可见性上升,不是新缺陷,也不该混进这个 PR 改信封。queryDataset里还有第二个 message 嗅探器isMissingSourceError,命中即静默返回空结果 ——dataset-compiler的一条拒收措辞已经命中它,只因抛点在 try 之外才没出事 #5717(isMissingSourceError嗅探器)——「完全无影响」。该嗅探器只在queryDataset的 catch(analytics-service.ts:796)里,而 dataset 路由不走即席推断;自由查询路由query()上没有这个降级。本 PR 既没有新增到达该 catch 的路径,也没有改动任何被它嗅探的措辞。dataset-executor.ts的buildQuery/resolveDimensionGranularity(dataset 路由,已编译 cube)。本 PR 只改了即席 cube 铸造时 timeDimension 的键(created_at→owner.created_at),既没碰默认粒度逻辑,也没碰projectedDimensions。两者不同函数、不同路由,无交互。验证(全部前台阻塞执行,持容器级 flock,heap 上限 4G,范围化 filter)
未触
packages/spec;git status确认没有任何 spec 产物被重生成。本 PR 未新增 fake engine,故无assertEngineDeleteDispatch相关改动。范围外发现
measures上的关系穿越点号 member 仍被剥成基表列 ——owner.region_count_distinct静默聚合基表region(#5739 裁决未覆盖的第四个铸造点) #5918(新立,未认领):同一函数measures铸造点上的同族残留 —— 实测measures: ['owner.region_count_distinct']在两个策略上都静默聚合基表region(NativeSQL 编出SELECT COUNT(DISTINCT region) AS "owner.region_count_distinct",无 JOIN)。为什么不能照抄本单裁决(synthetic 档是 dimension-only;total.sum这类手滑拼写会因此丢掉 analytics: a measure naming a missing field 500s with SQLITE_ERROR instead of a 400 naming the field #4437 的 400)已写进单子,需要自己的裁定。Generated by Claude Code