Skip to content

fix(service-analytics): 编译期拒绝 dataset 的跨 datasource JOIN (#5115) - #5287

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5115-dataset-cross-datasource-compile-gate
Aug 4, 2026
Merged

fix(service-analytics): 编译期拒绝 dataset 的跨 datasource JOIN (#5115)#5287
os-zhuang merged 1 commit into
mainfrom
claude/issue-5115-dataset-cross-datasource-compile-gate

Conversation

@claude

@claude claude Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #5115

按维护者在 issue 里的拍板,本 PR 只做第 1 步:compileDataset 在编译期拒绝跨 datasource 的 join。

做了什么

compileDataset 新增一个可选的 DatasetCompileOptions(第三个参数),里面是两个早已存在AnalyticsServiceConfig 上的探针:getObjectDatasource(#5033 为诊断文案引入)和 isExternalObject(ADR-0062 D6)。AnalyticsService.registerDataset —— 启动预注册、保存、Studio 草稿预览都要走的那一道门 —— 把它们交给编译器,于是被证明的冲突在任何 SQL 被构造之前就被拒绝。

判定接在既有的 include 解析链路上:resolveHop 每解析出一个 join 目标就判一次,没有另起一套关系解析。多跳路径的每一跳都是同一条语句里的 join,所以每一跳都判(不只是最后一段)。

错误文案点名两个对象、两个 datasource、出问题的 include 路径,以及两条出路:

[dataset-compiler] dataset "revenue_by_region" declares a JOIN that crosses datasources:
its base object "opportunity" is on datasource "billing_db", but the joined object
"crm_account" — reached via the `include` path "account" — is on datasource "crm_db".
A dataset JOIN cannot cross datasources: the whole dataset is executed as ONE statement
on the base object's datasource, so "crm_account" is simply not there. Fix it by binding
both objects to the same datasource, or by dropping "account" from the dataset's
`include` (and every dimension/measure that references it).

#5033 查询期那句同源(同样的「不能跨库 + 两条修法」),不会读成两个 bug。

分级放行的判据(这是本 PR 最需要评审的部分)

拒绝只在元数据自己能证明冲突时发生:基对象与某个 join 目标各自显式声明object.datasource,且两个名字不同(大小写不敏感比较)。以下一律放行,由 #5033 的查询期防线兜底:

  1. 宿主没接探针(无 data engine)—— 编译行为与改动前完全一致。有专门的测试钉住:不传 options、传 {}、以及用 new AnalyticsService({ relationshipResolver }) 直接注册,三种都照常编译。
  2. 探针答不上来(对基对象或对某个 join 目标返回 undefined)。基对象答不上来时不判任何 join —— 不能因为「问不出基对象」就拒绝所有 join。
  3. 任意一侧停在 'default'。这条是本 PR 唯一一处需要判断力的地方,理由写在代码注释里:ObjectQL.getDriver 的解析顺序是「显式非 'default'object.datasourcedatasourceMapping 规则 → ADR-0057 §3.6 生命周期分流(audit/telemetry/event → telemetry)→ package 的 defaultDatasource → 全局默认」。也就是说 'default' 是 schema 的默认值(ObjectSchema.datasource.default('default')),不是一个路由决定;声明 'default' 的对象仍然可能被 mapping 规则或生命周期分流落到别的库,甚至正好落到对面那个库。把 'default' 当成「主库」会误拒这类今天能正常工作的 dataset,还会让判定取决于该对象是否恰好被 Zod parse 过(parse 才会把默认值物化出来)。误放行的代价是一条已经存在的响亮运行时错误;误拒绝的代价是升级后看板直接变白 —— 所以这里选了「证明不了就不拒绝」。
  4. 任意一侧是 federated(external)对象NativeSQLStrategy.canHandle 本就 decline 这种 cube(base 或 join 目标为 external),查询由 ObjectQL FK-expand 路径(两段读 + 内存 join)承接,那条路径天然跨库。在这里拒绝会打断今天能工作的路径。

明确不在范围

  • 第 2 步(换策略)不做:让跨库看板真正能工作(NativeSQLStrategy.canHandle 在 join 跨库时 decline + 两段读内存 join)完全不在本 PR 内,也没有为它预埋任何抽象。顺带记录一个对第 2 步有用的现场事实:那条路径的机制已经存在 —— ObjectQLStrategyexecuteCrossObject(FK-expand,analytics/objectql: 跨对象(点号 lookup)维度聚合在 ObjectQL 路径上静默塌成 (null) 桶 / native SQL 报错 #3654)就是「按 FK 分组 + 单独读关联对象」,每一段各自按对象路由,因此天然跨库;第 2 步大概率是让 NativeSQLStrategy 在跨库时 decline,而不是新写一条路径。
  • 不碰 packages/spec/**packages/lint/**;content/docs/releases/** 未触碰,变更记录只走 .changeset/

发布闸门(publish-time lint)落点的调研结论 —— 只调研,不实现

维护者要求本单只做注册期拒绝,发布闸门另立单。调研结论如下,供开那一单时直接用:

STALE-PREMISE 核对(issue 基于 c272e48be,已对最新 origin/main 重核)

  • getObjectDatasourceAnalyticsServiceConfig 上,plugin 侧从 engine.getObject(name).datasource 接出 —— 仍然成立。
  • compileDataset 已校验 include 关系是否存在于基对象上,并已通过 relationshipResolver 拿到 join 目标对象名 —— 仍然成立,新判定就接在这条链上。
  • service-analytics 的 executeRawSql 自动桥接丢弃 objectName —— dataset 原始 SQL 永远打在默认 datasource 上,凡被路由到非默认 datasource 的对象一律读成 0 #5033 的查询期响亮失败文案仍在 analytics-service.ts 的 missing-source 分诊里,措辞未变。
  • ⚠️ 一处补正:issue 说「只需要问每个参与对象绑定在哪个 datasource 上」。现场比这句更微妙 —— 'default' 不是一个绑定答案,而是「没有绑定」;真正的路由还要经过 mapping / 生命周期分流 / package 默认值。本 PR 因此把判据收窄到「双方都显式声明且不同」,并把理由写进代码注释与 changeset。

行为变更(收紧)

原本能编译通过、要到查询期才失败的跨库 dataset,现在在注册期就被拒。启动时预注册的 dataset 走既有的 per-dataset try/catch:记一条点名冲突的 WARN 并跳过,同一宿主里其它 dataset 照常注册,不会因此起不来。changeset 里写清了受影响面。

验证

  • pnpm --filter @objectstack/service-analytics test —— 42 个文件 / 555 个用例全绿(新增 17 个:dataset-compiler.test.ts 里 11 个判定与放行用例,dataset-cross-datasource-registration.test.ts 里 6 个接线用例)。
  • tsc --noEmit -p packages/services/service-analytics/tsconfig.json —— 与改动前同样的 13 行既有报错(全部在我未触碰的测试文件里:analytics-service.test.tsmeasure-source-field-gate.test.tsobjectql-timedimension-projection.test.ts),没有新增。改动前后各跑一次对比确认。
  • eslint --no-inline-config packages/services/service-analytics/src/ —— 干净;测试里没有 as any
  • pnpm --filter @objectstack/service-analytics build —— 通过(含 DTS)。

顺手记的账(不在本 PR 修)


Generated by Claude Code

#5033 把 dataset 的原始 SQL 路由到基对象自己的 datasource 之后,join 目标
落在另一个库上的 dataset 会在查询期响亮失败 —— 正确,但太晚:这类 dataset
仍然可以被保存、发布、挂上看板,失败最终发生在别的环境、别的时间、别人面前。
它是一个纯元数据错误:整个 dataset 被降解为基对象 datasource 上的一条语句,
绑在别处的 join 目标根本不在那里,这件事在编译的那一刻就完全可判定。

现在由 `compileDataset` 判定。`AnalyticsService.registerDataset` —— 每个
dataset(启动预注册、保存、Studio 草稿预览)都要走的那一道门 —— 把
`AnalyticsServiceConfig` 上早已存在的 datasource / federation 探针交给编译器,
被证明的冲突在任何 SQL 被构造之前就被拒绝。错误文案点名两个对象、两个
datasource、出问题的 `include` 路径,以及两条出路(把两个对象绑到同一个
datasource,或者删掉这条关系),与 #5033 的查询期文案同源,读起来不会像两个 bug。

判据刻意收窄:只在元数据自己能证明冲突时才拒绝 —— 基对象与某个 join 目标
各自显式声明了 `object.datasource` 且两个名字不同。以下情形一律按仓里既有的
"cannot answer, do not block" 分级放行:

- 宿主根本没有接 datasource 探针(无 data engine)—— 编译行为与改动前完全一致;
- 任意一侧停留在默认值。`'default'` 是 schema 的默认**值**,不是路由决定:
  `ObjectQL.getDriver` 只在显式的非 `'default'` 名字上短路,随后落到
  `datasourceMapping` 规则、ADR-0057 §3.6 生命周期分流(audit/telemetry/event)
  和所属 package 的 `defaultDatasource` —— 这些编译器都看不见。把 `'default'`
  当成「主库」会误拒那些实际被 mapping 规则落到同一个库的 dataset,
  并且会让判定取决于该对象是否恰好被 Zod parse 过(那才会把默认值物化出来);
- 任意一侧是 federated(external)对象。`NativeSQLStrategy` 本就会 decline
  这样的 cube(ADR-0062 D6),查询由 ObjectQL FK-expand 路径承接,
  那条路径天然跨库 —— 在这里拒绝会打断今天能工作的路径。

证明不了的部分继续由 #5033 的查询期防线响亮兜底。让跨库看板真正能工作
(在 `NativeSQLStrategy` decline + 两段读内存 join)是另一件事,不在本次改动内。

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

vercel Bot commented Aug 4, 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 4, 2026 3:52pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/l labels Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 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.

@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 16:06
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 1f0e7cb Aug 4, 2026
25 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5115-dataset-cross-datasource-compile-gate branch August 4, 2026 16:15
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

Development

Successfully merging this pull request may close these issues.

dataset 跨 datasource JOIN 应在编译/发布期就被拒绝,而不是留到查询期才响亮失败(#5033 的后续)

2 participants