Skip to content

Commit 098b2b9

Browse files
committed
wip(service-analytics): refuse a dataset dimension over a virtual field at compile
Claude-Session: https://claude.ai/code/session_016WV4cz1PAaWWtcjFnbNsbW Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2d04cd2 commit 098b2b9

1 file changed

Lines changed: 55 additions & 1 deletion

File tree

‎packages/services/service-analytics/src/dataset-compiler.ts‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
AggregationFunction,
88
isAggregateCompatibleWithFieldType,
99
isMultiValueField,
10+
isVirtualSearchField,
1011
virtualFieldAggregateRemedy,
1112
} from '@objectstack/spec/data';
1213
import type { Dataset, DatasetMeasure, DatasetDimension } from '@objectstack/spec/ui';
@@ -467,6 +468,54 @@ function assertAggregateFieldTypeCompatible(
467468
);
468469
}
469470

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+
470519
/**
471520
* One column of the base object by its bare name — the spec's `duration.from` /
472521
* `to` shape (`ANALYTICS_BASE_COLUMN`, which `@objectstack/spec` does not
@@ -805,8 +854,13 @@ export function compileDataset(
805854

806855
// Compile dimensions.
807856
const dimensions: Record<string, CubeDimension> = {};
808-
for (const d of dataset.dimensions) {
857+
for (const [index, d] of dataset.dimensions.entries()) {
809858
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);
810864
// The dimension's NAME is the key it is filed under (`dimensions[d.name]`
811865
// below) — the cube member carries no inner copy: #20300 retired
812866
// `Dimension.name` (ADR-0049 enforce-or-remove), because every consumer

0 commit comments

Comments
 (0)