diff --git a/.changeset/20887-analytics-nested-relation-engine-answer.md b/.changeset/20887-analytics-nested-relation-engine-answer.md new file mode 100644 index 00000000000..5cb7f21ae00 --- /dev/null +++ b/.changeset/20887-analytics-nested-relation-engine-answer.md @@ -0,0 +1,28 @@ +--- +"@objectstack/service-analytics": minor +--- + +fix(service-analytics)!: the nested-relation filter `{ relation: { field: value } }` gets the engine's answer on every analytics face — the related object read as the caller, capped + +Clause-②: yes (narrowing) + + + +**BREAKING**: this narrows what the analytics query doors answer for the nested-relation filter form — a plain object with no `$` key beneath a field, `{ owner: { region: 'NA' } }` — on the native-SQL path, and widens it everywhere else. It holds on `POST /api/v1/analytics/query`, on `POST /api/v1/analytics/dataset/query`, and on their dry run `POST /api/v1/analytics/sql`, on 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.** The same answer `find()` gives for the same filter. The data engine reads the related object with the condition as the caller — that object's row scope and field permissions apply — and matches the relation against the ids it returns: `$in` on a single-valued relation, any member on a multi-valued one. It is the one rule, in the engine; the analytics layer holds no copy of it. + +- A condition on a field of the related object the caller cannot read is refused with `403 PERMISSION_DENIED`, naming the field — never answered. +- A condition matching more than 1,000 related records is refused with `400 INVALID_FILTER`, naming the two-step route — never run over a cut-off list. +- At a measure's own `filter` the form is refused with `400 INVALID_FILTER`, as the engine refuses it at an aggregation's own `filter`: put the condition in the query's `where`. +- `POST /api/v1/analytics/sql` refuses a `where` carrying the form with `400 INVALID_FILTER`: no statement it could print reproduces a read of the related object as the caller. The query itself is answered by `POST /api/v1/analytics/query`. + +**Why.** Measured on the base over one fixture with the real security layer (a related field the caller may not read, a related row scope, 1,001 matching related records). The native-SQL strategy flattened the form to a dotted member and joined the related table: through a dataset that `include`d the relationship it answered rows for a condition on a field the caller cannot read, answered a match past the engine's cap, and counted a measure filter carrying the form; without the declared join it named a table that does not exist (500), and a multi-valued relation was refused. The engine-aggregate strategy refused the form as a cross-object filter (400). The engine serves the form since the nested-relation filter landed in `where`. + +**How.** The native-SQL strategy declines a query in which the form appears in the `where`, the dataset's own `filter` or a requested measure's `filter`, so the query runs on the engine-aggregate path, which hands the form to the engine as written. + +**A read scope carrying the form.** Unchanged in outcome: where a read scope is compiled to SQL (`compileScopedFilterToSql`, on the native-SQL path and in both SQL echoes) it is still refused fail-closed with `500 READ_SCOPE_COMPILE_FAILED`, the policy withheld — that compile holds no data engine to read the related object with. Its words now name the route that serves the form. On the engine-aggregate path the scope reaches the engine as written, and the engine serves it as the caller, as before. + +**Who is affected.** A dashboard, dataset or caller that wrote the nested form in an analytics filter on a SQL driver and read the joined answer: a condition on a related field the caller may not read, a match past 1,000 related records, a measure filter carrying the form, or a query that needs the native-SQL strategy for another part (a cross-object measure, a multi-hop dimension), which the engine-aggregate path refuses in its own words. + +**Unchanged.** The dotted cube member (`{ 'owner.region': 'NA' }`), a traversal through the cube's declared join; an empty object beneath a field (`{ owner: {} }`), still refused as a field constraint with no operator; every filter without the form. diff --git a/packages/client/src/envelope-caller-census.test.ts b/packages/client/src/envelope-caller-census.test.ts index 636228e498a..bc0cf1fcb63 100644 --- a/packages/client/src/envelope-caller-census.test.ts +++ b/packages/client/src/envelope-caller-census.test.ts @@ -473,6 +473,12 @@ const LEDGER: readonly LedgerRow[] = [ method: 'analytics.query', receiver: 'service', count: 1, verdict: 'NOT_SDK', why: 'the real AnalyticsService, called to assert the SDK value equals what the producer returned', }, + // ── the nested-relation pin in `@objectstack/rest`: producer reads only ── + { + file: 'packages/rest/src/analytics-nested-relation-filter.test.ts', + method: 'analytics.query', receiver: 'service', count: 5, verdict: 'NOT_SDK', + why: 'the real AnalyticsService (the cube read), called to compare its answer for the nested-relation filter with the engine\'s', + }, { file: 'packages/client/src/analytics-automation-json-erasure.test.ts', method: 'analytics.meta', receiver: 'sdk', count: 2, verdict: 'PAYLOAD_DEPENDENT', @@ -670,8 +676,11 @@ describe('#13079 §2 — positive controls on the matcher itself', () => { // method, so a literal-embedded site lands HERE first, as a phantom // producer call. That makes this the assertion most likely to break // for a reason that has nothing to do with receivers. - expect(service.length, literalNote()).toBe(1); - expect(service[0]?.file).toBe('packages/client/src/analytics-automation-json-erasure.test.ts'); + expect(service.length, literalNote()).toBe(6); + expect([...new Set(service.map((s) => s.file))].sort()).toEqual([ + 'packages/client/src/analytics-automation-json-erasure.test.ts', + 'packages/rest/src/analytics-nested-relation-filter.test.ts', + ]); }); }); @@ -716,10 +725,10 @@ describe('#13079 §3 — every call site is classified', () => { expect(production, literalNote()).toEqual([]); }); - it('records the split: 18 payload pins, 10 result-insensitive, 1 not-SDK', () => { + it('records the split: 18 payload pins, 10 result-insensitive, 6 not-SDK', () => { expect(verdictTotal('PAYLOAD_DEPENDENT')).toBe(18); expect(verdictTotal('RESULT_INSENSITIVE')).toBe(10); - expect(verdictTotal('NOT_SDK')).toBe(1); + expect(verdictTotal('NOT_SDK')).toBe(6); // The three above are LEDGER sums and cannot move on a census reading; // this one is census-derived, so it carries the note. [#13874] expect(sdkSites.length, literalNote()).toBe(28); diff --git a/packages/rest/src/analytics-nested-relation-filter.test.ts b/packages/rest/src/analytics-nested-relation-filter.test.ts new file mode 100644 index 00000000000..b1d449ace1d --- /dev/null +++ b/packages/rest/src/analytics-nested-relation-filter.test.ts @@ -0,0 +1,395 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20887, the analytics half of #20802's ruling] The nested-relation form + * `{ relation: { field: value } }` gets ONE answer on every analytics face, and + * that answer is the engine's: the related object read AS THE CALLER (its row + * scope and field permissions apply), capped, a multi-valued relation matching on + * any member. The engine is the reference every assertion below compares with, + * computed in the same test over the same rows: `engine.find` for a `where`, + * `engine.aggregate` for an aggregation's own `filter`. + * + * The composition is the shipped one, with the REAL security layer: + * `SecurityPlugin` over a real `ObjectQL` on a real `SqlDriver`, and + * `AnalyticsServicePlugin` over the same engine as its `'data'` service. Two + * compositions, one per strategy: + * + * - `native` — the plugin's own capabilities, so `NativeSQLStrategy` is the + * strategy the service asks first (a SQL driver serves raw statements); + * - `objectql` — the capabilities narrowed to the engine-aggregate path, so + * `ObjectQLStrategy` answers every query. + * + * Two faces per composition: the cube read (`AnalyticsService.query`, what + * `POST /api/v1/analytics/query` relays) over the object's inferred cube, and + * the dataset door through this package's own route + * (`POST /api/v1/analytics/dataset/query`, the caller's filter as + * `selection.runtimeFilter`); plus the SQL echo (`AnalyticsService.generateSql`, + * what `POST /api/v1/analytics/sql` relays). + * + * ## Measured on the base (`00a92e18`), one fixture, before this file's change + * + * | face · strategy | `{ owner: { region: 'NA' } }` | `{ owners: … }` (multi) | unreadable field | past the cap | + * |:--|:--|:--|:--|:--| + * | engine `find` (member) | d1, d3 | d1, d3 | 403 `PERMISSION_DENIED` | 400 `INVALID_FILTER` | + * | cube read · native | 500 `DATABASE_ERROR` | 500 | 500 | 500 | + * | dataset door · native | d1, d3 | 400 `DATASET_INVALID` | **rows d1, d3** | **no rows, 200** | + * | cube read / dataset door · objectql | 400 `INVALID_FIELD` | 400 | 400 | 400 | + * + * The native dataset door joined the related table itself: the related object's + * row scope rode in as a `WHERE` conjunct, its field permissions did not (so it + * filtered by a value the caller may not read), and nothing bounded the match. + * + * SQLite only: the dialect axis of the lowering is the engine's, pinned in + * `data-nested-object-door.test.ts`; this file's axis is the analytics faces. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import type { FilterCondition } from '@objectstack/spec/data'; +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'; +import { RestServer } from './rest-server'; + +const OBJECT = 'rest_an_nested_ledger'; +const OWNER = 'rest_an_nested_owner'; + +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 } }, + // The field the caller may not read, on the RELATED object. + fields: { [`${OWNER}.secret`]: { readable: false, editable: false } }, + // The related object's row scope: the caller sees no owner in region HIDDEN. + rowLevelSecurity: [{ name: 'owner_scope', object: OWNER, operation: 'all', using: "record.region != 'HIDDEN'" }], +}); + +const MEMBER_CTX = { userId: 'usr_member', positions: [], permissions: [MEMBER_SET.name], posture: 'MEMBER' }; + +const OWNERS = [ + { id: 'u1', region: 'NA', secret: 's1' }, + { id: 'u2', region: 'EU', secret: 's2' }, + { id: 'u3', region: 'HIDDEN', secret: 's3' }, +]; +/** One more related record than the engine's cap matches `region: 'CAP'`. */ +const CAP_OWNERS = Array.from({ length: 1001 }, (_, i) => ({ id: `c${i}`, region: 'CAP', secret: 'x' })); +const ROWS = [ + { id: 'd1', title: 'a', owner: 'u1', owners: ['u1'] }, + { id: 'd2', title: 'b', owner: 'u2', owners: ['u2'] }, + { id: 'd3', title: 'c', owner: 'u1', owners: ['u2', 'u1'] }, + { id: 'd4', title: 'd', owner: 'u3', owners: ['u3'] }, +]; +const TITLE_OF = new Map(ROWS.map((r) => [r.id, r.title])); + +/** Conditions the engine SERVES: its rows are the answer every face must give. */ +const SERVED: ReadonlyArray = [ + ['single-valued', { owner: { region: 'NA' } }], + ['multi-valued', { owners: { region: 'NA' } }], + ['related row scope', { owner: { region: 'HIDDEN' } }], + ['under $not', { $not: { owner: { region: 'NA' } } }], + ['multi-valued under $not', { $not: { owners: { region: 'NA' } } }], + ['inside $or', { $or: [{ owner: { region: 'NA' } }, { title: 'b' }] }], +]; + +/** Conditions the engine REFUSES: its envelope is the answer every face must give. */ +const REFUSED: ReadonlyArray = [ + ['a related field the caller cannot read', { owner: { secret: 's1' } }, { code: 'PERMISSION_DENIED', status: 403 }], + ['a related field the caller cannot read, multi-valued', { owners: { secret: 's1' } }, { code: 'PERMISSION_DENIED', status: 403 }], + ['past the cap', { owner: { region: 'CAP' } }, { code: 'INVALID_FILTER', status: 400 }], + ['past the cap, multi-valued', { owners: { region: 'CAP' } }, { code: 'INVALID_FILTER', status: 400 }], +]; + +/** + * The inline dataset the dataset door queries: the ledger, grouped by title. It + * includes the `owner` relationship, the join the native strategy used to answer + * the form with before this file's change. + */ +const DATASET = { + name: 'nested_relation_inline', + label: 'Nested relation inline', + object: OBJECT, + include: ['owner'], + dimensions: [{ name: 'title_dim', field: 'title', type: 'string' }], + measures: [ + { name: 'row_count', aggregate: 'count' }, + { name: 'na_n', aggregate: 'count', filter: { owner: { region: 'NA' } } }, + ], +}; + +const quiet: any = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function mockProtocol() { + return { + getDiscovery: async () => ({ version: 'v0', routes: { data: '', metadata: '' } }), + getMetaTypes: async () => [], + getMetaItems: async () => [], + }; +} + +function makeRes() { + const res: any = { + statusCode: 200, + body: undefined as any, + header: () => res, + status: (code: number) => { res.statusCode = code; return res; }, + json: (body: unknown) => { res.body = body; return res; }, + end: () => res, + }; + return res; +} + +type Thrown = { code?: string; status?: number; statusCode?: number; message?: string } | null; +const envelopeOf = (e: Thrown) => ({ code: e?.code, status: e?.status ?? e?.statusCode }); +const titlesOf = (rows: ReadonlyArray>, key: string) => + rows.map((r) => String(r[key])).sort(); + +interface Harness { + engine: ObjectQL; + analytics: AnalyticsService; + dataset: (selection: Record) => Promise<{ status: number; body: any }>; +} + +/** + * Boot the shipped composition. `caps` narrows the strategy set; `getReadScope` + * is a host-supplied read scope (the plugin option), used by the read-scope rows. + */ +async function boot(opts: { caps?: 'objectql'; getReadScope?: (object: string) => FilterCondition | null }): 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-nested-relation-20887', + name: 'Analytics nested relation', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + { + name: OWNER, + label: 'Owner', + sharingModel: 'public_read_write', + fields: { region: { name: 'region', type: 'text' }, secret: { name: 'secret', type: 'text' } }, + }, + { + name: OBJECT, + label: 'Ledger', + sharingModel: 'public_read_write', + fields: { + title: { name: 'title', type: 'text' }, + owner: { name: 'owner', type: 'lookup', reference: OWNER }, + owners: { name: 'owners', type: 'lookup', reference: OWNER, multiple: true }, + }, + }, + ], + } 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(OWNER, OWNERS.map((r) => ({ ...r })), { context: SYS_CTX } as never); + // SQLite caps one compound insert at 500 terms. + for (let i = 0; i < CAP_OWNERS.length; i += 200) { + await engine.insert(OWNER, CAP_OWNERS.slice(i, i + 200).map((r) => ({ ...r })), { context: SYS_CTX } as never); + } + await engine.insert(OBJECT, ROWS.map((r) => ({ ...r })), { context: SYS_CTX } as never); + + await new AnalyticsServicePlugin({ + ...(opts.caps === 'objectql' + ? { queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }) } + : {}), + ...(opts.getReadScope ? { getReadScope: opts.getReadScope } : {}), + }).init(ctx); + const analytics = services.analytics as AnalyticsService; + + const rest = new RestServer( + createMockServer() as any, mockProtocol() as any, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + async () => analytics, + ); + (rest as any).resolveExecCtx = async () => MEMBER_CTX; + rest.registerRoutes(); + const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/analytics/dataset/query'); + expect(route).toBeDefined(); + const dataset = async (selection: Record) => { + const res = makeRes(); + const body = JSON.parse(JSON.stringify({ dataset: DATASET, selection })); + await route!.handler({ method: 'POST', params: {}, headers: {}, body, query: {} } as any, res); + return { status: res.statusCode, body: res.body }; + }; + return { engine, analytics, dataset }; +} + +/** The engine's answer for a `where`, as the member: its titles, or its refusal. */ +async function engineAnswer(engine: ObjectQL, where: FilterCondition, context: unknown = MEMBER_CTX) { + return engine.find(OBJECT, { where, context } as never).then( + (rows: any[]) => ({ titles: rows.map((r) => TITLE_OF.get(r.id) as string).sort() }), + (e: Thrown) => ({ refused: envelopeOf(e) }), + ); +} + +const cubeQuery = (where: FilterCondition) => + ({ cube: OBJECT, measures: ['count'], dimensions: ['title'], where }) as never; + +for (const strategy of ['native', 'objectql'] as const) { + describe(`[#20887] the nested-relation form at the analytics faces is the engine's answer — ${strategy} composition`, () => { + let h: Harness; + + beforeAll(async () => { + h = await boot(strategy === 'objectql' ? { caps: 'objectql' } : {}); + }, 60_000); + + afterAll(async () => { + try { await h?.engine.destroy(); } catch { /* noop */ } + }); + + it('the cube read and the dataset door answer the engine\'s rows — single-valued, multi-valued, the related row scope, $not and $or', async () => { + for (const [label, where] of SERVED) { + const reference = await engineAnswer(h.engine, where); + expect('titles' in reference, `${label}: the engine serves it`).toBe(true); + + const cube = await h.analytics.query(cubeQuery(where), MEMBER_CTX as never).then( + (r) => ({ titles: titlesOf(r.rows, 'title') }), + (e: Thrown) => ({ refused: envelopeOf(e), message: e?.message }), + ); + expect(cube, `${label}: the cube read`).toEqual(reference); + + const ds = await h.dataset({ measures: ['row_count'], dimensions: ['title_dim'], runtimeFilter: where }); + expect(ds.status, `${label}: the dataset door ${JSON.stringify(ds.body)}`).toBe(200); + expect(titlesOf(ds.body.rows, 'title_dim'), `${label}: the dataset door`).toEqual((reference as { titles: string[] }).titles); + } + }); + + it('a related field the caller cannot read, and a match past the cap, are refused as the engine refuses them — never rows', async () => { + for (const [label, where, expected] of REFUSED) { + const reference = await engineAnswer(h.engine, where); + expect(reference, `${label}: the engine`).toEqual({ refused: expected }); + + const cube = await h.analytics.query(cubeQuery(where), MEMBER_CTX as never).then( + (r) => ({ rows: r.rows }), + (e: Thrown) => ({ refused: envelopeOf(e) }), + ); + expect(cube, `${label}: the cube read`).toEqual({ refused: expected }); + + const ds = await h.dataset({ measures: ['row_count'], dimensions: ['title_dim'], runtimeFilter: where }); + expect(ds.status, `${label}: the dataset door ${JSON.stringify(ds.body)}`).toBe(expected.status); + expect(ds.body?.code, `${label}: the dataset door`).toBe(expected.code); + expect(ds.body?.rows, `${label}: no rows beside the refusal`).toBeUndefined(); + } + // What the permission refusal withholds: a system caller's condition does match. + const system = await h.analytics.query(cubeQuery({ owner: { secret: 's1' } }), SYS_CTX as never); + expect(titlesOf(system.rows, 'title')).toEqual(['a', 'c']); + }); + + it('a measure\'s own filter carrying the form answers what the engine answers at an aggregation\'s filter: refused INVALID_FILTER, never a count', async () => { + const reference = await h.engine + .aggregate(OBJECT, { + groupBy: ['title'], + aggregations: [{ function: 'count', alias: 'na_n', filter: { owner: { region: 'NA' } } }], + context: MEMBER_CTX, + } as never) + .then(() => null, (e: Thrown) => envelopeOf(e)); + expect(reference, 'the engine refuses the form at an aggregation\'s filter').toEqual({ code: 'INVALID_FILTER', status: 400 }); + + const ds = await h.dataset({ measures: ['row_count', 'na_n'], dimensions: ['title_dim'] }); + expect(ds.status, JSON.stringify(ds.body)).toBe(400); + expect(ds.body?.code, JSON.stringify(ds.body)).toBe('INVALID_FILTER'); + expect(ds.body?.rows).toBeUndefined(); + }); + + it('the SQL echo refuses the form in the where-door envelope, naming the served route, rather than print a statement that is not what ran', async () => { + const echo = await h.analytics.generateSql(cubeQuery({ owner: { region: 'NA' } }), MEMBER_CTX as never).then( + (r) => ({ sql: r.sql }), + (e: Thrown) => ({ refused: envelopeOf(e), message: String(e?.message) }), + ); + expect(echo).toMatchObject({ refused: { code: 'INVALID_FILTER', status: 400 } }); + const message = (echo as { message: string }).message; + expect(message).toContain('"owner"'); + expect(message).toContain('/analytics/query'); + }); + }); +} + +describe('[#20887] a read scope carrying the nested-relation form', () => { + /** A host read scope (the plugin option): the member reads only ledger rows whose owner is in NA. */ + const scope = (object: string): FilterCondition | null => (object === OBJECT ? { owner: { region: 'NA' } } : null); + + /** + * B4, measured: the read scope's SQL compile (`compileScopedFilterToSql`) is a + * synchronous string builder that holds the caller's context for placeholders + * and no data engine — it cannot read the related object as the caller. So + * where a scope is compiled to SQL — the native strategy's statement and both + * SQL echoes — it keeps its fail-closed refusal, now in words that name the + * route that serves the form. On the engine-aggregate path the scope reaches + * the engine as written, and the engine serves it as the caller, as it did + * before this change. + */ + const bootWithScope = (strategy: 'native' | 'objectql') => + boot({ ...(strategy === 'objectql' ? { caps: 'objectql' as const } : {}), getReadScope: scope }); + const query = { cube: OBJECT, measures: ['count'], dimensions: ['title'] } as never; + + it('the native strategy compiles the scope to SQL and keeps the fail-closed refusal, naming the route — never unscoped rows', async () => { + const h = await bootWithScope('native'); + try { + for (const run of [() => h.analytics.query(query, MEMBER_CTX as never), () => h.analytics.generateSql(query, MEMBER_CTX as never)]) { + const answer = await run().then((r) => ({ answered: r }), (e: Thrown) => ({ refused: envelopeOf(e), message: String(e?.message) })); + expect(answer).toMatchObject({ refused: { code: 'READ_SCOPE_COMPILE_FAILED', status: 500 } }); + const message = (answer as { message: string }).message; + expect(message).toContain("The engine serves the form in a query's where"); + expect(message).toContain('reads the related object as the caller'); + } + } finally { + try { await h.engine.destroy(); } catch { /* noop */ } + } + }, 60_000); + + it('the engine-aggregate path hands the scope to the engine, which serves it as the caller — its SQL echo refuses', async () => { + const h = await bootWithScope('objectql'); + try { + const reference = await engineAnswer(h.engine, { owner: { region: 'NA' } }); + expect(reference).toEqual({ titles: ['a', 'c'] }); + const cube = await h.analytics.query(query, MEMBER_CTX as never) + .then((r) => ({ titles: titlesOf(r.rows, 'title') }), (e: Thrown) => ({ refused: envelopeOf(e), message: e?.message })); + expect(cube).toEqual(reference); + const echo = await h.analytics.generateSql(query, MEMBER_CTX as never) + .then((r) => ({ sql: r.sql }), (e: Thrown) => ({ refused: envelopeOf(e), message: String(e?.message) })); + expect(echo).toMatchObject({ refused: { code: 'READ_SCOPE_COMPILE_FAILED', status: 500 } }); + expect((echo as { message: string }).message).toContain('reads the related object as the caller'); + } finally { + try { await h.engine.destroy(); } catch { /* noop */ } + } + }, 60_000); +}); diff --git a/packages/services/service-analytics/src/__tests__/filter-normalizer-mixed-wrapper.test.ts b/packages/services/service-analytics/src/__tests__/filter-normalizer-mixed-wrapper.test.ts index e04b656816c..4e50b14a94e 100644 --- a/packages/services/service-analytics/src/__tests__/filter-normalizer-mixed-wrapper.test.ts +++ b/packages/services/service-analytics/src/__tests__/filter-normalizer-mixed-wrapper.test.ts @@ -47,7 +47,8 @@ * * `the two pure shapes do not move` is the risk. The gate must move the * refusal set by EXACTLY the mixed shape: all-`$` wrappers keep compiling, - * all-non-`$` wrappers keep flattening to dotted members. An over-reaching + * all-non-`$` wrappers keep their nested-relation path (flattened to dotted + * members until #20887, carried as written for the engine since). An over-reaching * gate shows up there as a throw. * * `the #5146 rewrite cannot swallow the wrapper` is the gate-side question, @@ -162,14 +163,13 @@ const MIXED: Array<{ nonOpKeys: ['nested'], wasReadAs: 'd notSet — the flag survived, the member did not', }, - { - name: '⑥ the mix one relation DOWN, refused on the DOTTED member', - where: { profile: { verified: { $eq: 1, extra: 'x' } } }, - field: 'profile.verified', - opKeys: ['$eq'], - nonOpKeys: ['extra'], - wasReadAs: 'profile.verified equals [1]', - }, + // [#20887] Row ⑥ — the mix one relation DOWN, `{ profile: { verified: { $eq: + // 1, extra: 'x' } } }` — left this table: the nested-relation condition is + // no longer flattened to the dotted member, it is carried as written for the + // engine, which reads the related object with it and refuses the mixed + // wrapper there (`INVALID_FILTER` / 400, "Unsupported filter operator + // "extra"", measured on the engine for #20887). Its carriage is pinned in + // the pure-shapes block below. { name: '⑦ inside a $and branch', where: { $and: [{ d: { $eq: 1, nested: 'x' } }] }, @@ -250,11 +250,11 @@ describe('[#6444] a mixed $/non-$ field wrapper is ONE refusal', () => { // key-by-key and shown in place. expect(message).toContain('"gte" → "$gte"'); expect(message).toContain('{ "amount": { "$gte": ... } }'); - // Intent 2 — a nested-relation member: a wrapper of its own, the dotted - // member it compiles to, and the explicit $and (one JSON object cannot - // spell the same field key twice). + // Intent 2 — a nested-relation member: a wrapper of its own, read as a + // condition on the related record (#20887: the engine's reading), and the + // explicit $and (one JSON object cannot spell the same field key twice). expect(message).toContain('{ "amount": { "gte": ... } }'); - expect(message).toContain('"amount.gte"'); + expect(message).toContain('a condition on the related record\'s own "gte"'); expect(message).toContain('"$and"'); // …and why it refuses rather than picking: the drop it replaces WIDENED. expect(message).toContain('WIDENS'); @@ -284,11 +284,10 @@ describe('[#6444] the #5146 rewrite cannot swallow the wrapper', () => { where: { $not: { d: { $eq: null, nested: 'x' } } }, field: 'd', }, - { - name: 'the nested-relation recursion in `guardFieldEntry`, which guards the DOTTED member', - where: { $not: { profile: { verified: { $eq: 1, extra: 2 } } } }, - field: 'profile.verified', - }, + // [#20887] The fourth path — the nested-relation recursion in + // `guardFieldEntry`, which guarded the DOTTED member — is gone: a + // nested-relation condition is carried as written for the engine, and the + // engine refuses a mixed wrapper inside it (row ⑥'s note above). ]; for (const c of REWRITE_PATHS) { @@ -303,19 +302,26 @@ describe('[#6444] the #5146 rewrite cannot swallow the wrapper', () => { }); describe('[#6444] the two pure shapes do not move', () => { - it('an ALL-non-$ wrapper still flattens to the dotted member (the nested-relation path)', () => { + it('an ALL-non-$ wrapper is the nested-relation condition, carried as written for the engine (#20887)', () => { // The pin the issue's own control row named: the ONLY reason the siblings // were droppable is that this legitimate path sat after the early return. + // [#20887] It used to flatten to the dotted member (`d.nested`); it is now + // the engine's form, carried whole — the engine reads the related object + // with it as the caller, and judges it there (one level, declared keys, the + // mixed wrapper one level down included). expect(treeFor({ d: { nested: 'x' } })).toEqual({ - kind: 'leaf', member: 'd.nested', operator: 'equals', values: ['x'], + kind: 'relation', member: 'd', condition: { nested: 'x' }, }); expect(treeFor({ a: { b: { c: 1 } } })).toEqual({ - kind: 'leaf', member: 'a.b.c', operator: 'equals', values: [1], + kind: 'relation', member: 'a', condition: { b: { c: 1 } }, }); - // A nested member carrying an OPERATOR wrapper (all-$ one level down) is - // legal on both levels and keeps compiling. + // An OPERATOR wrapper one level down (all-$) and a mixed one travel as + // written too: both are the related object's to judge. expect(treeFor({ profile: { verified: { $eq: true } } })).toEqual({ - kind: 'leaf', member: 'profile.verified', operator: 'equals', values: [true], + kind: 'relation', member: 'profile', condition: { verified: { $eq: true } }, + }); + expect(treeFor({ profile: { verified: { $eq: 1, extra: 'x' } } })).toEqual({ + kind: 'relation', member: 'profile', condition: { verified: { $eq: 1, extra: 'x' } }, }); }); diff --git a/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts b/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts index d2f10d4aa7c..c602659cf0a 100644 --- a/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts +++ b/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts @@ -442,19 +442,25 @@ describe('[#5325] analytics `where` — NULL-safe `$not` and the boolean identit }); it('a guarded relation traversal guards the DOTTED member, not its alias', async () => { - // `{account: {region: 'NA'}}` flattens to the member `account.region` (the - // normalizer's own dotted-key flattening), so the guard has to flatten the - // same way: guarding `account` would test the relation, not the column the - // leaf reads. Asserted on the generated SQL only — `region` is not a column - // of this fixture, which is the point: both halves resolve to ONE member. - const { sql } = await sqlFor({ $not: { account: { region: 'NA' } } }); - // [ADR-0053 D-D1, amended — #5930 step 3] The door spells the nested - // relation dotted before the shared lowering reads it, so the lowering's - // guard lands on the dotted member too (outer), and this face's own copy - // adds its own (inner) — never a guard on `account` itself. + // The cube member `account.region` — a traversal through the cube's + // join — so the guard lands on the member the leaf reads: guarding + // `account` would test the relation, not the column. Asserted on the + // generated SQL only — `region` is not a column of this fixture, which is + // the point: both halves resolve to ONE member. + const { sql } = await sqlFor({ $not: { 'account.region': 'NA' } }); + // [ADR-0053 D-D1, amended — #5930 step 3] The shared lowering's guard + // lands on the dotted member (outer), and this face's own copy adds its + // own (inner) — never a guard on `account` itself. expect(sql).toContain('NOT (("account"."region" IS NOT NULL AND ("account"."region" IS NOT NULL AND "account"."region" = $1)))'); expect(sql).not.toContain('"deal"."account" IS NOT NULL'); expect(sql).not.toMatch(/(^|[^."])account IS NOT NULL/); + // [#20887] REPLACED spelling. This case wrote the NESTED form + // (`{ account: { region: 'NA' } }`), which this compiler used to flatten + // to the same member. The nested form is now the engine's — the related + // object read as the caller, capped — and `NativeSQLStrategy.canHandle` + // declines a query carrying it; reaching this compiler anyway is a + // routing fault, refused bare. + await expect(sqlFor({ $not: { account: { region: 'NA' } } })).rejects.toThrowError(/reached the SQL compiler/); }); }); @@ -738,8 +744,10 @@ describe('[#5325] analytics `where` — NULL-safe `$not` and the boolean identit await expect(ids({ stage: {} })).rejects.toThrowError(/zero operators/); await expect(ids({ $or: [{ stage: {} }, { owner: 'u1' }] })).rejects.toThrowError(/zero operators/); await expect(ids({ $not: { stage: {} } })).rejects.toThrowError(/zero operators/); - // A nested relation is NOT this shape and still flattens. - const { sql } = await sqlFor({ account: { region: 'NA' } }); + // A relation traversal is NOT this shape: the dotted cube member still + // compiles. [#20887] (The nested spelling of it is the engine's now, and + // never reaches this compiler — see the guarded-traversal case above.) + const { sql } = await sqlFor({ 'account.region': 'NA' }); expect(sql).toContain('"account"."region" = $1'); }); diff --git a/packages/services/service-analytics/src/__tests__/icontains-text-comparand-refusal.test.ts b/packages/services/service-analytics/src/__tests__/icontains-text-comparand-refusal.test.ts index 5ecc8e4c6a1..c4cb5f1f59a 100644 --- a/packages/services/service-analytics/src/__tests__/icontains-text-comparand-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/icontains-text-comparand-refusal.test.ts @@ -147,10 +147,14 @@ describe('[#20068] the `where` door refuses the two rows, both spellings, in the } }); - it('in every position: under $not, in an $or beside a TRUE arm, and on a nested relation', () => { + it('in every position: under $not, in an $or beside a TRUE arm, and on a relation traversal', () => { expectWhereRefusal(refusalOf(() => tree({ $not: { name: { $icontains: '' } } })), 'name', ''); expectWhereRefusal(refusalOf(() => tree({ $or: [{}, { name: { $icontains: '' } }] })), 'name', ''); - expectWhereRefusal(refusalOf(() => tree({ acct: { name: { $icontains: '' } } })), 'acct.name', ''); + // [#20887] The traversal as the dotted cube member. Its NESTED spelling + // (`{ acct: { name: { $icontains: '' } } }`) is no longer this door's leaf: + // it is carried as written to the engine, which reads the related object + // with it and refuses the comparand there, in the same published words. + expectWhereRefusal(refusalOf(() => tree({ 'acct.name': { $icontains: '' } })), 'acct.name', ''); }); it('existing refusals keep their sentence: an array and an object are not re-diagnosed', () => { diff --git a/packages/services/service-analytics/src/__tests__/infer-cube-relation-traversal.test.ts b/packages/services/service-analytics/src/__tests__/infer-cube-relation-traversal.test.ts index df7dbf6623c..968a6d28563 100644 --- a/packages/services/service-analytics/src/__tests__/infer-cube-relation-traversal.test.ts +++ b/packages/services/service-analytics/src/__tests__/infer-cube-relation-traversal.test.ts @@ -498,16 +498,27 @@ describe('[#5739] the source-field gates keep every rejection they already made' expect(sqls).toEqual([]); }); - it('leaves a NESTED relation object reading exactly as it did', async () => { + it('leaves a NESTED relation object minting exactly as it did — and hands it to the engine as written', async () => { // `{owner: {region: 'NA'}}`'s top-level key is the bare `owner`, so the mint - // is unchanged by the ruling — and the LEAF `owner.region` reaches the - // strategies through `lookupMember`'s synthetic tier, as it always has. - const { sqls, dimensions } = await run( + // is unchanged by the ruling. + // + // [#20887] REPLACED second half. It used to assert that the LEAF + // `owner.region` reached the native strategy through `lookupMember`'s + // synthetic tier (a JOIN). The nested form is the ENGINE's now — the related + // object read as the caller, capped (#20802's ruling) — so the engine path + // receives it as written, and a host with no engine path is refused loudly, + // with no statement run. + const engine = await run({ cube: 'crm_account', measures: ['count'], where: { owner: { region: 'NA' } } }); + expect(engine.dimensions).toEqual(['owner']); + expect(engine.calls).toHaveLength(1); + expect(JSON.stringify(engine.calls[0].filter)).toContain('{"owner":{"region":"NA"}}'); + expect(JSON.stringify(engine.calls[0].filter)).not.toContain('owner.region'); + + const nativeOnly = await run( { cube: 'crm_account', measures: ['count'], where: { owner: { region: 'NA' } } }, { native: true }, ); - - expect(dimensions).toEqual(['owner']); - expect(sqls[0]).toContain('WHERE "owner"."region" = '); + expect(nativeOnly.error?.message).toMatch(/nested-relation condition on "owner"/); + expect(nativeOnly.sqls).toEqual([]); }); }); diff --git a/packages/services/service-analytics/src/__tests__/infer-cube-where-spelling-parity.test.ts b/packages/services/service-analytics/src/__tests__/infer-cube-where-spelling-parity.test.ts index f250bd64adc..cc09d4c4482 100644 --- a/packages/services/service-analytics/src/__tests__/infer-cube-where-spelling-parity.test.ts +++ b/packages/services/service-analytics/src/__tests__/infer-cube-where-spelling-parity.test.ts @@ -443,9 +443,17 @@ describe('[#5353/#5739] a dotted `where` key is unified too — as a traversal', // `owner` — unchanged. The LEAF member is `owner.region`, which is why a // `collectFilterLeaves`-based seeder would have produced `region` here and // walked into the mis-cast above from a third direction. - const { dimensions, sqls } = await inferredDimensions({ owner: { region: 'NA' } }, NO_REGION); + // [#20887] The seeding is unchanged; what the query then runs is not. The + // nested form is the ENGINE's now (the related object read as the caller, + // capped), so it is asked of the engine path — handed over as written — + // where this case used to read the JOIN the native strategy compiled for + // the flattened `owner.region`. + const { dimensions, filters } = await inferredDimensions( + { owner: { region: 'NA' } }, + { fields: NO_REGION.fields }, + ); expect(dimensions).toEqual(['owner']); - expect(sqls[0]).toContain('WHERE "owner"."region" = '); + expect(JSON.stringify(filters[0])).toContain('{"owner":{"region":"NA"}}'); }); it('bare and dotted keys reach parity together when both ride along', async () => { diff --git a/packages/services/service-analytics/src/__tests__/nested-relation-engine-handoff.test.ts b/packages/services/service-analytics/src/__tests__/nested-relation-engine-handoff.test.ts new file mode 100644 index 00000000000..1c4a2aa987e --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/nested-relation-engine-handoff.test.ts @@ -0,0 +1,206 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20887, the analytics half of #20802's ruling] The nested-relation form + * `{ relation: { field: value } }` is answered by the ENGINE on every analytics + * face — the one place that reads the related object as the caller, bounded — + * so this package carries no second copy of that rule. What that takes here, + * pinned at the seams this package owns: + * + * 1. `NativeSQLStrategy` DECLINES a query in which a filter the caller or the + * dataset writes carries the form — the caller's `where` (either spelling), + * the dataset's own `filter`, a requested measure's `filter` — so the query + * routes to the ObjectQL/engine path. The same mechanism, for the same + * reason, as the #7598 cross-field decline (maintainer ruling 2026-08-12, + * Q1 = B): the rule lives in one place and the strategy that cannot enforce + * it routes to the one that does. The DOTTED member (`'owner.region'`) is a + * cube member, not this form, and stays on the native path. A READ SCOPE + * carrying the form is not routed: it is a policy compiled to SQL (item 4). + * 2. `ObjectQLStrategy` hands the engine the form AS WRITTEN — never flattened + * to the dotted member, which the engine cannot join — so the engine's seam + * lowers it. + * 3. The native compiler refuses the form if it ever reaches it (the routing's + * fail-closed backstop, unreachable by construction). + * 4. The read-scope compiler, which compiles a policy to SQL and reads no other + * object — a synchronous string builder with the caller's context for + * placeholders and no data engine — keeps its fail-closed refusal of the + * form, in words that name the route that serves it. + * + * The rows each face then answers — the engine's, with the real security layer — + * are `@objectstack/rest`'s `analytics-nested-relation-filter.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import type { Cube, FilterCondition } from '@objectstack/spec/data'; +import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts'; + +import { AnalyticsService } from '../analytics-service.js'; +import { compileScopedFilterToSql } from '../read-scope-sql.js'; +import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js'; +import type { DatasetScope } from '../strategies/types.js'; + +const OBJECT = 'nested_ledger'; +const OWNER = 'nested_owner'; + +const CUBE: Cube = { + name: 'ledger', + sql: OBJECT, + measures: { + row_count: { label: 'Rows', type: 'count', sql: '*' }, + na_count: { label: 'NA rows', type: 'count', sql: '*' }, + }, + dimensions: { + title: { label: 'Title', type: 'string', sql: 'title' }, + owner: { label: 'Owner', type: 'string', sql: 'owner' }, + }, + joins: { owner: { name: OWNER, relationship: 'many_to_one', sql: 'owner' } }, + public: true, +} as unknown as Cube; + +const NESTED: FilterCondition = { owner: { region: 'NA' } }; + +/** A native-capable context whose every optional producer is a knob. */ +function nativeCtx(knobs: { + datasetScope?: DatasetScope; + readScopes?: Record; +} = {}): StrategyContext { + return { + getCube: (name: string) => (name === CUBE.name ? CUBE : undefined), + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }), + executeRawSql: async () => [], + ...(knobs.readScopes ? { getReadScope: (object: string) => knobs.readScopes![object] ?? null } : {}), + ...(knobs.datasetScope ? { getDatasetScope: () => knobs.datasetScope } : {}), + } as StrategyContext; +} + +const q = (extra: Partial = {}): AnalyticsQuery => + ({ cube: CUBE.name, measures: ['row_count'], dimensions: ['title'], ...extra }) as AnalyticsQuery; + +describe('[#20887] NativeSQLStrategy declines the nested-relation form, from every producer it compiles', () => { + const native = new NativeSQLStrategy(); + + it('CONTROL a query with no nested-relation condition stays on the native path, the dotted cube member included', () => { + expect(native.canHandle(q(), nativeCtx())).toBe(true); + expect(native.canHandle(q({ where: { title: 'a' } as FilterCondition }), nativeCtx())).toBe(true); + expect(native.canHandle(q({ where: { 'owner.region': 'NA' } as FilterCondition }), nativeCtx())).toBe(true); + // A measure filter the query does not ask for is never compiled, so it routes nothing. + expect(native.canHandle(q(), nativeCtx({ datasetScope: { measureFilters: { na_count: NESTED } } }))).toBe(true); + }); + + it('declines the form in the caller\'s where — top level, inside $and / $or / $not, and in the FilterArray spelling', () => { + for (const where of [ + NESTED, + { $and: [{ title: 'a' }, NESTED] }, + { $or: [{ title: 'a' }, NESTED] }, + { $not: NESTED }, + [['owner', '=', { region: 'NA' }]], + ]) { + expect(native.canHandle(q({ where: where as FilterCondition }), nativeCtx()), JSON.stringify(where)).toBe(false); + } + }); + + it('declines the form in the dataset\'s own filter and in a requested measure\'s filter', () => { + expect(native.canHandle(q(), nativeCtx({ datasetScope: { filter: NESTED } }))).toBe(false); + expect( + native.canHandle(q({ measures: ['row_count', 'na_count'] }), nativeCtx({ datasetScope: { measureFilters: { na_count: NESTED } } })), + ).toBe(false); + }); + + it('does NOT decline for a read scope carrying the form: its compile to SQL keeps the fail-closed refusal', async () => { + // The base object's scope, and a joined object's (compiled once the statement joins it). + const cases: ReadonlyArray, AnalyticsQuery]> = [ + [{ [OBJECT]: NESTED }, q()], + [{ [OWNER]: { account: { tier: 'gold' } } }, q({ dimensions: ['owner.region'] })], + ]; + for (const [readScopes, query] of cases) { + const ctx = nativeCtx({ readScopes }); + expect(native.canHandle(query, ctx), JSON.stringify(readScopes)).toBe(true); + const err = await native.generateSql(query, ctx).then(() => null, (e: Error & { code?: string; status?: number }) => e); + expect({ code: err?.code, status: err?.status }, JSON.stringify(readScopes)).toEqual({ code: 'READ_SCOPE_COMPILE_FAILED', status: 500 }); + expect(String(err?.message)).toContain('carries a nested-relation condition'); + } + }); + + it('the native compiler refuses the form if the routing ever lets it through — a bare fault, never a statement', async () => { + const err = await native.generateSql(q({ where: NESTED }), nativeCtx()).then(() => null, (e: Error & { code?: string }) => e); + expect(err).toBeInstanceOf(Error); + expect(err?.code, 'our own routing drift, not the caller\'s 400').toBeUndefined(); + expect(String(err?.message)).toContain('"owner"'); + }); +}); + +describe('[#20887] ObjectQLStrategy hands the engine the nested-relation form as written', () => { + /** A service with BOTH paths available, so the routing decides; the engine call is recorded. */ + function service() { + const aggregates: Array<{ object: string; filter: unknown }> = []; + const rawSql: string[] = []; + const svc = new AnalyticsService({ + cubes: [CUBE], + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }), + executeRawSql: async (_object, sql) => { + rawSql.push(sql); + return []; + }, + executeAggregate: async (object, options) => { + aggregates.push({ object, filter: options.filter }); + return []; + }, + }); + return { svc, aggregates, rawSql }; + } + + it('the engine receives the form beneath the relation field — not the dotted member, and not a raw statement', async () => { + const cases: ReadonlyArray = [ + [NESTED, NESTED], + [{ owners: { region: 'NA' } }, { owners: { region: 'NA' } }], + [[['owner', '=', { region: 'NA' }]], NESTED], + ]; + for (const [where, expected] of cases) { + const { svc, aggregates, rawSql } = service(); + await svc.query(q({ where: where as FilterCondition })); + expect(rawSql, JSON.stringify(where)).toEqual([]); + expect(aggregates, JSON.stringify(where)).toHaveLength(1); + expect(JSON.stringify(aggregates[0].filter), JSON.stringify(where)).toContain(JSON.stringify(expected)); + expect(JSON.stringify(aggregates[0].filter), JSON.stringify(where)).not.toContain('owner.region'); + } + }); + + it('inside $or and $not the form keeps its place in the structure the engine reads', async () => { + const { svc, aggregates } = service(); + await svc.query(q({ where: { $or: [{ title: 'a' }, NESTED] } as FilterCondition })); + await svc.query(q({ where: { $not: NESTED } as FilterCondition })); + expect(aggregates).toHaveLength(2); + expect(aggregates[0].filter).toMatchObject({ $and: [{ $or: [{ title: 'a' }, NESTED] }] }); + expect(JSON.stringify(aggregates[1].filter)).toMatch(/^\{"\$and":\[\{"\$not":/); + expect(JSON.stringify(aggregates[1].filter)).toContain(JSON.stringify(NESTED)); + }); +}); + +describe('[#20887] the read-scope compiler keeps its refusal of the form, naming the route that serves it', () => { + const refusalOf = (filter: FilterCondition) => { + try { + compileScopedFilterToSql(filter, 't'); + return null; + } catch (e) { + return e as Error & { code?: string; status?: number }; + } + }; + + it('refuses the form fail-closed, and says where it is served and how to write it here', () => { + const err = refusalOf(NESTED); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'READ_SCOPE_COMPILE_FAILED', status: 500 }); + const message = String(err?.message); + expect(message).toContain('"owner"'); + expect(message).toContain("The engine serves the form in a query's where"); + expect(message).toContain('reads the related object as the caller'); + expect(message).toContain('{ "owner": { "$in": [ID, …] } }'); + }); + + it('CONTROL an empty or mixed value object keeps its own refusal — it is not the nested-relation form', () => { + for (const filter of [{ owner: {} }, { owner: { $eq: 'u1', region: 'NA' } }] as FilterCondition[]) { + const err = refusalOf(filter); + expect(err?.code, JSON.stringify(filter)).toBe('READ_SCOPE_COMPILE_FAILED'); + expect(String(err?.message), JSON.stringify(filter)).toContain('has a nested/relation value'); + } + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/read-scope-not-null-safe.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-not-null-safe.test.ts index 749d08e7b0a..ac060ea2275 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-not-null-safe.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-not-null-safe.test.ts @@ -455,7 +455,7 @@ describe('[#5297] read-scope `$not` — boolean identities and NULL safety', () it('a nested relation / bare array / zero-operator spec inside a `$not` still THROWS', () => { expect(() => compileScopedFilterToSql({ $not: { account: { region: 'NA' } } } as FilterCondition, ALIAS)) - .toThrowError(/nested\/relation value/); + .toThrowError(/carries a nested-relation condition/); expect(() => compileScopedFilterToSql({ $not: { stage: ['won'] } } as FilterCondition, ALIAS)) .toThrowError(/bare array value/); expect(() => compileScopedFilterToSql({ $not: { stage: {} } } as FilterCondition, ALIAS)) diff --git a/packages/services/service-analytics/src/__tests__/read-scope-refusal-envelope.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-refusal-envelope.test.ts index bd91df216ed..61aa536ca17 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-refusal-envelope.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-refusal-envelope.test.ts @@ -94,7 +94,7 @@ function refusalFor(filter: unknown, alias = 'crm_opportunity'): Refusal | undef * Every refusing site in `read-scope-sql.ts`, in source order — except row ⑯, * appended when it was added (its row says where it runs). * - * SIXTEEN rows over FOURTEEN throw sites: TWO sites are each reached by two + * SEVENTEEN rows over FIFTEEN throw sites: TWO sites are each reached by two * triggers, and every trigger is listed on purpose. * * - `quoteIdent`, with two `kind` values. That alias-vs-field split was option @@ -188,10 +188,13 @@ const REFUSALS: Array<{ sensitive: 'region_code', }, { - name: '⑩ nested / relation value', - site: 'compileField: nested/relation value', + // [#20887] The nested-relation form keeps its refusal here, in words that + // name the route that serves it (the engine); the value object that is not + // that form keeps the old words, row ⑰. + name: '⑩ nested-relation condition', + site: 'compileField: nested-relation condition', filter: { owner: { manager_id: 'u1' } }, - message: /"owner" has a nested\/relation value which is not supported in a read scope \(fail-closed\)/, + message: /"owner" carries a nested-relation condition \(\{ "owner": \{ … \} \}\), which a read scope compiled to SQL cannot serve \(fail-closed\)/, sensitive: 'owner', }, { @@ -246,6 +249,16 @@ const REFUSALS: Array<{ message: /array value for "region_code"\.\$eq — an equality compares one value, so a list is refused rather than bound; use \{ \$in: \[\.\.\.\] \} \(fail-closed\)/, sensitive: 'region_code', }, + { + // [#20887] Split out of row ⑩'s site when that site's nested-relation + // form got words of its own: a value object with NO key, or with a non-$ + // key beside a $ key, is no shape this compiler reads. + name: '⑰ an empty or mixed value object', + site: 'compileField: nested/relation value', + filter: { owner: { $eq: 'u1', manager_id: 'u1' } }, + message: /"owner" has a nested\/relation value which is not supported in a read scope \(fail-closed\)/, + sensitive: 'owner', + }, ]; /** @@ -336,15 +349,16 @@ describe('[#5367] every read-scope refusal carries the ADR-0112 envelope (READ_S // #5352's lesson, stated as a guard: seven of `filter-normalizer.ts`'s nine // sites carrying an envelope was indistinguishable from none of them at the // HTTP boundary, because the commonest input hit one of the two bare ones. - // Sixteen inputs over the module's FOURTEEN throw sites (see the table's + // Seventeen inputs over the module's FIFTEEN throw sites (see the table's // note on the two sites with two triggers each), and every one of them // enveloped. [#6125] added the eleventh site, [#6387] the twelfth, - // [#13571] the thirteenth (the empty-`$nin` refusal) and [#19975] the - // fourteenth (a list under `$eq`); these two numbers are the ratchet that - // makes a future unenveloped `throw` fail HERE instead of at an HTTP - // boundary. - expect(REFUSALS).toHaveLength(16); - expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(14); + // [#13571] the thirteenth (the empty-`$nin` refusal), [#19975] the + // fourteenth (a list under `$eq`) and [#20887] the fifteenth (the + // nested-relation form, split from row ⑰'s site); these two numbers are + // the ratchet that makes a future unenveloped `throw` fail HERE instead of + // at an HTTP boundary. + expect(REFUSALS).toHaveLength(17); + expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(15); for (const c of REFUSALS) { expect(refusalFor(c.filter, c.alias)?.code, `${c.site} is still bare`).toBe('READ_SCOPE_COMPILE_FAILED'); } diff --git a/packages/services/service-analytics/src/__tests__/read-scope-sql.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-sql.test.ts index b4935e6bdba..1d8bddf691e 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-sql.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-sql.test.ts @@ -84,7 +84,8 @@ describe('compileScopedFilterToSql', () => { }); it('THROWS on a nested relation value (cannot join in a flat scope)', () => { - expect(() => compileScopedFilterToSql({ account: { region: 'NA' } }, 't')).toThrowError(/nested\/relation value/); + // [#20887] Still refused; the words now name the route that serves the form. + expect(() => compileScopedFilterToSql({ account: { region: 'NA' } }, 't')).toThrowError(/carries a nested-relation condition/); }); it('empty combinators reduce to their boolean identities (#5322)', () => { diff --git a/packages/services/service-analytics/src/__tests__/read-scope-undefined-comparand.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-undefined-comparand.test.ts index a61a7e85476..f99f3eedbc0 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-undefined-comparand.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-undefined-comparand.test.ts @@ -285,7 +285,8 @@ describe('[#6125] what the sweep deliberately leaves alone', () => { // either. The one deliberate divergence from `driver-sql`'s twin. const err = refusalFor({ owner: { manager_id: undefined } }); expect(err?.code).toBe('READ_SCOPE_COMPILE_FAILED'); - expect(String(err?.message)).toContain('has a nested/relation value'); + // [#20887] Its own refusal, in the words that name the route serving the form. + expect(String(err?.message)).toContain('carries a nested-relation condition'); }); it('the boolean identities still reduce — the refusal is not reached through them', () => { diff --git a/packages/services/service-analytics/src/__tests__/where-door-shared-lowering-seam.test.ts b/packages/services/service-analytics/src/__tests__/where-door-shared-lowering-seam.test.ts index 78f95d461e6..063c0ddfdb6 100644 --- a/packages/services/service-analytics/src/__tests__/where-door-shared-lowering-seam.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-door-shared-lowering-seam.test.ts @@ -113,14 +113,27 @@ describe('[ADR-0053 D-D1 amended — #5930 step 3] F10: the where → tree face }); }); - it('a nested relation under $not is guarded on the dotted member its leaf reads, never on the relation key', () => { - // The nested spelling is this door's sugar (the engine refuses it); the - // door spells it dotted before the lowering reads it, so the lowering's - // guard lands on `account.region` — not on whatever `account` resolves to. + it('a nested relation under $not travels as written and unguarded: the engine guards what it lowers it to', () => { + // [#20887] REPLACED. This case pinned the door spelling the nested form + // dotted (`account.region`) before the lowering read it, so the guard landed + // on the joined member — the reading of a door that compiled the form into a + // JOIN. The form is the ENGINE's now (#20802's ruling: served in `where` by + // reading the related object as the caller, capped), carried as written and + // held out of the shared lowering: the engine lowers it to `account IN + // (ids)` and puts the NULL guard on that `$in` itself. + expect(tree({ $not: { account: { region: 'NA' } } })).toEqual({ + kind: 'not', + child: { kind: 'relation', member: 'account', condition: { region: 'NA' } }, + }); + // A second level is carried as written too: the engine refuses it (one level). + expect(tree({ $not: { account: { owner: { name: 'x' } } } })).toEqual({ + kind: 'not', + child: { kind: 'relation', member: 'account', condition: { owner: { name: 'x' } } }, + }); + // Beside a guarded leaf, only the leaf is guarded. const members = (where: FilterCondition) => [...new Set(collectFilterLeaves(tree(where) as never).map((l) => l.member))].sort(); - expect(members({ $not: { account: { region: 'NA' } } })).toEqual(['account.region']); - expect(members({ $not: { account: { owner: { name: 'x' } } } })).toEqual(['account.owner.name']); + expect(members({ $not: { $and: [{ account: { region: 'NA' } }, { stage: 'won' }] } })).toEqual(['account', 'stage']); }); it('an instant is never widened', () => { diff --git a/packages/services/service-analytics/src/__tests__/where-equality-slot-list-refusal.test.ts b/packages/services/service-analytics/src/__tests__/where-equality-slot-list-refusal.test.ts index 526ee755385..221834b8db8 100644 --- a/packages/services/service-analytics/src/__tests__/where-equality-slot-list-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-equality-slot-list-refusal.test.ts @@ -186,7 +186,8 @@ describe('[#19888] the neighbouring shapes compile exactly as before', () => { // [ADR-0053 D-D1, amended — #5930 step 3] The TRUE constant, inside the // shared lowering's NULL escape (`$nin` is negative-polarity): TRUE still. ['the empty $nin — the TRUE constant', { stage: { $nin: [] } }, { kind: 'or', children: [leaf('stage', 'notSet', []), { kind: 'const', value: true }] }], - ['a nested-relation scalar', { acct: { region: 'NA' } }, leaf('acct.region', 'equals', ['NA'])], + // [#20887] Accepted, and carried as written for the engine (it used to flatten to `acct.region`). + ['a nested-relation scalar', { acct: { region: 'NA' } }, { kind: 'relation', member: 'acct', condition: { region: 'NA' } }], ['a field reference under $eq (served on the engine path)', { amount: { $eq: { $field: 'budget' } } }, leaf('amount', 'equals', [{ $field: 'budget' }])], ]; diff --git a/packages/services/service-analytics/src/__tests__/where-face-arms-refusal.test.ts b/packages/services/service-analytics/src/__tests__/where-face-arms-refusal.test.ts index 01dc8867234..a91c3387282 100644 --- a/packages/services/service-analytics/src/__tests__/where-face-arms-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-face-arms-refusal.test.ts @@ -264,7 +264,8 @@ describe('[#20010] the neighbouring shapes compile exactly as before', () => { ['$eq: null — the same predicate', { stage: { $eq: null } }, leaf('stage', 'notSet', [])], ['$ne: null — has a value', { stage: { $ne: null } }, leaf('stage', 'set', [])], ["$contains: null — LIKE '%null%' (#5526)", { stage: { $contains: null } }, leaf('stage', 'contains', [null])], - ['a nested-relation scalar', { acct: { amt: 5 } }, leaf('acct.amt', 'equals', [5])], + // [#20887] Accepted, and carried as written for the engine (it used to flatten to `acct.amt`). + ['a nested-relation scalar', { acct: { amt: 5 } }, { kind: 'relation', member: 'acct', condition: { amt: 5 } }], ]; for (const [name, where, expected] of ACCEPTED) { diff --git a/packages/services/service-analytics/src/__tests__/where-source-field-gate.test.ts b/packages/services/service-analytics/src/__tests__/where-source-field-gate.test.ts index 94c48c60888..c12305e56ea 100644 --- a/packages/services/service-analytics/src/__tests__/where-source-field-gate.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-source-field-gate.test.ts @@ -622,14 +622,17 @@ describe('#5669 — what the gate must NOT do', () => { expect(settled.param).toBe('where'); }); - it('reads a NESTED relation filter as the same dotted member the strategies do', async () => { - // `{owner: {region: 'NA'}}` is the object spelling of the same traversal — - // `fieldLeaves` flattens it to the member `owner.region`, so reading the - // TREE (rather than the raw top-level keys) is what keeps the two - // spellings judged alike. A gate over raw keys would have judged `owner`, - // a field the object does not have, and 400'd a legal relation filter; - // measured here, both spellings reach ObjectQL's identical cross-object - // decline instead. + it('judges a NESTED relation filter by its relation field, a column of the object — and hands it to the engine as written', async () => { + // [#20887] REPLACED. This case used to read `{owner: {region: 'NA'}}` as + // the dotted member `owner.region` (the door flattened it), so the gate + // stood down and the query reached ObjectQL's cross-object decline, exactly + // like the dotted spelling above; it argued that judging the raw key + // `owner` would 400 "a legal relation filter". The nested form is the + // ENGINE's now — served in `where` by reading the related object as the + // caller, capped (#20802's ruling) — and in that form `owner` IS what is + // judged: it names a relation field OF THE QUERIED OBJECT (the ruling's + // words). So the gate reads the member `owner`, and it is legal exactly + // when the object declares it. const joined: Cube = { name: 'joined_cube', title: 'Joined', @@ -638,20 +641,24 @@ describe('#5669 — what the gate must NOT do', () => { dimensions: {}, public: true, }; - const { service } = makeService({ cubes: [joined] }); + const where = { owner: { region: 'NA' } }; + + // Declared: the query reaches the engine with the condition as written. + const declared = makeService({ cubes: [joined], fields: [...ACCOUNT_FIELDS, 'owner'] }); + expect(await settle(declared.service.query({ cube: 'joined_cube', measures: ['count'], where } as any))).toEqual({}); + expect(declared.filters).toHaveLength(1); + expect(JSON.stringify(declared.filters[0])).toContain('{"owner":{"region":"NA"}}'); + expect(JSON.stringify(declared.filters[0])).not.toContain('owner.region'); + // Not declared: refused in this gate's envelope, naming the relation field. + const undeclared = makeService({ cubes: [joined] }); const settled = await settle( - service.query({ cube: 'joined_cube', measures: ['count'], where: { owner: { region: 'NA' } } } as any), + undeclared.service.query({ cube: 'joined_cube', measures: ['count'], where } as any), ); - - // [#5716] Same substitution as the case above: `code !== 'INVALID_FIELD'` - // no longer separates the two producers, `field` does. - expect(settled.message).toMatch(/cross-object filter \("owner\.region"\)/); - expect(settled.message).not.toMatch(/constrains field 'owner'/); - expect(settled.field).toBeUndefined(); - // Both spellings reach the SAME refusal, envelope included — which is the - // invariant this case is really about. - expect(settled.member).toBe('owner.region'); + expect(settled.code).toBe('INVALID_FIELD'); + expect(settled.field).toBe('owner'); + expect(settled.message).toMatch(/constrains field 'owner'/); + expect(undeclared.filters).toEqual([]); }); it('stands down for a dotted member on the INFERENCE path, exactly as the shipped dimension gate does', async () => { diff --git a/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts b/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts index 5a585491cf0..1ce16ad9c95 100644 --- a/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts @@ -252,8 +252,9 @@ describe('[#20035] a bigint within 2^53 is NARROWED, copy-on-write — and every kind: 'and', children: [leaf('amt', 'gte', [2]), leaf('amt', 'lte', [5])], }); - // A nested relation's bigint is narrowed on its dotted member. - expect(tree({ acct: { amt: 7n } })).toEqual(leaf('acct.amt', 'equals', [7])); + // A nested relation's bigint is narrowed inside the condition the engine + // receives as written (#20887: it used to flatten to the dotted member). + expect(tree({ acct: { amt: 7n } })).toEqual({ kind: 'relation', member: 'acct', condition: { amt: 7 } }); }); it('the caller\'s condition is never edited, and nothing is copied when nothing narrowed', () => { @@ -281,7 +282,8 @@ describe('[#20035] a bigint within 2^53 is NARROWED, copy-on-write — and every [{ stage: { $null: true } }, leaf('stage', 'notSet', [])], [{ stage: { $contains: null } }, leaf('stage', 'contains', [null])], [{ amt: { $gt: { $field: 'id' } } }, leaf('amt', 'gt', [{ $field: 'id' }])], - [{ acct: { region: 'emea' } }, leaf('acct.region', 'equals', ['emea'])], + // [#20887] Carried as written for the engine (it used to flatten to `acct.region`). + [{ acct: { region: 'emea' } }, { kind: 'relation', member: 'acct', condition: { region: 'emea' } }], ]; for (const [where, expected] of ACCEPTED) { expect(tree(where), JSON.stringify(where)).toEqual(expected); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 62925f1270d..524f09364e1 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -70,6 +70,7 @@ import { collectFilterLeaves, lowerAnalyticsWhere, conjunctFieldKeys, + findNestedRelationCondition, NO_DATETIME_COLUMNS, } from './strategies/filter-normalizer.js'; import { findCrossFieldComparand } from './comparand-shape.js'; @@ -3024,8 +3025,9 @@ export class AnalyticsService implements IAnalyticsService { * predicate. This is deliberate and is the whole reason this gate is not a * second filter-tree walker: a hand-rolled walk would have to re-derive * `$and`/`$or`/`$not` recursion, `$`-prefixed operator keys, `$between` - * lowering, the nested-relation dot flattening (`{owner: {region: 'NA'}}` → - * member `owner.region`) and the #5334 array lowering, and every divergence + * lowering, the nested-relation condition (`{owner: {region: 'NA'}}` names + * the member `owner`, since #20887 carried as written for the engine) and the + * #5334 array lowering, and every divergence * would show up as "the field the gate saw" not being "the column that reached * SQL" — in either direction (a phantom rejection, or a hole). * `collectFilterLeaves` discards structure, which is exactly right here: @@ -3342,6 +3344,9 @@ export class AnalyticsService implements IAnalyticsService { // `queryCapabilities` by hand. Cheap to say, and the alternative is a dead // end that reads like a misconfiguration. const crossField = findCrossFieldComparand(lowerAnalyticsWhereQuietly(query)); + // [#20887] …and the second such decline: the nested-relation form, which + // `NativeSQLStrategy` routes to the engine path for the same reason. + const nested = crossField ? null : findNestedRelationCondition(lowerAnalyticsWhereQuietly(query)); throw new Error( `[Analytics] No strategy can handle query for cube "${query.cube}". ` + `Checked: ${this.strategies.map(s => s.name).join(', ')}${skip?.size ? ` (skipped at runtime: ${[...skip].map((s) => s.name).join(', ')})` : ''}. ` + @@ -3355,6 +3360,15 @@ export class AnalyticsService implements IAnalyticsService { `engine), or compare against a literal value. Every other query on this cube is ` + `unaffected. ` : '') + + (nested + ? `This query's filter carries a nested-relation condition on "${nested.field}" ` + + `({ "${nested.field}": { … } }), and NativeSQLStrategy DECLINES it so that it routes to the ` + + `ObjectQL engine path — the engine reads the related object as the caller, with that ` + + `object's field permissions and a cap. No such path is configured here: supply an ` + + `\`executeAggregate\` bridge (the plugin auto-wires one from the engine), or match ` + + `"${nested.field}" against ids you hold ({ "${nested.field}": { "$in": [ID, …] } }). Every ` + + `other query on this cube is unaffected. ` + : '') + 'Ensure a compatible driver is configured or a fallback service is registered.', ); } diff --git a/packages/services/service-analytics/src/read-scope-sql.ts b/packages/services/service-analytics/src/read-scope-sql.ts index 1549b1a601f..9d3e416e692 100644 --- a/packages/services/service-analytics/src/read-scope-sql.ts +++ b/packages/services/service-analytics/src/read-scope-sql.ts @@ -1312,8 +1312,27 @@ function compileField(field: string, value: unknown, qAlias: string, params: unk const ops = value as Record; const keys = Object.keys(ops); - // A value object must be ALL operators; a non-$ key means a nested relation, - // which a flat read scope cannot join — fail closed. + // [#20887] The nested-relation form — a value object whose keys are ALL + // fields (`{ owner: { region: 'NA' } }`) — keeps its fail-closed refusal + // HERE, in words that name the route that serves it. The form is answered by + // READING the related object as the caller (#20802's ruling: its row scope + // and field permissions, and a cap), and this compile cannot: it is a + // synchronous string builder that holds the caller's context for + // placeholders and no data engine, so there is nothing here to read the + // related object with, and a second copy of the permission rule is not an + // answer. The engine is where the form is served. + if (keys.length > 0 && keys.every((k) => !k.startsWith('$'))) { + throw readScopeCompileError( + `[read-scope-sql] "${field}" carries a nested-relation condition ({ "${field}": { … } }), ` + + `which a read scope compiled to SQL cannot serve (fail-closed): the condition is answered by ` + + `reading the related object, and this compile reads no other object. The engine serves the ` + + `form in a query's where — it reads the related object as the caller, with that object's row ` + + `scope and field permissions, and refuses a match past its cap. In a read scope compiled to SQL, ` + + `name the related ids on a single-valued relation: { "${field}": { "$in": [ID, …] } }.`, + ); + } + // A value object must be ALL operators; a non-$ key beside a $ key (or no + // key at all) is no shape this compiler reads — fail closed. if (keys.length === 0 || keys.some((k) => !k.startsWith('$'))) { throw readScopeCompileError(`[read-scope-sql] "${field}" has a nested/relation value which is not supported in a read scope (fail-closed).`); } diff --git a/packages/services/service-analytics/src/strategies/filter-normalizer.ts b/packages/services/service-analytics/src/strategies/filter-normalizer.ts index bfa50896183..564c80f58c5 100644 --- a/packages/services/service-analytics/src/strategies/filter-normalizer.ts +++ b/packages/services/service-analytics/src/strategies/filter-normalizer.ts @@ -329,8 +329,9 @@ * cause (a dropped `$`) into a predicate on a non-existent member `amount.gte`, * turning a diagnosable mistake into a harder one. * - * ⛔ What does not move: a wrapper that is ALL non-`$` keys keeps flattening to - * the dotted member (`{d: {nested: 'x'}}` → `d.nested`); a wrapper that is ALL + * ⛔ What does not move: a wrapper that is ALL non-`$` keys is a nested-relation + * condition (since #20887 carried as written for the engine to answer, where it + * used to flatten to the dotted member `d.nested`); a wrapper that is ALL * `$`-operators compiles exactly as before; `$null` / `$exists` flag semantics * (#5526 / #5332 / #5347) and {@link comparand} are untouched. The sibling door * `read-scope-sql.ts` already fails closed on this exact shape @@ -652,7 +653,19 @@ export type NormalizedFilterNode = | { kind: 'const'; value: boolean } | { kind: 'and'; children: NormalizedFilterNode[] } | { kind: 'or'; children: NormalizedFilterNode[] } - | { kind: 'not'; child: NormalizedFilterNode }; + | { kind: 'not'; child: NormalizedFilterNode } + /** + * [#20887] A NESTED-RELATION condition — `{ owner: { region: 'NA' } }`, a + * plain object with no `$` key beneath a field — carried AS WRITTEN for the + * engine to answer. The engine serves it in `where` by reading the related + * object as the caller, capped (#20802's ruling), and refuses it at an + * aggregation's own `filter`; this package holds no second copy of either + * rule. So the one compiler of this node is the engine hand-off + * (`ObjectQLStrategy`): `NativeSQLStrategy.canHandle` declines every query + * that would give it one ({@link findNestedRelationCondition}), and the + * native compiler and the display-SQL renderer refuse it. + */ + | { kind: 'relation'; member: string; condition: Record }; /** * The SQL boolean constants the compilers of this tree emit for a `const` node. @@ -897,11 +910,12 @@ function undefinedComparandError(field: string, path: string): Error { * The positions are enumerated rather than swept, because "comparand" is a * POSITION and not a type: * - * - the DIRECT comparand — `{d: undefined}`, the implicit `=`. Reached for a - * nested relation too, because {@link fieldLeaves} recurses into one with the - * DOTTED member name, so `{profile: {verified: undefined}}` is refused as - * `"profile.verified"` — the member the leaf would have carried, not the - * relation. (`read-scope-sql`'s twin has no such case: it refuses nested + * - the DIRECT comparand — `{d: undefined}`, the implicit `=`. [#20887] Not + * inside a nested relation any more: {@link fieldLeaves} no longer recurses + * into one, it carries it as written for the engine, and an `undefined` + * inside it (`{profile: {verified: undefined}}`) is refused before that by + * the shared comparand-TYPE face, which {@link mapWhereFieldEntries} + * descends into the relation for. (`read-scope-sql`'s twin refuses nested * relations outright.) * - [#19888] ⛔ NOT a member of a bare array — `{d: [1, undefined]}`. That * position used to be swept here, because the bare array was read as an @@ -1006,8 +1020,8 @@ function assertDefinedComparands(field: string, spec: unknown): void { * `{amount: {gte: 10, $lte: 20}}` — repaired by the prefixed spelling * (`"gte" → "$gte"`); * - a NESTED-RELATION member that strayed into an operator wrapper — - * repaired by giving it a wrapper of its own (`{ "d": { "nested": … } }` - * compiles to the member `d.nested`), ANDed with the operator constraint + * repaired by giving it a wrapper of its own (`{ "d": { "nested": … } }`, + * a condition on the related record), ANDed with the operator constraint * explicitly, since one JSON object cannot spell the same field key twice. * * Option B — flattening the sibling as a nested path beside the operators — @@ -1027,7 +1041,7 @@ function mixedFieldWrapperError(field: string, opKeys: string[], nonOpKeys: stri `guess. If an operator missing its "$" was meant — the usual authoring slip — spell it with the ` + `prefix: ${rewrites}, as in { "${field}": { "$${example}": ... } }. If a nested-relation member ` + `was meant, give it a wrapper of its OWN with no $ siblings — { "${field}": { "${example}": ... } } ` + - `compiles to the member "${field}.${example}" — and AND it with the operator constraint ` + + `is a condition on the related record's own "${example}" — and AND it with the operator constraint ` + `explicitly: { "$and": [{ "${field}": { "$op": ... } }, { "${field}": { "${example}": ... } }] }. ` + `This shape used to compile by silently DROPPING every non-$ sibling, and a dropped conjunct ` + `does not narrow the query, it WIDENS it: the chart included rows the author excluded, with ` + @@ -1090,6 +1104,11 @@ function assertUnmixedFieldWrapper(field: string, wrapper: Record, guarded: unknown[], ): void { - if ( - isFilterObject(spec) && - Object.keys(spec).length > 0 && - !Object.keys(spec).some((k) => k.startsWith('$')) - ) { - for (const [nested, value] of Object.entries(spec)) { - guardFieldEntry(`${key}.${nested}`, value, out, guarded); - } + if (isNestedRelationCondition(spec)) { + out[key] = spec; return; } @@ -1751,6 +1769,63 @@ function isNestedRelationSpec(spec: unknown): spec is Record { return !Object.keys(spec).some((k) => k.startsWith('$')); } +/** + * [#20887] A nested-relation CONDITION: a {@link isNestedRelationSpec} that + * names at least one field. The empty object `{}` is not one — it keeps + * #5240's zero-operator refusal in {@link fieldLeaves}, the answer the engine + * gives it too (a condition naming no field of the related object). + */ +function isNestedRelationCondition(spec: unknown): spec is Record { + return isNestedRelationSpec(spec) && Object.keys(spec).length > 0; +} + +/** + * [#20887] The first nested-relation condition in a `FilterCondition` — + * `{ owner: { region: 'NA' } }` beneath a field, at any depth of `$and` / + * `$or` / `$not` — with the field and the key path it sits at, or `null`. + * + * The form is the ENGINE's: it serves it in `where` by reading the related + * object AS THE CALLER, with the related object's row scope and field + * permissions, and refuses a match past its cap (#20802's ruling, lowered at + * the #5930 seam). A compiler in this package that emits SQL itself cannot + * enforce that — it would need the caller's field permissions and a read of the + * related object, which is a second copy of the rule. So this is the ROUTING + * detector: `NativeSQLStrategy.canHandle` declines a query in which any + * producer it would compile carries the form, and the query goes to the + * engine path — the mechanism and the reasoning of the #7598 cross-field + * decline (maintainer ruling 2026-08-12, Q1 = B), for the same reason: the + * rule lives in one place. + * + * Reads the traversal the shared comparand faces read (`$and` / `$or` arrays, + * `$not`, field entries); any other `$` key is not a field. Never throws — a + * malformed shape is the normalizer's to refuse, in its own words. + */ +export function findNestedRelationCondition( + node: unknown, + path = 'where', +): { field: string; path: string } | null { + if (!isFilterObject(node)) return null; + for (const [key, spec] of Object.entries(node)) { + const here = `${path}.${key}`; + if (key === '$and' || key === '$or') { + if (!Array.isArray(spec)) continue; + for (const [index, child] of spec.entries()) { + const hit = findNestedRelationCondition(child, `${here}[${index}]`); + if (hit) return hit; + } + continue; + } + if (key === '$not') { + const hit = findNestedRelationCondition(spec, here); + if (hit) return hit; + continue; + } + if (key.startsWith('$')) continue; + if (isNestedRelationCondition(spec)) return { field: key, path: here }; + } + return null; +} + /** * [#20010, copy-on-write since #20035] Visit every FIELD ENTRY of an * object-form `where` — `{ key: spec }` with the `path` of the node that holds @@ -1765,9 +1840,10 @@ function isNestedRelationSpec(spec: unknown): spec is Record { * The extra step: a NESTED-RELATION object (`{ acct: { region: … } }`, see * {@link isNestedRelationSpec}) is descended, where the faces leave one alone * because a driver reads it as a deep-equality comparand or another object's - * condition. This compiler reads it as neither: {@link fieldLeaves} flattens it - * to the dotted member `acct.region`, so its entries are comparisons in their - * own right. The entry is visited as `{ region: … }` at path `where.acct`, + * condition. Its entries ARE comparisons in their own right — on the related + * object's fields, which the engine judges with these same faces when it reads + * that object for the condition (#20887: the condition reaches the engine as + * written) — so this door judges them first, in its own envelope. The entry is visited as `{ region: … }` at path `where.acct`, * which is how the #19888 gate has named that position since it landed, and * which the type face renders `where.acct.region` — the path the dotted * spelling `{ 'acct.region': … }` gets. @@ -1872,9 +1948,9 @@ function forEachWhereFieldEntry( * * One step past the face: a NESTED-RELATION object (`{ acct: { region: [...] } }`, * no `$` key) is descended. The face leaves one alone, because to a driver it - * is a deep-equality comparand or another object's condition. Here it is - * neither: {@link fieldLeaves} flattens it to the dotted member `acct.region`, - * whose implicit-equality slot is the one the list sits in. + * is a deep-equality comparand or another object's condition. Here it is the + * latter, a condition on the related object's own `region`, whose + * implicit-equality slot is the one the list sits in. * * It runs before every gate {@link buildNode} reaches, so a list is diagnosed * as the list and not by one of its members (`{ f: [1, undefined] }`), the @@ -1949,8 +2025,8 @@ function assertNoListInEqualitySlot(node: unknown, path = 'where'): void { * one-entry node with the same path seed, `where`: the face then reports * exactly the path and field it reports on the whole condition, so the object * spelling gets the `FilterArray` spelling's refusal byte for byte. A nested - * relation's entries are handed over too, since this compiler flattens them to - * the dotted member. + * relation's entries are handed over too: they are comparisons on the related + * object's fields. * * Refusals this door already gave in its own words now give the face's, the * same words the `FilterArray` spelling gets: a `$between` that is not a @@ -1991,8 +2067,8 @@ function assertWhereComparandShapes(node: unknown, path = 'where'): void { * reports exactly the path it reports on the whole condition and the object * spelling gets the `FilterArray` spelling's refusal byte for byte. A nested * relation's entries are handed over too ({@link mapWhereFieldEntries}): the - * face leaves such an object alone as filter STRUCTURE, and this compiler - * flattens it to dotted members whose comparands are literals like any other. + * face leaves such an object alone as filter STRUCTURE, and its comparands are + * literals on the related object's fields like any other. */ function normalizeWhereComparandTypes(node: T, path = 'where'): T { return mapWhereFieldEntries(node, path, (key, spec, at) => @@ -2388,88 +2464,67 @@ export function normalizeAnalyticsFilterTree( ): NormalizedFilterNode | null { const condition = lowerAnalyticsWhere(query); if (!condition) return null; - return buildNode(lowerFilterCondition(spellNestedRelationsDotted(condition), lowering)); + return buildNode(lowerFilterCondition(shieldNestedRelations(condition), lowering)); } /** - * [ADR-0053 D-D1, amended — #5930 step 3] Spell every nested-relation field - * spec (`{ account: { region: 'NA' } }`) as the DOTTED members it names - * (`{ 'account.region': 'NA' }`), before the shared lowering reads the - * condition. - * - * The nested spelling is this door's own sugar: the schema accepts it, the - * engine refuses it on every driver, and {@link fieldLeaves} is what gives it a - * meaning here — it compiles each nested key as the dotted member. The shared - * lowering has no such reading: it takes `account` for a column and, inside a - * `$not`, guards it — `account IS NOT NULL`, a predicate on whatever the member - * `account` resolves to, which is not the member the leaf reads (the reason - * {@link guardFieldEntry} flattens before it guards). Spelled dotted first, - * the lowering guards `account.region`, the member the leaf binds. - * - * It rewrites only the spelling: every dotted member is the one - * {@link fieldLeaves} would have produced, in the same order, and a member that - * already has an entry keeps both, the second as an `$and` conjunct. An EMPTY - * spec is left as it is, for {@link fieldLeaves}' zero-operator refusal — - * flattening `{}` would make the constraint vanish. Copy-on-write: a condition - * with no nested relation comes back as the same object. + * [#20887] The nested-relation conditions a `where` carries, held OUT of the + * shared lowering: each stands in the lowered condition as a sentinel spec + * registered here, and {@link fieldLeaves} turns the sentinel back into the + * `relation` node carrying the author's condition, as written. + * + * Why the lowering must not read the condition itself: it is not a door, and it + * reads every field key as a column of THIS object. Under a `$not` it would + * guard the relation column (`{ owners: { $null: false } }`) before anything + * knows which related ids match — a guard the engine adds itself, over the + * `$in` / `$contains` it lowers the condition to, and one this package's engine + * hand-off spells `$ne: null`, which the SQL driver refuses over a multi-valued + * relation's JSON column (measured: `{ $not: { owners: { region: 'NA' } } }` + * answered `INVALID_FILTER` where the engine answers its rows). And the + * condition's own comparands belong to the related object, whose declared + * types the engine reads when it lowers them. + * + * The sentinel is `{ $exists: true }`, a fresh object per condition: TOTAL for + * a row with no value, so the lowering neither guards nor rewrites it and hands + * it on by reference, and it names no column as required (only an exact + * `{ $null: false }` does). Copy-on-write, like the lowering: a condition with + * no nested relation comes back as the same object. */ -function spellNestedRelationsDotted(node: Record): Record { - const isNonEmptyRelation = (spec: unknown): spec is Record => - isNestedRelationSpec(spec) && Object.keys(spec).length > 0; - const dottedPairs = (prefix: string, spec: Record): Array<[string, unknown]> => - Object.entries(spec).flatMap(([key, value]): Array<[string, unknown]> => { - const dotted = `${prefix}.${key}`; - return isNonEmptyRelation(value) ? dottedPairs(dotted, value) : [[dotted, value]]; - }); +const NESTED_RELATION_SENTINELS = new WeakMap>(); - let changed = false; - const entries: Array<[string, unknown]> = []; - const conjuncts: Record[] = []; - const seen = new Set(); - const put = (key: string, value: unknown): void => { - if (seen.has(key)) conjuncts.push({ [key]: value }); - else { - seen.add(key); - entries.push([key, value]); - } +function shieldNestedRelations(node: Record): Record { + let out: Record | undefined; + const replace = (key: string, value: unknown): void => { + out ??= { ...node }; + out[key] = value; }; for (const [key, spec] of Object.entries(node)) { - if ((key === '$and' || key === '$or') && Array.isArray(spec)) { + if (key === '$and' || key === '$or') { + if (!Array.isArray(spec)) continue; let copy: unknown[] | undefined; spec.forEach((child, index) => { if (!isFilterObject(child)) return; - const spelled = spellNestedRelationsDotted(child); - if (spelled !== child) { + const shielded = shieldNestedRelations(child); + if (shielded !== child) { copy ??= [...spec]; - copy[index] = spelled; + copy[index] = shielded; } }); - if (copy) changed = true; - put(key, copy ?? spec); - continue; - } - if (key === '$not' && isFilterObject(spec)) { - const spelled = spellNestedRelationsDotted(spec); - if (spelled !== spec) changed = true; - put(key, spelled); + if (copy) replace(key, copy); continue; } - if (!key.startsWith('$') && isNonEmptyRelation(spec)) { - changed = true; - for (const [dotted, value] of dottedPairs(key, spec)) put(dotted, value); + if (key === '$not') { + if (!isFilterObject(spec)) continue; + const shielded = shieldNestedRelations(spec); + if (shielded !== spec) replace(key, shielded); continue; } - put(key, spec); - } - if (!changed) return node; - const out: Record = Object.fromEntries(entries); - if (conjuncts.length > 0) { - // A malformed `$and` is {@link buildNode}'s to refuse, in its own words; - // folding a conjunct into it would replace the shape it refuses. - if ('$and' in out && !Array.isArray(out.$and)) return node; - out.$and = [...((out.$and as unknown[] | undefined) ?? []), ...conjuncts]; + if (key.startsWith('$') || !isNestedRelationCondition(spec)) continue; + const sentinel = { $exists: true }; + NESTED_RELATION_SENTINELS.set(sentinel, spec); + replace(key, sentinel); } - return out; + return out ?? node; } /** @@ -2532,6 +2587,10 @@ export function collectFilterLeaves( ): NormalizedAnalyticsFilter[] { if (!node) return []; if (node.kind === 'leaf') return [{ member: node.member, operator: node.operator, values: node.values }]; + // [#20887] A nested-relation condition constrains the relation FIELD of the + // queried object — the member it names here. The related object's fields in + // it are the engine's to judge, as the caller, when it reads that object. + if (node.kind === 'relation') return [{ member: node.member, operator: 'relation', values: [] }]; // A boolean constant names no member — it constrains rows, not columns — so // it contributes nothing to the cross-object envelope check. if (node.kind === 'const') return []; 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 bc6e15abfc9..517016f5ea6 100644 --- a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts @@ -5,6 +5,7 @@ import type { Cube } from '@objectstack/spec/data'; import type { AnalyticsStrategy, StrategyContext, DatasetScopedStrategyContext } from './types.js'; import { declaredDatetimeLowering, + findNestedRelationCondition, lowerAnalyticsWhere, normalizeAnalyticsFilterTree, toSqlBindValue, @@ -252,6 +253,25 @@ export class NativeSQLStrategy implements AnalyticsStrategy { // filter the engine door answers 400 for — two envelopes for one mistake, // which is the drift this card exists to remove. if (this.carriesUninterpretableTemporalComparand(query, ctx)) return false; + // ── [#20887] DECLINE the nested-relation form ───────────────────────── + // + // `{ relation: { field: value } }` is served by the ENGINE (#20802's + // ruling, lowered at the #5930 seam): the related object is read AS THE + // CALLER — its row scope AND its field permissions — and a match past the + // engine's cap is refused, never truncated. This strategy compiles SQL + // itself and can do neither without a second copy of that rule (it holds no + // field permissions and reads no related object), which is what it used to + // do instead: flatten the form to a dotted member and LEFT JOIN the related + // table, filtering by a field the caller may not read and bounded by + // nothing. So it declines, and the query routes to the ObjectQL strategy, + // which hands the form to the engine as written. The mechanism of the + // #7598 decline above, for the same reason — one rule, in one place. Every + // filter a caller or a dataset writes is read (see + // {@link nestedRelationConditionIn}); a READ SCOPE is not, deliberately — + // it is a policy this strategy compiles to SQL, and that compile keeps its + // fail-closed refusal of the form (`read-scope-sql.ts`). The DOTTED member + // (`account.region`) is a cube member, not this form, and stays here. + if (this.nestedRelationConditionIn(query, ctx)) return false; const caps = ctx.queryCapabilities(query.cube); return caps.nativeSql && typeof ctx.executeRawSql === 'function'; } @@ -353,6 +373,50 @@ export class NativeSQLStrategy implements AnalyticsStrategy { return null; } + /** + * [#20887] The first nested-relation condition in a filter this strategy + * would compile for `query` — or `null`. See the decline at {@link canHandle}. + * + * Every filter the caller or the dataset writes, as {@link generateSql} + * compiles them: the caller's `where` (lowered, so the `FilterArray` spelling + * is read as the object it lowers to), the compiled dataset's own `filter`, + * and the `filter` of each REQUESTED measure (one it does not ask for is never + * compiled). + * + * ⛔ NOT the read scope. A read scope is a policy, compiled to SQL here by + * `compileScopedFilterToSql` — a synchronous string builder that holds the + * caller's context for placeholders and no data engine, so it cannot read the + * related object as the caller. It keeps its fail-closed refusal of the form + * (`READ_SCOPE_COMPILE_FAILED` / 500, the policy withheld), in words that name + * the route; routing the query away would trade that declared refusal for + * whatever the next strategy answers, on a host that has none a generic fault. + */ + private nestedRelationConditionIn( + query: AnalyticsQuery, + ctx: StrategyContext, + ): { source: string; field: string; path: string } | null { + let where: unknown = null; + try { + where = lowerAnalyticsWhere(query); + } catch { + // A `where` this compiler cannot even lower is refused downstream, with + // its own message. Nothing to route. + } + const inWhere = findNestedRelationCondition(where); + if (inWhere) return { source: 'the query\'s `where`', ...inWhere }; + + const datasetScope = query.cube + ? (ctx as DatasetScopedStrategyContext).getDatasetScope?.(query.cube) + : undefined; + const inScope = findNestedRelationCondition(datasetScope?.filter, 'filter'); + if (inScope) return { source: 'the dataset\'s own `filter`', ...inScope }; + for (const measure of query.measures ?? []) { + const inMeasure = findNestedRelationCondition(datasetScope?.measureFilters?.[measure], 'filter'); + if (inMeasure) return { source: `the \`filter\` of measure "${measure}"`, ...inMeasure }; + } + return null; + } + /** * [#7598] The fail-closed backstop at the door that BINDS. * @@ -1090,6 +1154,25 @@ export class NativeSQLStrategy implements AnalyticsStrategy { return node.value ? SQL_CONST_TRUE : SQL_CONST_FALSE; } + if (node.kind === 'relation') { + // [#20887] Unreachable by construction: {@link canHandle} declines every + // query that carries a nested-relation condition, reading the same + // producers this compiler reads. Kept because the failure mode if the two + // ever disagree is the one #20887 closed — a JOIN that filters by a field + // the caller may not read, with no cap — and a routing regression must + // be a loud fault, never that. Deliberately BARE, an undeclared 500: the + // caller's filter is legal and is served on the engine path, so arriving + // here is our own drift, not the caller's 400 (the tier and the reasoning + // of {@link assertNoCrossFieldComparison}). + throw new Error( + `[native-sql-strategy] the nested-relation condition on "${node.member}" reached the SQL ` + + `compiler. It is served by the engine, which reads the related object as the caller — ` + + `\`canHandle\` declines such a query so it routes to the ObjectQL/engine path; reaching ` + + `this throw means the decline and this compiler stopped agreeing, which is our bug and ` + + `must never degrade to a joined statement.`, + ); + } + if (node.kind === 'leaf') { const colExpr = this.resolveFieldSql(cube, node.member, parentTable, joins); // Resolve the (object, column) this member binds against so the value diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index 4dcf27e647d..e38bb755968 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -1596,6 +1596,14 @@ export class ObjectQLStrategy implements AnalyticsStrategy { return; } + if (node.kind === 'relation') { + // [#20887] Its own conjunct, never merged into the field's entry: an + // operator beside it on the same field would make one mixed object the + // engine refuses, where the author wrote two constraints. + conjuncts.push(this.relationCondition(node)); + return; + } + if (node.kind === 'and') { for (const child of node.children) this.applyFilterNode(child, cube, filter, conjuncts); return; @@ -1625,6 +1633,8 @@ export class ObjectQLStrategy implements AnalyticsStrategy { return node.value ? null : { $not: {} }; } + if (node.kind === 'relation') return this.relationCondition(node); + if (node.kind === 'not') { const inner = this.filterNodeToCondition(node.child, cube); // `NOT TRUE ≡ FALSE` — a negation of nothing is the zero-row filter, not @@ -1651,6 +1661,25 @@ export class ObjectQLStrategy implements AnalyticsStrategy { return Object.keys(filter).length > 0 ? filter : null; } + /** + * [#20887] A nested-relation condition as the engine reads it — the author's + * `{ owner: { region: 'NA' } }`, beneath the relation field it was written + * under. The engine answers it (#20802's ruling, lowered at the #5930 seam): + * in `where` it reads the related object AS THE CALLER — the context this + * strategy forwards with the aggregate — with the related object's row scope + * and field permissions, and matches the relation against the ids, refusing + * past its cap; at an aggregation's own `filter` it refuses the form. This + * strategy judges none of that: one rule, the engine's. + * + * The key is the field of the queried object the author named, not a cube + * member resolved through `resolveFieldName`: the form names "a relation field + * on the queried object" (the ruling's words), and the engine judges it + * against that object's declared fields. + */ + private relationCondition(node: Extract): Record { + return { [node.member]: node.condition }; + } + /** * Render a normalized filter node as the display SQL `/analytics/sql` * echoes. Values still bind as `$n` placeholders — the echo travels to the @@ -1676,6 +1705,25 @@ export class ObjectQLStrategy implements AnalyticsStrategy { return node.value ? SQL_CONST_TRUE : SQL_CONST_FALSE; } + if (node.kind === 'relation') { + // [#20887] The echo DECLINES the nested-relation form, the way it + // declines a cross-field comparison (#7598's echo ruling: one consistent, + // loud answer, never a half-rendering). What runs is a read of the + // related object as the caller, then a match against the ids it + // returned; no statement this renderer can print reproduces that — a + // JOIN would name rows the caller's field permissions and the engine's + // cap never let through. `execute()` swallows the echo's refusal, so the + // query face still answers; only the dry-run face refuses. + throw invalidFilterError( + `[analytics] cannot render display SQL for the nested-relation condition on "${node.member}" ` + + `({ "${node.member}": { … } }). The query itself is answered by the engine: it reads the ` + + `related object as the caller, with that object's row scope and field permissions, and ` + + `matches "${node.member}" against the ids it returns, refusing a match past its cap. No ` + + `statement printed here reproduces that read. Run the query itself (/analytics/query) for ` + + `its rows.`, + ); + } + if (node.kind === 'leaf') { return this.buildFilterClauseSql( this.resolveFieldName(cube, node.member, 'any'), diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index 329dc77870d..e00bc5f12e3 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -748,6 +748,10 @@ export const CROSS_PACKAGE_TEST_INPUTS = { // `@objectstack/spec` already declares it verbatim, so Layer C reaches it // today. What stays uncovered stays recorded in that test's header. 'scripts/**', + // The census's hand LEDGER names this file: a NOT_SDK row pins its five + // producer reads of `analytics.query`. A real input, so a changed call + // count re-runs this suite. + 'packages/rest/src/analytics-nested-relation-filter.test.ts', ], heldBy: { // `scripts/**` is rostered TODAY through the census's own diff --git a/turbo.json b/turbo.json index 0ddd48454ba..599806afa3b 100644 --- a/turbo.json +++ b/turbo.json @@ -174,7 +174,8 @@ "$TURBO_ROOT$/scripts/check-route-envelope.mjs", "$TURBO_ROOT$/scripts/js-comment-mask.mjs", "$TURBO_ROOT$/scripts/js-comment-mask.d.mts", - "$TURBO_ROOT$/scripts/**" + "$TURBO_ROOT$/scripts/**", + "$TURBO_ROOT$/packages/rest/src/analytics-nested-relation-filter.test.ts" ] }, "@objectstack/lint#test": {