|
7 | 7 | AggregationFunction, |
8 | 8 | isAggregateCompatibleWithFieldType, |
9 | 9 | isMultiValueField, |
| 10 | + isVirtualSearchField, |
10 | 11 | virtualFieldAggregateRemedy, |
11 | 12 | } from '@objectstack/spec/data'; |
12 | 13 | import type { Dataset, DatasetMeasure, DatasetDimension } from '@objectstack/spec/ui'; |
@@ -467,6 +468,54 @@ function assertAggregateFieldTypeCompatible( |
467 | 468 | ); |
468 | 469 | } |
469 | 470 |
|
| 471 | +/** |
| 472 | + * [#30773] Refuse a dataset DIMENSION whose field the base object declares |
| 473 | + * VIRTUAL — computed on read, never stored (the spec's `isVirtualSearchField` |
| 474 | + * / `SEARCH_VIRTUAL_TYPES`, today `formula`), whatever its `returnType`. |
| 475 | + * |
| 476 | + * A dimension is a GROUP KEY: it compiles to a cube dimension whose `sql` is |
| 477 | + * the field, and every query that selects it groups (or buckets) by that |
| 478 | + * column. No driver materialises a column for a virtual field, so since |
| 479 | + * #29304 every analytics face refuses that group at query time with |
| 480 | + * `INVALID_FIELD` / 400 at `groupBy.N` (`aggregate-query-shape-door.ts` |
| 481 | + * `assertGroupByTargetsAreStored`; the service's copy in |
| 482 | + * `dataset-date-bucket-door.ts`). Before this gate the dataset itself still |
| 483 | + * compiled and registered, so the author published a dimension no query could |
| 484 | + * serve and the refusal reached a dashboard reader instead. This is the |
| 485 | + * authoring half Prime Directive #12 asks for, the dimension sibling of the |
| 486 | + * measure's virtual-field refusal (#24426, `virtualFieldAggregateRemedy`): |
| 487 | + * `DATASET_INVALID` / 400 — a verdict about the dataset DOCUMENT, decided from |
| 488 | + * metadata alone — naming the dimension, its position and the remedy the |
| 489 | + * query-time doors name (store the value on a stored field and group by that). |
| 490 | + * |
| 491 | + * Tiering is the measure gate's ("cannot answer, do not block"): no |
| 492 | + * `declaredValueShape` hook, a field the hook cannot resolve, or a |
| 493 | + * RELATIONSHIP-PATH field (the hook answers for the base object only) is not |
| 494 | + * judged here. A dotted dimension over a virtual leaf stays the query-time |
| 495 | + * doors' and the lint leg's (`dimension-virtual-field-refused`), which resolves |
| 496 | + * the object graph. ⛔ No local type list: the verdict is the spec predicate's. |
| 497 | + */ |
| 498 | +function assertDimensionFieldStored( |
| 499 | + datasetName: string, |
| 500 | + objectName: string, |
| 501 | + dimension: DatasetDimension, |
| 502 | + index: number, |
| 503 | + declaredValueShape?: (objectName: string, field: string) => ValueShapeFieldDef | undefined, |
| 504 | +): void { |
| 505 | + if (!declaredValueShape) return; |
| 506 | + const field = dimension.field; |
| 507 | + if (!field || field.includes('.')) return; |
| 508 | + const fieldType = declaredValueShape(objectName, field)?.type; |
| 509 | + if (!fieldType || !isVirtualSearchField({ type: fieldType })) return; |
| 510 | + throw datasetInvalidError( |
| 511 | + `[dataset-compiler] dataset "${datasetName}" dimension "${dimension.name}" (dimensions.${index}) groups by ` + |
| 512 | + `field "${field}", which object "${objectName}" declares as \`${fieldType}\` — its value is computed on read ` + |
| 513 | + 'and never stored, so no driver materialises a column to group by, and every query selecting this dimension ' + |
| 514 | + `is refused. Denormalise the value onto "${objectName}" (a stored field, written when the source changes) and ` + |
| 515 | + 'point the dimension at that field.', |
| 516 | + ); |
| 517 | +} |
| 518 | + |
470 | 519 | /** |
471 | 520 | * One column of the base object by its bare name — the spec's `duration.from` / |
472 | 521 | * `to` shape (`ANALYTICS_BASE_COLUMN`, which `@objectstack/spec` does not |
@@ -805,8 +854,13 @@ export function compileDataset( |
805 | 854 |
|
806 | 855 | // Compile dimensions. |
807 | 856 | const dimensions: Record<string, CubeDimension> = {}; |
808 | | - for (const d of dataset.dimensions) { |
| 857 | + for (const [index, d] of dataset.dimensions.entries()) { |
809 | 858 | assertDeclared(d.field, 'dimension', d.name); |
| 859 | + // [#30773] …and the column must be STORED: a group key over a virtual field |
| 860 | + // is one no query can serve. Placed after the join-declaration check, as the |
| 861 | + // measure's type gate is, so a dotted field is refused for the reason it is |
| 862 | + // actually wrong before this gate stands down on it. |
| 863 | + assertDimensionFieldStored(dataset.name, dataset.object, d, index, options?.declaredValueShape); |
810 | 864 | // The dimension's NAME is the key it is filed under (`dimensions[d.name]` |
811 | 865 | // below) — the cube member carries no inner copy: #20300 retired |
812 | 866 | // `Dimension.name` (ADR-0049 enforce-or-remove), because every consumer |
|
0 commit comments