Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .objectui-sha
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5ab1e0e630a5db14defba535cc69f67cb71746c7
3d275b5bfc3aac253ec6c1a63e389e5f084f0f5a
38 changes: 38 additions & 0 deletions packages/spec/src/ui/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
63 changes: 56 additions & 7 deletions packages/spec/src/ui/report.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)'),
Expand Down Expand Up @@ -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<typeof JoinedReportBlockSchema>;
Expand Down
Loading