diff --git a/.changeset/20986-analytics-hop-object-reference.md b/.changeset/20986-analytics-hop-object-reference.md new file mode 100644 index 00000000000..0cef1387e15 --- /dev/null +++ b/.changeset/20986-analytics-hop-object-reference.md @@ -0,0 +1,25 @@ +--- +"@objectstack/service-analytics": minor +--- + +fix(service-analytics)!: a relationship-path hop the cube declares no join for reads the object its lookup field declares, so an inferred cube's dotted path through a lookup named differently from its target is answered + +Clause-②: yes (narrowing) + + + +**BREAKING**: this widens what the analytics query doors answer for a dotted relationship path the cube declares no join for — an inferred cube's dotted member (`owner.region`), or an authored member whose `sql` walks a relationship its `joins` does not list — and narrows it in one case, named below. It holds on `POST /api/v1/analytics/query` and on its dry run `POST /api/v1/analytics/sql`, on both strategies and every SQL driver. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes. + +**What an author sees now.** Each hop of the path reads the object its lookup field declares as its target, the way a join the cube declares already did. With a lookup `owner` that references a person object: + +- the caller may read the person object: `dimensions: ['owner.region']` is answered with the person rows' regions on both strategies, and `where: { 'owner.region': 'NA' }` is answered on the native-SQL strategy with what the nested form `{ owner: { region: 'NA' } }` answers. The engine-aggregate strategy keeps refusing a filter on a related value with its own `400 INVALID_FIELD`, as it does through a declared join; +- the caller may not read the person object: `403 PERMISSION_DENIED` naming the person object, before any statement runs; +- the field-level gate judges `region` on the person object, and the caller's row scope on the person object is applied where the related value is read (the join on the native-SQL strategy, the related read on the engine-aggregate one). + +A lookup to the cube's own object (a self-reference such as `parent`) is read the same way. A lookup named after its target answers exactly as before. + +**Why.** An inferred cube declares no join, so a hop fell back to an object named after the lookup field. For a lookup named differently from its target that is no object: a caller who may read both objects was refused `403` "reading "owner" is not permitted", and a caller the object check passes reached a statement over a table named `owner` (`500`). + +**The narrowing.** A lookup whose name is ALSO the name of another object — a field `account` referencing `crm_account` while an object `account` exists — used to be read from that other object: joined by the ids of the records the field points to, admitted and scoped as that other object. It now reads its declared target. So that path answers from the target's rows, and a caller who may not read the target is refused `403 PERMISSION_DENIED` naming it, where the query used to be answered. + +**Unchanged.** A cube that declares a join for the path keeps reading the join's object. A host that wires no `relationshipResolver` (`AnalyticsServicePlugin` always wires it, from the data engine's object schema), or a relationship field it cannot answer for, keeps reading the object named after the field. A dataset's `include` compiles to declared joins, so a path it declares is unchanged. diff --git a/packages/rest/src/analytics-hop-object-reference.test.ts b/packages/rest/src/analytics-hop-object-reference.test.ts new file mode 100644 index 00000000000..7dda9909152 --- /dev/null +++ b/packages/rest/src/analytics-hop-object-reference.test.ts @@ -0,0 +1,258 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20986] On an inferred cube, a dotted path through a lookup whose NAME + * differs from its target object reads the TARGET object — the object the + * lookup field declares as its `reference` — on both strategies and on both + * faces, the cube read (`AnalyticsService.query`, what + * `POST /api/v1/analytics/query` relays) and the SQL echo + * (`AnalyticsService.generateSql`, what `POST /api/v1/analytics/sql` relays). + * + * An inferred cube declares no join, so each hop's object used to fall back to + * the ALIAS, the lookup's own name. For a lookup named after its target that + * is the target; for `owner` → a person object it is no object at all. The + * door admitted the name as an object, so a member who may read both objects + * was refused `403` "reading "owner" is not permitted", and a caller the + * object check passes reached a statement over a table named `owner`. + * + * The reference for every answer is the same question asked through a + * DECLARED join — an authored cube whose `joins` keys the lookup and names the + * target — by the same caller, on the same strategy, in the same test: a + * declared join was always resolved to its target. Each reference is also + * checked absolutely, so an equality between two wrong answers cannot pass. + * On the native strategy the dotted filter also answers what the engine's + * nested form `{ owner: { region: … } }` answers, the form the dotted + * spelling maps onto. A lookup named after its target is the control. + * + * The composition is the shipped one, with the REAL security layer: + * `SecurityPlugin` over a real `ObjectQL` on a real `SqlDriver` (SQLite), and + * `AnalyticsServicePlugin` over the same engine as its `'data'` service, whose + * `relationshipResolver` reads each lookup's declared `reference` off the + * engine's object schema. Two compositions, one per strategy: `native` (the + * plugin's own capabilities) and `objectql` (narrowed to the engine-aggregate + * path). + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SecurityPlugin } from '@objectstack/plugin-security'; +import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics'; + +const LEDGER = 'rest_an_hop_ledger'; +/** The target of lookup `owner`: readable by the member. */ +const PERSON = 'rest_an_hop_person'; +/** The target of lookup `keeper`: the member holds no read grant on it. */ +const VAULT = 'rest_an_hop_vault'; +/** A lookup named after its target, readable — the control. */ +const OPEN = 'rest_an_hop_open'; +/** A lookup named after its target, without a read grant — the control's refusal. */ +const SHUT = 'rest_an_hop_shut'; + +const SYS_CTX = { isSystem: true, userId: 'usr_system' }; + +const MEMBER_SET = PermissionSetSchema.parse({ + name: 'member_default', + label: 'Member', + objects: { + '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + [VAULT]: { allowRead: false, allowCreate: false, allowEdit: false, allowDelete: false }, + [SHUT]: { allowRead: false, allowCreate: false, allowEdit: false, allowDelete: false }, + }, +}); + +const MEMBER_CTX = { userId: 'usr_member', positions: [], permissions: [MEMBER_SET.name], posture: 'MEMBER' }; + +const count = { type: 'count', sql: '*', label: 'Count' }; + +/** The declared-join references: the lookups keyed in `joins`, each naming its target. */ +const VIA_PERSON = { + name: 'rest_an_hop_via_person', + title: 'Via person', + sql: LEDGER, + measures: { count }, + dimensions: { owner_region: { type: 'string', sql: 'owner.region', label: 'Region' } }, + joins: { owner: { name: PERSON } }, +}; +const VIA_VAULT = { + name: 'rest_an_hop_via_vault', + title: 'Via vault', + sql: LEDGER, + measures: { count }, + dimensions: { keeper_code: { type: 'string', sql: 'keeper.code', label: 'Code' } }, + joins: { keeper: { name: VAULT } }, +}; + +const PERSONS = [{ id: 'p1', region: 'NA' }, { id: 'p2', region: 'EU' }]; +const VAULTS = [{ id: 'v1', code: 'c1' }]; +const OPENS = [{ id: 'o1', region: 'NA' }, { id: 'o2', region: 'EU' }]; +const SHUTS = [{ id: 's1', code: 'c1' }]; +const LEDGER_ROWS = [ + { id: 'd1', title: 't1', owner: 'p1', keeper: 'v1', [OPEN]: 'o1', [SHUT]: 's1' }, + { id: 'd2', title: 't2', owner: 'p2', keeper: 'v1', [OPEN]: 'o2', [SHUT]: 's1' }, + { id: 'd3', title: 't3', owner: 'p1', [OPEN]: 'o1' }, +]; + +const quiet: any = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +interface Harness { + engine: ObjectQL; + service: AnalyticsService; + /** Every object the security service was asked to admit, since the last `clear()`. */ + admitted: { objects: () => string[]; clear: () => void }; +} + +async function boot(strategy: 'native' | 'objectql'): Promise { + const engine = new ObjectQL({ logger: quiet } as any); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.analytics-hop-object-reference-20986', + name: 'Analytics hop object reference', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + { name: PERSON, label: 'Person', sharingModel: 'public_read_write', fields: { region: { name: 'region', type: 'text' } } }, + { name: VAULT, label: 'Vault', sharingModel: 'public_read_write', fields: { code: { name: 'code', type: 'text' } } }, + { name: OPEN, label: 'Open', sharingModel: 'public_read_write', fields: { region: { name: 'region', type: 'text' } } }, + { name: SHUT, label: 'Shut', sharingModel: 'public_read_write', fields: { code: { name: 'code', type: 'text' } } }, + { + name: LEDGER, + label: 'Ledger', + sharingModel: 'public_read_write', + fields: { + title: { name: 'title', type: 'text' }, + // Named differently from their targets. + owner: { name: 'owner', type: 'lookup', reference: PERSON }, + keeper: { name: 'keeper', type: 'lookup', reference: VAULT }, + // Named after their targets: the control. + [OPEN]: { name: OPEN, type: 'lookup', reference: OPEN }, + [SHUT]: { name: SHUT, type: 'lookup', reference: SHUT }, + }, + }, + ], + } as never); + await engine.syncSchemas(); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + data: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [MEMBER_SET], + }, + }; + const ctx: any = { + logger: quiet, + hook: () => {}, + registerService: (name: string, svc: unknown) => { services[name] = svc; }, + replaceService: (name: string, svc: unknown) => { services[name] = svc; }, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const security = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await security.init(ctx); + await security.start(ctx); + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + + await engine.insert(PERSON, PERSONS.map((r) => ({ ...r })), { context: SYS_CTX } as never); + await engine.insert(VAULT, VAULTS.map((r) => ({ ...r })), { context: SYS_CTX } as never); + await engine.insert(OPEN, OPENS.map((r) => ({ ...r })), { context: SYS_CTX } as never); + await engine.insert(SHUT, SHUTS.map((r) => ({ ...r })), { context: SYS_CTX } as never); + await engine.insert(LEDGER, LEDGER_ROWS.map((r) => ({ ...r })), { context: SYS_CTX } as never); + + await new AnalyticsServicePlugin({ + cubes: [VIA_PERSON, VIA_VAULT] as never, + ...(strategy === 'objectql' + ? { queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }) } + : {}), + }).init(ctx); + + const spy = vi.spyOn(services.security as { canReadObject: (object: string, context?: unknown) => Promise }, 'canReadObject'); + return { + engine, + service: services.analytics as AnalyticsService, + admitted: { objects: () => spy.mock.calls.map((call) => call[0] as string), clear: () => spy.mockClear() }, + }; +} + +type Thrown = { code?: string; status?: number; statusCode?: number; object?: string } | null; + +/** + * What a face answered — its rows with the column names dropped (a path member + * and the declared member that reads the same column are named differently), + * or its refusal's envelope and the object it names. + */ +const answerOf = (run: () => Promise<{ rows?: ReadonlyArray>; sql?: unknown }>) => + run().then( + (r) => ({ answered: r.rows ? [...r.rows].map((row) => JSON.stringify(Object.values(row))).sort() : typeof r.sql }), + (e: Thrown) => ({ refused: { code: e?.code, status: e?.status ?? e?.statusCode, object: e?.object } }), + ); + +for (const strategy of ['native', 'objectql'] as const) { + describe(`[#20986] a dotted path through a lookup named differently from its target reads the target — ${strategy} composition`, () => { + let h: Harness; + + beforeAll(async () => { + h = await boot(strategy); + }, 60_000); + + afterAll(async () => { + try { await h?.engine.destroy(); } catch { /* noop */ } + }); + + it('readable target, a dimension: the rows a declared join answers, and only the target is admitted', async () => { + const reference = await answerOf(() => h.service.query({ cube: VIA_PERSON.name, measures: ['count'], dimensions: ['owner_region'] } as never, MEMBER_CTX as never)); + expect(reference).toEqual({ answered: [JSON.stringify(['EU', 1]), JSON.stringify(['NA', 2])] }); + h.admitted.clear(); + const answer = await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], dimensions: ['owner.region'] } as never, MEMBER_CTX as never)); + expect(answer).toEqual(reference); + expect([...new Set(h.admitted.objects())].sort()).toEqual([LEDGER, PERSON].sort()); + expect(await answerOf(() => h.service.generateSql({ cube: LEDGER, measures: ['count'], dimensions: ['owner.region'] } as never, MEMBER_CTX as never))).toEqual({ answered: 'string' }); + }); + + it('readable target, a filter member: what a declared join answers in the same position', async () => { + const reference = await answerOf(() => h.service.query({ cube: VIA_PERSON.name, measures: ['count'], where: { owner_region: 'NA' } } as never, MEMBER_CTX as never)); + const answer = await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], where: { 'owner.region': 'NA' } } as never, MEMBER_CTX as never)); + expect(answer).toEqual(reference); + if (strategy === 'native') { + // Served: the two rows whose owner is in region NA — what the engine's + // nested form answers for the same condition. + expect(answer).toEqual({ answered: [JSON.stringify([2])] }); + const nested = await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], where: { owner: { region: 'NA' } } } as never, MEMBER_CTX as never)); + expect(answer).toEqual(nested); + } else { + // The engine-aggregate strategy filters on no related value, through a + // declared join or not: its own capability refusal, never the door's. + expect(answer).toEqual({ refused: { code: 'INVALID_FIELD', status: 400, object: undefined } }); + } + }); + + it.each([ + ['a dimension', { cube: LEDGER, measures: ['count'], dimensions: ['keeper.code'] }, { cube: VIA_VAULT.name, measures: ['count'], dimensions: ['keeper_code'] }], + ['a filter member', { cube: LEDGER, measures: ['count'], where: { 'keeper.code': 'c1' } }, { cube: VIA_VAULT.name, measures: ['count'], where: { keeper_code: 'c1' } }], + ])('unreadable target, %s: refused 403 naming the target, as through a declared join, on both faces', async (_label, path, declared) => { + const reference = await answerOf(() => h.service.query(declared as never, MEMBER_CTX as never)); + expect(reference).toEqual({ refused: { code: 'PERMISSION_DENIED', status: 403, object: VAULT } }); + expect(await answerOf(() => h.service.query(path as never, MEMBER_CTX as never)), 'the cube read').toEqual(reference); + expect(await answerOf(() => h.service.generateSql(path as never, MEMBER_CTX as never)), 'the SQL echo').toEqual(reference); + }); + + it('the control: a lookup named after its target is answered when readable and refused naming it when not', async () => { + expect(await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], dimensions: [`${OPEN}.region`] } as never, MEMBER_CTX as never))).toEqual({ + answered: [JSON.stringify(['EU', 1]), JSON.stringify(['NA', 2])], + }); + expect(await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], dimensions: [`${SHUT}.code`] } as never, MEMBER_CTX as never))).toEqual({ + refused: { code: 'PERMISSION_DENIED', status: 403, object: SHUT }, + }); + }); + }); +} diff --git a/packages/services/service-analytics/src/__tests__/hop-object-reference-resolution.test.ts b/packages/services/service-analytics/src/__tests__/hop-object-reference-resolution.test.ts new file mode 100644 index 00000000000..ebea959e91c --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/hop-object-reference-resolution.test.ts @@ -0,0 +1,264 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20986] A relationship-path hop the cube declares no join for reads the + * object its relationship field DECLARES as its target — the field's + * `reference` — and every reader names that one object: the door's object + * admission and read scope, its field gate, the native strategy's join and + * the scope it applies to the joined alias, and the engine-aggregate + * strategy's cross-object read. + * + * Before, such a hop fell back to its ALIAS, the relationship field's own + * name. For a lookup named after its target that is the same object; for one + * named differently (`owner` → a person object) it is not an object at all, + * so 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. + * + * The fixture's lookups, on the base object: + * + * | field | declared `reference` | role | + * |:--|:--|:--| + * | `owner` | the person object | named differently from its target | + * | `parent` | the base object itself | a self-reference, also named differently | + * | the same-named one | the object it is named after | the control | + * + * and `badge` on the person object, a second hop. The host's + * `relationshipResolver` answers the declared reference, as + * `AnalyticsServicePlugin`'s does from the data engine's object schema. An + * authored cube that DECLARES a join for `owner` keeps it: only the no-join + * fallback changed. + * + * The end-to-end form, over the real security layer and both strategies, is + * the route pin `packages/rest/src/analytics-hop-object-reference.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { AnalyticsQuery } from '@objectstack/spec/contracts'; +import { AnalyticsService } from '../analytics-service.js'; + +const BASE = 'hr_ledger'; +const PERSON = 'hr_person'; +const BADGE = 'hr_badge'; +const OTHER = 'hr_other'; +const SAME = 'hr_same'; + +const CALLER = { userId: 'u_member', tenantId: 'org_a' } as ExecutionContext; + +/** Each object's relationship fields and the object each one declares as its target. */ +const REFERENCES: Record> = { + [BASE]: { owner: PERSON, parent: BASE, [SAME]: SAME }, + [PERSON]: { badge: BADGE }, +}; + +const count = { type: 'count', sql: '*', label: 'Count' }; + +/** An authored cube whose member walks `owner` without declaring a join for it. */ +const AUTHORED = { + name: 'hr_authored', + title: 'Authored', + sql: BASE, + measures: { count }, + dimensions: { owner_region: { type: 'string', sql: 'owner.region', label: 'Region' } }, +}; + +/** An authored cube that DECLARES its join for `owner`, to another object: tier 1 wins. */ +const DECLARED = { + name: 'hr_declared', + title: 'Declared', + sql: BASE, + measures: { count }, + dimensions: { owner_region: { type: 'string', sql: 'owner.region', label: 'Region' } }, + joins: { owner: { name: OTHER } }, +}; + +/** Every position a differently named lookup is reached through, and the object it reads. */ +const POSITIONS: ReadonlyArray = [ + ['an inferred cube\'s dimension', { cube: BASE, measures: ['count'], dimensions: ['owner.region'] }, PERSON], + ['an inferred cube\'s filter member', { cube: BASE, measures: ['count'], where: { 'owner.region': 'NA' } }, PERSON], + [ + 'an inferred cube\'s time-dimension window', + { cube: BASE, measures: ['count'], timeDimensions: [{ dimension: 'owner.seen_at', dateRange: ['2026-01-01', '2026-01-31'] }] }, + PERSON, + ], + ['an authored dimension over an undeclared relationship', { cube: AUTHORED.name, measures: ['count'], dimensions: ['owner_region'] }, PERSON], + ['the second hop of a two-hop path', { cube: BASE, measures: ['count'], dimensions: ['owner.badge.label'] }, BADGE], +] as never; + +const STRATEGIES = [ + { label: 'NativeSQLStrategy', capabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }) }, + { label: 'ObjectQLStrategy', capabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }) }, +] as const; + +interface Seen { + admitted: string[]; + scoped: string[]; + fieldsAskedOf: string[]; + fieldMetaAskedOf: Array; + sql: Array<{ sql: string; params: unknown[] }>; + aggregate: Array<{ object: string; groupBy?: unknown; filter: unknown }>; +} + +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +function makeService( + capabilities: () => { nativeSql: boolean; objectqlAggregate: boolean; inMemory: boolean }, + opts: { + denied?: string; + scopeOf?: (object: string) => Record | undefined; + readableFieldsOf?: (object: string) => readonly string[] | undefined; + } = {}, +): { service: AnalyticsService; seen: Seen } { + const seen: Seen = { admitted: [], scoped: [], fieldsAskedOf: [], fieldMetaAskedOf: [], sql: [], aggregate: [] }; + const service = new AnalyticsService({ + logger: quiet as never, + cubes: [AUTHORED as never, DECLARED as never], + queryCapabilities: capabilities, + isRegisteredObject: (name: string) => name === BASE, + relationshipResolver: (object: string, field: string) => REFERENCES[object]?.[field], + admitObjectRead: (object: string) => { + seen.admitted.push(object); + return object !== opts.denied; + }, + getReadScope: (async (object: string) => { + seen.scoped.push(object); + return opts.scopeOf?.(object); + }) as never, + getReadableFields: (object: string) => { + seen.fieldsAskedOf.push(object); + return opts.readableFieldsOf?.(object); + }, + sourceFieldMeta: (object: string, field: string) => { + seen.fieldMetaAskedOf.push([object, field]); + return field === 'seen_at' ? { type: 'datetime' } : undefined; + }, + executeRawSql: async (_object: string, sql: string, params: unknown[]) => { + seen.sql.push({ sql, params }); + return [{ 'owner.region': 'NA', 'parent.title': 't1', count: 2 }]; + }, + executeAggregate: async (object: string, options: { groupBy?: unknown; filter?: unknown }) => { + seen.aggregate.push({ object, groupBy: options?.groupBy, filter: options?.filter }); + if (seen.aggregate.length === 1) return [{ owner: 'p1', parent: 'd1', count: 2 }]; + return [{ id: 'p1', region: 'NA', _c: 1 }, { id: 'd1', title: 't1', _c: 1 }]; + }, + } as never); + return { service, seen }; +} + +/** The refusal's envelope and the object it names, or the answer. */ +const outcomeOf = (run: () => Promise) => + run().then( + (r) => ({ answered: (r as { rows?: unknown }).rows }), + (e: { code?: string; status?: number; object?: string }) => ({ refused: { code: e?.code, status: e?.status, object: e?.object } }), + ); + +describe('[#20986] a hop with no declared join reads the object its relationship field declares', () => { + describe.each(STRATEGIES)('$label', ({ capabilities }) => { + it.each(POSITIONS)('%s: a target object the caller may not read is refused, naming the target, before anything runs', async (_label, query, object) => { + const { service, seen } = makeService(capabilities, { denied: object }); + expect(await outcomeOf(() => service.query(query, CALLER))).toEqual({ + refused: { code: 'PERMISSION_DENIED', status: 403, object }, + }); + expect(await outcomeOf(() => service.generateSql(query, CALLER))).toEqual({ + refused: { code: 'PERMISSION_DENIED', status: 403, object }, + }); + expect(seen.sql).toEqual([]); + expect(seen.aggregate).toEqual([]); + }); + + it('the field gate judges a hop\'s column on the target object', async () => { + const { service, seen } = makeService(capabilities, { + readableFieldsOf: (object) => (object === PERSON ? ['id', 'badge'] : undefined), + }); + expect(await outcomeOf(() => service.query({ cube: BASE, measures: ['count'], dimensions: ['owner.region'] }, CALLER))).toEqual({ + refused: { code: 'PERMISSION_DENIED', status: 403, object: PERSON }, + }); + expect(seen.fieldsAskedOf).toContain(PERSON); + expect(seen.fieldsAskedOf).not.toContain('owner'); + expect(seen.sql).toEqual([]); + expect(seen.aggregate).toEqual([]); + }); + + it('the control: a lookup named after its target admits, and is refused as, that object', async () => { + const query = { cube: BASE, measures: ['count'], dimensions: [`${SAME}.region`] } as AnalyticsQuery; + expect(await outcomeOf(() => makeService(capabilities, { denied: SAME }).service.query(query, CALLER))).toEqual({ + refused: { code: 'PERMISSION_DENIED', status: 403, object: SAME }, + }); + const { service, seen } = makeService(capabilities); + await service.generateSql(query, CALLER); + expect([...new Set(seen.admitted)].sort()).toEqual([BASE, SAME].sort()); + }); + + it('a join the cube declares is kept: its object is admitted, never the field\'s reference', async () => { + const { service, seen } = makeService(capabilities); + await service.generateSql({ cube: DECLARED.name, measures: ['count'], dimensions: ['owner_region'] }, CALLER); + expect([...new Set(seen.admitted)].sort()).toEqual([BASE, OTHER].sort()); + }); + }); + + it.each(POSITIONS)('%s: admission and the read scope are asked for one set, carrying the target and never the field\'s name', async (_label, query, object) => { + const { service, seen } = makeService(STRATEGIES[0].capabilities); + await service.generateSql(query, CALLER); + expect(seen.admitted).toContain(object); + expect(seen.admitted.filter((o) => o === 'owner' || o.startsWith('owner__'))).toEqual([]); + expect([...seen.scoped].sort()).toEqual([...seen.admitted].sort()); + }); + + it('a two-hop path resolves hop by hop through each field\'s declared reference', async () => { + const { service, seen } = makeService(STRATEGIES[0].capabilities); + await service.generateSql({ cube: BASE, measures: ['count'], dimensions: ['owner.badge.label'] }, CALLER); + expect([...new Set(seen.admitted)].sort()).toEqual([BASE, PERSON, BADGE].sort()); + }); + + it('NativeSQLStrategy joins the target object under the field\'s alias, and applies the target\'s read scope to it', async () => { + const { service, seen } = makeService(STRATEGIES[0].capabilities, { + scopeOf: (object) => (object === PERSON ? { tenant_id: 'org_a' } : undefined), + }); + await service.query({ cube: BASE, measures: ['count'], dimensions: ['owner.region'] }, CALLER); + expect(seen.sql).toHaveLength(1); + expect(seen.sql[0].sql).toContain(`LEFT JOIN "${PERSON}" "owner" ON "${BASE}"."owner" = "owner"."id"`); + expect(seen.sql[0].sql).toContain('"owner"."tenant_id" = $'); + expect(seen.sql[0].params).toContain('org_a'); + }); + + it('NativeSQLStrategy asks the declared column type of the target object', async () => { + const { service, seen } = makeService(STRATEGIES[0].capabilities); + await service.query({ cube: BASE, measures: ['count'], where: { 'owner.seen_at': { $lte: '2026-01-31' } } } as AnalyticsQuery, CALLER); + expect(seen.fieldMetaAskedOf).toContainEqual([PERSON, 'seen_at']); + expect(seen.fieldMetaAskedOf.filter(([object]) => object === 'owner')).toEqual([]); + }); + + it('ObjectQLStrategy reads the related value from the target object, under the target\'s read scope', async () => { + const { service, seen } = makeService(STRATEGIES[1].capabilities, { + scopeOf: (object) => (object === PERSON ? { tenant_id: 'org_a' } : undefined), + }); + const result = await service.query({ cube: BASE, measures: ['count'], dimensions: ['owner.region'] }, CALLER); + expect(seen.aggregate.map((a) => a.object)).toEqual([BASE, PERSON]); + expect(JSON.stringify(seen.aggregate[1].filter)).toContain('"tenant_id":"org_a"'); + expect(result.rows).toEqual([{ 'owner.region': 'NA', count: 2 }]); + }); + + describe('a self-reference — a lookup to the base object itself, named differently from it', () => { + const query = { cube: BASE, measures: ['count'], dimensions: ['parent.title'] } as AnalyticsQuery; + + it('admits the base object alone', async () => { + const { service, seen } = makeService(STRATEGIES[0].capabilities); + await service.generateSql(query, CALLER); + expect([...new Set(seen.admitted)]).toEqual([BASE]); + }); + + it('NativeSQLStrategy joins the base object under the field\'s alias', async () => { + const { service, seen } = makeService(STRATEGIES[0].capabilities); + await service.query(query, CALLER); + expect(seen.sql[0].sql).toContain(`LEFT JOIN "${BASE}" "parent" ON "${BASE}"."parent" = "parent"."id"`); + }); + + it('ObjectQLStrategy still reads it as a cross-object hop, from the base object', async () => { + const { service, seen } = makeService(STRATEGIES[1].capabilities); + const result = await service.query(query, CALLER); + expect(seen.aggregate.map((a) => a.object)).toEqual([BASE, BASE]); + expect(seen.aggregate[0].groupBy).toEqual(['parent']); + expect(result.rows).toEqual([{ 'parent.title': 't1', count: 2 }]); + }); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index c4965ee89bc..7d6ae954c41 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -101,6 +101,10 @@ import { assertNoStructuredJsonDimension } from './structured-json-dimension-doo // the compilers read, so the contract has one definition and this file states // it rather than restating it. import { ACCEPTED_SQL_DIALECTS, isUnrecognisedSqlDialectAnswer, type AcceptedSqlDialect } from './text-match-sql.js'; +// [#20986] The one resolver of the object a relationship-path hop reads. The +// door's field gate and its admitted and scoped set read it here; both +// strategies read it through the context's `relationshipReference`. +import { resolvePathHops, type HopReference } from './hop-object.js'; /** * [#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_]*)+$/; * - A bare identifier is a column of the base object. * - A dotted identifier path is a relationship path: every segment but the * last is a relationship field on the object before it, the last is the - * column. Each hop's object is the cube's join at that path, keyed as the - * strategies key it (the path with its dots as `__`) and falling back to the - * alias itself — the object both strategies read there. The relationship - * fields are named too: a hidden relationship field discloses which record - * each row points to, and the engine judges a path's first segment on the - * local object for the same reason. + * column. [#20986] Each hop's object is the one `hop-object.ts` resolves — + * the cube's join at that path, else the relationship field's declared + * `reference`, else the alias itself — which both strategies read there + * through the same resolver. The relationship fields are named too: a + * hidden relationship field discloses which record each row points to, and + * the engine judges a path's first segment on the local object for the same + * reason. * - Anything else is an EXPRESSION the cube's author wrote (`CASE WHEN …`, * `SUM(…)`, `*`). It names no field this gate can attribute, so it adds * nothing — the author's declaration of a derived value, the way a formula * field is. */ -function fieldsOfColumnSql(cube: Cube, baseObject: string, sql: string, role: FieldReadRole): NamedField[] { +function fieldsOfColumnSql( + cube: Cube, + baseObject: string, + sql: string, + role: FieldReadRole, + referenceOf: HopReference | undefined, +): NamedField[] { const path = sql.trim(); if (BARE_IDENTIFIER.test(path)) return [{ object: baseObject, field: path, role }]; if (!IDENTIFIER_PATH.test(path)) return []; const segments = path.split('.'); - const joins = cube.joins as Record | undefined; - const out: NamedField[] = []; - let object = baseObject; - let alias = ''; - for (const segment of segments.slice(0, -1)) { - out.push({ object, field: segment, role }); - alias = alias ? `${alias}__${segment}` : segment; - const joined = joins?.[alias]?.name; - object = typeof joined === 'string' && joined !== '' ? joined : alias; - } - out.push({ object, field: segments[segments.length - 1], role }); + const hops = resolvePathHops(cube, baseObject, segments.slice(0, -1), referenceOf); + const out: NamedField[] = hops.map((hop) => ({ object: hop.from, field: hop.field, role })); + out.push({ object: hops[hops.length - 1].object, field: segments[segments.length - 1], role }); return out; } @@ -459,11 +462,15 @@ function fieldsOfColumnSql(cube: Cube, baseObject: string, sql: string, role: Fi * predicates — they may name fields the caller cannot read. * * A cube whose `sql` is not a bare object name names no attributable field. + * + * [#20986] `referenceOf` answers a relationship field's declared target: the + * service's own, the same one it hands the strategies. */ function namedQueryFields( query: AnalyticsQuery, cube: Cube, datasetScope: DatasetScope | undefined, + referenceOf: HopReference | undefined, ): NamedField[] { const baseObject = typeof cube.sql === 'string' ? cube.sql.trim() : ''; if (!baseObject || !BARE_IDENTIFIER.test(baseObject)) return []; @@ -472,7 +479,7 @@ function namedQueryFields( if (typeof member !== 'string' || member === '') return; const entry = declaredMemberEntry(cube, member, kind); const sql = entry ? entry.sql : kind === 'measure' ? undefined : member; - if (typeof sql === 'string') out.push(...fieldsOfColumnSql(cube, baseObject, sql, role)); + if (typeof sql === 'string') out.push(...fieldsOfColumnSql(cube, baseObject, sql, role, referenceOf)); }; const filterMembers = (where: unknown): string[] => { if (!where || typeof where !== 'object') return []; @@ -898,6 +905,13 @@ export interface AnalyticsServiceConfig { * ADR-0021 — optional object-graph resolver used when compiling datasets: * `(baseObject, relationshipName) => relatedObjectName | undefined`. When * provided, `queryDataset` validates that every declared `include` exists. + * + * [#20986] It also answers the object a relationship-path hop reads when the + * cube declares no join for it — an inferred cube's dotted member, an + * authored member walking a relationship its `joins` does not list — so a + * lookup named differently from its target is admitted, scoped, joined and + * read as the target. Absent, or `undefined` for a field, such a hop reads + * the object named after the relationship (`hop-object.ts`). */ relationshipResolver?: RelationshipResolver; /** @@ -1164,8 +1178,22 @@ export class AnalyticsService implements IAnalyticsService { private readonly sharedScope: CubeScope; /** The configured join-allowlist hook for cubes that are not compiled datasets. */ private readonly configuredAllowedRelationships?: AnalyticsServiceConfig['getAllowedRelationships']; - /** Optional object-graph resolver used when compiling datasets. */ + /** Optional object-graph resolver used when compiling datasets, and for a hop's declared target (#20986). */ private readonly relationshipResolver?: RelationshipResolver; + /** + * [#20986] Tier 2 of the one hop resolver (`hop-object.ts`): the object a + * relationship field declares as its target, read through + * {@link relationshipResolver}. ONE function, read by the door's field gate + * and admitted and scoped set and handed to both strategies as the context's + * `relationshipReference`, so every reader resolves a hop with the same + * answer. A `RelationshipTarget` answers with its `object`: the object + * is what is admitted and scoped, and it names the table too (an object's + * name is its table's name). + */ + private readonly hopReference: HopReference = (object, field) => { + const target = this.relationshipResolver?.(object, field); + return typeof target === 'string' ? target : target?.object; + }; private readonly sourceFieldMeta?: AnalyticsServiceConfig['sourceFieldMeta']; /** Optional dimension display-label resolver (select options / lookup names). */ private readonly labelResolver?: DimensionLabelDeps; @@ -1269,6 +1297,11 @@ export class AnalyticsService implements IAnalyticsService { coerceTemporalFilterValue: config.coerceTemporalFilterValue, coerceTemporalFilterColumn: config.coerceTemporalFilterColumn, isExternalObject: config.isExternalObject, + // [#20986] A relationship field's declared target — the SAME function + // the door's field gate and its admitted and scoped set resolve hops + // with, so a strategy joins, scopes and reads the object the door + // admitted, never a second resolution of it. + relationshipReference: this.hopReference, // [#14079] The declared field type, read off the same `sourceFieldMeta` // hook the display chains use — so the three SQL compilers can give a // text operator over a numeric or boolean column the contract's answer @@ -1589,10 +1622,17 @@ export class AnalyticsService implements IAnalyticsService { * read scope reaches the strategy exactly as a declared join's does — the * strategies apply the scope of every object they read from this set, and * carry no rule of their own. Each hop's object is the one the field gate - * attributes the hop's fields to ({@link namedQueryFields}: the cube's join - * keyed by the path with its dots as `__`, falling back to the alias itself), - * reused rather than re-derived, so the field gate and this set can never - * name different objects for the same hop. + * attributes the hop's fields to ({@link namedQueryFields}), reused rather + * than re-derived, so the field gate and this set can never name different + * objects for the same hop. + * + * [#20986] That object comes from the one hop resolver (`hop-object.ts`): + * the cube's join keyed by the path with its dots as `__`, else the + * relationship field's declared `reference` ({@link hopReference}), else the + * alias itself. An inferred cube declares no join, so a lookup named + * differently from its target admits the TARGET, never the lookup's name; + * the strategies join, scope and read the same object through the same + * resolver. * * An unregistered cube yields the empty set — the query fails its own * cube-existence gate downstream, and inventing an object name here would @@ -1609,7 +1649,7 @@ export class AnalyticsService implements IAnalyticsService { const cube = scope.getCube(query.cube); if (!cube) return new Set(); const objects = this.cubeObjects(cube); - for (const { object } of namedQueryFields(query, cube, this.cubeReads(scope).getDatasetScope(query.cube))) { + for (const { object } of namedQueryFields(query, cube, this.cubeReads(scope).getDatasetScope(query.cube), this.hopReference)) { objects.add(object); } return objects; @@ -1679,7 +1719,7 @@ export class AnalyticsService implements IAnalyticsService { ): Promise { const provider = this.readableFieldsProvider; if (!provider || !cube) return; - const named = namedQueryFields(query, cube, datasetScope); + const named = namedQueryFields(query, cube, datasetScope, this.hopReference); if (named.length === 0) return; await assertNamedFieldsReadable( named, diff --git a/packages/services/service-analytics/src/hop-object.ts b/packages/services/service-analytics/src/hop-object.ts new file mode 100644 index 00000000000..489bc64f35f --- /dev/null +++ b/packages/services/service-analytics/src/hop-object.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20986] The ONE answer to "which object does this relationship-path hop + * read?" — for every reader in this package that needs it. + * + * A relationship path is a dotted identifier path (`owner.region`, + * `account.owner.email`): every segment but the last is a relationship field + * on the object before it, the last is a column. The object each hop reaches + * is resolved in three tiers, first answer wins: + * + * 1. **The cube's declared join** at that path, keyed by the path with its + * dots as `__` (`account__owner`) — the key the dataset compiler registers + * and both strategies alias the join by. An authored cube that declares a + * join keeps it, whatever the field declares. + * 2. **The relationship field's declared `reference`** — the object it points + * to — asked of the host through {@link HopReference}, on the object the + * previous hop reached. This is the tier an inferred cube takes: it declares + * no join, and a lookup named differently from its target (`owner` → + * `crm_person`) reaches the target, never an object named `owner`. + * 3. **The alias itself** — the legacy same-name convention — when the host + * cannot answer: no resolver wired, a field it does not know, or a field + * that declares no reference. For a lookup named after its target this is + * the object tier 2 would have named. + * + * Every reader takes its answer from here — the door's field gate and its + * admitted and scoped object set (`analytics-service.ts`), the native + * strategy's join and the read scope it applies to the joined alias, and the + * engine-aggregate strategy's cross-object plan and the object its FK-expand + * reads — so the object a hop is ADMITTED and SCOPED as and the object it is + * JOINED or READ as are the same value by construction. ⛔ No reader resolves + * a hop on its own: a second copy of this walk is how the alias came to be + * admitted as an object in the first place. + */ + +import type { Cube } from '@objectstack/spec/data'; +import type { DatasetScopedStrategyContext, StrategyContext } from './strategies/types.js'; + +/** + * `(object, field) => the object `field` on `object` declares as its target`, + * or `undefined` when the host cannot answer. `AnalyticsService` answers it + * from `AnalyticsServiceConfig.relationshipResolver`, the declared `reference` + * of a `lookup` / `master_detail` field that `AnalyticsServicePlugin` reads + * off the data engine's object schema. + */ +export type HopReference = (object: string, field: string) => string | undefined; + +/** One hop of a relationship path, resolved. */ +export interface ResolvedHop { + /** The relationship field this hop walks, on {@link from}. */ + readonly field: string; + /** The object that declares {@link field}: the base object, or the previous hop's object. */ + readonly from: string; + /** The join alias: the path up to and including this hop, its dots as `__`. */ + readonly alias: string; + /** The object this hop reads. */ + readonly object: string; + /** Which tier named {@link object}: the cube's declared join, the field's declared reference, or the alias. */ + readonly via: 'join' | 'reference' | 'alias'; +} + +/** + * Resolve every hop of a relationship path, in order. + * + * @param cube The cube the path is read on (its `joins` are tier 1). + * @param baseObject The object the path starts on — the cube's base object. + * @param hops The relationship fields, in order: the path's segments minus its column. + * @param referenceOf The host's answer for tier 2; absent ⇒ tier 3. + */ +export function resolvePathHops( + cube: Pick | undefined, + baseObject: string, + hops: readonly string[], + referenceOf: HopReference | undefined, +): ResolvedHop[] { + const joins = cube?.joins as Record | undefined; + const out: ResolvedHop[] = []; + let from = baseObject; + let alias = ''; + for (const field of hops) { + alias = alias ? `${alias}__${field}` : field; + const joined = joins?.[alias]?.name; + let object: string; + let via: ResolvedHop['via']; + if (typeof joined === 'string' && joined !== '') { + object = joined; + via = 'join'; + } else { + const reference = referenceOf?.(from, field); + if (typeof reference === 'string' && reference !== '') { + object = reference; + via = 'reference'; + } else { + object = alias; + via = 'alias'; + } + } + out.push({ field, from, alias, object, via }); + from = object; + } + return out; +} + +/** + * The object a dotted relationship path's COLUMN lives on — the last hop's + * object — for a path written `hop.hop.column`. `baseObject` for a path with + * no hop. + */ +export function columnObjectOf( + cube: Pick | undefined, + baseObject: string, + path: string, + referenceOf: HopReference | undefined, +): string { + const hops = path.split('.').slice(0, -1); + const resolved = resolvePathHops(cube, baseObject, hops, referenceOf); + return resolved.length > 0 ? resolved[resolved.length - 1].object : baseObject; +} + +/** + * The host's {@link HopReference} a strategy context carries — the + * `relationshipReference` `AnalyticsService` hands its strategies, the same + * function its own field gate and admitted and scoped set resolve hops with — + * or `undefined` for a context built without it, whose hops then read the + * object named after the relationship (tier 3). + */ +export function relationshipReferenceOf(ctx: StrategyContext): HopReference | undefined { + const scoped = ctx as DatasetScopedStrategyContext; + return typeof scoped.relationshipReference === 'function' + ? (object, field) => scoped.relationshipReference!(object, field) + : undefined; +} diff --git a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts index 3bce7bb0734..07ae33a3dac 100644 --- a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts @@ -18,6 +18,8 @@ import { findCrossFieldComparand, findUninterpretableTemporalMember } from '../c import { assertReadScopeCannotVacate, compileScopedFilterToSql } from '../read-scope-sql.js'; import { nonTextColumnResolver, textOperatorPolarity } from '../non-text-column.js'; import { declaredValueShapeResolver, whereEmptyLeafSql } from '../empty-operator-sql.js'; +// [#20986] The one resolver of the object a relationship-path hop reads. +import { columnObjectOf, relationshipReferenceOf, resolvePathHops, type HopReference } from '../hop-object.js'; import { datasetInvalidError, invalidMemberError } from '../dataset-refusal.js'; import { type LikeShape } from '../like-pattern.js'; import { textMatchPredicateSql, sqlDialectFor } from '../text-match-sql.js'; @@ -129,6 +131,24 @@ export const EXPRESSION_METRIC_TYPES = new Set(['number', 'string', 'boolean']); */ const IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/; +/** + * [#20986] The joins ONE statement registers, keyed by alias: each join's SQL + * and the object it reads — the object {@link resolvePathHops} named for that + * hop, which `generateSql` then scopes the alias as. One value for both, so + * the object a statement joins and the object whose read scope it applies to + * that join cannot differ. + * + * Carries the host's {@link HopReference} (the context's + * `relationshipReference`) so every path the statement walks — a dimension, a + * measure, a filter member, a time dimension — is resolved with the answer the + * door admitted the query with. + */ +class StatementJoins extends Map { + constructor(readonly referenceOf: HopReference | undefined) { + super(); + } +} + /** * NativeSQLStrategy — Priority 1 * @@ -528,9 +548,11 @@ export class NativeSQLStrategy implements AnalyticsStrategy { const selectClauses: string[] = []; const groupByClauses: string[] = []; const tableName = this.extractObjectName(cube); - // Map of relation alias → JOIN clause. Populated lazily as dotted - // dimensions/measures/filters are resolved. - const joins = new Map(); + // Map of relation alias → JOIN clause and the object it reads. Populated + // lazily as dotted dimensions/measures/filters are resolved. [#20986] Each + // hop's object comes from the one resolver, with the host's answer for a + // relationship field's declared target — the door's own. + const joins = new StatementJoins(relationshipReferenceOf(ctx)); // Build SELECT for dimensions if (query.dimensions && query.dimensions.length > 0) { @@ -558,7 +580,7 @@ export class NativeSQLStrategy implements AnalyticsStrategy { // asked of the SAME target `compileFilterNode` coerces for. This face's own // bare-day copy (`buildFilterClause`'s `lte` arm) stays until its deletion // card, and is idempotent on the lowered bound. - const lowering = declaredDatetimeLowering(ctx, (member) => this.resolveStorageTarget(cube, member, tableName)); + const lowering = declaredDatetimeLowering(ctx, (member) => this.resolveStorageTarget(cube, member, tableName, joins.referenceOf)); // Build SELECT for measures if (query.measures && query.measures.length > 0) { @@ -649,7 +671,7 @@ export class NativeSQLStrategy implements AnalyticsStrategy { // and normalise the column to that form too, because the column holds // BOTH forms at once and coercing only the bounds still empties the // half the writer stored the other way (#3912). - const td2 = this.resolveStorageTarget(cube, td.dimension, tableName); + const td2 = this.resolveStorageTarget(cube, td.dimension, tableName, joins.referenceOf); const column = this.temporalColumn(ctx, td2, colExpr); // A bare-day window end means "through that whole day" (#3777). A // BETWEEN's inclusive upper bound anchors a bare `YYYY-MM-DD` to @@ -720,17 +742,18 @@ export class NativeSQLStrategy implements AnalyticsStrategy { // 2. Inject the tenant/RLS read scope for the base table AND every joined // object — this is the predicate the raw-SQL path would otherwise skip. this.applyReadScope(this.extractObjectName(cube), tableName, ctx, whereClauses, params); - for (const alias of joins.keys()) { - // The joined OBJECT (for the RLS lookup) is the target table from the - // cube's join map; the ALIAS is how it's referenced in SQL. These differ - // for namespaced objects (alias `account` → object `crm_account`). - const joinedObject = cube.joins?.[alias]?.name ?? alias; - this.applyReadScope(joinedObject, alias, ctx, whereClauses, params); + for (const [alias, join] of joins) { + // The joined OBJECT (for the RLS lookup) is the object the join reads; + // the ALIAS is how it's referenced in SQL. These differ whenever the + // relationship is named differently from its target (alias `account` → + // object `crm_account`). [#20986] Read off the registered join itself — + // the object `qualifyAndRegisterJoin` joined, never a second resolution. + this.applyReadScope(join.object, alias, ctx, whereClauses, params); } let sql = `SELECT ${selectClauses.join(', ')} FROM "${tableName}"`; if (joins.size > 0) { - sql += ' ' + Array.from(joins.values()).join(' '); + sql += ' ' + Array.from(joins.values(), (join) => join.sql).join(' '); } if (whereClauses.length > 0) { sql += ` WHERE ${whereClauses.join(' AND ')}`; @@ -816,13 +839,6 @@ export class NativeSQLStrategy implements AnalyticsStrategy { whereClauses.push(`(${rendered})`); } - /** SQL-safe join alias for a relationship path (dots → `__`); single-segment - * paths are unchanged. Mirrors the dataset compiler's `cube.joins` keying so - * alias, allowlist, and per-hop RLS all agree on one valid identifier. */ - private joinAlias(path: string): string { - return path.replace(/\./g, '__'); - } - /** * Resolve a dimension/measure/filter SQL expression that may reference a * related table via dot notation (e.g. `account.industry`). @@ -835,9 +851,11 @@ export class NativeSQLStrategy implements AnalyticsStrategy { * * . = .id * - * i.e. the lookup field name on the parent table equals the related - * table name. This holds for all `Field.lookup({ object: '...' })` - * declarations where the field is named after its target object. + * and the joined TABLE at each hop is the object {@link resolvePathHops} + * names for it ([#20986]): the cube's declared join, else the lookup + * field's declared target, else the alias itself. A lookup named after its + * target joins `LEFT JOIN "account" ON …`; one named differently joins its + * target under the field's alias, `LEFT JOIN "crm_person" "owner" ON …`. * * Returns the qualified SQL reference (e.g. `"account"."industry"`). * Pure column references (no dot) are returned as-is. @@ -845,7 +863,7 @@ export class NativeSQLStrategy implements AnalyticsStrategy { private qualifyAndRegisterJoin( rawSql: string, parentTable: string, - joins: Map, + joins: StatementJoins, cube?: Cube, ): string { if (!rawSql.includes('.')) { @@ -878,22 +896,21 @@ export class NativeSQLStrategy implements AnalyticsStrategy { const hops = segments.slice(0, -1); if (hops.length === 0 || !column) return rawSql; let parentAlias = parentTable; - let prefix = ''; - for (const seg of hops) { - prefix = prefix ? `${prefix}.${seg}` : seg; - const alias = this.joinAlias(prefix); + // [#20986] The joined TABLE at each hop is the object the one resolver + // names — the cube's join keyed by the same alias (emitted by the dataset + // compiler), else the relationship field's declared target, else the alias + // for a host that cannot answer — and the join records it, so the read + // scope `generateSql` applies to the alias is that object's. + for (const hop of resolvePathHops(cube, parentTable, hops, joins.referenceOf)) { + const alias = hop.alias; if (!joins.has(alias)) { - // The joined TABLE is resolved from the Cube's `joins` map (emitted by - // the dataset compiler, keyed by the same alias); fall back to the alias - // as the table for legacy/same-name cubes. - const joinTable = cube?.joins?.[alias]?.name ?? alias; // Only emit an explicit alias when the table differs from it; when they // match, `LEFT JOIN "account" ON …` is cleaner (and back-compat). - const tableRef = joinTable === alias ? `"${alias}"` : `"${joinTable}" "${alias}"`; - joins.set( - alias, - `LEFT JOIN ${tableRef} ON "${parentAlias}"."${seg}" = "${alias}"."id"`, - ); + const tableRef = hop.object === alias ? `"${alias}"` : `"${hop.object}" "${alias}"`; + joins.set(alias, { + sql: `LEFT JOIN ${tableRef} ON "${parentAlias}"."${hop.field}" = "${alias}"."id"`, + object: hop.object, + }); } parentAlias = alias; } @@ -945,7 +962,7 @@ export class NativeSQLStrategy implements AnalyticsStrategy { cube: Cube, member: string, parentTable: string, - joins: Map, + joins: StatementJoins, ): string { const dim = this.lookupMember(cube, member, 'dimension'); const raw = dim ? dim.sql : (member.includes('.') ? member.split('.')[1] : member); @@ -961,7 +978,7 @@ export class NativeSQLStrategy implements AnalyticsStrategy { cube: Cube, member: string, parentTable: string, - joins: Map, + joins: StatementJoins, predicate: string | null = null, ): string { const measure = this.lookupMember(cube, member, 'measure') as @@ -1043,7 +1060,7 @@ export class NativeSQLStrategy implements AnalyticsStrategy { cube: Cube, member: string, parentTable: string, - joins: Map, + joins: StatementJoins, ): string { const dim = this.lookupMember(cube, member, 'dimension'); if (dim) return this.qualifyAndRegisterJoin(dim.sql, parentTable, joins, cube); @@ -1060,9 +1077,10 @@ export class NativeSQLStrategy implements AnalyticsStrategy { * Mirrors `resolveFieldSql`'s `sql` resolution but yields the *logical* * target rather than the qualified SQL: * - A dotted column (`account.region`, emitted for a relation traversal) - * belongs to the JOINED object — resolve the alias → target table via the - * cube's `joins` map (alias `account` → object `crm_account` when - * namespaced) and take the tail as the column. + * belongs to the JOINED object — the object the path's last hop reads, + * as {@link resolvePathHops} names it for the join itself ([#20986]: the + * cube's join, else the lookup field's declared target, else the alias) + * — and the tail is the column. * - Otherwise the column lives on the cube's BASE table. Use the dimension's * resolved `sql` (the real column, which may differ from the member name, * e.g. dimension `assessed` → column `assessed_at`) rather than the member. @@ -1071,18 +1089,19 @@ export class NativeSQLStrategy implements AnalyticsStrategy { cube: Cube, member: string, baseTable: string, + referenceOf: HopReference | undefined, ): { object: string; field: string } { const dim = this.lookupMember(cube, member, 'dimension'); const measure = dim ? undefined : this.lookupMember(cube, member, 'measure'); const rawSql = dim?.sql ?? measure?.sql ?? (member.includes('.') ? member.split('.').slice(1).join('.') : member); if (rawSql.includes('.')) { - // Multi-hop (ADR-0071): the column's owning object is the join at the - // relationship PATH (all segments but the last); the column is the last. + // Multi-hop (ADR-0071): the column's owning object is the object the + // relationship PATH (all segments but the last) reaches; the column is + // the last. const segments = rawSql.split('.'); const field = segments[segments.length - 1]; - const relPath = segments.slice(0, -1).join('.'); - const object = cube.joins?.[this.joinAlias(relPath)]?.name ?? relPath; + const object = columnObjectOf(cube, baseTable, rawSql, referenceOf); return { object, field }; } return { object: baseTable, field: rawSql }; @@ -1185,7 +1204,7 @@ export class NativeSQLStrategy implements AnalyticsStrategy { node: NormalizedFilterNode | null, cube: Cube, parentTable: string, - joins: Map, + joins: StatementJoins, params: unknown[], ctx: StrategyContext, ): string | null { @@ -1218,7 +1237,7 @@ export class NativeSQLStrategy implements AnalyticsStrategy { const colExpr = this.resolveFieldSql(cube, node.member, parentTable, joins); // Resolve the (object, column) this member binds against so the value // can be coerced to the column's storage form (see buildFilterClause). - const target = this.resolveStorageTarget(cube, node.member, parentTable); + const target = this.resolveStorageTarget(cube, node.member, parentTable, joins.referenceOf); return this.buildFilterClause(colExpr, node.operator, node.values, params, ctx, target); } @@ -1243,7 +1262,7 @@ export class NativeSQLStrategy implements AnalyticsStrategy { if (node.kind !== 'or') continue; params.length = paramBase; joins.clear(); - for (const [alias, clauseSql] of joinBase) joins.set(alias, clauseSql); + for (const [alias, join] of joinBase) joins.set(alias, join); return null; } parts.push(clause); diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index 63ce11d10a0..4fba6f7c724 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -27,6 +27,8 @@ import { } from '../read-scope-sql.js'; import { nonTextColumnResolver, textOperatorPolarity } from '../non-text-column.js'; import { declaredValueShapeResolver, whereEmptyLeafSql } from '../empty-operator-sql.js'; +// [#20986] The one resolver of the object a relationship-path hop reads. +import { columnObjectOf, relationshipReferenceOf, resolvePathHops, type HopReference } from '../hop-object.js'; import { invalidMemberError } from '../dataset-refusal.js'; import { type LikeShape } from '../like-pattern.js'; import { textMatchPredicateSql, sqlDialectFor } from '../text-match-sql.js'; @@ -188,7 +190,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // hands the engine (item 7): a member is `datetime` when the column it binds // against is declared so. The engine seam lowers the same filter again with // the same scope, and the lowering is idempotent. - const lowering = declaredDatetimeLowering(ctx, (member) => this.resolveStorageTarget(cube, member, objectName)); + const lowering = declaredDatetimeLowering(ctx, (member) => this.resolveStorageTarget(cube, member, objectName, relationshipReferenceOf(ctx))); // Build aggregations from measures. // @@ -290,7 +292,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // differently for one query. `/analytics/query` reached `engine.aggregate` // with a predicate the engine cannot join and silently mis-bucketed it, // which is the exact outcome #3654's loud refusal exists to prevent. - const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx)); + const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx), relationshipReferenceOf(ctx)); if (plan) { return this.executeCrossObject(cube, query, aggregations, filter, plan, ctx); } @@ -442,7 +444,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // rather than two copies: "the preview accepts/rejects the same set" is an // invariant between two call sites, and two copies of a view can drift // apart while each stays individually correct — which is how they drifted. - const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx)); + const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx), relationshipReferenceOf(ctx)); // Read once, reused below for both the per-measure conditional aggregate // (#10413 phase 2) and the dataset-scope WHERE conjunct (#10413 phase 1) — // the same channel `execute()` reads it from, so the echo cannot drift @@ -453,7 +455,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // the lowered bound the engine receives — a bare-day `$lte` on a `datetime` // member reads `< next-day` here because that is what runs. const echoLowering = declaredDatetimeLowering(ctx, (member) => - this.resolveStorageTarget(cube, member, this.extractObjectName(cube)), + this.resolveStorageTarget(cube, member, this.extractObjectName(cube), relationshipReferenceOf(ctx)), ); const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd])); const joinClauses: string[] = []; @@ -717,12 +719,22 @@ export class ObjectQLStrategy implements AnalyticsStrategy { return { $and: [userFilter, scopeFilter] }; } - /** Is `field` a resolved cross-object (relationship-traversal) reference? */ - private isCrossObjectField(cube: Cube, field: string, baseObject: string): boolean { + /** + * Is `field` a resolved cross-object (relationship-traversal) reference? + * + * [#20986] Its first hop is resolved by the one resolver + * ({@link resolvePathHops}): the cube's join, else the relationship field's + * declared target, else the alias. A field whose DECLARED target is the base + * object itself — a self-reference, named differently from that object — is + * still a traversal: its value is another record's id, read through the + * FK-expand like any other. Only the cube's own qualifier (`.`, + * which no field declares) and a join the cube declares onto its own object + * keep reading as base. + */ + private isCrossObjectField(cube: Cube, field: string, baseObject: string, referenceOf: HopReference | undefined): boolean { if (!field.includes('.')) return false; - const alias = field.split('.')[0]; - const joinedObject = cube.joins?.[alias]?.name ?? alias; - return joinedObject !== baseObject; + const [hop] = resolvePathHops(cube, baseObject, [field.split('.')[0]], referenceOf); + return hop.via === 'reference' || hop.object !== baseObject; } /** @@ -896,6 +908,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { cube: Cube, query: AnalyticsQuery, filter: Record, + referenceOf: HopReference | undefined, ): CrossObjectPlan | null { const baseObject = this.extractObjectName(cube); @@ -905,7 +918,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // true of the lowered predicate, but not what the author wrote. for (const td of query.timeDimensions ?? []) { const field = this.resolveFieldName(cube, td.dimension, 'dimension'); - if (this.isCrossObjectField(cube, field, baseObject)) { + if (this.isCrossObjectField(cube, field, baseObject, referenceOf)) { throw invalidMemberError( `[Analytics] ObjectQLStrategy cannot bucket a cross-object time dimension ("${field}").`, { member: td.dimension, param: 'timeDimensions', cube: cube.name }, @@ -926,7 +939,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { ...Object.entries(filter) .filter(([, origin]) => origin.kind === 'where') .map(([f]) => ({ where: 'filter', member: f, field: f })), - ].filter((r) => this.isCrossObjectField(cube, r.field, baseObject)); + ].filter((r) => this.isCrossObjectField(cube, r.field, baseObject, referenceOf)); if (nonDim.length > 0) { throw invalidMemberError( `[Analytics] ObjectQLStrategy cannot evaluate a cross-object ${nonDim[0].where} ` + @@ -963,7 +976,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // locator that IS actionable: the dataset whose definition holds the leaf. const scopeCross = Object.entries(filter) .filter(([field, origin]) => - origin.kind === 'dataset-filter' && this.isCrossObjectField(cube, field, baseObject)) + origin.kind === 'dataset-filter' && this.isCrossObjectField(cube, field, baseObject, referenceOf)) .map(([field]) => field); if (scopeCross.length > 0) { throw invalidMemberError( @@ -1019,7 +1032,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // `measure` field of its own would be a new wire shape for one diagnostic; // the message is where a locator with no request key belongs. const measureCross = Object.entries(filter).flatMap(([field, origin]) => - origin.kind === 'measure-filter' && this.isCrossObjectField(cube, field, baseObject) + origin.kind === 'measure-filter' && this.isCrossObjectField(cube, field, baseObject, referenceOf) ? [{ field, measure: origin.measure }] : [], ); @@ -1041,7 +1054,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { const crossDims: CrossObjectPlanDim[] = []; for (const dim of query.dimensions ?? []) { const field = this.resolveFieldName(cube, dim, 'dimension'); - if (!this.isCrossObjectField(cube, field, baseObject)) continue; + if (!this.isCrossObjectField(cube, field, baseObject, referenceOf)) continue; const [alias, ...rest] = field.split('.'); const attr = rest.join('.'); if (attr.includes('.')) { @@ -1051,7 +1064,11 @@ export class ObjectQLStrategy implements AnalyticsStrategy { { member: dim, param: 'dimensions', cube: cube.name }, ); } - crossDims.push({ outputName: dim, fkField: alias, attr, refObject: cube.joins?.[alias]?.name ?? alias }); + // [#20986] The object the FK-expand reads the attribute from — and whose + // read scope it applies there — is the hop's object from the one + // resolver, the object the door admitted and scoped for this query. + const [hop] = resolvePathHops(cube, baseObject, [alias], referenceOf); + crossDims.push({ outputName: dim, fkField: alias, attr, refObject: hop.object }); } if (crossDims.length === 0) return null; @@ -1442,18 +1459,23 @@ export class ObjectQLStrategy implements AnalyticsStrategy { * renders a description of the statement THAT compiler produces, and the * declared-type test both apply is keyed by object and field. A dotted * `sql` is a relationship path (ADR-0071): every segment but the last is a - * hop whose join alias is the dot-to-`__` spelling the dataset compiler keys - * `cube.joins` by, the last is the column. + * hop, the last is the column, and the column's object is the one the last + * hop reaches — [#20986] as {@link resolvePathHops} names it, the same object + * that compiler joins there. */ - private resolveStorageTarget(cube: Cube, member: string, baseObject: string): { object: string; field: string } { + private resolveStorageTarget( + cube: Cube, + member: string, + baseObject: string, + referenceOf: HopReference | undefined, + ): { object: string; field: string } { const dim = this.lookupMember(cube, member, 'dimension'); const measure = dim ? undefined : this.lookupMember(cube, member, 'measure'); const rawSql = dim?.sql ?? measure?.sql ?? (member.includes('.') ? member.split('.').slice(1).join('.') : member); if (rawSql.includes('.')) { const segments = rawSql.split('.'); const field = segments[segments.length - 1]; - const relPath = segments.slice(0, -1).join('.'); - const object = cube.joins?.[relPath.replace(/\./g, '__')]?.name ?? relPath; + const object = columnObjectOf(cube, baseObject, rawSql, referenceOf); return { object, field }; } return { object: baseObject, field: rawSql.replace(/^\$/, '') }; @@ -1734,7 +1756,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // way `NativeSQLStrategy.resolveStorageTarget` resolves it, so the echo // asks the declared-type hook the same question the executed statement // asked and prints the same constant for a non-text column. - this.resolveStorageTarget(cube, node.member, this.extractObjectName(cube)), + this.resolveStorageTarget(cube, node.member, this.extractObjectName(cube), relationshipReferenceOf(ctx)), ctx, ); } diff --git a/packages/services/service-analytics/src/strategies/types.ts b/packages/services/service-analytics/src/strategies/types.ts index a9dfcf5f51c..d374adafb20 100644 --- a/packages/services/service-analytics/src/strategies/types.ts +++ b/packages/services/service-analytics/src/strategies/types.ts @@ -83,6 +83,24 @@ export interface DatasetScopedStrategyContext extends StrategyContext { * reason `getDatasetScope` is: nothing about it is an authorable surface. */ readScopedObjects?: readonly string[]; + /** + * [#20986] The object `field` on `objectName` DECLARES as its target — a + * relationship field's `reference` — or `undefined` when the host cannot + * answer (no resolver wired, a field it does not know, a field that names + * no target). + * + * Tier 2 of the one hop resolver (`hop-object.ts`), which every strategy + * reads to name the object a relationship-path hop with no declared join + * reaches: the table it joins, the read scope it applies to that join, the + * object it reads the related value from. `AnalyticsService` hands its + * strategies the SAME function its field gate and its admitted and scoped + * set resolve hops with, so the object a strategy joins is the object the + * door admitted. Declared HERE rather than on the spec's + * {@link StrategyContext} for the reason `getDatasetScope` is: nothing about + * it is an authorable surface. Absent, a hop reads the object named after + * the relationship — the behaviour a strategy had before it knew the hook. + */ + relationshipReference?(objectName: string, field: string): string | undefined; /** * [#14079] The DECLARED type of `field` on `objectName` — `'number'`, * `'boolean'`, `'text'`, … — or `undefined` when the host cannot answer (no