Chart widgets: server-side aggregation via DataSource.aggregate()#853
Chart widgets: server-side aggregation via DataSource.aggregate()#853
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…te() Chart widgets (bar/line/area/pie/donut/scatter) now prefer dataSource.aggregate() for server-side grouping/aggregation, avoiding large data downloads. Falls back to dataSource.find() + client-side aggregation when aggregate() is not available. - Add AggregateParams/AggregateResult types and optional aggregate() method to DataSource interface - ObjectChart prefers aggregate() over find() when aggregate config set - Implement aggregate() in ValueDataSource, ApiDataSource, and ObjectStackAdapter - Add 9 new tests covering the aggregation paths - Update ROADMAP.md Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…tch() Replace manual URL construction, auth header handling, and raw fetch() in ObjectStackAdapter.aggregate() with this.client.analytics.query() from the @objectstack/client SDK. This leverages the SDK's built-in auth, headers, custom fetch config, and base URL resolution — consistent with all other adapter methods. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds an optional server-side aggregation capability to the DataSource abstraction and updates chart widgets to use it, reducing large raw-record downloads for dashboard charts.
Changes:
- Extend
@object-ui/typesDataSourcewith optionalaggregate()plusAggregateParams/AggregateResulttypes. - Update
ObjectChartto preferdataSource.aggregate()whenschema.aggregateis provided, withfind()+ client-side aggregation fallback. - Implement
aggregate()inValueDataSource,ApiDataSource, andObjectStackAdapter, and add tests + roadmap entry.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/types/src/index.ts | Re-exports new aggregation types. |
| packages/types/src/data.ts | Adds DataSource.aggregate() and related type definitions. |
| packages/plugin-charts/src/ObjectChart.tsx | Prefers server-side aggregation when available; retains client-side fallback path. |
| packages/plugin-charts/src/tests/ObjectChart.dataFetch.test.tsx | Adds tests covering aggregate preference/fallback and filter passthrough. |
| packages/core/src/adapters/ValueDataSource.ts | Adds in-memory aggregate() implementation. |
| packages/core/src/adapters/tests/ValueDataSource.test.ts | Adds unit tests for aggregation functions. |
| packages/core/src/adapters/ApiDataSource.ts | Adds HTTP aggregate() implementation via GET .../aggregate. |
| packages/data-objectstack/src/index.ts | Adds analytics-backed aggregation with fallback to client-side aggregation. |
| ROADMAP.md | Marks the feature as completed in the roadmap. |
| } catch { | ||
| // If the analytics endpoint is not available, fall back to | ||
| // find() + client-side aggregation | ||
| const result = await this.find(resource as any); | ||
| const records = result.data || []; | ||
| if (records.length === 0) return []; | ||
|
|
||
| return this.aggregateClientSide(records, params); |
There was a problem hiding this comment.
ObjectStackAdapter.aggregate() fallback path drops the requested filter: it calls find(resource) without passing { $filter: params.filter }, so filtered charts will show incorrect totals when analytics is unavailable. Pass the filter through to find() (and/or apply it before client-side grouping) to keep results consistent with the server-side aggregation path.
| } catch { | ||
| // If the analytics endpoint is not available, fall back to | ||
| // find() + client-side aggregation | ||
| const result = await this.find(resource as any); | ||
| const records = result.data || []; | ||
| if (records.length === 0) return []; | ||
|
|
||
| return this.aggregateClientSide(records, params); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
ObjectStackAdapter.aggregate() catches all errors from client.analytics.query() and silently falls back to find() + client-side aggregation. This can mask real analytics failures (e.g., auth/validation errors) and reintroduce large data downloads unexpectedly. Consider falling back only for “endpoint unavailable” cases (e.g., 404/501) and rethrowing/logging other errors so failures are visible.
| } catch { | |
| // If the analytics endpoint is not available, fall back to | |
| // find() + client-side aggregation | |
| const result = await this.find(resource as any); | |
| const records = result.data || []; | |
| if (records.length === 0) return []; | |
| return this.aggregateClientSide(records, params); | |
| } | |
| } | |
| } catch (error: unknown) { | |
| if (this.isAnalyticsEndpointUnavailable(error)) { | |
| // If the analytics endpoint is not available, fall back to | |
| // find() + client-side aggregation | |
| const result = await this.find(resource as any); | |
| const records = result.data || []; | |
| if (records.length === 0) return []; | |
| return this.aggregateClientSide(records, params); | |
| } | |
| // Re-throw non-endpoint errors (auth, validation, server errors, etc.) | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Determine whether an analytics error indicates that the endpoint | |
| * is unavailable (e.g. not found or not implemented). | |
| */ | |
| private isAnalyticsEndpointUnavailable(error: unknown): boolean { | |
| if (!error || typeof error !== 'object') return false; | |
| const anyError = error as any; | |
| const status = | |
| anyError.status ?? | |
| anyError.statusCode ?? | |
| (anyError.response && anyError.response.status); | |
| if (status === 404 || status === 501) { | |
| return true; | |
| } | |
| const code = anyError.code ?? anyError.errorCode; | |
| if (code === 'ANALYTICS_NOT_IMPLEMENTED' || code === 'ANALYTICS_NOT_FOUND') { | |
| return true; | |
| } | |
| return false; | |
| } |
| // Prefer server-side aggregation when aggregate config is provided | ||
| // and dataSource supports the aggregate() method. | ||
| if (schema.aggregate && typeof dataSource.aggregate === 'function') { | ||
| const results = await dataSource.aggregate(schema.objectName, { | ||
| field: schema.aggregate.field, | ||
| function: schema.aggregate.function, | ||
| groupBy: schema.aggregate.groupBy, | ||
| filter: schema.filter, | ||
| }); | ||
| data = Array.isArray(results) ? results : []; | ||
| } else if (typeof dataSource.find === 'function') { | ||
| // Fallback: fetch all records and aggregate client-side | ||
| const results = await dataSource.find(schema.objectName, { | ||
| $filter: schema.filter | ||
| }); | ||
|
|
||
| data = extractRecords(results); | ||
|
|
||
| // Apply client-side aggregation when aggregate config is provided | ||
| if (schema.aggregate && data.length > 0) { | ||
| data = aggregateRecords(data, schema.aggregate); | ||
| } | ||
| } else { | ||
| return; |
There was a problem hiding this comment.
ObjectChart will always choose dataSource.aggregate() when present, but if that call throws it only logs and does not attempt the find() fallback. This is a behavior regression vs the previous “always find()” path and can cause blank charts when analytics is temporarily failing. Consider wrapping the aggregate() call and, on failure, falling back to find() + client-side aggregation to preserve backward compatibility.
| /** Client-side aggregation fallback */ | ||
| private aggregateClientSide(records: any[], params: { field: string; function: string; groupBy: string }): any[] { | ||
| const { field, function: aggFn, groupBy } = params; | ||
| const groups: Record<string, any[]> = {}; | ||
|
|
||
| for (const record of records) { | ||
| const key = String(record[groupBy] ?? 'Unknown'); | ||
| if (!groups[key]) groups[key] = []; | ||
| groups[key].push(record); | ||
| } | ||
|
|
||
| return Object.entries(groups).map(([key, group]) => { | ||
| const values = group.map(r => Number(r[field]) || 0); | ||
| let result: number; | ||
|
|
||
| switch (aggFn) { | ||
| case 'count': result = group.length; break; | ||
| case 'avg': result = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; break; | ||
| case 'min': result = values.length > 0 ? Math.min(...values) : 0; break; | ||
| case 'max': result = values.length > 0 ? Math.max(...values) : 0; break; | ||
| case 'sum': default: result = values.reduce((a, b) => a + b, 0); break; | ||
| } | ||
|
|
||
| return { [groupBy]: key, [field]: result }; | ||
| }); | ||
| } |
There was a problem hiding this comment.
Client-side aggregation logic is duplicated across ObjectChart.aggregateRecords(), ValueDataSource.aggregate(), and ObjectStackAdapter.aggregateClientSide(). This increases the chance of subtle divergence (e.g., how nulls/NaN are handled). Consider extracting a shared helper in @object-ui/core (logic-only) and reusing it from these call sites.
| const groups: Record<string, any[]> = {}; | ||
|
|
||
| for (const record of this.items as any[]) { | ||
| const key = String(record[groupBy] ?? 'Unknown'); | ||
| if (!groups[key]) groups[key] = []; | ||
| groups[key].push(record); | ||
| } | ||
|
|
||
| return Object.entries(groups).map(([key, group]) => { | ||
| const values = group.map(r => Number(r[field]) || 0); |
There was a problem hiding this comment.
ValueDataSource.aggregate() currently ignores params.filter, so charts passing a filter will get aggregated results computed over all in-memory items. Consider applying params.filter (same semantics as find($filter)), or implement aggregate() by delegating to find() with { $filter: params.filter } before grouping.
| const groups: Record<string, any[]> = {}; | |
| for (const record of this.items as any[]) { | |
| const key = String(record[groupBy] ?? 'Unknown'); | |
| if (!groups[key]) groups[key] = []; | |
| groups[key].push(record); | |
| } | |
| return Object.entries(groups).map(([key, group]) => { | |
| const values = group.map(r => Number(r[field]) || 0); | |
| // Support optional filter on aggregate, with the same semantics as `find($filter)`. | |
| // We intentionally read both `filter` and `$filter` for compatibility. | |
| const rawFilter = (params as any).filter ?? (params as any).$filter; | |
| let itemsToAggregate: any[]; | |
| if (rawFilter != null) { | |
| // Delegate filtering to the existing `find` implementation to ensure consistency. | |
| const queryParams = { $filter: rawFilter } as unknown as QueryParams; | |
| const result = await this.find(_resource, queryParams); | |
| itemsToAggregate = (result?.data ?? []) as any[]; | |
| } else { | |
| itemsToAggregate = this.items as any[]; | |
| } | |
| const groups: Record<string, any[]> = {}; | |
| for (const record of itemsToAggregate) { | |
| const key = String((record as any)[groupBy] ?? 'Unknown'); | |
| if (!groups[key]) groups[key] = []; | |
| groups[key].push(record); | |
| } | |
| return Object.entries(groups).map(([key, group]) => { | |
| const values = group.map(r => Number((r as any)[field]) || 0); |
Chart widgets (bar/line/area/pie/donut/scatter) fetch all raw records and aggregate client-side, causing unnecessary data transfer at scale. This adds a server-side aggregation path through the
DataSourceinterface.@object-ui/types—DataSource.aggregate()interfaceaggregate?(resource, params: AggregateParams): Promise<AggregateResult[]>toDataSourceAggregateParams(field,function,groupBy,filter?),AggregateResultObjectChart— prefer server-side aggregationschema.aggregateis set anddataSource.aggregate()exists → call it directly (no raw data transfer)dataSource.find()+ client-sideaggregateRecords()(backward compat)Adapter implementations
ValueDataSource— in-memory aggregation (tests/demos)ApiDataSource—GET {base}/aggregate?field=…&function=…&groupBy=…ObjectStackAdapter— delegates tothis.client.analytics.query()from@objectstack/clientSDK; falls back tofind()+ client-side aggregation if analytics endpoint unavailableDetail widgets (grid/table/list) are unaffected — they continue using
find().Tests
ObjectChart.dataFetchtests: aggregate preference, find fallback, no-aggregate-config skip, filter passthroughValueDataSourcetests: sum/count/avg/min/max aggregationOriginal prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.