diff --git a/.changeset/dataset-cross-datasource-compile-gate.md b/.changeset/dataset-cross-datasource-compile-gate.md new file mode 100644 index 0000000000..211e0c867e --- /dev/null +++ b/.changeset/dataset-cross-datasource-compile-gate.md @@ -0,0 +1,52 @@ +--- +"@objectstack/service-analytics": minor +--- + +fix(service-analytics): reject a dataset's cross-datasource JOIN when it is compiled, not when it is queried (#5115) + +#5033 routed a dataset's raw SQL to its base object's own datasource, which +turned a JOIN whose target lives in another database into a **loud query-time +failure** — correct, but late: the dataset can still be saved, published and +put on a dashboard, and the failure lands in front of whoever opens that +dashboard, usually in another environment on another day. It is a pure metadata +error, decidable the moment the dataset is compiled: the whole dataset is +lowered into ONE statement on the base object's datasource, so a join target +bound elsewhere is simply not there. + +`compileDataset` now decides it. `AnalyticsService.registerDataset` — the single +door every dataset passes through, whether pre-registered at boot, saved, or +previewed as a Studio draft — hands the compiler the datasource and federation +probes that already existed on `AnalyticsServiceConfig`, and a proven conflict +is rejected before any SQL is built. The message names both objects, both +datasources, the offending `include` path, and the two ways out (bind both +objects to the same datasource, or drop the relationship), in the same wording +family as the #5033 query-time diagnostic so the two never read as two bugs. + +**Who is affected.** This is a tightening: a dataset that used to compile and +then fail (or, before #5033, silently read the wrong database) now fails at +registration. It fires only where the metadata *proves* the conflict — the base +object and a join target each declare an explicit `object.datasource` and the +two names differ. A dataset registered at boot is skipped with a WARN naming the +conflict, as before; the rest of the host's datasets still register. + +**What is deliberately not rejected** ("cannot answer, do not block", the same +tiering as `isRegisteredObject` / `getObjectFieldNames`): + +- a host that wires no datasource probe at all (no data engine) — compiles + exactly as it did before; +- either side leaving `datasource` at its default. `'default'` is the schema's + default *value*, not a routing decision: `ObjectQL.getDriver` short-circuits + only on an explicit non-`'default'` name, then falls through to + `datasourceMapping` rules, the ADR-0057 §3.6 lifecycle split + (audit/telemetry/event) and the owning package's `defaultDatasource` — none of + which are visible to the compiler. Treating `'default'` as "the primary DB" + would reject datasets whose objects a mapping rule in fact lands on the *same* + database; +- a federated (external) participant on either side. `NativeSQLStrategy` already + declines such a cube (ADR-0062 D6), so the query is served by the ObjectQL + FK-expand path, which crosses datasources by construction. + +Everything not proven here keeps failing loudly at query time via #5033. +Making cross-datasource dashboards actually *work* (declining in +`NativeSQLStrategy` and serving the join with two reads) is separate and not +part of this change. diff --git a/packages/services/service-analytics/src/__tests__/dataset-compiler.test.ts b/packages/services/service-analytics/src/__tests__/dataset-compiler.test.ts index 659fb12306..0c2fcfc438 100644 --- a/packages/services/service-analytics/src/__tests__/dataset-compiler.test.ts +++ b/packages/services/service-analytics/src/__tests__/dataset-compiler.test.ts @@ -217,3 +217,199 @@ describe('compileDataset — multi-hop joins (ADR-0071)', () => { expect(cube.joins?.['account__owner']?.name).toBe('owner'); }); }); + + +/** + * #5115 — a dataset whose JOIN crosses datasources is metadata that can never + * execute: the whole dataset is lowered into ONE statement on the base object's + * datasource (raw SQL routes by object since #5033), so the joined table is + * simply not there. #5033 made that failure loud at QUERY time, in front of + * whoever opened the dashboard; this suite pins the same verdict at COMPILE + * time, while the author (often an AI author) is still holding the metadata. + * + * The gate is deliberately narrow, and these cases pin the boundary as much as + * the rejection: it fires only on a conflict the METADATA PROVES — two explicit, + * different `object.datasource` bindings — because `'default'` is the schema's + * default value rather than a routing decision (mapping rules / the ADR-0057 + * lifecycle split / a package's `defaultDatasource` all route objects that + * never say a word about `datasource`). Everything it cannot prove stays + * compilable and is caught by #5033's query-time defence. + */ +describe('compileDataset — cross-datasource join gate (#5115)', () => { + /** opportunity → crm_account → core_user (all to-one). */ + const chainResolver = (obj: string, rel: string) => { + const graph: Record> = { + opportunity: { account: { object: 'crm_account', table: 'crm_account' } }, + crm_account: { owner: { object: 'core_user', table: 'core_user' } }, + }; + return graph[obj]?.[rel]; + }; + + const datasetWith = (include: string[], dimensions: Record[]) => + DatasetSchema.parse({ + name: 'revenue_by_region', + label: 'Revenue by region', + object: 'opportunity', + include, + dimensions: dimensions.map((d) => ({ ...d, type: 'string' })), + measures: [{ name: 'revenue', label: 'Revenue', aggregate: 'sum', field: 'amount' }], + }); + + const crossDs = datasetWith(['account'], [{ name: 'region', field: 'account.region' }]); + + /** Compile `crossDs` with a datasource map; returns the thrown error, if any. */ + const compileWith = ( + datasources: Record, + isExternalObject?: (o: string) => boolean, + ): Error | undefined => { + try { + compileDataset(crossDs, chainResolver, { + getObjectDatasource: (o) => datasources[o], + ...(isExternalObject ? { isExternalObject } : {}), + }); + return undefined; + } catch (e) { + return e as Error; + } + }; + + it('rejects two explicitly-bound objects on different datasources, naming BOTH sides and the fix', () => { + const err = compileWith({ opportunity: 'billing_db', crm_account: 'crm_db' }); + + expect(err).toBeDefined(); + const msg = String(err?.message); + // Both objects … + expect(msg).toContain('base object "opportunity"'); + expect(msg).toContain('joined object "crm_account"'); + // … both datasources … + expect(msg).toContain('datasource "billing_db"'); + expect(msg).toContain('datasource "crm_db"'); + // … the offending include path … + expect(msg).toContain('"account"'); + // … the rule, and a remedy the author can act on — same wording family as + // the #5033 query-time diagnostic, so the two never read as two bugs. + expect(msg).toMatch(/JOIN cannot cross datasources/); + expect(msg).toMatch(/binding both objects to the same datasource/); + expect(msg).toMatch(/dropping "account" from the dataset's `include`/); + }); + + it('compiles normally when both sides declare the SAME datasource', () => { + expect(compileWith({ opportunity: 'crm_db', crm_account: 'crm_db' })).toBeUndefined(); + // …and the join is still emitted, unchanged. + const { cube } = compileDataset(crossDs, chainResolver, { getObjectDatasource: () => 'crm_db' }); + expect(cube.joins?.account?.name).toBe('crm_account'); + }); + + it('compares datasource ids case-insensitively (casing is not two databases)', () => { + expect(compileWith({ opportunity: 'CRM_DB', crm_account: 'crm_db' })).toBeUndefined(); + }); + + // ── "cannot answer, do not block" — the tiering, pinned case by case ─────── + + it('a host with NO datasource probe compiles exactly as before', () => { + // The headline tiering case: an embedding with no data engine passes no + // options at all, and every dataset it registers must still compile. + expect(() => compileDataset(crossDs, chainResolver)).not.toThrow(); + expect(() => compileDataset(crossDs, chainResolver, {})).not.toThrow(); + const { cube } = compileDataset(crossDs, chainResolver, {}); + expect(cube.joins?.account?.name).toBe('crm_account'); + }); + + it('does not block when the probe cannot place the JOIN TARGET', () => { + expect(compileWith({ opportunity: 'billing_db', crm_account: undefined })).toBeUndefined(); + }); + + it('does not block when the probe cannot place the BASE object', () => { + // "Cannot answer for the base" must not reject every join — the base is the + // side every comparison is made against. + expect(compileWith({ opportunity: undefined, crm_account: 'crm_db' })).toBeUndefined(); + }); + + it('treats the DEFAULT binding as unanswered on either side', () => { + // `datasource: 'default'` is the schema's default VALUE, not a routing + // decision: `ObjectQL.getDriver` only short-circuits on a name OTHER than + // 'default', then falls through to datasourceMapping rules, the ADR-0057 + // lifecycle split and the package's defaultDatasource. An object that says + // 'default' may well be routed elsewhere — and may land on exactly the + // datasource the other side declares, which is why rejecting here would + // blank a working dashboard. + expect(compileWith({ opportunity: 'default', crm_account: 'crm_db' })).toBeUndefined(); + expect(compileWith({ opportunity: 'billing_db', crm_account: 'default' })).toBeUndefined(); + expect(compileWith({ opportunity: 'default', crm_account: 'DEFAULT' })).toBeUndefined(); + }); + + it('exempts a FEDERATED join target (ADR-0062 D6 — served by the FK-expand path)', () => { + // NativeSQLStrategy already declines a cube whose base or joined object is + // external, so such a query runs on the ObjectQL FK-expand path (two reads + // joined in memory), which crosses datasources by construction. Rejecting + // it here would break a path that works today. + expect( + compileWith({ opportunity: 'billing_db', crm_account: 'sf_prod' }, (o) => o === 'crm_account'), + ).toBeUndefined(); + }); + + it('exempts a FEDERATED base object', () => { + expect( + compileWith({ opportunity: 'sf_prod', crm_account: 'crm_db' }, (o) => o === 'opportunity'), + ).toBeUndefined(); + }); + + // ── which hop gets named ────────────────────────────────────────────────── + + it('names the ONE offending hop when a multi-hop path crosses on its second hop', () => { + const twoHop = datasetWith( + ['account', 'account.owner'], + [{ name: 'owner_region', field: 'account.owner.region' }], + ); + const datasources: Record = { + opportunity: 'billing_db', + crm_account: 'billing_db', // first hop stays home … + core_user: 'identity_db', // … the second one leaves + }; + let thrown: Error | undefined; + try { + compileDataset(twoHop, chainResolver, { getObjectDatasource: (o) => datasources[o] }); + } catch (e) { + thrown = e as Error; + } + const msg = String(thrown?.message); + expect(msg).toContain('joined object "core_user"'); + expect(msg).toContain('datasource "identity_db"'); + expect(msg).toContain('path "account.owner"'); + // The innocent intermediate hop is not blamed. + expect(msg).not.toContain('joined object "crm_account"'); + }); + + it('rejects only the crossing target when several joins are declared', () => { + const multi = DatasetSchema.parse({ + name: 'multi', + label: 'Multi', + object: 'opportunity', + include: ['owner', 'account'], + dimensions: [ + { name: 'owner_name', field: 'owner.name', type: 'string' }, + { name: 'region', field: 'account.region', type: 'string' }, + ], + measures: [{ name: 'cnt', label: 'Count', aggregate: 'count' }], + }); + const flatResolver = (obj: string, rel: string) => { + if (obj !== 'opportunity') return undefined; + if (rel === 'owner') return { object: 'core_user', table: 'core_user' }; + if (rel === 'account') return { object: 'crm_account', table: 'crm_account' }; + return undefined; + }; + const datasources: Record = { + opportunity: 'billing_db', + core_user: 'billing_db', // same datasource — fine + crm_account: 'crm_db', // the offender + }; + let thrown: Error | undefined; + try { + compileDataset(multi, flatResolver, { getObjectDatasource: (o) => datasources[o] }); + } catch (e) { + thrown = e as Error; + } + expect(String(thrown?.message)).toContain('joined object "crm_account"'); + expect(String(thrown?.message)).not.toContain('core_user'); + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/dataset-cross-datasource-registration.test.ts b/packages/services/service-analytics/src/__tests__/dataset-cross-datasource-registration.test.ts new file mode 100644 index 0000000000..119477c846 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/dataset-cross-datasource-registration.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5115 — the WIRING half of the compile-time cross-datasource gate. + * + * `dataset-compiler.test.ts` pins the verdict; this file pins that the service + * actually asks the question. `registerDataset` is the one door every dataset + * goes through (pre-registered datasets at construction, `queryDataset` for + * saved and Studio-draft datasets alike), so hooking the probes up there is + * what moves the failure off the dashboard and onto the author. A compiler that + * can reject but is never handed the probes would pass every unit test above + * and change nothing in production. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import { AnalyticsService, type AnalyticsServiceConfig } from '../analytics-service.js'; + +/** opportunity (billing_db) --account--> crm_account (crm_db). */ +const DATASOURCES: Record = { + opportunity: 'billing_db', + crm_account: 'crm_db', +}; + +const relationshipResolver = (obj: string, rel: string) => + obj === 'opportunity' && rel === 'account' + ? { object: 'crm_account', table: 'crm_account' } + : undefined; + +const crossDsDataset = DatasetSchema.parse({ + name: 'revenue_by_region', + label: 'Revenue by region', + object: 'opportunity', + include: ['account'], + dimensions: [{ name: 'region', label: 'Region', field: 'account.region', type: 'string' }], + measures: [{ name: 'revenue', label: 'Revenue', aggregate: 'sum', field: 'amount' }], +}); + +/** Silent by default — one case swaps in a spy to read the boot-time WARN. */ +const silentLogger = { info() {}, warn() {}, error() {}, debug() {} } as AnalyticsServiceConfig['logger']; + +const serviceWith = (config: AnalyticsServiceConfig = {}) => + new AnalyticsService({ + relationshipResolver, + getObjectDatasource: (o: string) => DATASOURCES[o], + logger: silentLogger, + ...config, + }); + +describe('registerDataset rejects a cross-datasource join at compile time (#5115)', () => { + it('throws when registering the dataset — no query needed', () => { + expect(() => serviceWith().registerDataset(crossDsDataset)).toThrowError( + /JOIN cannot cross datasources/, + ); + }); + + it('names both objects and both datasources, so the author can act', () => { + const err = (() => { + try { + serviceWith().registerDataset(crossDsDataset); + return undefined; + } catch (e) { + return e as Error; + } + })(); + expect(err?.message).toContain('base object "opportunity"'); + expect(err?.message).toContain('datasource "billing_db"'); + expect(err?.message).toContain('joined object "crm_account"'); + expect(err?.message).toContain('datasource "crm_db"'); + }); + + it('fails the QUERY too — before any SQL is built or any driver is touched', async () => { + // The point of the gate: the widget no longer gets as far as a statement, + // so nothing depends on the driver's error text (that was #5033's job). + const executeRawSql = vi.fn(); + const executeAggregate = vi.fn(); + const service = serviceWith({ executeRawSql, executeAggregate }); + + await expect( + service.queryDataset(crossDsDataset, { dimensions: ['region'], measures: ['revenue'] }), + ).rejects.toThrow(/JOIN cannot cross datasources/); + + expect(executeRawSql).not.toHaveBeenCalled(); + expect(executeAggregate).not.toHaveBeenCalled(); + }); + + it('does not take the kernel down at boot — a bad pre-registered dataset warns and is skipped', () => { + // Pre-registered datasets are compiled in the constructor, which already + // catches and warns per dataset. A metadata error must stay a metadata + // error: the OTHER datasets in the same host still register. + const warn = vi.fn(); + const sane = DatasetSchema.parse({ + name: 'pipeline', + label: 'Pipeline', + object: 'opportunity', + dimensions: [{ name: 'stage', label: 'Stage', field: 'stage', type: 'string' }], + measures: [{ name: 'cnt', label: 'Count', aggregate: 'count' }], + }); + const service = serviceWith({ + datasets: [crossDsDataset, sane], + logger: { info() {}, warn, error() {}, debug() {} } as AnalyticsServiceConfig['logger'], + }); + + expect(warn.mock.calls.map(String).join('\n')).toMatch(/JOIN cannot cross datasources/); + expect(service.cubeRegistry.get('pipeline')).toBeDefined(); + expect(service.cubeRegistry.get('revenue_by_region')).toBeUndefined(); + }); + + it('a host that wires NO datasource probe registers the same dataset unchanged', () => { + // The tiering, at the seam that matters: every embedding without a data + // engine (and every existing test double) must be unaffected by #5115. + const service = new AnalyticsService({ relationshipResolver }); + const compiled = service.registerDataset(crossDsDataset); + expect(compiled.cube.joins?.account?.name).toBe('crm_account'); + }); + + it('a FEDERATED join target still registers (ADR-0062 D6 — FK-expand serves it)', () => { + const service = serviceWith({ isExternalObject: (o) => o === 'crm_account' }); + expect(() => service.registerDataset(crossDsDataset)).not.toThrow(); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 62b3daa59d..8a3b83bc3b 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -241,12 +241,18 @@ export interface AnalyticsServiceConfig { * [#5033] The datasource `objectName` is bound to, or `undefined` when it * rides the default one (or nothing authoritative can answer). * - * Diagnostics only — it never selects a driver (that is `engine.execute`'s - * `object` key, which the `plugin.ts` bridge now passes). It exists so that - * when a dataset's SQL references a table that is NOT on the datasource its - * base object routed to, the failure can name the actual cause — *table X is - * not on datasource Y* — instead of the misleading "backing object … - * is unavailable" that a cross-datasource join used to produce. + * It never selects a driver (that is `engine.execute`'s `object` key, which + * the `plugin.ts` bridge now passes). It exists so that when a dataset's SQL + * references a table that is NOT on the datasource its base object routed to, + * the failure can name the actual cause — *table X is not on datasource Y* — + * instead of the misleading "backing object … is unavailable" that a + * cross-datasource join used to produce. + * + * [#5115] The same probe now also gates COMPILATION: `registerDataset` hands + * it to `compileDataset`, which rejects a dataset whose join crosses + * datasources before any query is ever built. Absence keeps the pre-#5115 + * behaviour exactly ("cannot answer, do not block") — the query-time + * diagnostic above stays as the backstop. */ getObjectDatasource?: (objectName: string) => string | undefined; /** @@ -380,8 +386,13 @@ export class AnalyticsService implements IAnalyticsService { private readonly isRegisteredObject?: AnalyticsServiceConfig['isRegisteredObject']; /** [#4437] Field-name probe gating measure source-field resolution. */ private readonly getObjectFieldNames?: AnalyticsServiceConfig['getObjectFieldNames']; - /** [#5033] Diagnostics-only datasource probe for the missing-source triage. */ + /** + * [#5033] Datasource probe for the missing-source triage — and, since #5115, + * for the compile-time cross-datasource join gate in `compileDataset`. + */ private readonly getObjectDatasource?: AnalyticsServiceConfig['getObjectDatasource']; + /** ADR-0062 D6 — federated-object probe (strategy routing + #5115's gate). */ + private readonly isExternalObject?: AnalyticsServiceConfig['isExternalObject']; /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */ private warnedNoObjectRegistry = false; readonly cubeRegistry: CubeRegistry; @@ -404,6 +415,7 @@ export class AnalyticsService implements IAnalyticsService { this.isRegisteredObject = config.isRegisteredObject; this.getObjectFieldNames = config.getObjectFieldNames; this.getObjectDatasource = config.getObjectDatasource; + this.isExternalObject = config.isExternalObject; // Compile + register pre-defined datasets (ADR-0021). if (config.datasets) { @@ -581,7 +593,13 @@ export class AnalyticsService implements IAnalyticsService { * compiled dataset. */ registerDataset(dataset: Dataset): CompiledDataset { - const compiled = compileDataset(dataset, this.relationshipResolver); + // #5115 — the datasource/federation probes turn a cross-datasource join + // from a query-time explosion into a registration-time rejection. Both are + // optional and tiered "cannot answer, do not block" inside the compiler. + const compiled = compileDataset(dataset, this.relationshipResolver, { + getObjectDatasource: this.getObjectDatasource, + isExternalObject: this.isExternalObject, + }); this.cubeRegistry.register(compiled.cube); this.datasetRegistry.set(dataset.name, compiled); return compiled; diff --git a/packages/services/service-analytics/src/dataset-compiler.ts b/packages/services/service-analytics/src/dataset-compiler.ts index 28b7c8998a..6cb9a6d3fa 100644 --- a/packages/services/service-analytics/src/dataset-compiler.ts +++ b/packages/services/service-analytics/src/dataset-compiler.ts @@ -86,6 +86,46 @@ export type RelationshipResolver = ( relationshipName: string, ) => string | RelationshipTarget | undefined; +/** + * Optional probes the compiler consults to reject metadata that is decidable + * BEFORE any query runs. Every probe is optional and every one of them is + * tiered "cannot answer, do not block" (the same stand-down as + * `isRegisteredObject` / `getObjectFieldNames` on `AnalyticsServiceConfig`): + * a host without a data engine compiles exactly as it did before. + */ +export interface DatasetCompileOptions { + /** + * [#5115] The datasource `objectName` DECLARES (`object.datasource`), or + * `undefined` when nothing authoritative can answer (no data engine, unknown + * object). + * + * With it the compiler can settle at COMPILE time what #5033 could only + * report at QUERY time: a dataset whose join crosses datasources declares a + * statement no driver can execute, because the analytics engine lowers the + * whole dataset into ONE SQL statement on the base object's datasource. + * + * IMPORTANT — `'default'` is not an answer. In `ObjectQL.getDriver`'s + * resolution order an explicit `object.datasource` other than `'default'` + * wins outright (step 1); `'default'` is the schema's DEFAULT value and means + * only "no explicit binding", after which routing is decided by + * `datasourceMapping` rules, the ADR-0057 §3.6 lifecycle split, and the + * owning package's `defaultDatasource` — none of which are visible from here. + * The compiler therefore treats `'default'`/`undefined` as UNANSWERED. See + * {@link compileDataset}. + */ + getObjectDatasource?: (objectName: string) => string | undefined; + /** + * ADR-0062 D6 — is `objectName` federated (bound to an external datasource)? + * + * A federated participant is EXEMPT from the cross-datasource rejection: + * `NativeSQLStrategy.canHandle` already declines a cube whose base or joined + * object is external, so such a dataset is served by the ObjectQL FK-expand + * path (two reads, joined in memory) — which crosses datasources by + * construction. Rejecting it here would break a path that works today. + */ + isExternalObject?: (objectName: string) => boolean; +} + /** Map a dataset measure's aggregate to the Cube metric `type`. */ function aggregateToMetricType(m: DatasetMeasure): Metric['type'] { // Only reached for non-derived measures, where the spec refinement guarantees @@ -137,9 +177,71 @@ const joinAlias = (path: string): string => path.replace(/\./g, '__'); export function compileDataset( dataset: Dataset, resolver?: RelationshipResolver, + options?: DatasetCompileOptions, ): CompiledDataset { const include = dataset.include ?? []; + // ── #5115 — cross-datasource joins are rejected HERE, at compile time ────── + // + // A dataset lowers to ONE SQL statement executed on the base object's + // datasource (`plugin.ts` routes raw SQL by object since #5033). So a join + // whose target lives on a DIFFERENT datasource is not a query that sometimes + // fails — it is metadata that can never execute, and the question "which + // datasource is each participant bound to" is fully answerable the moment the + // dataset is compiled. #5033 made that failure loud at QUERY time (in front of + // whoever opened the dashboard); this gate moves the same verdict to + // registration, where the AUTHOR is still holding the metadata. + // + // Tiering — "cannot answer, do not block": no probe, no answer for the base + // object, or no answer for a target ⇒ compile as before and let #5033's + // query-time defence report it. A false ALLOW costs a loud runtime error that + // already exists; a false REJECT would blank a working dashboard on upgrade, + // so this gate fires ONLY on a conflict the metadata itself proves. + // + // What counts as an ANSWER (deliberately narrow): an EXPLICIT, non-`'default'` + // `object.datasource`. That is step 1 of `ObjectQL.getDriver`'s resolution + // order and it wins outright, so two objects declaring two different names are + // provably in two databases. `'default'` is the schema's default VALUE, not a + // routing decision: an object that leaves it alone is still routed by + // `datasourceMapping` rules, by the ADR-0057 §3.6 lifecycle split + // (audit/telemetry/event → the `telemetry` datasource), or by its package's + // `defaultDatasource` — rules this compiler cannot see. Treating `'default'` + // as "the primary DB" would reject a dataset whose two objects a mapping rule + // in fact lands on the SAME datasource, and would make the verdict depend on + // whether the object happened to be Zod-parsed (which materializes the + // default) — so `'default'` is read as UNANSWERED. + const declaredDatasource = (objectName: string): string | undefined => { + const declared = options?.getObjectDatasource?.(objectName); + return declared && declared.toLowerCase() !== 'default' ? declared : undefined; + }; + const isExternal = (objectName: string): boolean => + options?.isExternalObject?.(objectName) ?? false; + const baseDatasource = declaredDatasource(dataset.object); + // Datasource ids are compared case-insensitively: an id differing only in case + // is not evidence of two different databases, and an uncertain answer must + // not reject. + const sameDatasource = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); + const baseIsFederated = isExternal(dataset.object); + const assertSameDatasource = (targetObject: string, path: string): void => { + // Judgeable only when the BASE side is placed and non-federated; it is the + // side every comparison is made against, so an unplaceable base means no + // join can be judged (never reject every join for want of the base). + if (!baseDatasource || baseIsFederated) return; + if (isExternal(targetObject)) return; // served by the FK-expand path, not by one statement + const targetDatasource = declaredDatasource(targetObject); + if (!targetDatasource) return; // cannot answer for this target + if (sameDatasource(targetDatasource, baseDatasource)) return; + throw new Error( + `[dataset-compiler] dataset "${dataset.name}" declares a JOIN that crosses datasources: ` + + `its base object "${dataset.object}" is on datasource "${baseDatasource}", but the joined ` + + `object "${targetObject}" — reached via the \`include\` path "${path}" — is on datasource ` + + `"${targetDatasource}". A dataset JOIN cannot cross datasources: the whole dataset is ` + + `executed as ONE statement on the base object's datasource, so "${targetObject}" is simply ` + + `not there. Fix it by binding both objects to the same datasource, or by dropping "${path}" ` + + `from the dataset's \`include\` (and every dimension/measure that references it).`, + ); + }; + // Resolve each declared relationship PATH into its ordered join chain, emitting // one Cube join per PATH PREFIX (ADR-0071 multi-hop, to-one only). The join // ALIAS is the full dotted path (`account.owner`), which self-describes the @@ -174,6 +276,9 @@ export function compileDataset( for (const seg of segments) { prefix = prefix ? `${prefix}.${seg}` : seg; const target = resolveHop(fromObject, seg); + // #5115 — every hop is a join target in the single statement, so each one + // (not just the last segment of a path) must sit on the base datasource. + assertSameDatasource(target.object, prefix); const alias = joinAlias(prefix); if (!joins[alias]) { // KEY is the SQL-safe alias; `name` carries the join TABLE; the strategy diff --git a/packages/services/service-analytics/src/index.ts b/packages/services/service-analytics/src/index.ts index f26f0188c3..7042cb07a6 100644 --- a/packages/services/service-analytics/src/index.ts +++ b/packages/services/service-analytics/src/index.ts @@ -13,7 +13,13 @@ export { CubeRegistry } from './cube-registry.js'; // Dataset semantic layer (ADR-0021) export { compileDataset } from './dataset-compiler.js'; -export type { CompiledDataset, DerivedMeasureSpec, RelationshipResolver, RelationshipTarget } from './dataset-compiler.js'; +export type { + CompiledDataset, + DatasetCompileOptions, + DerivedMeasureSpec, + RelationshipResolver, + RelationshipTarget, +} from './dataset-compiler.js'; export { resolveDimensionLabels,