@@ -1443,7 +1443,10 @@ export class AnalyticsService implements IAnalyticsService {
14431443 // read"). `callCtx` is the ONE thing `query()` and `generateSql()` share,
14441444 // so gating it covers the direct `/analytics/query` door, the `/analytics/sql`
14451445 // echo door and — through `DatasetExecutor` — every dataset door.
1446- await this . assertReadAdmitted ( this . queryObjects ( query , scope ) , context ) ;
1446+ // [#20933] The set is derived ONCE here and handed to both questions, so
1447+ // the objects admitted and the objects scoped are one value, not two calls.
1448+ const objects = this . queryObjects ( query , scope ) ;
1449+ await this . assertReadAdmitted ( objects , context ) ;
14471450 // [#20917] …and the FIELD-level gate, right behind it and for the same
14481451 // reason: every member the query names is judged here, once, so every
14491452 // strategy — and the SQL echo — inherits the verdict by construction. The
@@ -1461,17 +1464,21 @@ export class AnalyticsService implements IAnalyticsService {
14611464 // a deployment with no `getReadScope` provider is exactly the one that most
14621465 // needs the engine to scope for it.
14631466 if ( ! this . readScopeProvider ) return { ...this . baseCtx , ...reads , context, getDatasetScope } ;
1464- // Pre-resolve the read scope for every object the strategy will scan (base
1465- // + all declared joins) BEFORE the synchronous SQL builder runs, since the
1467+ // Pre-resolve the read scope for every object the strategy will scan (base,
1468+ // declared joins, relationship paths — `queryObjects`, the set the admission
1469+ // above read) BEFORE the synchronous SQL builder runs, since the
14661470 // provider may be async (the production `security.getReadFilter` bridge).
14671471 // The strategy then reads each object's filter synchronously from the map.
1468- const scopes = await this . resolveReadScopes ( query , context , scope ) ;
1472+ const scopes = await this . resolveReadScopes ( objects , context ) ;
14691473 return {
14701474 ...this . baseCtx ,
14711475 ...reads ,
14721476 context,
14731477 getDatasetScope,
14741478 getReadScope : ( objectName : string ) => scopes . get ( objectName ) ?? null ,
1479+ // [#20933] …and the set itself, for the strategy that looks at the scopes
1480+ // before it compiles (`NativeSQLStrategy`'s cross-field decline).
1481+ readScopedObjects : [ ...objects ] ,
14751482 } ;
14761483 }
14771484
@@ -1530,15 +1537,30 @@ export class AnalyticsService implements IAnalyticsService {
15301537 }
15311538
15321539 /**
1533- * Every object this query will READ — the cube's base object plus every
1534- * joined object.
1540+ * Every object this query will READ — the cube's base object, every join the
1541+ * cube declares, and every object a member the query names reaches through a
1542+ * relationship path.
15351543 *
15361544 * ONE derivation, two consumers: {@link resolveReadScopes} scopes exactly
15371545 * this set and {@link assertReadAdmitted} admits exactly this set, so the set
15381546 * that is row-scoped and the set that is admitted are provably the same set
15391547 * rather than two lists that agree today. It is a SUPERSET of what a strategy
1540- * actually scans (a strategy only joins along declared relationships), which
1541- * is the safe direction: no scanned object is ever left ungated.
1548+ * actually scans (a declared join the query never uses is in it), which is
1549+ * the safe direction: no scanned object is ever left ungated.
1550+ *
1551+ * [#20933] A relationship path is read whether or not the cube declares it.
1552+ * A dotted member of an inferred cube, an authored member whose `sql` walks a
1553+ * relationship the cube's `joins` never lists, a dotted member the query
1554+ * names itself: `NativeSQLStrategy` joins the object at each hop
1555+ * (`qualifyAndRegisterJoin`) and `ObjectQLStrategy` reads it to resolve the
1556+ * related value. Those objects are in this set, so each is admitted and its
1557+ * read scope reaches the strategy exactly as a declared join's does — the
1558+ * strategies apply the scope of every object they read from this set, and
1559+ * carry no rule of their own. Each hop's object is the one the field gate
1560+ * attributes the hop's fields to ({@link namedQueryFields}: the cube's join
1561+ * keyed by the path with its dots as `__`, falling back to the alias itself),
1562+ * reused rather than re-derived, so the field gate and this set can never
1563+ * name different objects for the same hop.
15421564 *
15431565 * An unregistered cube yields the empty set — the query fails its own
15441566 * cube-existence gate downstream, and inventing an object name here would
@@ -1553,14 +1575,21 @@ export class AnalyticsService implements IAnalyticsService {
15531575 private queryObjects ( query : AnalyticsQuery , scope : CubeScope ) : Set < string > {
15541576 if ( ! query . cube ) return new Set < string > ( ) ;
15551577 const cube = scope . getCube ( query . cube ) ;
1556- return cube ? this . cubeObjects ( cube ) : new Set < string > ( ) ;
1578+ if ( ! cube ) return new Set < string > ( ) ;
1579+ const objects = this . cubeObjects ( cube ) ;
1580+ for ( const { object } of namedQueryFields ( query , cube , this . cubeReads ( scope ) . getDatasetScope ( query . cube ) ) ) {
1581+ objects . add ( object ) ;
1582+ }
1583+ return objects ;
15571584 }
15581585
15591586 /**
1560- * {@link queryObjects } for a cube already in hand — the draft-preview branch
1561- * holds the COMPILED dataset rather than a query naming it, and reaching for
1562- * the registry there would make the gate depend on a registration side
1563- * effect. One derivation, two entry points.
1587+ * The cube's own part of {@link queryObjects}: its base object and every
1588+ * join it declares. The draft-preview branch asks this part alone — it holds
1589+ * the COMPILED dataset rather than a query naming it (reaching for the
1590+ * registry there would make the gate depend on a registration side effect),
1591+ * and it evaluates the executor's queries over the base object's drafted seed
1592+ * rows in memory, so it reads no object through a relationship path.
15641593 */
15651594 private cubeObjects ( cube : Cube ) : Set < string > {
15661595 const objects = new Set < string > ( ) ;
@@ -1631,13 +1660,14 @@ export class AnalyticsService implements IAnalyticsService {
16311660
16321661 /**
16331662 * Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
1634- * AND every joined object of the query's cube, keyed by object name. This is
1635- * the async pre-pass that lets the synchronous strategy enforce scoping even
1636- * when the provider (security `getReadFilter`) resolves asynchronously.
1663+ * AND every object the query reads through a join or a relationship path,
1664+ * keyed by object name. This is the async pre-pass that lets the synchronous
1665+ * strategy enforce scoping even when the provider (security `getReadFilter`)
1666+ * resolves asynchronously.
16371667 *
1638- * The object set is `cube.sql` (base) plus every `cube.joins[*].name` — a
1639- * SUPERSET of what the strategy actually scans (the strategy only joins along
1640- * declared relationships), so no scanned object is ever left unscoped.
1668+ * The object set is { @link queryObjects} — the one the admission reads, a
1669+ * SUPERSET of what the strategy actually scans, so no scanned object is ever
1670+ * left unscoped.
16411671 *
16421672 * Fail-closed: if the provider throws for an object, the whole query is
16431673 * rejected rather than emitting SQL with that object unscoped.
@@ -1651,15 +1681,14 @@ export class AnalyticsService implements IAnalyticsService {
16511681 * enveloped, it is re-thrown before the wording is ever read.
16521682 */
16531683 private async resolveReadScopes (
1654- query : AnalyticsQuery ,
1684+ objects : Iterable < string > ,
16551685 context : ExecutionContext | undefined ,
1656- scope : CubeScope ,
16571686 ) : Promise < Map < string , FilterCondition > > {
16581687 const map = new Map < string , FilterCondition > ( ) ;
16591688 const provider = this . readScopeProvider ;
1660- if ( ! provider || ! query . cube ) return map ;
1689+ if ( ! provider ) return map ;
16611690
1662- for ( const object of this . queryObjects ( query , scope ) ) {
1691+ for ( const object of objects ) {
16631692 let filter : FilterCondition | null | undefined ;
16641693 try {
16651694 filter = await provider ( object , context ) ;
0 commit comments