Skip to content

Commit bf58903

Browse files
committed
fix(service-analytics): a hop with no declared join reads the object its lookup field declares
An inferred cube declares no join, so each relationship-path hop fell back to its alias, the lookup field's own name. For a lookup named differently from its target that names no object: the door admitted and refused the field's name as if it were one, and the strategies joined and read a table of that name. One resolver (hop-object.ts) now names a hop's object in three tiers: the cube's declared join, else the field's declared reference (the host's relationshipResolver), else the alias. The door's field gate and its admitted and scoped set read it, and both strategies read it through the context's relationshipReference, the same function. The native strategy records the object on each registered join and scopes the alias as that object; the engine-aggregate strategy plans and reads its FK-expand from it. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
1 parent f31ca81 commit bf58903

5 files changed

Lines changed: 327 additions & 96 deletions

File tree

‎packages/services/service-analytics/src/analytics-service.ts‎

Lines changed: 66 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ import { assertNoStructuredJsonDimension } from './structured-json-dimension-doo
101101
// the compilers read, so the contract has one definition and this file states
102102
// it rather than restating it.
103103
import { ACCEPTED_SQL_DIALECTS, isUnrecognisedSqlDialectAnswer, type AcceptedSqlDialect } from './text-match-sql.js';
104+
// [#20986] The one resolver of the object a relationship-path hop reads. The
105+
// door's field gate and its admitted and scoped set read it here; both
106+
// strategies read it through the context's `relationshipReference`.
107+
import { resolvePathHops, type HopReference } from './hop-object.js';
104108

