Skip to content

Commit 39a912e

Browse files
fix(lint)!: os validate refuses an analyticsCubes member the analytics door refuses — a dimension over a JSON-stored column, and count_distinct over one (#21416)
Fixes #21082 Clause-②: no (narrowing) ## What changes `@objectstack/lint`'s dataset-member rule (`packages/lint/src/validate-dataset-measure-aggregates.ts`) gains a cube leg. It walks `analyticsCubes` and refuses the three shapes that the analytics door (`service-analytics` `structured-json-dimension-door.ts`) refuses with `400 INVALID_FIELD`: 1. a cube dimension whose `sql` column is structured-JSON (`STRUCTURED_JSON_TYPES`); 2. a cube dimension whose `sql` column is multi-value (`isMultiValueField`, `multiple: true` included); 3. a cube `count_distinct` measure whose `sql` column is JSON-stored (the table's `count_distinct` row AND `isMultiValueField`). **No parallel copy.** The verdicts are the dataset leg's own helpers, `groupKeyClassOf` and `acceptsDeclaration`. The findings carry the dataset leg's two ids: - `dimension-json-stored-field-refused`, at `analyticsCubes[N].dimensions.KEY.sql`; - `measure-aggregate-field-type-refused`, at `analyticsCubes[N].measures.KEY.type`. The dataset and cube legs now share two builders for the finding text. The dataset findings stay byte-identical (measured below). The column is read the way the door reads it (`analytics-service.ts`, `hop-object.ts`): - **The cube's object** is its `sql`, trimmed, when it names an object the stack defines with a field map. - **A member's `sql`** is either a column of that object, or a relationship path whose leaf is read on the object its last hop reaches. - A hop the cube declares a join for reaches that join's `name`. This is the door's first tier. - Every other hop is walked by the shared `resolveFieldPath`. This is the door's second tier. - A hop the graph cannot follow is skipped. - **`'*'`** resolves to nothing and is skipped on both member kinds. Whether `'*'` belongs on a dimension or on a non-count measure is #21000's question, and it is not refused here. **Not judged here:** cube `sum` / `avg` / `min` / `max`, which are the #21044 door's pairs. See Acceptance notes. **Runtime door.** The registry entry is unchanged (`runtimeTypes: ['dataset']`). A `dataset` write's per-write snapshot carries no `analyticsCubes`, so the cube leg reads nothing at the runtime door. `authoring-rules.ts` gains a comment that says so. **Files changed:** - the rule and its tests; - that comment in `authoring-rules.ts`; - a cube paragraph in §6 of `content/docs/deployment/validating-metadata.mdx`; - one changeset. `index.ts` is untouched. ## Clause-②: the measured arm `node scripts/pm/check-widening-tells.mjs --declaration no --diff` on the final diff exits 0. It also reports all 5 files as NOT MEASURED, because no declared surface covers `packages/lint`, so it decided nothing. The arm therefore comes from the dispatch's criterion: - The diff adds 0 `export` lines in `packages/lint/src` (tests excluded). The positive control, the model commit `5e470f8c1c`, adds 1. - `index.ts`, `runtime.ts` and `package.json` are unchanged. So the diff adds no symbol and no listing row, and the arm is `no (narrowing)`. The changeset is still BREAKING because metadata that used to pass `os validate` can now fail. It carries `!`, the banner, `minor`, and one ADR-0087 marker: `not-required (no-migration-prescription)`. ## `os validate`, before (`4b20c84748`) and after (`07a308acdc`) Each fixture is `fx_ledger` plus one cube, `fx_cube`. | fixture | before | after | |---|---|---| | `cube-json-dim` (dimension `meta`, `sql: 'meta'`, over `json`), the card's measured instance | exit 0 | **exit 1** (`dimension-json-stored-field-refused`) | | dimension over `tags` | exit 0 | **exit 1** | | dimension over `select` with `multiple: true` | exit 0 | **exit 1** | | dimension over `account.hq` (lookup to `fx_account`, where `hq` is `json`) | exit 0 | **exit 1** | | `count_distinct` over `json` | exit 0 | **exit 1** (`measure-aggregate-field-type-refused`) | | `count_distinct` over `select` with `multiple: true` | exit 0 | **exit 1** | | scalar control (see below) | exit 0 | exit 0 | The scalar control has dimensions over `text`, a single `select` and `account.name`, and `count_distinct` over `text` and a single `select`. **Corpus after:** `os validate` passes on `examples/app-showcase`, `app-crm` and `app-todo`, with 0 findings of either id. App-showcase holds `showcase_delivery`, the repo's one real cube. ## Tests `src/validate-dataset-measure-aggregates.test.ts` gains 11 cube tests: - each shape, paired with a scalar control; - a sweep of every `FieldType`, flagged and not, against the door's predicates; - the reference tier and the declared-join tier; - the skips and the `'*'` wildcard; - report order; - `runAuthoringRules` on all three commands. Runs at `07a308acdc`: - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2`: 119 files, 5586 tests passed. - `pnpm --filter @objectstack/lint typecheck`: exit 0. `--listFiles` shows the test file is in the `tsconfig.test.json` program. **Byte identity of the dataset leg.** I ran BASE's rule and HEAD's rule over 98 stacks: every aggregate × every `FieldType` × the `multiple` flag, plus joined paths and missing names. They produced 920 findings with 0 mismatches. As a control, a perturbed copy of the old rule produced 26 mismatches. **Ablations.** Each one went through `scripts/ablation-replace.mjs` on the committed tree. Every restore was proven by its blob hash matching HEAD and by an empty `git diff HEAD`. | ablation | result | |---|---| | A1: cube walk dropped | 8 red / 37 green | | A2: declared-join tier disabled | 1 red (the join test: without the tier, the lookup's target is judged instead of the join's) | | A3: cube `count_distinct` leg disabled | 5 red | | A4: cube measure leg widened to every aggregate | 1 red (`judges only count_distinct`) | ## Gates All gates ran at `07a308acdc`: - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 90 commands. I ran all 90. - I also ran the 4 roster gates whose roster sits under a changed path: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. - All 94 exit 0. - `check:skill-examples` and `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: 8 packages had no `dist`). After those packages were built, both exit 0. - `--ran` reconciliation: 90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN. `main` has moved 6 commits since the branch point, and the branch is not merged with it. None of those commits touches `packages/lint` or the docs page. CI's merge ref tests the combined state. ## Changeset `.changeset/21082-cube-member-json-stored-refused.md`: `@objectstack/lint` `minor`, BREAKING. It states what is refused, what an author now sees, and what is unchanged. ## Acceptance notes - **Cube `sum` / `avg` / `min` / `max` are not judged at authoring.** The cube door from #21044 refuses those pairs at query time. Measured at `07a308acdc`: a cube `sum` over a `text` column passes `os validate` with exit 0. This is the same family as this card. It is reported to the seat, not fixed here. - **The runtime `analytics_cube` write door runs no authoring rule.** No rule declares that type, and `TYPE_TO_STACK_KEY` has no row for it. This was read from code, not measured. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 22c2d6f commit 39a912e

5 files changed

Lines changed: 621 additions & 86 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
fix(lint)!: `os validate`, `os build` and `os lint` refuse an `analyticsCubes` dimension over a JSON-stored column, and a cube `count_distinct` measure over one, which the analytics door already refuses at query time
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at authoring of two cube member TARGETS the analytics door already refuses with 400 INVALID_FIELD at query time: an analyticsCubes dimension whose sql column is declared structured-JSON (json, composite, repeater, record, location, address, vector) or multi-value (multiselect, checkboxes, tags, or a select, radio, lookup, user, file or image declared multiple: true), and a count_distinct measure whose sql column is either. It is the cube face of the dataset dimension refusal, which declared this category for the same door. No authorable key, spelling, export or stored shape moves: CubeSchema keeps parsing every member, no stored row is read or rewritten, and which scalar part of a document, or which member of a list, an author meant to group on or count is not something a ledger entry can rewrite. The other categories are closed on facts: the package publishes (not unpublished); dataset-measure-aggregate-field-type-refused scopes itself to DatasetMeasureSchema rows and no ADR-0087 id covers a cube member (not already-registered); and the change is a rule verdict, not a declaration (not runtime-interface-only or type-surface-only). -->
10+
11+
**BREAKING**: metadata that passed `os validate`, `os build` and `os lint` can now fail. An authored analytics cube (`defineStack({ analyticsCubes })`) is queried through the same analytics door as a compiled dataset, and that door refuses a query that groups by a JSON-stored column, or counts its distinct values, with `400 INVALID_FIELD` before any SQL is built. So such a member could be declared but never served, and until now no authoring rule read `analyticsCubes` at all. The dataset rule's two ids now judge cube members as well: `dimension-json-stored-field-refused` and `measure-aggregate-field-type-refused` (gating, `error`). It ships as `minor` under the launch-window convention for accept-set narrowings. No export is added or removed.
12+
13+
**What is refused.** On a cube whose `sql` names an object the stack defines: a `dimensions` entry whose `sql` column is declared with a structured-JSON type (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`) or a multi-value declaration (`multiselect`, `checkboxes`, `tags`, or a `select`, `radio`, `lookup`, `user`, `file` or `image` declared `multiple: true`); and a `measures` entry of `type: 'count_distinct'` whose `sql` column is either. The column is the member's `sql`: a column of the cube's object, or a relationship path read on the object its last hop reaches (the join the cube declares for that hop, else the lookup field's `reference`). The classes are `@objectstack/spec/data`'s `STRUCTURED_JSON_TYPES`, `isMultiValueField` and the `count_distinct` row of `AGGREGATE_FIELD_TYPE_COMPATIBILITY`, the predicates the door reads.
14+
15+
**What an author sees now.** The finding names the cube, the member, the column, the object that declares it and its declaration, and says the analytics door refuses it with `400 INVALID_FIELD`. It names the route: group by, or count the distinct values of, a field that stores one scalar value; for a multi-value field, filter by one member with `$contains` in a record query. It is located at `analyticsCubes[N].dimensions.KEY.sql` or `analyticsCubes[N].measures.KEY.type`, where `KEY` is the member's key.
16+
17+
**Unchanged.** Every dataset finding, word for word. A cube member over any other column, a single-value `select` or `lookup` included; a `count` measure, and a `sum`, `avg`, `min` or `max` measure, which this check does not judge; the row wildcard `'*'`; a member whose column does not resolve or declares no type; a cube whose `sql` names no object this stack defines. The runtime metadata write door: no authoring rule is dispatched for an `analytics_cube` save, and a `dataset` save's snapshot carries no cubes.

‎content/docs/deployment/validating-metadata.mdx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,18 @@ above declared `multiple: true`), is refused
232232
Group by a field that stores one value instead. Like the measure check, it
233233
stays silent when the field's type cannot be resolved.
234234

235+
Both checks also judge the members of an analytics cube (`analyticsCubes`),
236+
because an authored cube is queried through the same analytics door. A cube
237+
dimension whose `sql` column is declared with a structured-JSON type or as a
238+
multi-value field is refused (`dimension-json-stored-field-refused`) at
239+
`analyticsCubes[N].dimensions.<key>.sql`, and so is a `count_distinct` measure
240+
over such a column (`measure-aggregate-field-type-refused`, at
241+
`analyticsCubes[N].measures.<key>.type`). The column is read where the door
242+
reads it: on the object the cube's `sql` names, or, for a relationship path,
243+
on the object the last hop reaches — the join the cube declares for that hop,
244+
else the lookup's `reference`. A cube's other measure types and the row
245+
wildcard `'*'` are not judged by this check.
246+
235247
### 7. Navigation exposing objects nobody can read
236248

237249
Navigation and permissions are separate metadata, each valid on its own — so an

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,12 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
718718
// over the shipped dataset corpus before crossing, at the door's own
719719
// snapshot shape: 11 datasets (platform-objects 5, showcase 4, crm 1,
720720
// todo 1) — 0 findings, with a lit synthetic probe refused.
721+
//
722+
// [#21082] The rule also walks `analyticsCubes` (the cube leg, same two
723+
// ids). That leg is CLI-only by construction, not by a narrowing here: a
724+
// `dataset` write's snapshot carries no `analyticsCubes` (the context
725+
// collections are `RuntimeStackContext`'s), so it reads nothing at this
726+
// door, and an `analytics_cube` write has no `TYPE_TO_STACK_KEY` row.
721727
surfaces: CLI_AND_RUNTIME,
722728
runtimeTypes: ['dataset'],
723729
run: (stack) => validateDatasetMeasureAggregates(stack),

‎packages/lint/src/validate-dataset-measure-aggregates.test.ts‎

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,3 +619,243 @@ describe('dimension-json-stored-field-refused — a dimension over a JSON-stored
619619
).toEqual([]);
620620
});
621621
});
622+
623+
// ───────────────────────────────────────────────────────────────────────────
624+
// [#21082] The cube leg — `analyticsCubes` members, under the same two ids.
625+
// The analytics door (`structured-json-dimension-door.ts`) refuses a grouped
626+
// cube member whose column is JSON-stored, and a cube `count_distinct` measure
627+
// over one, with `400 INVALID_FIELD`; until this leg `os validate` read no cube
628+
// at all, so all three passed authoring (measured: `cube-json-dim`, exit 0).
629+
// ───────────────────────────────────────────────────────────────────────────
630+
631+
/**
632+
* The measured instance's shape (`cube-json-dim`, os-dev-report on #20890): a
633+
* cube over `fx_ledger` whose members name the column in `sql`. `members`
634+
* replaces the cube's `dimensions` / `measures`; `extra` adds cube keys.
635+
*/
636+
const cubeStack = (
637+
members: { dimensions?: Record<string, unknown>; measures?: Record<string, unknown> },
638+
extra: Record<string, unknown> = {},
639+
ledgerFields: Record<string, unknown> = {},
640+
): Record<string, unknown> => ({
641+
name: 'analytics_probe',
642+
objects: [
643+
{
644+
name: 'fx_ledger',
645+
sharingModel: 'private',
646+
fields: {
647+
name: { type: 'text' },
648+
meta: { type: 'json' },
649+
labels: { type: 'tags' },
650+
stages: { type: 'select', multiple: true },
651+
stage: { type: 'select' },
652+
account: { type: 'lookup', reference: 'fx_account' },
653+
...ledgerFields,
654+
},
655+
},
656+
{
657+
name: 'fx_account',
658+
sharingModel: 'private',
659+
fields: { name: { type: 'text' }, hq: { type: 'json' }, region: { type: 'text' } },
660+
},
661+
{
662+
name: 'fx_branch',
663+
sharingModel: 'private',
664+
fields: { name: { type: 'text' }, hq: { type: 'text' }, region: { type: 'json' } },
665+
},
666+
],
667+
analyticsCubes: [
668+
{
669+
name: 'fx_cube',
670+
sql: 'fx_ledger',
671+
measures: { count: { label: 'Count', type: 'count', sql: '*' }, ...(members.measures ?? {}) },
672+
dimensions: members.dimensions ?? {},
673+
...extra,
674+
},
675+
],
676+
});
677+
678+
const dim = (sql: string) => ({ label: 'Dimension', type: 'string', sql });
679+
const measure = (type: string, sql: string) => ({ label: 'Measure', type, sql });
680+
681+
const cubeDimensionFindings = (stack: unknown) =>
682+
validateDatasetMeasureAggregates(stack).filter((f) => f.rule === DIMENSION_RULE);
683+
684+
describe('the cube leg — an analyticsCubes member the analytics door refuses is refused at authoring', () => {
685+
// ⭐ The card's measured instance and its scalar control, one pair.
686+
it('refuses cube-json-dim — a cube dimension over a json field — and accepts one over a text field', () => {
687+
const found = validateDatasetMeasureAggregates(cubeStack({ dimensions: { meta: dim('meta') } }));
688+
expect(found).toHaveLength(1);
689+
const issue = found[0];
690+
expect(issue.severity).toBe('error');
691+
expect(issue.rule).toBe(DIMENSION_RULE);
692+
expect(issue.path).toBe('analyticsCubes[0].dimensions.meta.sql');
693+
expect(issue.where).toBe('cube "fx_cube" › dimension "meta"');
694+
expect(issue.message).toContain('object "fx_ledger" declares as `json`');
695+
expect(issue.message).toContain('structured-JSON');
696+
expect(issue.message).toContain('400 INVALID_FIELD');
697+
expect(issue.message).toContain('so a query that selects it gets that refusal');
698+
expect(issue.hint).toContain('scalar value');
699+
700+
expect(validateDatasetMeasureAggregates(cubeStack({ dimensions: { name: dim('name') } }))).toEqual([]);
701+
});
702+
703+
it('refuses a cube dimension over a multi-value field — tags, or a select flagged multiple: true — and serves a single select', () => {
704+
const [tags] = cubeDimensionFindings(cubeStack({ dimensions: { labels: dim('labels') } }));
705+
expect(tags?.path).toBe('analyticsCubes[0].dimensions.labels.sql');
706+
expect(tags?.message).toContain('multi-value');
707+
const [flagged] = cubeDimensionFindings(cubeStack({ dimensions: { stages: dim('stages') } }));
708+
expect(flagged?.message).toContain('`select` with `multiple: true`');
709+
// The route the door names: filter by one member on the declaring object.
710+
expect(flagged?.hint).toContain('a record query on "fx_ledger"');
711+
expect(flagged?.hint).toContain('$contains');
712+
expect(cubeDimensionFindings(cubeStack({ dimensions: { stage: dim('stage') } }))).toEqual([]);
713+
});
714+
715+
it('refuses a cube count_distinct over a JSON-stored column, under the measure id, located at the aggregate', () => {
716+
const [json] = findings(cubeStack({ measures: { distinct_meta: measure('count_distinct', 'meta') } }));
717+
expect(json?.severity).toBe('error');
718+
expect(json?.path).toBe('analyticsCubes[0].measures.distinct_meta.type');
719+
expect(json?.where).toBe('cube "fx_cube" › measure "distinct_meta"');
720+
expect(json?.message).toContain('aggregate "count_distinct" to field "meta"');
721+
expect(json?.message).toContain('`json`');
722+
// The door that refuses it later is the cube's, never the dataset compile leg.
723+
expect(json?.hint).toContain('on the cube with `400 INVALID_FIELD`');
724+
expect(json?.hint).not.toContain('DATASET_INVALID');
725+
726+
const [flagged] = findings(cubeStack({ measures: { distinct_stages: measure('count_distinct', 'stages') } }));
727+
expect(flagged?.message).toContain('`select` with `multiple: true`');
728+
expect(flagged?.hint).toContain('accepts: count.');
729+
const [tags] = findings(cubeStack({ measures: { distinct_labels: measure('count_distinct', 'labels') } }));
730+
expect(tags?.path).toBe('analyticsCubes[0].measures.distinct_labels.type');
731+
});
732+
733+
it('accepts a scalar cube — the control: scalar dimensions and count_distinct over scalar columns', () => {
734+
const stack = cubeStack({
735+
dimensions: { name: dim('name'), stage: dim('stage'), acct: dim('account.name') },
736+
measures: {
737+
distinct_name: measure('count_distinct', 'name'),
738+
distinct_stage: measure('count_distinct', 'stage'),
739+
},
740+
});
741+
expect(validateDatasetMeasureAggregates(stack)).toEqual([]);
742+
});
743+
744+
it('judges only count_distinct among the cube measures: count reads no value, and the other rows are not this leg\'s', () => {
745+
for (const type of ['count', 'sum', 'avg', 'min', 'max']) {
746+
expect(findings(cubeStack({ measures: { m: measure(type, 'meta') } })), type).toEqual([]);
747+
}
748+
});
749+
750+
// The whole surface against the door's predicates, read from the spec.
751+
it('agrees with the door\'s predicates on every declared FieldType, flagged and not, for a dimension and a count_distinct', () => {
752+
let refused = 0;
753+
let accepted = 0;
754+
for (const fieldType of FieldType.options) {
755+
for (const multiple of [false, true]) {
756+
const def = multiple ? { type: fieldType, multiple: true } : { type: fieldType };
757+
const stack = cubeStack(
758+
{ dimensions: { probe: dim('probe') }, measures: { probe_distinct: measure('count_distinct', 'probe') } },
759+
{},
760+
{ probe: def },
761+
);
762+
const found = validateDatasetMeasureAggregates(stack);
763+
const dimFires = found.some((f) => f.rule === DIMENSION_RULE);
764+
const cdFires = found.some((f) => f.rule === RULE);
765+
const shape = { type: fieldType, multiple };
766+
expect(dimFires, `dimension over ${fieldType}${multiple ? ', multiple' : ''}`).toBe(
767+
STRUCTURED_JSON_TYPES.has(fieldType) || isMultiValueField(shape),
768+
);
769+
expect(cdFires, `count_distinct over ${fieldType}${multiple ? ', multiple' : ''}`).toBe(
770+
!isAggregateCompatibleWithFieldType('count_distinct', fieldType) || isMultiValueField(shape),
771+
);
772+
for (const fires of [dimFires, cdFires]) {
773+
if (fires) refused++;
774+
else accepted++;
775+
}
776+
}
777+
}
778+
expect(refused).toBeGreaterThan(30);
779+
expect(accepted).toBeGreaterThan(100);
780+
});
781+
782+
it('reads a relationship path on the object the last hop reaches — the lookup\'s reference, or the join the cube declares for it', () => {
783+
// No declared join: the lookup's `reference` (`fx_account.hq` is json).
784+
const [byReference] = cubeDimensionFindings(cubeStack({ dimensions: { acct_hq: dim('account.hq') } }));
785+
expect(byReference?.message).toContain('object "fx_account" (reached through this cube\'s join chain)');
786+
expect(cubeDimensionFindings(cubeStack({ dimensions: { acct_region: dim('account.region') } }))).toEqual([]);
787+
788+
// A declared join wins over the reference, as at the door: keyed `account`,
789+
// reaching `fx_branch`, where `hq` is text and `region` is json.
790+
const joined = { joins: { account: { name: 'fx_branch' } } };
791+
expect(cubeDimensionFindings(cubeStack({ dimensions: { acct_hq: dim('account.hq') } }, joined))).toEqual([]);
792+
const [byJoin] = cubeDimensionFindings(cubeStack({ dimensions: { acct_region: dim('account.region') } }, joined));
793+
expect(byJoin?.message).toContain('object "fx_branch"');
794+
const [distinctByJoin] = findings(
795+
cubeStack({ measures: { d: measure('count_distinct', 'account.region') } }, joined),
796+
);
797+
expect(distinctByJoin?.path).toBe('analyticsCubes[0].measures.d.type');
798+
});
799+
800+
it('never hands the predicates a guess — the same skips as the dataset leg', () => {
801+
const refusedShape = { dimensions: { meta: dim('meta') }, measures: { d: measure('count_distinct', 'meta') } };
802+
expect(validateDatasetMeasureAggregates(cubeStack(refusedShape))).toHaveLength(2);
803+
// A cube whose `sql` names no object this stack defines, or is not an object name.
804+
expect(validateDatasetMeasureAggregates(cubeStack(refusedShape, { sql: 'not_here' }))).toEqual([]);
805+
expect(validateDatasetMeasureAggregates(cubeStack(refusedShape, { sql: 'SELECT * FROM fx_ledger' }))).toEqual([]);
806+
// A column that does not resolve, and a hop the graph cannot follow.
807+
expect(validateDatasetMeasureAggregates(cubeStack({ dimensions: { x: dim('nope') } }))).toEqual([]);
808+
expect(validateDatasetMeasureAggregates(cubeStack({ dimensions: { x: dim('ghost.hq') } }))).toEqual([]);
809+
// An untyped column, and a member that writes no `sql` / a non-string one.
810+
expect(
811+
validateDatasetMeasureAggregates(cubeStack({ dimensions: { u: dim('untyped') } }, {}, { untyped: { label: 'U' } })),
812+
).toEqual([]);
813+
expect(validateDatasetMeasureAggregates(cubeStack({ dimensions: { x: { label: 'X', type: 'string' } } }))).toEqual([]);
814+
expect(validateDatasetMeasureAggregates(cubeStack({ dimensions: { x: { ...dim('meta'), sql: ['meta'] } } }))).toEqual([]);
815+
});
816+
817+
// `'*'` on a dimension or a non-count measure is a question of its own; the
818+
// row wildcard names no column, so this leg reads nothing from it.
819+
it('reads nothing from the row wildcard, on a dimension or a count_distinct', () => {
820+
expect(
821+
validateDatasetMeasureAggregates(
822+
cubeStack({ dimensions: { all: dim('*') }, measures: { d: measure('count_distinct', '*') } }),
823+
),
824+
).toEqual([]);
825+
});
826+
827+
it('reports each dataset before each cube, and a cube stack with no datasets is walked', () => {
828+
const stack = {
829+
...dimensionStack({ type: 'json' }),
830+
analyticsCubes: (cubeStack({ dimensions: { meta: dim('meta') } }).analyticsCubes as unknown[]),
831+
objects: [
832+
...((dimensionStack({ type: 'json' }).objects as unknown[])),
833+
...((cubeStack({}).objects as unknown[])),
834+
],
835+
};
836+
expect(validateDatasetMeasureAggregates(stack).map((f) => f.path)).toEqual([
837+
'datasets[0].dimensions[0].field',
838+
'analyticsCubes[0].dimensions.meta.sql',
839+
]);
840+
});
841+
842+
it('fires through runAuthoringRules on all three commands, and only on the refused members', () => {
843+
const stack = cubeStack({ dimensions: { meta: dim('meta') }, measures: { d: measure('count_distinct', 'meta') } });
844+
for (const command of ['validate', 'build', 'lint'] as const) {
845+
const found = runAuthoringRules(command, { normalized: stack, parsed: stack }).filter(
846+
(f) => f.rule === DIMENSION_RULE || f.rule === RULE,
847+
);
848+
expect(found.map((f) => [f.rule, f.path]), command).toEqual([
849+
[DIMENSION_RULE, 'analyticsCubes[0].dimensions.meta.sql'],
850+
[RULE, 'analyticsCubes[0].measures.d.type'],
851+
]);
852+
expect(found.every((f) => f.severity === 'error'), command).toBe(true);
853+
}
854+
const control = cubeStack({ dimensions: { name: dim('name') }, measures: { d: measure('count_distinct', 'name') } });
855+
expect(
856+
runAuthoringRules('lint', { normalized: control, parsed: control }).filter(
857+
(f) => f.rule === DIMENSION_RULE || f.rule === RULE,
858+
),
859+
).toEqual([]);
860+
});
861+
});

0 commit comments

Comments
 (0)