Skip to content

Commit 7bb7830

Browse files
committed
feat(analytics): honour widget dateGranularity, sortBy/sortOrder, and limit in dataset queries (#3588)
Three presentation options were accepted by the metadata layer and then dropped by the analytics query builder — no SQL, no error. The only symptom was the `sql` a dataset response echoes, so a dashboard could declare `dateGranularity: 'month'` and quietly render one bar per record. - `DatasetSelection.dateGranularity` buckets every selected date dimension. Precedence per dimension: an explicit `timeDimensions` granularity, then the selection's, then the dataset dimension's own default — so a widget can bucket a trend by month without the dataset committing every consumer to it. - `order`/`limit`/`offset` are applied to the ASSEMBLED grid: after measure-scoped sub-queries merge, after `compareTo` columns attach, and after derived measures are computed. A derived measure is therefore a valid sort key, and the ObjectQL aggregate path — which has no ordering grammar, and which native SQL hands every date-bucketed query to — orders identically to native SQL. A single-query selection still pushes the window into the statement. An order key naming nothing the selection projects is rejected (400) rather than ignored. - A `limit` without an `order` orders by the selected dimensions first, so it truncates a reproducible window instead of an arbitrary subset. - The four query-affecting widget `options` keys plus `stageOrder` are declared on `DashboardWidgetOptionsSchema`, making a typo an author-time error. The bag stays open, so renderer extras pass through untouched. Two latent bugs surfaced while fixing the above: - `order`/`limit` were forwarded to EVERY sub-query. A measure-scoped supplementary query selects one measure, so an inherited ORDER BY named a column it never selected and an inherited LIMIT truncated it before the merge, dropping rows from the grid. Nothing hit this only because nothing passed `order`. - The `compareTo` pass hand-built its query and skipped granularity resolution, merging a month-bucketed primary grid against raw-timestamp comparison rows. No dimension key matched, so every `<measure>__compare` column came back empty. `ObjectQLStrategy` now also echoes a representative `sql` (date_trunc, WHERE, ORDER BY, LIMIT; filter values parameterized, never inlined). The `sql` field previously vanished whenever a query was date-bucketed, leaving an author unable to tell "not implemented" from "this strategy doesn't report". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SEZLBGyNoJMLvtGPqsMPRC
1 parent 6ba3788 commit 7bb7830

8 files changed

Lines changed: 826 additions & 30 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/service-analytics": patch
4+
"@objectstack/rest": patch
5+
---
6+
7+
feat(analytics): honour widget `dateGranularity`, `sortBy`/`sortOrder`, and `limit` in the dataset query (#3588)
8+
9+
Three presentation options were accepted by the metadata layer and then dropped
10+
by the analytics query builder. They reached no SQL, produced no error, and the
11+
only way to notice was to read the `sql` a dataset response echoes — so a
12+
dashboard could declare `dateGranularity: 'month'` and quietly render one bar
13+
per record.
14+
15+
- **`dateGranularity` now buckets.** `DatasetSelection` gained an optional
16+
`dateGranularity`, applied to every selected `date` dimension. Precedence per
17+
dimension: an explicit `timeDimensions` granularity, then the selection's,
18+
then the dataset dimension's own default. A widget can bucket a trend by month
19+
without the dataset committing every other consumer to that granularity.
20+
- **`order` / `limit` / `offset` now apply on every path.** They are applied to
21+
the ASSEMBLED grid — after measure-scoped sub-queries merge, after `compareTo`
22+
columns attach, and after derived measures are computed — so a derived measure
23+
is a valid sort key and the ObjectQL aggregate path (which has no ordering
24+
grammar, and which native SQL hands every date-bucketed query to) orders
25+
identically to native SQL. A single-query selection still pushes the window
26+
down into the statement. An `order` key that names nothing the selection
27+
projects is now rejected (400) rather than silently ignored.
28+
- **`limit` is deterministic.** Without an `order`, a limit orders by the
29+
selected dimensions first, so it truncates a reproducible window instead of an
30+
arbitrary subset.
31+
- **Widget `options` is a contract again.** The four query-affecting keys
32+
(`dateGranularity`, `sortBy`, `sortOrder`, `limit`) plus `stageOrder` are
33+
declared on `DashboardWidgetOptionsSchema`, so a typo like `sortDirection` is
34+
an author-time error. The bag stays open — renderer extras (`icon`, `columns`,
35+
`striped`, …) pass through untouched.
36+
37+
Two latent bugs surfaced while fixing the above and are fixed here too:
38+
39+
- `order`/`limit` were forwarded to EVERY sub-query. A measure-scoped
40+
supplementary query selects one measure, so an inherited `ORDER BY` named a
41+
column it never selected, and an inherited `LIMIT` truncated it before the
42+
merge — dropping rows from the assembled grid. Nothing hit this only because
43+
nothing passed `order`.
44+
- The `compareTo` pass built its query by hand and skipped granularity
45+
resolution, so a month-bucketed primary grid was merged against raw-timestamp
46+
comparison rows. No dimension key matched and every `<measure>__compare`
47+
column came back empty.
48+
49+
`ObjectQLStrategy` now also echoes a representative `sql` (with `date_trunc`,
50+
`WHERE`, `ORDER BY`, and `LIMIT`; filter values parameterized, never inlined).
51+
Previously the `sql` field simply vanished from the response whenever a query
52+
was date-bucketed, leaving an author unable to tell "not implemented" from "this
53+
strategy doesn't report".

packages/rest/src/rest-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5260,7 +5260,7 @@ export class RestServer {
52605260
const msg = String(error?.message ?? error ?? '');
52615261
// Dataset-compiler D-C / unsupported-aggregate / read-scope
52625262
// errors are client-side mistakes — surface as 400.
5263-
if (/not declared in the dataset|not backed by a declared relationship|not supported by the v1 dataset runtime|read-scope-sql/.test(msg)) {
5263+
if (/not declared in the dataset|not backed by a declared relationship|not supported by the v1 dataset runtime|read-scope-sql|not a selected dimension or measure|is not a subset of the selected dimensions/.test(msg)) {
52645264
return res.status(400).json({ code: 'DATASET_INVALID', message: msg.slice(0, 1000) });
52655265
}
52665266
logError('[REST] Analytics dataset query error:', error);

0 commit comments

Comments
 (0)