Skip to content

Commit 1986594

Browse files
os-zhuangclaude
andauthored
feat(analytics): honour widget dateGranularity, sortBy/sortOrder, and limit in dataset queries (#3652)
* 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 * docs(spec,ui): regenerate dashboard reference + document the widget query options (#3588) `content/docs/references/ui/dashboard.mdx` is generated from the spec and went stale when `DashboardWidgetOptionsSchema` was added — CI's generated-artifact drift check caught it. Also rewrites the hand-written `options` row in the dashboards guide, which described the bag as "Additional display options". That description is what let this bug happen: four of its keys change the compiled query, and an author had no way to know which. The guide now lists the five declared keys with their effects and the behavioural notes that are easy to get wrong (an unprojectable `sortBy` is rejected, a bare `limit` orders by dimensions first, a derived measure is a valid sort key, a funnel without a declared stage order falls back to value-descending). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SEZLBGyNoJMLvtGPqsMPRC * chore(spec): refresh the public API surface snapshot for DashboardWidgetOptions (#3588) The api-surface gate reports 0 breaking, 2 added — the new `DashboardWidgetOptionsSchema` const and its inferred `DashboardWidgetOptions` type, both purely additive. Regenerated and committed as the gate asks. Verified the whole spec generated-artifact set locally this time rather than one gate per CI round-trip: check:docs, check:api-surface, check:skill-refs, check:skill-docs, check:spec-changes, check:upgrade-guide, check:react-blocks — all pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SEZLBGyNoJMLvtGPqsMPRC --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fdb4f50 commit 1986594

11 files changed

Lines changed: 890 additions & 34 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".

content/docs/references/ui/dashboard.mdx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ Color variant for dashboard widgets (e.g., KPI cards).
1414
## TypeScript Usage
1515

1616
```typescript
17-
import { Dashboard, DashboardHeader, DashboardHeaderAction, DashboardWidget, GlobalFilter, GlobalFilterOptionsFrom, WidgetActionType, WidgetColorVariant } from '@objectstack/spec/ui';
18-
import type { Dashboard, DashboardHeader, DashboardHeaderAction, DashboardWidget, GlobalFilter, GlobalFilterOptionsFrom, WidgetActionType, WidgetColorVariant } from '@objectstack/spec/ui';
17+
import { Dashboard, DashboardHeader, DashboardHeaderAction, DashboardWidget, DashboardWidgetOptions, GlobalFilter, GlobalFilterOptionsFrom, WidgetActionType, WidgetColorVariant } from '@objectstack/spec/ui';
18+
import type { Dashboard, DashboardHeader, DashboardHeaderAction, DashboardWidget, DashboardWidgetOptions, GlobalFilter, GlobalFilterOptionsFrom, WidgetActionType, WidgetColorVariant } from '@objectstack/spec/ui';
1919

2020
// Validate data
2121
const result = Dashboard.parse(data);
@@ -107,13 +107,30 @@ Dashboard header action
107107
| **dimensions** | `string[]` | optional | Dimension names — X/group/split |
108108
| **values** | `string[]` || Measure names — Y (at least one) |
109109
| **layout** | `{ x: number; y: number; w: number; h: number }` | optional | Grid layout position (auto-flowed when omitted) |
110-
| **options** | `any` | optional | Widget specific configuration |
110+
| **options** | `Record<string, any>` | optional | Widget specific configuration |
111111
| **filterBindings** | `Record<string, string \| 'false'>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |
112112
| **suppressWarnings** | `string[]` | optional | Build diagnostic rule ids suppressed on this widget |
113113
| **responsive** | `{ breakpoint?: Enum<'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl'>; hiddenOn?: Enum<'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl'>[]; columns?: object; order?: object }` | optional | Responsive layout configuration |
114114
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
115115

116116

117+
---
118+
119+
## DashboardWidgetOptions
120+
121+
Widget configuration — declared query keys + open renderer extras
122+
123+
### Properties
124+
125+
| Property | Type | Required | Description |
126+
| :--- | :--- | :--- | :--- |
127+
| **dateGranularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | Bucket selected date dimensions (day/week/month/quarter/year) |
128+
| **sortBy** | `string` | optional | Dimension/measure name to order by |
129+
| **sortOrder** | `Enum<'asc' \| 'desc'>` | optional | Sort direction for sortBy |
130+
| **limit** | `integer` | optional | Max rows (applied after ordering) |
131+
| **stageOrder** | `string \| number \| boolean[]` | optional | Explicit category order for funnel/pyramid stages (stored values) |
132+
133+
117134
---
118135

119136
## GlobalFilter

content/docs/ui/dashboards.mdx

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,50 @@ selects `dimensions` (X / group / split) and `values` (the measures to plot):
104104
| `chartConfig` | `object` | optional | Advanced chart configuration |
105105
| `colorVariant` | `enum` | optional | KPI/card accent color |
106106
| `compareTo` | `enum \| object` | optional | Period-over-period comparison window |
107-
| `options` | `object` | optional | Additional display options |
107+
| `options` | `object` | optional | Renderer extras **plus** the query keys below |
108108
| `responsive` | `object` | optional | Responsive behavior |
109109

110+
### Widget `options`
111+
112+
`options` is an open bag: keys the renderer understands (`icon`, `columns`,
113+
`striped`, `density`, …) pass through untouched. Five keys are **declared**,
114+
because they are not presentation-only — four of them change the query the
115+
dataset compiles to:
116+
117+
| Key | Type | Effect |
118+
| :--- | :--- | :--- |
119+
| `dateGranularity` | `day \| week \| month \| quarter \| year` | Buckets this widget's selected **date** dimensions. Overrides the dataset dimension's own default for this widget only. |
120+
| `sortBy` | `string` | Orders rows by a dimension or measure **this widget selects**. |
121+
| `sortOrder` | `asc \| desc` | Direction for `sortBy` (default `asc`). |
122+
| `limit` | `number` | Max rows, applied **after** ordering. |
123+
| `stageOrder` | `(string \| number \| boolean)[]` | Explicit stage order for `funnel` / `pyramid`, as the dimension's **stored values**. Omit to use the dimension field's picklist option order. |
124+
125+
Because they are declared, a misspelling (`sortDirection`, `granularity`) is an
126+
author-time error rather than an option that reads as if it works.
127+
128+
```ts
129+
{
130+
id: 'revenue_trend',
131+
type: 'area',
132+
dataset: 'opportunity_metrics',
133+
dimensions: ['close_date'],
134+
values: ['total_amount'],
135+
// Group by month rather than by raw timestamp.
136+
options: { dateGranularity: 'month' },
137+
}
138+
```
139+
140+
Notes on behaviour:
141+
142+
- `sortBy` must name something the widget selects; the server rejects an order
143+
key it cannot resolve rather than ignoring it.
144+
- A `limit` with no `sortBy` orders by the selected dimensions first, so it
145+
truncates a reproducible window instead of an arbitrary subset.
146+
- Ordering is applied to the finished grid, so a **derived measure** is a valid
147+
`sortBy` even though no single SQL statement computes it.
148+
- A `funnel` with no declared stage order falls back to sorting by value
149+
descending.
150+
110151
## Datasets
111152

112153
Every widget binds to a **dataset** — the single semantic layer (ADR-0021).

packages/rest/src/rest-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5291,7 +5291,7 @@ export class RestServer {
52915291
const msg = String(error?.message ?? error ?? '');
52925292
// Dataset-compiler D-C / unsupported-aggregate / read-scope
52935293
// errors are client-side mistakes — surface as 400.
5294-
if (/not declared in the dataset|not backed by a declared relationship|not supported by the v1 dataset runtime|read-scope-sql/.test(msg)) {
5294+
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)) {
52955295
return res.status(400).json({ code: 'DATASET_INVALID', message: msg.slice(0, 1000) });
52965296
}
52975297
logError('[REST] Analytics dataset query error:', error);

0 commit comments

Comments
 (0)