From 7a08d3d2355668b1dd450ad2722094505cea9ecb Mon Sep 17 00:00:00 2001 From: Jack Date: Sun, 7 Jun 2026 18:48:43 +0800 Subject: [PATCH] feat(spec): report can bind to a dataset (ADR-0021 B-P1, dual-form) Additive dual-form for ReportSchema: a report may now reference a semantic-layer `dataset` and pick `rows` (dimension names) + `values` (measure names) + `runtimeFilter`, INSTEAD of the inline `objectName` + `columns` query. `objectName`/`columns` become optional; a superRefine enforces exactly one shape (dataset+values, or objectName+columns). Existing inline reports are unchanged and still validate. This is the value unlock for ADR-0021: report/dashboard consume the governed dataset (one definition of every metric) instead of re-declaring a query inline. Also bumps .objectui-sha to the matching objectui commit (ReportPreview renders dataset-bound reports via queryDataset). Tests: report.test +5 (dataset-bound valid; missing values rejected; legacy inline still valid; missing objectName/columns rejected; runtimeFilter carried). Verified live: a dataset-bound report renders its table (revenue by stage) in the Studio preview. Note: client-side validation in the Studio editor recognises the new shape only after objectui bumps its @objectstack/spec dependency (cross-repo spec skew); rendering already works because the preview reads draft fields directly. Co-Authored-By: Claude Opus 4.8 --- .objectui-sha | 2 +- packages/spec/src/ui/report.test.ts | 38 +++++++++++++++++ packages/spec/src/ui/report.zod.ts | 63 +++++++++++++++++++++++++---- 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/.objectui-sha b/.objectui-sha index 5ba9d5f37..24cbfd904 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -5ab1e0e630a5db14defba535cc69f67cb71746c7 +3d275b5bfc3aac253ec6c1a63e389e5f084f0f5a diff --git a/packages/spec/src/ui/report.test.ts b/packages/spec/src/ui/report.test.ts index e1834d727..3df2dd3fc 100644 --- a/packages/spec/src/ui/report.test.ts +++ b/packages/spec/src/ui/report.test.ts @@ -543,3 +543,41 @@ describe('ReportSchema - Negative Validation', () => { })).toThrow(); }); }); + +describe('ReportSchema — dataset binding (ADR-0021 dual-form)', () => { + it('accepts a dataset-bound report (dataset + values, no objectName/columns)', () => { + expect(() => ReportSchema.parse({ + name: 'revenue_by_region', + label: 'Revenue by Region', + dataset: 'sales', + rows: ['region'], + values: ['revenue'], + })).not.toThrow(); + }); + + it('rejects a dataset-bound report with no values', () => { + expect(() => ReportSchema.parse({ + name: 'bad', label: 'Bad', dataset: 'sales', rows: ['region'], + })).toThrowError(/needs `values`/); + }); + + it('still accepts a legacy inline report (objectName + columns)', () => { + expect(() => ReportSchema.parse({ + name: 'legacy', label: 'Legacy', objectName: 'crm_opportunity', + columns: [{ field: 'amount', aggregate: 'sum' }], + })).not.toThrow(); + }); + + it('rejects an inline report missing objectName/columns (and no dataset)', () => { + expect(() => ReportSchema.parse({ name: 'bad2', label: 'Bad2' })) + .toThrowError(/needs `objectName`|needs `columns`/); + }); + + it('carries runtimeFilter on a dataset-bound report', () => { + const r = ReportSchema.parse({ + name: 'scoped', label: 'Scoped', dataset: 'sales', values: ['revenue'], + runtimeFilter: { stage: 'won' }, + }); + expect((r as any).runtimeFilter).toEqual({ stage: 'won' }); + }); +}); diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index 27a520b22..9a98ca4a8 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -103,15 +103,36 @@ export const ReportSchema = lazySchema(() => z.object({ name: SnakeCaseIdentifierSchema.describe('Report unique name'), label: I18nLabelSchema.describe('Report label'), description: I18nLabelSchema.optional(), - - /** Data Source */ - objectName: z.string().describe('Primary object'), - + + /** + * Data Source — inline single-object query. + * Optional since ADR-0021: a report may instead bind to a semantic-layer + * `dataset` (see `dataset`/`rows`/`values` below). Exactly one shape is used + * per report; the `superRefine` enforces it. Required for the legacy inline + * shape (no `dataset`). + */ + objectName: z.string().optional().describe('Primary object (inline-query reports; omit when `dataset` is set)'), + /** Report Configuration */ type: ReportType.default('tabular').describe('Report format type'), - - columns: z.array(ReportColumnSchema).describe('Columns to display'), - + + columns: z.array(ReportColumnSchema).optional().describe('Columns to display (inline-query reports)'), + + /** + * ADR-0021 — bind to a semantic-layer `dataset` (the governed alternative to + * the inline `objectName` + `columns` query). When set, the report renders + * the dataset's named measures grouped by the chosen dimensions — numbers + * stay consistent with every other surface that uses the same dataset. + * Additive / dual-form: existing inline reports are unchanged. + */ + dataset: SnakeCaseIdentifierSchema.optional().describe('Dataset name to bind (ADR-0021)'), + /** Dimension names (from the dataset) to group rows by. Dataset-bound only. */ + rows: z.array(z.string()).optional().describe('Dimension names down (dataset-bound)'), + /** Measure names (from the dataset) to display. Dataset-bound only. */ + values: z.array(z.string()).optional().describe('Measure names to show (dataset-bound)'), + /** Render-time scope filter, ANDed at query time. Dataset-bound only. */ + runtimeFilter: FilterConditionSchema.optional().describe('Render-time scope filter (dataset-bound)'), + /** Grouping (for Summary/Matrix) */ groupingsDown: z.array(ReportGroupingSchema).optional().describe('Row groupings'), groupingsAcross: z.array(ReportGroupingSchema).optional().describe('Column groupings (Matrix only)'), @@ -154,6 +175,34 @@ export const ReportSchema = lazySchema(() => z.object({ // ADR-0010 — runtime protection envelope (internal — set by loader). ...MetadataProtectionFields, +}).superRefine((r, ctx) => { + // ADR-0021 dual-form: a report is EITHER dataset-bound OR an inline query. + if (r.dataset) { + if (!r.values || r.values.length === 0) { + ctx.addIssue({ + code: 'custom', + message: 'a dataset-bound report needs `values` (measure names from the dataset).', + path: ['values'], + }); + } + } else { + // Legacy inline shape — keep requiring objectName + columns so existing + // reports stay valid and a half-specified report is rejected. + if (!r.objectName) { + ctx.addIssue({ + code: 'custom', + message: 'report needs `objectName` (or bind a `dataset`).', + path: ['objectName'], + }); + } + if (!r.columns) { + ctx.addIssue({ + code: 'custom', + message: 'report needs `columns` (or bind a `dataset` with `values`).', + path: ['columns'], + }); + } + } })); export type JoinedReportBlock = z.infer;