@@ -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.
103103import { 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 */
463469function 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 ,
0 commit comments