105109
/**
106110
* [#5717] Does this error carry an ADR-0112 envelope — i.e. did its PRODUCER
@@ -402,33 +406,32 @@ const IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$/;
402406
* - A bare identifier is a column of the base object.
403407
* - A dotted identifier path is a relationship path: every segment but the
404408
* last is a relationship field on the object before it, the last is the
405-
* column. Each hop's object is the cube's join at that path, keyed as the
406-
* strategies key it (the path with its dots as `__`) and falling back to the
407-
* alias itself — the object both strategies read there. The relationship
408-
* fields are named too: a hidden relationship field discloses which record
409-
* each row points to, and the engine judges a path's first segment on the
410-
* local object for the same reason.
409+
* column. [#20986] Each hop's object is the one `hop-object.ts` resolves —
410+
* the cube's join at that path, else the relationship field's declared
411+
* `reference`, else the alias itself — which both strategies read there
412+
* through the same resolver. The relationship fields are named too: a
413+
* hidden relationship field discloses which record each row points to, and
414+
* the engine judges a path's first segment on the local object for the same
415+
* reason.
411416
* - Anything else is an EXPRESSION the cube's author wrote (`CASE WHEN …`,
412417
* `SUM(…)`, `*`). It names no field this gate can attribute, so it adds
413418
* nothing — the author's declaration of a derived value, the way a formula
414419
* field is.
415420
*/
416-
function fieldsOfColumnSql(cube: Cube, baseObject: string, sql: string, role: FieldReadRole): NamedField[] {
421+
function fieldsOfColumnSql(
422+
cube: Cube,
423+
baseObject: string,
424+
sql: string,
425+
role: FieldReadRole,
426+
referenceOf: HopReference | undefined,
427+
): NamedField[] {
417428
const path = sql.trim();
418429
if (BARE_IDENTIFIER.test(path)) return [{ object: baseObject, field: path, role }];
419430
if (!IDENTIFIER_PATH.test(path)) return [];
420431
const segments = path.split('.');
421-
const joins = cube.joins as Record<string, { name?: unknown } | undefined> | undefined;
422-
const out: NamedField[] = [];
423-
let object = baseObject;
424-
let alias = '';
425-
for (const segment of segments.slice(0, -1)) {
426-
out.push({ object, field: segment, role });
427-
alias = alias ? `${alias}__${segment}` : segment;
428-
const joined = joins?.[alias]?.name;
429-
object = typeof joined === 'string' && joined !== '' ? joined : alias;
430-
}
431-
out.push({ object, field: segments[segments.length - 1], role });
432+
const hops = resolvePathHops(cube, baseObject, segments.slice(0, -1), referenceOf);
433+
const out: NamedField[] = hops.map((hop) => ({ object: hop.from, field: hop.field, role }));
434+
out.push({ object: hops[hops.length - 1].object, field: segments[segments.length - 1], role });
432435
return out;
433436
}
434437

@@ -459,11 +462,15 @@ function fieldsOfColumnSql(cube: Cube, baseObject: string, sql: string, role: Fi
459462
* predicates — they may name fields the caller cannot read.
460463
*
461464
* A cube whose `sql` is not a bare object name names no attributable field.
465+
*
466+
* [#20986] `referenceOf` answers a relationship field's declared target: the
467+
* service's own, the same one it hands the strategies.
462468
*/
463469
function namedQueryFields(
464470
query: AnalyticsQuery,
465471
cube: Cube,
466472
datasetScope: DatasetScope | undefined,
473+
referenceOf: HopReference | undefined,
467474
): NamedField[] {
468475
const baseObject = typeof cube.sql === 'string' ? cube.sql.trim() : '';
469476
if (!baseObject || !BARE_IDENTIFIER.test(baseObject)) return [];
@@ -472,7 +479,7 @@ function namedQueryFields(
472479
if (typeof member !== 'string' || member === '') return;
473480
const entry = declaredMemberEntry(cube, member, kind);
474481
const sql = entry ? entry.sql : kind === 'measure' ? undefined : member;
475-
if (typeof sql === 'string') out.push(...fieldsOfColumnSql(cube, baseObject, sql, role));
482+
if (typeof sql === 'string') out.push(...fieldsOfColumnSql(cube, baseObject, sql, role, referenceOf));
476483
};
477484
const filterMembers = (where: unknown): string[] => {
478485
if (!where || typeof where !== 'object') return [];
@@ -898,6 +905,13 @@ export interface AnalyticsServiceConfig {
898905
* ADR-0021 — optional object-graph resolver used when compiling datasets:
899906
* `(baseObject, relationshipName) => relatedObjectName | undefined`. When
900907
* provided, `queryDataset` validates that every declared `include` exists.
908+
*
909+
* [#20986] It also answers the object a relationship-path hop reads when the
910+
* cube declares no join for it — an inferred cube's dotted member, an
911+
* authored member walking a relationship its `joins` does not list — so a
912+
* lookup named differently from its target is admitted, scoped, joined and
913+
* read as the target. Absent, or `undefined` for a field, such a hop reads
914+
* the object named after the relationship (`hop-object.ts`).
901915
*/
902916
relationshipResolver?: RelationshipResolver;
903917
/**
@@ -1164,8 +1178,22 @@ export class AnalyticsService implements IAnalyticsService {
11641178
private readonly sharedScope: CubeScope;
11651179
/** The configured join-allowlist hook for cubes that are not compiled datasets. */
11661180
private readonly configuredAllowedRelationships?: AnalyticsServiceConfig['getAllowedRelationships'];
1167-
/** Optional object-graph resolver used when compiling datasets. */
1181+
/** Optional object-graph resolver used when compiling datasets, and for a hop's declared target (#20986). */
11681182
private readonly relationshipResolver?: RelationshipResolver;
1183+
/**
1184+
* [#20986] Tier 2 of the one hop resolver (`hop-object.ts`): the object a
1185+
* relationship field declares as its target, read through
1186+
* {@link relationshipResolver}. ONE function, read by the door's field gate
1187+
* and admitted and scoped set and handed to both strategies as the context's
1188+
* `relationshipReference`, so every reader resolves a hop with the same
1189+
* answer. A `RelationshipTarget` answers with its `object`: the object
1190+
* is what is admitted and scoped, and it names the table too (an object's
1191+
* name is its table's name).
1192+
*/
1193+
private readonly hopReference: HopReference = (object, field) => {
1194+
const target = this.relationshipResolver?.(object, field);
1195+
return typeof target === 'string' ? target : target?.object;
1196+
};
11691197
private readonly sourceFieldMeta?: AnalyticsServiceConfig['sourceFieldMeta'];
11701198
/** Optional dimension display-label resolver (select options / lookup names). */
11711199
private readonly labelResolver?: DimensionLabelDeps;
@@ -1269,6 +1297,11 @@ export class AnalyticsService implements IAnalyticsService {
12691297
coerceTemporalFilterValue: config.coerceTemporalFilterValue,
12701298
coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
12711299
isExternalObject: config.isExternalObject,
1300+
// [#20986] A relationship field's declared target — the SAME function
1301+
// the door's field gate and its admitted and scoped set resolve hops
1302+
// with, so a strategy joins, scopes and reads the object the door
1303+
// admitted, never a second resolution of it.
1304+
relationshipReference: this.hopReference,
12721305
// [#14079] The declared field type, read off the same `sourceFieldMeta`
12731306
// hook the display chains use — so the three SQL compilers can give a
12741307
// text operator over a numeric or boolean column the contract's answer
@@ -1589,10 +1622,17 @@ export class AnalyticsService implements IAnalyticsService {
15891622
* read scope reaches the strategy exactly as a declared join's does — the
15901623
* strategies apply the scope of every object they read from this set, and
15911624
* carry no rule of their own. Each hop's object is the one the field gate
1592-
* attributes the hop's fields to ({@link namedQueryFields}: the cube's join
1593-
* keyed by the path with its dots as `__`, falling back to the alias itself),
1594-
* reused rather than re-derived, so the field gate and this set can never
1595-
* name different objects for the same hop.
1625+
* attributes the hop's fields to ({@link namedQueryFields}), reused rather
1626+
* than re-derived, so the field gate and this set can never name different
1627+
* objects for the same hop.
1628+
*
1629+
* [#20986] That object comes from the one hop resolver (`hop-object.ts`):
1630+
* the cube's join keyed by the path with its dots as `__`, else the
1631+
* relationship field's declared `reference` ({@link hopReference}), else the
1632+
* alias itself. An inferred cube declares no join, so a lookup named
1633+
* differently from its target admits the TARGET, never the lookup's name;
1634+
* the strategies join, scope and read the same object through the same
1635+
* resolver.
15961636
*
15971637
* An unregistered cube yields the empty set — the query fails its own
15981638
* cube-existence gate downstream, and inventing an object name here would
@@ -1609,7 +1649,7 @@ export class AnalyticsService implements IAnalyticsService {
16091649
const cube = scope.getCube(query.cube);
16101650
if (!cube) return new Set<string>();
16111651
const objects = this.cubeObjects(cube);
1612-
for (const { object } of namedQueryFields(query, cube, this.cubeReads(scope).getDatasetScope(query.cube))) {
1652+
for (const { object } of namedQueryFields(query, cube, this.cubeReads(scope).getDatasetScope(query.cube), this.hopReference)) {
16131653
objects.add(object);
16141654
}
16151655
return objects;
@@ -1679,7 +1719,7 @@ export class AnalyticsService implements IAnalyticsService {
16791719
): Promise<void> {
16801720
const provider = this.readableFieldsProvider;
16811721
if (!provider || !cube) return;
1682-
const named = namedQueryFields(query, cube, datasetScope);
1722+
const named = namedQueryFields(query, cube, datasetScope, this.hopReference);
16831723
if (named.length === 0) return;
16841724
await assertNamedFieldsReadable(
16851725
named,
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20986] The ONE answer to "which object does this relationship-path hop
5+
* read?" — for every reader in this package that needs it.
6+
*
7+
* A relationship path is a dotted identifier path (`owner.region`,
8+
* `account.owner.email`): every segment but the last is a relationship field
9+
* on the object before it, the last is a column. The object each hop reaches
10+
* is resolved in three tiers, first answer wins:
11+
*
12+
* 1. **The cube's declared join** at that path, keyed by the path with its
13+
* dots as `__` (`account__owner`) — the key the dataset compiler registers
14+
* and both strategies alias the join by. An authored cube that declares a
15+
* join keeps it, whatever the field declares.
16+
* 2. **The relationship field's declared `reference`** — the object it points
17+
* to — asked of the host through {@link HopReference}, on the object the
18+
* previous hop reached. This is the tier an inferred cube takes: it declares
19+
* no join, and a lookup named differently from its target (`owner` →
20+
* `crm_person`) reaches the target, never an object named `owner`.
21+
* 3. **The alias itself** — the legacy same-name convention — when the host
22+
* cannot answer: no resolver wired, a field it does not know, or a field
23+
* that declares no reference. For a lookup named after its target this is
24+
* the object tier 2 would have named.
25+
*
26+
* Every reader takes its answer from here — the door's field gate and its
27+
* admitted and scoped object set (`analytics-service.ts`), the native
28+
* strategy's join and the read scope it applies to the joined alias, and the
29+
* engine-aggregate strategy's cross-object plan and the object its FK-expand
30+
* reads — so the object a hop is ADMITTED and SCOPED as and the object it is
31+
* JOINED or READ as are the same value by construction. ⛔ No reader resolves
32+
* a hop on its own: a second copy of this walk is how the alias came to be
33+
* admitted as an object in the first place.
34+
*/
35+
36+
import type { Cube } from '@objectstack/spec/data';
37+
import type { DatasetScopedStrategyContext, StrategyContext } from './strategies/types.js';
38+
39+
/**
40+
* `(object, field) => the object `field` on `object` declares as its target`,
41+
* or `undefined` when the host cannot answer. `AnalyticsService` answers it
42+
* from `AnalyticsServiceConfig.relationshipResolver`, the declared `reference`
43+
* of a `lookup` / `master_detail` field that `AnalyticsServicePlugin` reads
44+
* off the data engine's object schema.
45+
*/
46+
export type HopReference = (object: string, field: string) => string | undefined;
47+
48+
/** One hop of a relationship path, resolved. */
49+
export interface ResolvedHop {
50+
/** The relationship field this hop walks, on {@link from}. */
51+
readonly field: string;
52+
/** The object that declares {@link field}: the base object, or the previous hop's object. */
53+
readonly from: string;
54+
/** The join alias: the path up to and including this hop, its dots as `__`. */
55+
readonly alias: string;
56+
/** The object this hop reads. */
57+
readonly object: string;
58+
/** Which tier named {@link object}: the cube's declared join, the field's declared reference, or the alias. */
59+
readonly via: 'join' | 'reference' | 'alias';
60+
}
61+
62+
/**
63+
* Resolve every hop of a relationship path, in order.
64+
*
65+
* @param cube The cube the path is read on (its `joins` are tier 1).
66+
* @param baseObject The object the path starts on — the cube's base object.
67+
* @param hops The relationship fields, in order: the path's segments minus its column.
68+
* @param referenceOf The host's answer for tier 2; absent ⇒ tier 3.
69+
*/
70+
export function resolvePathHops(
71+
cube: Pick<Cube, 'joins'> | undefined,
72+
baseObject: string,
73+
hops: readonly string[],
74+
referenceOf: HopReference | undefined,
75+
): ResolvedHop[] {
76+
const joins = cube?.joins as Record<string, { name?: unknown } | undefined> | undefined;
77+
const out: ResolvedHop[] = [];
78+
let from = baseObject;
79+
let alias = '';
80+
for (const field of hops) {
81+
alias = alias ? `${alias}__${field}` : field;
82+
const joined = joins?.[alias]?.name;
83+
let object: string;
84+
let via: ResolvedHop['via'];
85+
if (typeof joined === 'string' && joined !== '') {
86+
object = joined;
87+
via = 'join';
88+
} else {
89+
const reference = referenceOf?.(from, field);
90+
if (typeof reference === 'string' && reference !== '') {
91+
object = reference;
92+
via = 'reference';
93+
} else {
94+
object = alias;
95+
via = 'alias';
96+
}
97+
}
98+
out.push({ field, from, alias, object, via });
99+
from = object;
100+
}
101+
return out;
102+
}
103+
104+
/**
105+
* The object a dotted relationship path's COLUMN lives on — the last hop's
106+
* object — for a path written `hop.hop.column`. `baseObject` for a path with
107+
* no hop.
108+
*/
109+
export function columnObjectOf(
110+
cube: Pick<Cube, 'joins'> | undefined,
111+
baseObject: string,
112+
path: string,
113+
referenceOf: HopReference | undefined,
114+
): string {
115+
const hops = path.split('.').slice(0, -1);
116+
const resolved = resolvePathHops(cube, baseObject, hops, referenceOf);
117+
return resolved.length > 0 ? resolved[resolved.length - 1].object : baseObject;
118+
}
119+
120+
/**
121+
* The host's {@link HopReference} a strategy context carries — the
122+
* `relationshipReference` `AnalyticsService` hands its strategies, the same
123+
* function its own field gate and admitted and scoped set resolve hops with —
124+
* or `undefined` for a context built without it, whose hops then read the
125+
* object named after the relationship (tier 3).
126+
*/
127+
export function relationshipReferenceOf(ctx: StrategyContext): HopReference | undefined {
128+
const scoped = ctx as DatasetScopedStrategyContext;
129+
return typeof scoped.relationshipReference === 'function'
130+
? (object, field) => scoped.relationshipReference!(object, field)
131+
: undefined;
132+
}

0 commit comments

Comments
 (0)