Skip to content

Chart widgets: server-side aggregation via DataSource.aggregate()#853

Merged
hotlong merged 3 commits intomainfrom
copilot/fix-chart-widget-data-loading
Feb 25, 2026
Merged

Chart widgets: server-side aggregation via DataSource.aggregate()#853
hotlong merged 3 commits intomainfrom
copilot/fix-chart-widget-data-loading

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Feb 25, 2026

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 DataSource interface.

@object-ui/typesDataSource.aggregate() interface

  • Added optional aggregate?(resource, params: AggregateParams): Promise<AggregateResult[]> to DataSource
  • New types: AggregateParams (field, function, groupBy, filter?), AggregateResult

ObjectChart — prefer server-side aggregation

  • When schema.aggregate is set and dataSource.aggregate() exists → call it directly (no raw data transfer)
  • Otherwise → fallback to dataSource.find() + client-side aggregateRecords() (backward compat)

Adapter implementations

  • ValueDataSource — in-memory aggregation (tests/demos)
  • ApiDataSourceGET {base}/aggregate?field=…&function=…&groupBy=…
  • ObjectStackAdapter — delegates to this.client.analytics.query() from @objectstack/client SDK; falls back to find() + client-side aggregation if analytics endpoint unavailable
// DataSource WITH aggregate() — only aggregated results transferred
const ds = new ObjectStackAdapter({ baseUrl, token });
// ObjectChart automatically routes through analytics API:
// POST /api/v1/analytics/query { object, measures, dimensions }

// DataSource WITHOUT aggregate() — transparent fallback
const legacy = { find: mockFind };
// Still works: find() → extractRecords() → aggregateRecords()

Detail widgets (grid/table/list) are unaffected — they continue using find().

Tests

  • 4 new ObjectChart.dataFetch tests: aggregate preference, find fallback, no-aggregate-config skip, filter passthrough
  • 5 new ValueDataSource tests: sum/count/avg/min/max aggregation
Original prompt

This section details on the original issue you should resolve

<issue_title>【Bug】图表控件加载数据时未聚合导致大量数据下载,需后端计算聚合</issue_title>
<issue_description>### 问题描述

在 dashboard 的图表控件(如柱状图、折线图、饼图等)中,当前加载数据时会直接获取全部原始数据(例如通过 https://demo.objectui.org/api/v1/data/opportunity 这类 API,一次性拉取所有数据),当数据量大时性能和体验极差。

期望行为

  • 图表类的 widget 在加载数据时,应该调用后端数据分析 API,由服务端完成分组/聚合计算,只返回聚合后的结果到前端。
  • 前端仅展示统计分析结果、而非全量数据。

建议修复思路

  • 梳理 dashboard 各 widget 的数据请求方式,区分明细 vs 聚合。
  • 图表控件如 area/bar/line/pie/donut/scatter/pivot 类型,统一走分析(aggregation)API,例如 GET /api/v1/analytics/opportunity?category=xxx&metric=yyy&agg=sum ,由后端负责聚合。
  • 仅明细类控件(如 grid/table/list)才可走全量数据接口。

请修复上述问题,并确保 dashboard 相关 test 通过。修复后同步更新项目 Roadmap。</issue_description>

Comments on the Issue (you are @copilot in this section)


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

@vercel
Copy link
Copy Markdown

vercel Bot commented Feb 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
objectui Ready Ready Preview, Comment Feb 25, 2026 7:25am
objectui-demo Ready Ready Preview, Comment Feb 25, 2026 7:25am
objectui-storybook Ready Ready Preview, Comment Feb 25, 2026 7:25am

Request Review

…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>
Copilot AI changed the title [WIP] Fix chart widget data loading by implementing aggregation fix: chart widgets use server-side aggregation via DataSource.aggregate() Feb 25, 2026
Copilot AI requested a review from hotlong February 25, 2026 07:08
…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>
Copilot AI changed the title fix: chart widgets use server-side aggregation via DataSource.aggregate() Chart widgets: server-side aggregation via DataSource.aggregate() Feb 25, 2026
@hotlong hotlong marked this pull request as ready for review February 25, 2026 07:23
Copilot AI review requested due to automatic review settings February 25, 2026 07:23
@hotlong hotlong merged commit 7a455fb into main Feb 25, 2026
4 of 5 checks passed
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/types DataSource with optional aggregate() plus AggregateParams / AggregateResult types.
  • Update ObjectChart to prefer dataSource.aggregate() when schema.aggregate is provided, with find() + client-side aggregation fallback.
  • Implement aggregate() in ValueDataSource, ApiDataSource, and ObjectStackAdapter, 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.

Comment on lines +718 to +725
} 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);
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +718 to +728
} 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);
}
}

Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
} 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;
}

Copilot uses AI. Check for mistakes.
Comment on lines +72 to +95
// 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;
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +729 to +754
/** 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 };
});
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +390 to +399
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);
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

【Bug】图表控件加载数据时未聚合导致大量数据下载,需后端计算聚合

3 participants