-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathdataset.zod.ts
More file actions
225 lines (210 loc) · 9.6 KB
/
Copy pathdataset.zod.ts
File metadata and controls
225 lines (210 loc) · 9.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
import { z } from 'zod';
import { lazySchema } from '../shared/lazy-schema';
import { ProtectionSchema } from '../shared/protection.zod';
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
import { FilterConditionSchema } from '../data/filter.zod';
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
import { I18nLabelSchema } from './i18n.zod';
import { AggregationFunction, DateGranularity } from '../data/query.zod';
/**
* Analytics Dataset — the one semantic layer (ADR-0021).
*
* A `dataset` is a named, reusable analytical definition: a base object, the
* relationships to include (joins are *derived* from the object graph — the
* author never writes an `ON` clause), and the declared **dimensions**
* (groupable axes) and **measures** (aggregatable values). It is deliberately
* SMALLER than `QuerySchema`: no raw SQL, no hand-authored join predicates,
* no window/having grammar in the author surface.
*
* Presentations (`report` / `dashboard`) bind to a dataset by reference and
* pick dimensions/measures *by name*. The dataset compiles to the existing
* Cube analytics runtime (ADR-0021 D-A=(c)); RLS / tenant scoping is enforced
* by the runtime per joined object (D-C), never declared here.
*
* Naming: this module owns the high-prior `dataset` / `dimension` / `measure`
* vocabulary (LookML / dbt / Cube / PowerBI). The Zod export identifiers are
* `Dataset`-prefixed (`DatasetDimensionSchema`, `DatasetMeasureSchema`) so they
* do not clash with the Cube layer's `DimensionSchema` / `MetricSchema` in
* `data/analytics.zod.ts` while the two layers coexist (Phase 1). The Cube
* layer is absorbed/retired in a later phase (D-A).
*/
/**
* Dimension — a groupable axis (e.g. "region", "close_date by quarter").
*/
export const DatasetDimensionSchema = lazySchema(() => z.object({
/** Referenced by presentations (report rows/columns, widget dimensions). */
name: SnakeCaseIdentifierSchema.describe('Dimension name — referenced by presentations'),
label: I18nLabelSchema.optional(),
/**
* A field on the base object, OR a relationship path (one or more to-one hops)
* ending in a field — e.g. `account.region` or `account.owner.region`
* (ADR-0071 multi-hop). The join chain is DERIVED from the relationship(s)
* declared in `Dataset.include`; the author never writes a predicate.
*/
field: z.string().describe('Base field, or `relationship[.relationship].field` path'),
type: z.enum(['string', 'number', 'date', 'boolean', 'lookup']).optional(),
/** Default bucketing for date dimensions (day/week/month/quarter/year). */
dateGranularity: DateGranularity.optional(),
}));
/**
* Derived-measure operator (ADR-0021 Q1).
* A derived measure references OTHER measures BY NAME only — no raw fields,
* no raw SQL — keeping it enumerable and reviewable.
*/
export const DerivedMeasureOp = z.enum(['ratio', 'sum', 'difference', 'product']);
/**
* Measure — an aggregatable value (e.g. "revenue = sum(amount)"). Defined ONCE
* here; every presentation references it by name.
*/
export const DatasetMeasureSchema = lazySchema(() => z.object({
name: SnakeCaseIdentifierSchema.describe('Measure name — e.g. "revenue"; defined once'),
label: I18nLabelSchema.optional(),
/** Aggregation function — reuses the canonical query.zod enum. */
aggregate: AggregationFunction.optional().describe('Aggregation (sum/avg/count/...); omit when `derived` is set'),
/** Base field, or `relationship[.relationship].field` path. Optional for `count` (count(*)). */
field: z.string().optional().describe('Aggregated field; optional for count(*)'),
/** Measure-scoped filter (e.g. only won deals for "won_amount"). */
filter: FilterConditionSchema.optional(),
/** Display format, e.g. "$0,0.00", "0.0%". */
format: z.string().optional(),
/**
* Display currency (ISO 4217, e.g. "USD", "CNY"). Carried onto the result
* field so presentations render a locale-correct symbol via `Intl` rather
* than a "$" baked into `format`. Declare it on the measure (the semantic
* layer) when the aggregated field is a fixed-currency amount.
*/
currency: z.string().length(3).optional().describe('Display currency code (ISO 4217)'),
/**
* Derived measure — computed from OTHER measures in this dataset by name
* only. e.g. `{ op: 'ratio', of: ['won_amount', 'total_amount'] }`.
* Mutually exclusive with `field`/`aggregate` semantics: when `derived` is
* set, `aggregate` is ignored at compile time.
*/
derived: z.object({
op: DerivedMeasureOp,
/** Names of other measures in this dataset (2+ for ratio/difference). */
of: z.array(SnakeCaseIdentifierSchema).min(1),
}).optional(),
}));
/**
* Dataset — the single analytical source of truth (ADR-0021 D1).
*/
export const DatasetSchema = lazySchema(() => z.object({
/** Identity. */
name: SnakeCaseIdentifierSchema.describe('Dataset unique name'),
label: I18nLabelSchema.describe('Dataset label'),
description: I18nLabelSchema.optional(),
/** Base object — the FROM. */
object: z.string().describe('Base object name'),
/**
* Relationships to include, by NAME or by PATH — lookup / master_detail field
* names on the object graph, optionally chained through to-one relationships
* up to 3 hops (`account`, `account.owner`; ADR-0071 multi-hop). Joins are
* COMPILED from these — the author writes no ON clause. Declaring `a.b`
* implicitly includes the intermediate `a`. D-C: only declared paths are
* joinable; no arbitrary predicates, and to-many traversal is out of scope.
*/
include: z
.array(
z
.string()
.refine((p) => p.split('.').length <= 3, {
message: 'include path exceeds the 3-hop limit (ADR-0071)',
}),
)
.optional()
.describe('Relationship names/paths to join (derived from object graph; max 3 hops)'),
/** Definition-level filter (the dataset's intrinsic scope, e.g. non-deleted). */
filter: FilterConditionSchema.optional().describe('Intrinsic dataset scope filter'),
/** The semantic contract presentations bind to. */
dimensions: z.array(DatasetDimensionSchema).describe('Groupable axes'),
measures: z.array(DatasetMeasureSchema).describe('Aggregatable values'),
/**
* ADR-0010 — package-author protection envelope; the loader translates this
* into the private `_lock` envelope at registration and strips it before
* persistence.
*/
protection: ProtectionSchema.optional().describe(
'Package author protection block — lock policy for this dataset.',
),
// ADR-0010 — runtime protection envelope (internal — set by loader).
...MetadataProtectionFields,
}).superRefine((ds, ctx) => {
// Measure names must be unique (presentations reference them by name).
const measureNames = new Set<string>();
for (const m of ds.measures) {
if (measureNames.has(m.name)) {
ctx.addIssue({ code: 'custom', message: `duplicate measure name "${m.name}"`, path: ['measures'] });
}
measureNames.add(m.name);
}
// Dimension names must be unique.
const dimNames = new Set<string>();
for (const d of ds.dimensions) {
if (dimNames.has(d.name)) {
ctx.addIssue({ code: 'custom', message: `duplicate dimension name "${d.name}"`, path: ['dimensions'] });
}
dimNames.add(d.name);
}
// Derived measures may only reference OTHER measures declared in this dataset.
for (const m of ds.measures) {
if (!m.derived) {
// A non-derived measure must declare an aggregate (a derived measure
// omits it — it combines other measures by name instead).
if (!m.aggregate) {
ctx.addIssue({
code: 'custom',
message: `measure "${m.name}" requires \`aggregate\` (or a \`derived\` spec)`,
path: ['measures'],
});
} else if (!m.field && m.aggregate !== 'count') {
// A non-derived measure needs a field unless it is a plain count.
ctx.addIssue({
code: 'custom',
message: `measure "${m.name}" requires \`field\` (only \`count\` may omit it)`,
path: ['measures'],
});
}
continue;
}
for (const ref of m.derived.of) {
if (ref === m.name) {
ctx.addIssue({ code: 'custom', message: `derived measure "${m.name}" cannot reference itself`, path: ['measures'] });
} else if (!measureNames.has(ref)) {
ctx.addIssue({
code: 'custom',
message: `derived measure "${m.name}" references unknown measure "${ref}"`,
path: ['measures'],
});
}
}
}
}));
/**
* Authoring helper — identity function that gives editors full type-checking
* and inference when defining a dataset in a `*.dataset.ts` file.
*
* @example
* ```ts
* export default defineDataset({
* name: 'sales',
* label: 'Sales',
* object: 'opportunity',
* include: ['account'],
* dimensions: [{ name: 'region', field: 'account.region' }],
* measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
* });
* ```
*/
export function defineDataset(dataset: DatasetInput): DatasetInput {
return dataset;
}
export type DatasetDimension = z.infer<typeof DatasetDimensionSchema>;
export type DatasetMeasure = z.infer<typeof DatasetMeasureSchema>;
export type DerivedMeasureOpValue = z.infer<typeof DerivedMeasureOp>;
export type Dataset = z.infer<typeof DatasetSchema>;
/** Input types for authoring (optional fields with defaults may be omitted). */
export type DatasetDimensionInput = z.input<typeof DatasetDimensionSchema>;
export type DatasetMeasureInput = z.input<typeof DatasetMeasureSchema>;
export type DatasetInput = z.input<typeof DatasetSchema>;