Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/20887-analytics-nested-relation-engine-answer.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) a change of what the analytics query doors answer for one filter form, the nested-relation condition `{ relation: { field: value } }`: it is now answered by the data engine, which reads the related object as the caller and refuses past its cap, where the analytics layer used to join the related table itself. No authorable key, spelling, export or stored shape moves: `@objectstack/service-analytics` exports nothing new and nothing less, `FilterCondition`, `CubeSchema`, `DatasetSchema` and the analytics query body keep parsing every value they parsed, and no stored row is read or rewritten. What a stored dashboard or dataset filter carrying the form now gets is the engine's own answer for the same filter on `find()`, so there is no older meaning to preserve or rewrite. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a filter's analytics semantics (not `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->

**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.
17 changes: 13 additions & 4 deletions packages/client/src/envelope-caller-census.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
]);
});
});

Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading