@segmentstream-cloud/ontology 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,7 @@
1
- import { isSystemProperty, SNAKE_CASE_NAME } from "./names.js";
1
+ import { dimensionTypeOf, filterTypeIssue, filterValuesIssue, MEASURE_CONDITION_OPERATORS, } from "./aggregate-filters.js";
2
+ import { measuresManifest } from "./manifest.js";
3
+ import { balanceOf, conditionProperty, conditionsOf, MEASURE_AGGREGATES, } from "./measure.js";
4
+ import { humanize, isSystemProperty, SNAKE_CASE_NAME } from "./names.js";
2
5
  const columnName = /^[A-Za-z_][A-Za-z0-9_]*$/;
3
6
  const types = new Set(["text", "integer", "number", "boolean", "date", "timestamp", "select"]);
4
7
  /**
@@ -92,23 +95,135 @@ export function checkWarehouseSource(entities, report) {
92
95
  }
93
96
  }
94
97
  }
95
- /** The aggregates a measure expression may contain. */
96
- const AGGREGATES = new Set(["sum", "countDistinct"]);
98
+ /**
99
+ * The aggregates of one property, which take one argument. `countDistinct` counts what compares
100
+ * exactly; `sum`, `avg`, `min` and `max` take numbers.
101
+ */
102
+ const AGGREGATES = new Set(["sum", "countDistinct", "avg", "min", "max"]);
97
103
  const OPERATORS = new Set([...AGGREGATES, "add", "subtract", "multiply", "divide"]);
104
+ /** The most conditions one aggregate takes. */
105
+ const MAXIMUM_CONDITIONS = 10;
106
+ const CONDITION_OPERATORS = new Set(MEASURE_CONDITION_OPERATORS);
107
+ const CONDITION_PROPERTIES = "Measure conditions name properties of the entity.";
98
108
  /**
99
109
  * The most dimensions and measures the platform accepts in one aggregate model. It also refuses a
100
- * model definition over 60 KiB, which the SDK cannot measure exactly.
110
+ * model definition over {@link MAXIMUM_MODEL_BYTES}.
101
111
  */
102
112
  const MAXIMUM_MODEL_MEMBERS = 100;
103
113
  /** Stored property types that are not dimensions: their values cannot be grouped by. */
104
114
  const UNGROUPABLE_TYPES = new Set(["json", "blob"]);
115
+ /** The most JSON the platform reads as one entity's model: its dimensions and measures. */
116
+ const MAXIMUM_MODEL_BYTES = 60 * 1024;
117
+ /**
118
+ * An entity's model as the platform decodes it (`warehouse-backed-entities.ts`): a warehouse-backed
119
+ * entity's properties as dimensions, or a stored entity's id and groupable properties over its
120
+ * replication view, and its measures as the manifest writes them.
121
+ */
122
+ function modelDefinition(entity) {
123
+ const { definition } = entity.entity;
124
+ const { source } = definition;
125
+ const label = (name, property) => property.label ?? humanize(name);
126
+ const identity = {
127
+ name: entity.name,
128
+ label: definition.label,
129
+ description: definition.description,
130
+ };
131
+ const measures = measuresManifest(entity);
132
+ if (source === undefined) {
133
+ const entityLabel = typeof definition.label === "string" ? definition.label.toLowerCase() : "";
134
+ const id = {
135
+ name: "id",
136
+ column: "id",
137
+ type: "string",
138
+ label: "ID",
139
+ description: `The ${entityLabel}'s id.`,
140
+ scope: [],
141
+ };
142
+ return {
143
+ ...identity,
144
+ source: `ontology/${entity.name}`,
145
+ dimensions: [
146
+ id,
147
+ ...entity.properties.flatMap(({ name, definition: property }) => {
148
+ const type = dimensionTypeOf(property.type);
149
+ return type === undefined
150
+ ? []
151
+ : [
152
+ {
153
+ name,
154
+ column: name,
155
+ type,
156
+ label: label(name, property),
157
+ description: property.description ?? "",
158
+ scope: [],
159
+ },
160
+ ];
161
+ }),
162
+ ],
163
+ measures,
164
+ };
165
+ }
166
+ return {
167
+ ...identity,
168
+ source: source.asset,
169
+ ...(source.currency ? { currencyDimension: source.currency } : {}),
170
+ dimensions: entity.properties.map(({ name, definition: property }) => ({
171
+ name,
172
+ label: label(name, property),
173
+ description: property.description ?? "",
174
+ type: dimensionTypeOf(property.type),
175
+ column: property.column,
176
+ scope: property.scope ?? [],
177
+ })),
178
+ measures,
179
+ };
180
+ }
181
+ function isRecord(value) {
182
+ return typeof value === "object" && value !== null && !Array.isArray(value);
183
+ }
184
+ /** Whether an expression is an aggregate: `count`, or an aggregate of one property. */
185
+ function isAggregate(expression) {
186
+ return MEASURE_AGGREGATES.includes(expression.op);
187
+ }
188
+ /**
189
+ * Why a measure condition is refused, or `undefined`. It names a property of the entity, never a
190
+ * path, with an operator and values a request filter on that property would take, and never a
191
+ * period relative to today.
192
+ */
193
+ function conditionIssue(condition, properties) {
194
+ if (!isRecord(condition)) {
195
+ return CONDITION_PROPERTIES;
196
+ }
197
+ const property = conditionProperty(condition);
198
+ const definition = typeof property === "string" ? properties.get(property) : undefined;
199
+ const type = definition === undefined ? undefined : dimensionTypeOf(definition.type);
200
+ const { operator, value, values } = condition;
201
+ if (type === undefined) {
202
+ return CONDITION_PROPERTIES;
203
+ }
204
+ if (operator === "inPeriod") {
205
+ return "Measure conditions cannot be relative to today.";
206
+ }
207
+ if (typeof operator !== "string" || !CONDITION_OPERATORS.has(operator)) {
208
+ return `Measure conditions use ${MEASURE_CONDITION_OPERATORS.join(", ")}.`;
209
+ }
210
+ // Values that are not a list are as wrong as an empty list, which no operator takes.
211
+ const listed = values === undefined || Array.isArray(values) ? values : [];
212
+ const filter = { operator, value, values: listed };
213
+ return filterValuesIssue(filter) ?? filterTypeIssue(filter, type);
214
+ }
105
215
  /**
106
216
  * The measures of a warehouse-backed or stored entity. A measure's columns name properties of the
107
217
  * entity. `hasCurrency` says whether the entity maps a text `source.currency`, which stored entities
108
218
  * never do, so their measures cannot be currency amounts.
109
219
  */
