From b9903956190a4ec76a66b3f0f3efc35535fd7ed1 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:35:45 +0800 Subject: [PATCH] test(example-crm): add a lookup-dimension report fixture (sales by account) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrates + exercises the raw-value lookup drill (ADR-0021 D2) end to end, the path that had no example coverage: - opportunity_metrics gains an `account` LOOKUP dimension. The analytics layer resolves the FK to the account's display name in `rows` but exposes the raw FK via drillRawRows + dimensionFields, so a drill filters by the stored id. - A new `SalesByAccountReport` groups `total_amount` (USD, currency-aware) by account — so this one report exercises BOTH render paths: Intl currency formatting AND raw-value lookup drill. - service-analytics test: a LOOKUP dimension is drillable (mirror of the date-dimension exclusion test) — asserts object + dimensionFields + raw FK. Example is private; the service-analytics change is test-only (no shipped API change) → no changeset. Co-Authored-By: Claude Opus 4.8 --- .../src/datasets/opportunity.dataset.ts | 4 ++++ examples/app-crm/src/reports/index.ts | 1 + .../src/reports/sales-by-account.report.ts | 24 +++++++++++++++++++ .../src/__tests__/query-dataset.test.ts | 19 +++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 examples/app-crm/src/reports/sales-by-account.report.ts diff --git a/examples/app-crm/src/datasets/opportunity.dataset.ts b/examples/app-crm/src/datasets/opportunity.dataset.ts index 993d0422ed..54b60a3abf 100644 --- a/examples/app-crm/src/datasets/opportunity.dataset.ts +++ b/examples/app-crm/src/datasets/opportunity.dataset.ts @@ -18,6 +18,10 @@ export const OpportunityDataset = defineDataset({ dimensions: [ { name: 'stage', label: 'Stage', field: 'stage', type: 'string' }, + // Lookup dimension — the analytics layer resolves the account FK to its + // display name in `rows`, but exposes the raw FK via drillRawRows so a + // report drill filters by the stored id (ADR-0021 D2), not the name. + { name: 'account', label: 'Account', field: 'account', type: 'lookup' }, // ADR-0021 single-form: the monthly bucketing the trend widget used to carry // as `categoryGranularity: 'month'` now lives on the dimension itself. { name: 'close_date', label: 'Close Date', field: 'close_date', type: 'date', dateGranularity: 'month' }, diff --git a/examples/app-crm/src/reports/index.ts b/examples/app-crm/src/reports/index.ts index 5cb0f1dbce..257688117e 100644 --- a/examples/app-crm/src/reports/index.ts +++ b/examples/app-crm/src/reports/index.ts @@ -1,3 +1,4 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. export { SalesByStageReport } from './sales-by-stage.report.js'; +export { SalesByAccountReport } from './sales-by-account.report.js'; diff --git a/examples/app-crm/src/reports/sales-by-account.report.ts b/examples/app-crm/src/reports/sales-by-account.report.ts new file mode 100644 index 0000000000..499e3abce2 --- /dev/null +++ b/examples/app-crm/src/reports/sales-by-account.report.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type * as UI from '@objectstack/spec/ui'; + +/** + * Example report — total opportunity amount grouped by ACCOUNT. + * + * Bound to `opportunity_metrics` with rows = `account`, a LOOKUP dimension. + * The analytics layer resolves each account FK to its display name in `rows`, + * but exposes the raw FK via `drillRawRows` + `dimensionFields` (ADR-0021 D2), + * so drilling a row filters the opportunity list by the account's stored id — + * not its (possibly non-unique) display name. Paired with the currency-aware + * `total_amount` measure (USD), this exercises both render paths — Intl + * currency formatting AND raw-value lookup drill — end to end. + */ +export const SalesByAccountReport: UI.ReportInput = { + name: 'crm_sales_by_account', + label: 'Sales by Account', + description: 'Total opportunity amount grouped by account (lookup-dimension drill).', + type: 'summary', + dataset: 'opportunity_metrics', + rows: ['account'], + values: ['total_amount'], +}; diff --git a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts index 2a8c9ad399..dba49e6c2c 100644 --- a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts +++ b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts @@ -154,4 +154,23 @@ describe('AnalyticsService.queryDataset', () => { expect(result.object).toBeUndefined(); expect(result.drillRawRows).toBeUndefined(); }); + + it('marks a LOOKUP dimension drillable, exposing the raw FK for exact-match drill', async () => { + const byAccount = DatasetSchema.parse({ + name: 'sales_acct', label: 'Sales', object: 'opportunity', include: [], + dimensions: [{ name: 'account', field: 'account', type: 'lookup', label: 'Account' }], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount', certified: true }], + }); + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async () => [{ account: 'acc_123', revenue: 1000 }], + getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined), + }); + const result = await svc.queryDataset(byAccount, { dimensions: ['account'], measures: ['revenue'] }, { tenantId: 'org_A' } as ExecutionContext) as any; + // A lookup dim IS drillable (unlike a date bucket): its raw FK is exposed so + // the report drill filters by the stored id, not the resolved display name. + expect(result.object).toBe('opportunity'); + expect(result.dimensionFields).toEqual({ account: 'account' }); + expect(result.drillRawRows).toEqual([{ account: 'acc_123' }]); + }); });