Skip to content

Commit 1caa603

Browse files
fix(service-analytics)!: refuse an analytics order key that names no member the query selects (#21314)
Fixes #21267 Clause-②: no (narrowing) ## What changed The analytics door now refuses an `order` key that names no member the query selects, with `400 INVALID_FIELD`, on the native-SQL and the ObjectQL face alike, before either strategy runs. This is triage's ruling (a) on the card (comment `5943018488`). **The rule.** An `order` key must be a column the answer carries: one of the query's own `dimensions` entries, one of its `measures` entries, or a `timeDimensions` entry that carries a `granularity`. It must be spelled exactly as it is selected. A `timeDimensions` entry with only a `dateRange` bounds the rows and is not a column, so it is not orderable. The spelling is exact because the answer keys each column by the spelling the caller sent (a cube-qualified measure keeps its qualifier), and both strategies write the key into `ORDER BY` as that column's name. The member-bearing keys were read off `AnalyticsQuerySchema` in `packages/spec/src/data/analytics.zod.ts`: `measures`, `dimensions` and `timeDimensions[].dimension`. `where` names filter members, not columns. Spec is not edited. - **`order-key-door.ts` (new, internal to the package, not exported from `index.ts`).** `assertOrderKeysSelected(query)` throws the door's existing member-gate envelope: `code: 'INVALID_FIELD'`, `status: 400`, `param: 'order'`, `field` (the first offending key). The message names every offending key and every member the query does select, and says the query was not run. ⛔ No new error code. - **One call site: `AnalyticsService.callCtx`.** That is the one seam `query()` (`/analytics/query`, and every query `DatasetExecutor` runs) and `generateSql()` (`/analytics/sql`) share, so both doors and both faces answer alike. It runs after `ensureCube` (an unknown cube still answers 404 first) and after the admission verdicts (object, stored metadata body, field read, field query). It runs before the read scopes are resolved and before any strategy is selected. ⛔ No per-strategy copy. - **One definition of "projected".** `ObjectQLStrategy.projectedDimensions` (which groups, maps rows and describes `fields[]`) now delegates to the door module's `projectedDimensions`. The door and the strategy read the same rule for which time dimensions are columns. ## Measured: `POST /api/v1/analytics/query` and `/sql` on the real dispatcher route Setup: `AnalyticsServicePlugin` over a real `ObjectQL` engine and `SqlDriver`, a signed-in caller, the real `dispatcher-plugin` mount, SQLite in memory and a private PostgreSQL 16.14. The cube over `deal` declares **no** join. `owner` is a lookup whose `reference` is a person object that also declares `note` and `amount`. The ObjectQL face is the same plugin narrowed to the engine-aggregate path. Before: `origin/main` at `3196ef1a1`. After: this branch's `service-analytics` build at `4362776a3`. The scratch probe was deleted. | query | native SQLite, before → after | native PostgreSQL, before → after | ObjectQL face (both drivers), before → after | |:--|:--|:--|:--| | `dimensions: ['owner.email']`, `order: { note }` | **500** `DATABASE_ERROR` → **400** `INVALID_FIELD` | **500** (42702, `note` ambiguous) → **400** | 200 → **400** | | `dimensions: ['note']`, `order: { amount }` | 200, ordered by an arbitrary row → **400** | **500** (42803, must appear in GROUP BY) → **400** | 200 → **400** | | `dimensions: ['note']`, `order: { 'owner.email' }` | **500** → **400** | **500** (42703, no such column) → **400** | 200 → **400** | | CONTROL `order: { note: 'desc' }` (selected dimension) | 200 → 200 | 200 → 200 | 200 → 200 | | CONTROL `order: { amount_sum: 'desc' }` (selected measure) | 200 → 200 | 200 → 200 | 200 → 200 | `/sql` (the dry run) answered 200 on every refused row before, with a statement whose `ORDER BY` names a column the statement does not select. It now answers the same 400. The control cells are byte-identical before and after: 16 cells (2 drivers × 2 faces × 2 routes × 2 controls), comparing status, rows and the echoed statement. **What the ObjectQL face answered today (triage's first measurement).** It answered `200` for all three rows on both drivers. That is because its execution never applies `order` at all, not because it orders by anything. The selected-dimension control asked for `desc` and came back `x, y, z` on SQLite and `z, x, y` on PostgreSQL. Its echoed statement shows the same `ORDER BY` the native face could not run. See Acceptance notes. ## Census (before the code change) - **`examples/**` and `packages/apps/**`: zero hits.** No shipped dashboard widget carries `sortBy` / `sortOrder`. The only `sortBy` text is a comment in `examples/app-todo/src/dashboards/task.dashboard.ts:21`. No report carries `order`. No dataset or cube carries one, and no page builds an analytics query with one. `packages/apps/**` issues no analytics query at all. The `order:` hits that do exist (`active-projects.page.ts:28`, `review-queue.page.ts:34`, the two `task.view.ts` files, `embed-objectql/src/index.ts:58`) are record-query `sort` entries on the data API, not analytics. - **`examples/app-showcase/src/ui/pages/command-center.page.ts` (held by #21251): no hit, so nothing to report.** Its KPIs are `object-metric` scalars (`aggregate` with no `order`, lines 147–152). Its charts are dataset-bound with no `sortBy` (lines 158–167). Its work queue is an `object-grid` (line 172). Not edited. - **objectui at the pinned `.objectui-sha` `31971ff1e28f`: no hit.** Its one `/analytics/query` sender is `ObjectStackAdapter.aggregate()` in `packages/data-objectstack/src/index.ts`. Its payload (lines 6278–6306) carries `cube`, `measures`, `dimensions` and `where`, never `order`. `git grep` for `analytics.query(` and for a `cube:` payload finds no other sender. Dashboard `sortBy` reaches the dataset door (`selection.order`), not this route. - **In-repo producers: none besides the dataset executor.** No AI tool, plugin or service other than `DatasetExecutor` calls the analytics query door with an `order`. The executor pushes an `order` down only when the selection is one query and every key is a dimension or unfiltered measure that query selects (`canPushDownWindow`, `dataset-executor.ts:1136–1143`). The dataset door refuses an unselected `selection.order` key itself first (`resolveOrdering`, `400 DATASET_INVALID`). So this door never refuses a dataset selection the dataset door accepted. A census pin was **not** added. The population of shipped analytics queries with an `order` is zero, and a pin over an empty population asserts nothing. A pin that walks future dashboards would be a new gate, which the dispatch's axes default to "no". The dataset door already refuses an unselected widget `sortBy` at runtime. ## Pins **New file:** `packages/services/service-analytics/src/__tests__/order-key-selected.test.ts`. It uses the plugin's own composition over a real engine, a SQLite cell and a PostgreSQL cell (a named skip without `OS_TEST_POSTGRES_URL`), and both faces. Each refusal pin runs both doors (`query` and `generateSql`) on both faces. It asserts `code`, `status`, `param` and `field`, that the message names the key and every selected member, and that nothing ran (zero raw statements and zero engine aggregates). There are 16 tests, 8 per cell. 1–3. The card's three rows are refused 400 `INVALID_FIELD` on both faces and both doors. 4. A `timeDimensions` entry that only sets a `dateRange` is not a column, so ordering by it is refused. 5. Exact spelling: a cube-qualified spelling of a measure selected bare is refused. 6. CONTROL: ordering by a selected dimension is served. The native face returns the requested order (`z, y, x`) and the ObjectQL face the same groups. `/sql` shows `ORDER BY "note" DESC`. 7. CONTROL: ordering by a selected measure is served. The native face returns `x 15, y 7, z 1`. 8. CONTROL: a bucketed time dimension is a column, so ordering by it is served (three month buckets). **Existing pins that now also hold this door's slot.** A key naming a field the caller may not read still gets the field gate's 403, because the door runs after admission. That is pinned, unchanged and green, by `field-read-admission-gate.test.ts` ("an order key", "an expression member as an order key"), `field-query-admission-gate.test.ts` ("a masked field as an order key") and the route-level `packages/rest/src/analytics-field-permission-gate.test.ts:300`. A first draft placed the door ahead of admission. Those six service-level cases went red (a 400 replacing the 403), and the rest route pin would have too. That placement was dropped rather than the fixtures rewritten. ## Ablations The pin file imports the subject by relative path (`../plugin.js`), so each run reads `src` and there is no `dist` leg. Each mutation went through `scripts/ablation-replace.mjs` in WRAP mode, with an outer `trap` restore on `EXIT INT TERM` against the absolute path. Both ran from committed `4362776a3` with the PostgreSQL cell live. Predictions were written before each run. | ablation | mutation (`order-key-door.ts`) | predicted | observed | |:--|:--|:--|:--| | A1 | the refusal removed: `if (unselected.length === 0) return;` becomes a test that is always true (greater than or equal to 0) | 10 red (pins 1–5 on each cell), 6 green (the controls) | **10 failed / 6 passed**, exactly pins 1–5 × 2 | | A2 | the refusal widened to selected members: `unselected = keys` | 6 red (the three controls on each cell), 10 green | **6 failed / 10 passed**, exactly the controls × 2 | Each mutation landed: anchor 1 → 0, and the blob changed from `21103a6b4884` to A1 `488d0e6274bb` and A2 `5c62c68e0315`. Each was restored and proven: the blob equals the HEAD blob `21103a6b4884`, and `git diff HEAD` is empty. ## Docs `git grep` over `content/docs/**` (excluding `releases/`) and `skills/**` for analytics `order`, `sortBy` and `orderBy` found no sentence or example this change makes false. Dashboards (`ui/dashboards.mdx:161`, `skills/objectstack-ui/rules/dashboards.md:349`) and reports (`data-modeling/analytics.mdx:140`) already say the sort key must be selected. The README example orders by a selected measure. Two sentences document the rule where `/analytics/query` had none: - `content/docs/api/data-api.mdx`, `POST /analytics/query`: a new callout after the `where` callout. It states the rule and the 400, says `/sql` refuses the same keys, and says "To order by a member, select it." - `packages/services/service-analytics/README.md:100`: the `order` row's empty description becomes the rule. ## Changeset `.changeset/21267-analytics-order-key-selected.md`: `@objectstack/service-analytics` **`minor`**, with `Clause-②: no (narrowing)`, a BREAKING note, and the ADR-0087 disposition `not-required (no-migration-prescription)` with its reasons. See Deviations for `minor`. ## Verification at `34d48294a` `34d48294a` is this branch's head. It carries `origin/main` at `4e530568a` merged in (no overlap with this diff's files). Install and the full build (`turbo run build`, 72/72) were refreshed after the merge. - `pnpm --filter @objectstack/service-analytics test`, with the PostgreSQL cells live: 166 files, **3843 passed, 0 skipped, 0 failed**. `typecheck` (`tsc --noEmit`): exit 0, and its program lists all 4 touched `.ts` files (`--listFiles`). - Consumer radius, against the post-merge build with `OS_TEST_POSTGRES_URL` set. These are the test files that drive the analytics service or its routes in `packages/rest` (21 files), `packages/runtime` (19), `packages/drivers/driver-memory` (26), `packages/drivers/driver-sql` (2) and `packages/plugins/plugin-security` (1). Results: 292/292, 551/551, 848/848, 60 passed / 1 skipped (a pre-existing skip in `sql-driver-13714-aggregate-alias-single-identifier.test.ts`), and 262/262. No failures. **NOT MEASURED:** the `packages/qa/dogfood` analytics files. They boot the example apps, which carry no analytics `order` (census above), and are left to the required `Dogfood Regression Gate`. - Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derives 91 commands at `34d48294a`, and all 91 exit 0. `--ran` reconciliation: "91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN", with every exit code recorded. No run left a managed block in `AGENTS.md`. - Lint, a declared narrowing: `eslint --no-inline-config --format json` over the 4 touched `.ts` files at `34d48294a` reports 4 files, 0 errors and 0 warnings. ① All 4 are inside the population `eslint.config.mjs` lints (`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`, minus `NEVER_LINTED`). The `.md` / `.mdx` files match no `files` glob ("File ignored because no matching configuration was supplied"). ② The count of 4 is read from the JSON output. ③ The config enables no type-aware linting (`--print-config` gives `parserOptions` `{"ecmaVersion":"latest","sourceType":"module"}`, with no `project`), so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is left to CI. ## Acceptance notes - **The ObjectQL face never applies `order`, `limit` or `offset` on `/analytics/query`, while its echoed statement says it does.** This is a separate defect, reported to the seat and not handled here. `ObjectQLStrategy.execute()` hands `engine.aggregate` no ordering or window (`objectql-strategy.ts:304–321`), but its `generateSql` renders `ORDER BY` / `LIMIT` / `OFFSET` (`:634–639`). On the default composition every bucketed time-dimension query lands on this face, because the native face declines granularity. Measured at `34d48294a`, SQLite and PostgreSQL 16.14: `timeDimensions: [{ dimension: 'closed_on', granularity: 'month' }]`, `order: { closed_on: 'desc' }`, `limit: 1` answers **all three** month rows, ascending on SQLite and `03, 05, 04` on PostgreSQL. The dataset door is unaffected: `DatasetExecutor` re-applies ordering and the window over the assembled grid. - The admission gate's `order` position (`namedQueryFields`) stays load-bearing. It still decides the 403 for an unselected key over a field the caller may not read, because the door runs after it. - The `/sql` dry run's answer changes only for a refused query: 200 with an unrunnable statement becomes 400. ## Deviations - **Bump `minor`, not `patch`.** The dispatch named `patch`. `check-changeset-no-major.mjs` grades a `Clause-②: no (narrowing)` declaration whose moved package is graded `patch` as `enforce` (exit 1). Its reading says a narrowing "is a BREAKING change; during the launch window it ships `minor`". The landed precedent for an analytics-door narrowing, the JSON-door changeset for #21232, is `minor`. - **The door's slot.** The dispatch said "at the shared entry, before strategy selection". The door sits on `callCtx`, the one seam both doors share, after admission and before strategy selection, for the reason under Pins. - File surface: the diff stays inside the claim: `packages/services/service-analytics/src/` and its tests, the package README, one docs page and the changeset. No census fix was needed. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bcd68a2 commit 1caa603

7 files changed

Lines changed: 482 additions & 7 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
fix(service-analytics)!: an analytics `order` key that names no member the query selects is refused with `INVALID_FIELD` / 400 at the analytics door, on both strategies, before either runs
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at the analytics door of an `order` key the query does not select. No authorable key, spelling, export or stored shape moves: `AnalyticsQuerySchema` still parses every `order` it parsed (the refusal is a runtime rule on the request, not a schema change), `CubeSchema`, `DatasetSchema` and the dashboard and report schemas are untouched, the new module is internal to the package (not exported from `index.ts`), and no stored row is read or rewritten. An unselected key had no defined meaning to preserve: the native-SQL strategy answered 500 for it, or on SQLite ordered the groups by an arbitrary row, and the ObjectQL strategy never applied `order` at all; which member a caller meant is not something a ledger entry can rewrite. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers an analytics order key, and this diff adds none (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
10+
11+
**BREAKING**: this narrows what `POST /api/v1/analytics/query` and its dry run `POST /api/v1/analytics/sql` accept, on both strategies and every driver. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
12+
13+
**The rule.** Each `order` key must be a column the answer carries: one of the query's own `dimensions` entries, one of its `measures` entries, or a `timeDimensions` entry that carries a `granularity`, spelled exactly as it is selected (a `<cube>.`-qualified measure keeps its qualifier in the answer, so the bare spelling names no column beside it, and the other way round). A `timeDimensions` entry with only a `dateRange` bounds the rows and is not a column. Any other key is refused with `400 INVALID_FIELD`, naming every such key and the members the query does select, and nothing is executed. The thrown error carries `param: 'order'` and `field` (the first such key).
14+
15+
**Before**, measured through `POST /api/v1/analytics/query` on SQLite and PostgreSQL 16.14, for a cube that declares no join over an object whose lookup target also declares `note`:
16+
17+
- `dimensions: ['owner.email']` with `order: { note: 'asc' }`: native-SQL strategy `500` on both drivers (PostgreSQL 42702, `note` is ambiguous); ObjectQL strategy `200`.
18+
- `dimensions: ['note']` with `order: { amount: 'asc' }`: native-SQL strategy `200` on SQLite, ordered by an arbitrary row's `amount`, and `500` on PostgreSQL (42803, must appear in GROUP BY); ObjectQL strategy `200`.
19+
- `dimensions: ['note']` with `order: { 'owner.email': 'asc' }`: native-SQL strategy `500` on both drivers (PostgreSQL 42703, no such column); ObjectQL strategy `200`.
20+
21+
**Now** each of those answers `400 INVALID_FIELD` on both strategies and both drivers, and `POST /api/v1/analytics/sql` refuses them the same way instead of returning a statement whose `ORDER BY` cannot run.
22+
23+
**What to write instead.** Add the key to the query's `dimensions` (or `measures`), so the answer carries it, or drop it from `order`.
24+
25+
**Who is affected.** A caller that posted an `order` key it did not select. On the native-SQL strategy those queries were already a 500 everywhere but the one SQLite shape, whose order was arbitrary. No example app, shipped dashboard, report, dataset or cube authors such a key, and the console's analytics adapter sends no `order` to this route.
26+
27+
**Unchanged.** Ordering by a selected dimension, a selected measure or a bucketed time dimension; the dataset door (`POST /api/v1/analytics/dataset/query`), which already refused an unselected `selection.order` key with `400 DATASET_INVALID` and pushes an `order` down only when the selection selects every key; and a key naming a field the caller may not read, which keeps the `403 PERMISSION_DENIED` the field-level read gate answers for every position.

‎content/docs/api/data-api.mdx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,15 @@ in its own `sql`.
430430
Filtering uses the canonical Query DSL `where` object (the same MongoDB-style `FilterCondition` accepted by `find()`), not a `filters` array.
431431
</Callout>
432432

433+
<Callout type="info">
434+
**An `order` key names a column the answer carries.** Each key must be one of the query's own
435+
`dimensions` or `measures` entries, or a `timeDimensions` entry that carries a `granularity`,
436+
spelled exactly as it is selected. Any other key — a field the query does not select, a
437+
relationship path it does not group by, a time dimension that only sets a `dateRange` window — is
438+
refused with `400 INVALID_FIELD` naming the key and the members the query does select, and nothing
439+
is executed. `POST /analytics/sql` refuses the same keys. To order by a member, select it.
440+
</Callout>
441+
433442
**Response**: the runtime dispatcher wraps the `AnalyticsResult` as `{ success: true, data: { rows, fields, sql?, totals? } }`:
434443
```json
435444
{

‎packages/services/service-analytics/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ is rejected rather than dropped.
9797
| `dimensions` | `string[]?` | |
9898
| `where` | `FilterCondition?` | Canonical Query DSL filter — the same shape `find()` takes. |
9999
| `timeDimensions` | `{ dimension, granularity?, dateRange? }[]?` | Also strict per item. |
100-
| `order` | `Record<string, 'asc' \| 'desc'>?` | |
100+
| `order` | `Record<string, 'asc' \| 'desc'>?` | Each key must be a selected `dimensions` / `measures` entry, or a `timeDimensions` entry with a `granularity`, spelled as selected. Any other key is refused with `400 INVALID_FIELD`. |
101101
| `limit` | `number?` | |
102102
| `offset` | `number?` | |
103103
| `timezone` | `string?` | IANA name. No default — an absent timezone means the engine resolves it. |
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21267] An analytics `order` key must name a member the query selects — a
5+
* `dimensions` entry, a `measures` entry, or a `timeDimensions` entry with a
6+
* `granularity` — or the analytics door refuses the query `INVALID_FIELD` /
7+
* 400, on the native-SQL and the ObjectQL face alike, before either strategy
8+
* runs (`order-key-door.ts`).
9+
*
10+
* ## The shape this closes
11+
*
12+
* Measured through `POST /api/v1/analytics/query` on the real dispatcher route
13+
* at `3196ef1a1`, SQLite and PostgreSQL 16.14. The cube over `deal` declares no
14+
* join; `owner` is a lookup whose target also declares `note` and `amount`:
15+
*
16+
* | query | native SQLite | native PostgreSQL | ObjectQL face |
17+
* |:--|:--|:--|:--|
18+
* | `dimensions: ['owner.email']`, `order: { note }` | 500 | 500 (42702) | 200 |
19+
* | `dimensions: ['note']`, `order: { amount }` | 200, arbitrary order | 500 (42803) | 200 |
20+
* | `dimensions: ['note']`, `order: { 'owner.email' }` | 500 | 500 (42703) | 200 |
21+
*
22+
* Every refusal pin below also asserts nothing ran: no raw statement and no
23+
* engine aggregate, on either face.
24+
*
25+
* ## The dialect axis of THIS file
26+
*
27+
* The SQLite cell always runs. The PostgreSQL cell runs where
28+
* `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise; no CI step
29+
* provisions that variable for this package, so the live cell is red-capable
30+
* and un-run in CI, and the PR that landed this file carries its local
31+
* PostgreSQL 16 run. The live cell owns its tables, dropped before and after.
32+
*/
33+
34+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
35+
import { ObjectQL } from '@objectstack/objectql';
36+
import { SqlDriver } from '@objectstack/driver-sql';
37+
import type { Cube } from '@objectstack/spec/data';
38+
import type { AnalyticsService } from '../analytics-service.js';
39+
import { AnalyticsServicePlugin } from '../plugin.js';
40+
41+
const PERSON = 'os21267_person';
42+
const DEAL = 'os21267_deal';
43+
44+
/** The lookup's target declares `note` and `amount` too, so an unqualified ORDER BY over a join is ambiguous. */
45+
const PERSON_OBJECT = {
46+
name: PERSON,
47+
label: 'Order key person',
48+
fields: {
49+
email: { name: 'email', type: 'text' as const },
50+
note: { name: 'note', type: 'text' as const },
51+
amount: { name: 'amount', type: 'number' as const },
52+
},
53+
};
54+
55+
const DEAL_OBJECT = {
56+
name: DEAL,
57+
label: 'Order key deal',
58+
fields: {
59+
note: { name: 'note', type: 'text' as const },
60+
amount: { name: 'amount', type: 'number' as const },
61+
closed_on: { name: 'closed_on', type: 'date' as const },
62+
owner: { name: 'owner', type: 'lookup' as const, reference: PERSON },
63+
},
64+
};
65+
66+
const PEOPLE = [
67+
{ id: 'p1', email: 'a@x', note: 'pn1', amount: 100 },
68+
{ id: 'p2', email: 'b@x', note: 'pn2', amount: 200 },
69+
] as const;
70+
// Groups by `note`: x (2 rows, amount 15), y (1, 7), z (1, 1). Neither the
71+
// note order nor the amount order matches insertion order reversed.
72+
const DEALS = [
73+
{ id: 'd1', note: 'x', amount: 10, closed_on: '2026-03-01', owner: 'p1' },
74+
{ id: 'd2', note: 'x', amount: 5, closed_on: '2026-03-02', owner: 'p1' },
75+
{ id: 'd3', note: 'y', amount: 7, closed_on: '2026-04-03', owner: 'p2' },
76+
{ id: 'd4', note: 'z', amount: 1, closed_on: '2026-05-01', owner: 'p2' },
77+
] as const;
78+
79+
/** The card's cube: it declares NO join, so `owner` is joined through the lookup's declared `reference`. */
80+
const CUBE = 'os21267_cube';
81+
const CUBES = [
82+
{
83+
name: CUBE,
84+
title: 'Order key cube',
85+
sql: DEAL,
86+
public: true,
87+
measures: {
88+
count: { type: 'count', sql: '*', label: 'Rows' },
89+
amount_sum: { type: 'sum', sql: 'amount', label: 'Amount' },
90+
},
91+
dimensions: {
92+
note: { type: 'string', sql: 'note', label: 'Note' },
93+
closed_on: { type: 'time', sql: 'closed_on', label: 'Closed on' },
94+
},
95+
},
96+
] as unknown as Cube[];
97+
98+
/** The card's three rows: each orders by a key the query does not select. */
99+
const CARD_ROWS = [
100+
{
101+
label: 'an unselected base column beside a relationship path (ambiguous on the native face)',
102+
query: { cube: CUBE, measures: ['count'], dimensions: ['owner.email'], order: { note: 'asc' } },
103+
key: 'note',
104+
selected: ['owner.email', 'count'],
105+
},
106+
{
107+
label: 'an unselected base column with no join (an arbitrary row on SQLite, 42803 on PostgreSQL)',
108+
query: { cube: CUBE, measures: ['count'], dimensions: ['note'], order: { amount: 'asc' } },
109+
key: 'amount',
110+
selected: ['note', 'count'],
111+
},
112+
{
113+
label: 'an unselected relationship path',
114+
query: { cube: CUBE, measures: ['count'], dimensions: ['note'], order: { 'owner.email': 'asc' } },
115+
key: 'owner.email',
116+
selected: ['note', 'count'],
117+
},
118+
] as const;
119+
120+
interface Cell {
121+
id: 'sqlite' | 'pg';
122+
label: string;
123+
env: string | null;
124+
config: () => Record<string, unknown> | null;
125+
}
126+
127+
const CELLS: readonly Cell[] = [
128+
{ id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) },
129+
{
130+
id: 'pg',
131+
label: 'live postgres',
132+
env: 'OS_TEST_POSTGRES_URL',
133+
config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null),
134+
},
135+
];
136+
137+
const FACES = ['native', 'objectql'] as const;
138+
type Face = (typeof FACES)[number];
139+
140+
const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } };
141+
142+
type Row = Record<string, unknown>;
143+
type Refusal = Error & { code?: string; status?: number; field?: string; param?: string };
144+
145+
/** Rows as tuples of the named columns, in the order they arrived; a measure reads as a number on every dialect. */
146+
const tuples = (rows: unknown, columns: readonly string[]) =>
147+
(rows as Row[]).map((row) => columns.map((c) => (typeof row[c] === 'number' || /^-?\d+(\.\d+)?$/.test(String(row[c])) ? Number(row[c]) : row[c])));
148+
const sorted = (list: unknown[][]) => [...list].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
149+
150+
for (const cell of CELLS) {
151+
const config = cell.config();
152+
describe.skipIf(!config)(
153+
`[#21267] an analytics order key must name a member the query selects (${cell.label})${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`,
154+
() => {
155+
let driver: any;
156+
let engine: ObjectQL;
157+
/** Raw-SQL statements and engine aggregates, on any object. */
158+
const reads = { rawSql: 0, aggregate: 0 };
159+
/** `native`: the plugin's own capabilities. `objectql`: narrowed to the engine-aggregate path. */
160+
const services: Partial<Record<Face, AnalyticsService>> = {};
161+
162+
const dropTables = async () => {
163+
if (cell.id !== 'pg') return;
164+
for (const table of [DEAL, PERSON]) await driver?.execute(`drop table if exists ${table}`).catch(() => {});
165+
};
166+
167+
/** One call on one face: its result or its error, and the reads it caused. */
168+
const call = async (face: Face, door: 'query' | 'generateSql', query: Record<string, unknown>) => {
169+
const before = { ...reads };
170+
const outcome = await (services[face]![door] as (q: any) => Promise<any>).call(services[face], query as any).then(
171+
(res) => ({ res, err: undefined as Refusal | undefined }),
172+
(err) => ({ res: undefined, err: err as Refusal }),
173+
);
174+
return { ...outcome, rawSql: reads.rawSql - before.rawSql, aggregate: reads.aggregate - before.aggregate };
175+
};
176+
177+
/** Both doors on both faces refuse `query` with the order-key envelope, and nothing ran. */
178+
const refusedEverywhere = async (query: Record<string, unknown>, key: string, selected: readonly string[]) => {
179+
for (const face of FACES) {
180+
for (const door of ['query', 'generateSql'] as const) {
181+
const { res, err, rawSql, aggregate } = await call(face, door, query);
182+
const where = `${face} ${door}`;
183+
expect(res, `${where}: answered instead of refusing`).toBeUndefined();
184+
expect(err?.code, `${where}: ${err?.message}`).toBe('INVALID_FIELD');
185+
expect(err?.status, where).toBe(400);
186+
expect(err?.param, where).toBe('order');
187+
expect(err?.field, where).toBe(key);
188+
// The refusal names the offending key and every member the query does select.
189+
for (const name of [key, ...selected]) expect(err?.message, where).toContain(`${name}`);
190+
expect(rawSql, `${where}: no raw statement ran`).toBe(0);
191+
expect(aggregate, `${where}: no engine aggregate ran`).toBe(0);
192+
}
193+
}
194+
};
195+
196+
beforeAll(async () => {
197+
driver = new SqlDriver(config as any);
198+
await dropTables();
199+
engine = new ObjectQL({ logger: quiet } as any);
200+
engine.registerDriver(driver, true);
201+
await engine.init();
202+
for (const object of [PERSON_OBJECT, DEAL_OBJECT]) engine.registry.registerObject(object as any);
203+
await engine.syncSchemas();
204+
for (const row of PEOPLE) await engine.insert(PERSON, { ...row } as any);
205+
for (const row of DEALS) await engine.insert(DEAL, { ...row } as any);
206+
207+
const realExecute = (engine as any).execute.bind(engine);
208+
(engine as any).execute = (sql: unknown, opts?: unknown) => {
209+
reads.rawSql += 1;
210+
return realExecute(sql, opts);
211+
};
212+
const realAggregate = engine.aggregate.bind(engine);
213+
(engine as any).aggregate = (...args: unknown[]) => {
214+
reads.aggregate += 1;
215+
return (realAggregate as any)(...args);
216+
};
217+
218+
for (const [face, caps] of [
219+
['native', undefined],
220+
['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })],
221+
] as const) {
222+
const registered: Record<string, unknown> = {};
223+
await new AnalyticsServicePlugin({ cubes: CUBES, ...(caps ? { queryCapabilities: caps } : {}) } as any).init({
224+
getService: (name: string) => (name === 'data' ? engine : registered[name]),
225+
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
226+
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
227+
hook: () => {},
228+
logger: quiet,
229+
} as never);
230+
services[face] = registered.analytics as AnalyticsService;
231+
}
232+
});
233+
234+
afterAll(async () => {
235+
await dropTables();
236+
try { await engine?.destroy(); } catch { /* noop */ }
237+
});
238+
239+
for (const row of CARD_ROWS) {
240+
it(`the card's row is refused 400 INVALID_FIELD on both faces and both doors: ${row.label}`, async () => {
241+
await refusedEverywhere(row.query, row.key, row.selected);
242+
});
243+
}
244+
245+
it('a time-dimension entry that only windows the rows is not a column, so ordering by it is refused', async () => {
246+
await refusedEverywhere(
247+
{
248+
cube: CUBE,
249+
measures: ['count'],
250+
dimensions: ['note'],
251+
timeDimensions: [{ dimension: 'closed_on', dateRange: ['2026-03-01', '2026-05-31'] }],
252+
order: { closed_on: 'asc' },
253+
},
254+
'closed_on',
255+
['note', 'count'],
256+
);
257+
});
258+
259+
it('a key matches by its exact spelling: a qualified spelling of a measure selected bare names no column', async () => {
260+
await refusedEverywhere(
261+
{ cube: CUBE, measures: ['amount_sum'], dimensions: ['note'], order: { [`${CUBE}.amount_sum`]: 'desc' } },
262+
`${CUBE}.amount_sum`,
263+
['note', 'amount_sum'],
264+
);
265+
});
266+
267+
it('CONTROL ordering by a selected dimension is served unchanged on both faces', async () => {
268+
const query = { cube: CUBE, measures: ['count'], dimensions: ['note'], order: { note: 'desc' } };
269+
for (const face of FACES) {
270+
const { res, err } = await call(face, 'query', query);
271+
expect(err, `${face}: ${err?.message}`).toBeUndefined();
272+
const got = tuples(res.rows, ['note', 'count']);
273+
// The native face runs the ORDER BY; the ObjectQL face serves the same groups.
274+
if (face === 'native') expect(got).toEqual([['z', 1], ['y', 1], ['x', 2]]);
275+
else expect(sorted(got)).toEqual([['x', 2], ['y', 1], ['z', 1]]);
276+
const { res: dry, err: dryErr } = await call(face, 'generateSql', query);
277+
expect(dryErr, `${face} dry run: ${dryErr?.message}`).toBeUndefined();
278+
expect(dry.sql, face).toContain('ORDER BY "note" DESC');
279+
}
280+
});
281+
282+
it('CONTROL ordering by a selected measure is served unchanged on both faces', async () => {
283+
const query = { cube: CUBE, measures: ['amount_sum'], dimensions: ['note'], order: { amount_sum: 'desc' } };
284+
for (const face of FACES) {
285+
const { res, err } = await call(face, 'query', query);
286+
expect(err, `${face}: ${err?.message}`).toBeUndefined();
287+
const got = tuples(res.rows, ['note', 'amount_sum']);
288+
if (face === 'native') expect(got).toEqual([['x', 15], ['y', 7], ['z', 1]]);
289+
else expect(sorted(got)).toEqual([['x', 15], ['y', 7], ['z', 1]]);
290+
const { res: dry, err: dryErr } = await call(face, 'generateSql', query);
291+
expect(dryErr, `${face} dry run: ${dryErr?.message}`).toBeUndefined();
292+
expect(dry.sql, face).toContain('ORDER BY "amount_sum" DESC');
293+
}
294+
});
295+
296+
it('CONTROL a bucketed time dimension is a column the answer carries, so ordering by it is served', async () => {
297+
const query = {
298+
cube: CUBE,
299+
measures: ['count'],
300+
timeDimensions: [{ dimension: 'closed_on', granularity: 'month' }],
301+
order: { closed_on: 'asc' },
302+
};
303+
for (const face of FACES) {
304+
const { res, err } = await call(face, 'query', query);
305+
expect(err, `${face}: ${err?.message}`).toBeUndefined();
306+
// Three month buckets (March, April, May), whichever face served them.
307+
expect((res.rows as Row[]).length, face).toBe(3);
308+
expect(sorted(tuples(res.rows, ['count'])), face).toEqual([[1], [1], [2]]);
309+
}
310+
});
311+
},
312+
);
313+
}

0 commit comments

Comments
 (0)