110
220
  function checkMeasures(input) {
111
- const { entity, measures, properties, hasCurrency, report } = input;
221
+ const { entity, measures, properties, hasCurrency } = input;
222
+ let refusals = 0;
223
+ const report = (code, path, message) => {
224
+ refusals++;
225
+ input.report(code, path, message);
226
+ };
112
227
  // A stored entity's rows carry its system fields, whose names a measure cannot take.
113
228
  const stored = entity.entity.definition.source === undefined;
114
229
  const measureCount = Object.keys(measures).length;
@@ -139,6 +254,35 @@ function checkMeasures(input) {
139
254
  report("invalid-measure", field, "Invalid measure format.");
140
255
  }
141
256
  let remaining = 10_000;
257
+ /** The balance properties of the measure's aggregates and of the measures it references. */
258
+ const balances = new Set();
259
+ /** An aggregate's balance property, which must be a date or timestamp property. */
260
+ const checkBalance = (balance) => {
261
+ const type = typeof balance === "string" ? properties.get(balance)?.type : undefined;
262
+ if (type !== "date" && type !== "timestamp") {
263
+ report("invalid-measure", field, "balanceOver names a date or timestamp property.");
264
+ }
265
+ if (typeof balance === "string") {
266
+ balances.add(balance);
267
+ }
268
+ };
269
+ /** An aggregate's conditions, from its `where` option or the `filtered` expression around it. */
270
+ const checkConditions = (where) => {
271
+ if (!Array.isArray(where) || where.length === 0) {
272
+ report("invalid-measure", field, "Measure conditions take at least one entry.");
273
+ return;
274
+ }
275
+ if (where.length > MAXIMUM_CONDITIONS) {
276
+ report("invalid-measure", field, "Measure conditions take at most 10 entries.");
277
+ return;
278
+ }
279
+ for (const condition of where) {
280
+ const issue = conditionIssue(condition, properties);
281
+ if (issue !== undefined) {
282
+ report("invalid-measure", field, issue);
283
+ }
284
+ }
285
+ };
142
286
  /** `aggregate` is the aggregate the expression is an argument of, if any. */
143
287
  const visit = (expression, references, depth, aggregate) => {
144
288
  if (--remaining < 0 ||
@@ -149,6 +293,24 @@ function checkMeasures(input) {
149
293
  report("invalid-measure", field, "Invalid or overly complex expression.");
150
294
  return false;
151
295
  }
296
+ const conditions = conditionsOf(expression);
297
+ const balance = balanceOf(expression);
298
+ if (conditions !== undefined && expression.op !== "filtered") {
299
+ if (isAggregate(expression)) {
300
+ checkConditions(conditions);
301
+ }
302
+ else {
303
+ report("invalid-measure", field, "Only count, sum, countDistinct, avg, min and max take where.");
304
+ }
305
+ }
306
+ if (balance !== undefined) {
307
+ if (isAggregate(expression)) {
308
+ checkBalance(balance);
309
+ }
310
+ else {
311
+ report("invalid-measure", field, "Only count, sum, countDistinct, avg, min and max take balanceOver.");
312
+ }
313
+ }
152
314
  switch (expression.op) {
153
315
  case "count":
154
316
  if (aggregate !== undefined) {
@@ -171,9 +333,12 @@ function checkMeasures(input) {
171
333
  "property.");
172
334
  }
173
335
  }
174
- else if (aggregate === undefined || !["integer", "number"].includes(type)) {
336
+ else if (aggregate === undefined) {
175
337
  report("invalid-measure", field, "Sums require a numeric property.");
176
338
  }
339
+ else if (!["integer", "number"].includes(type)) {
340
+ report("invalid-measure", field, "avg, min, max and sum require a numeric property.");
341
+ }
177
342
  return false;
178
343
  }
179
344
  case "measure": {
@@ -194,6 +359,22 @@ function checkMeasures(input) {
194
359
  }
195
360
  return visit(target, [...references, expression.name], depth + 1);
196
361
  }
362
+ case "filtered": {
363
+ // The wrapper counts as the aggregate it wraps, which must not declare conditions too.
364
+ const args = Array.isArray(expression.args)
365
+ ? expression.args
366
+ : [];
367
+ const [inner] = args;
368
+ if (args.length !== 1 ||
369
+ !isRecord(inner) ||
370
+ !isAggregate(inner) ||
371
+ conditionsOf(inner) !== undefined) {
372
+ report("invalid-measure", field, "Invalid expression operator or arity.");
373
+ return false;
374
+ }
375
+ checkConditions(expression.where);
376
+ return visit(inner, references, depth + 1, aggregate);
377
+ }
197
378
  default: {
198
379
  const aggregates = AGGREGATES.has(expression.op);
199
380
  if (!OPERATORS.has(expression.op) ||
@@ -221,6 +402,18 @@ function checkMeasures(input) {
221
402
  if (measure.unit === "currency" && !hasCurrency) {
222
403
  report("invalid-measure", field, "Currency measures require source.currency.");
223
404
  }
405
+ if (balances.size > 1) {
406
+ report("invalid-measure", field, "A measure reads balances over two properties.");
407
+ }
408
+ }
409
+ // The platform builds a model of every warehouse-backed entity, and of each stored entity with
410
+ // measures. Measured only once the measures are valid, which writing them requires.
411
+ if (refusals === 0 && (!stored || measureCount > 0)) {
412
+ const bytes = new TextEncoder().encode(JSON.stringify(modelDefinition(entity))).byteLength;
413
+ if (bytes > MAXIMUM_MODEL_BYTES) {
414
+ input.report("invalid-measure", entity.name, `${entity.name}'s model, its dimensions and measures as SegmentStream reads them, takes ` +
415
+ `${(bytes / 1024).toFixed(1)} KiB; the limit is ${MAXIMUM_MODEL_BYTES / 1024} KiB.`);
416
+ }
224
417
  }
225
418
  }
226
419
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/ontology",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",