Skip to content

Commit d70353f

Browse files
fix(lint)!: os validate refuses an analyticsCubes sum / avg / min / max measure over a column the cube door refuses — every cube measure is judged by the aggregate × field-type table (#21435)
Fixes #21419 Clause-②: no (narrowing) ## What changes `@objectstack/lint`'s dataset-member rule (`packages/lint/src/validate-dataset-measure-aggregates.ts`) now judges **every** `analyticsCubes` measure through `acceptsDeclaration`, the verdict a dataset measure already gets. Until now the cube leg judged `count_distinct` only, so a cube `sum` / `avg` / `min` / `max` over a column the cube door refuses passed `os validate`. - A cube measure's `type` is its aggregate. Every `type` that is a row of `AGGREGATE_FIELD_TYPE_COMPATIBILITY` is judged on the column the door reads. That column is the cube's object, or, for a relationship path, the last hop's declared join, else the lookup's `reference` (the resolution PR #21416 added, unchanged). - `count` reads no value and is accepted on every type, as on a dataset. - A `type` outside the table (`number` / `string` / `boolean`, the expression metric types) is skip 5, as on a dataset. Neither cube door judges it either. - The row wildcard `'*'` is still skipped. Whether it belongs on a non-count measure is the question #21409 owns; nothing here refuses it. - **No second table and no parallel copy.** The change in the rule body is one guard: `if (aggregate !== 'count_distinct') continue;` becomes `if (!aggregate) continue;`, and the existing skip 5 (`ACCEPTED_TYPES_BY_AGGREGATE.get`) now does the vocabulary work it does for a dataset. The finding id (`measure-aggregate-field-type-refused`), its location (`analyticsCubes[N].measures.KEY.type`) and its words are the ones the cube `count_distinct` leg already emits. **Text this PR makes false, corrected in it:** the rule's module note and the `validate-dataset-measure-aggregates.ts` docblock, and the cube paragraph in §6 of `content/docs/deployment/validating-metadata.mdx`. Both said a cube's other measure types were not judged. The landed `.changeset/21082-cube-member-json-stored-refused.md` is released history and is left as it is. **Files changed (4):** the rule, its test file, the docs paragraph, one changeset. `index.ts`, `runtime.ts`, `package.json` and `service-analytics` are untouched. ## The cube door's verdict, pair for pair The enumeration pin in the test file crosses every cube measure `type` (`AggregationMetricType`, which holds every row of the table) with every `FieldType`, flagged `multiple: true` and not. It checks each pair against the two cube doors' rules as their modules state them: - `count` / `sum` / `avg` / `min` / `max`: `cube-measure-field-type-door.ts`. The row is checked on the declared TYPE, and the `multiple` flag is not read. - `count_distinct`: `structured-json-dimension-door.ts`. The row is checked, and so is `isMultiValueField`. Per-type floors hold on both sides. As a one-off proof against the **real** door rather than its stated rule, a scratch script was run and not committed. For each case it built a real `AnalyticsService` with `sourceFieldMeta`, `relationshipResolver` and `getObjectFieldNames` wired. Then it called `generateSql` on one measure, which goes through `ensureCube` to both doors. It compared the refusal (`INVALID_FIELD` with the door's own words) against the lint verdict. The cases were every `AggregationMetricType` × every `FieldType` × `multiple` {false, true} × three column positions: bare, reference-tier path and join-tier path. | lint rule | pairs | agree | lint refuses | door refuses | mismatches | |---|---|---|---|---|---| | HEAD `1de24da8fb` (the rule's blob is `0d56d08213` since `1236c5a4b7`) | 2646 | 2646 | 1008 | 1008 | **0** | | BASE `39a912ea73` (control) | 2646 | 1716 | 78 | 1008 | 930, all lint=false door=true | No pair diverges, so there is nothing to report as a door/table disagreement. ## `os validate`, before and after Fixtures `fx_ledger` + one cube `fx_cube` were measured with the CLI built from this tree. **Before** is the same tree with the guard reverted through `scripts/ablation-replace.mjs`. The lint `dist/` was rebuilt, and `ablation-dist-preflight` confirmed the marker present in 4 built files. **After** is the restored tree, rebuilt, with the marker confirmed absent from all 8 built files and the tree clean against HEAD. | fixture | before | after | |---|---|---| | `oos-cube-sum-text` (`sum` over `name`, `text`), the card's measured instance | exit 0 | **exit 1** at `analyticsCubes[0].measures.sum_name.type` | | `avg` over `text` | exit 0 | **exit 1** | | `max` over `text` | exit 0 | **exit 1** | | `min` over a single `select` | exit 0 | **exit 1** | | `avg` over `datetime` | exit 0 | **exit 1** | | `sum` over `account.name`, reference tier (`fx_account`, `text`) | exit 0 | **exit 1** | | `sum` over `account.revenue`, join tier (`joins.account` reaches `fx_branch`, where it is `text`) | exit 0 | **exit 1**, naming `fx_branch` | | scalar control | exit 0 | exit 0 | The scalar control holds `sum` and `avg` over `number`, `max` and `min` over `datetime`, `count` and `count_distinct` over `text`, `sum` over `account.revenue` by reference (`number`), and `sum` over `'*'`. ## Census The census covers `examples/**`, `packages/**` (fixtures included) and the platform objects. It found **one** authored cube in the shipped corpus: showcase's `showcase_delivery`, with `count` over `'*'` and `sum` / `avg` over `estimate_hours` (`number`). It stays clean. Every other `analyticsCubes` or `defineCube` site is a spec, service-analytics, runtime, objectql or driver test, and none of them runs a lint rule. At HEAD `1de24da8fb`, `os validate` exits 0 on `examples/app-showcase`, `app-crm` and `app-todo`, with 0 findings of either cube id. ## Clause-②: the measured arm - `node scripts/pm/check-widening-tells.mjs --declaration no --diff` on the final diff (`git diff 39a912e...HEAD`) exits 0. All 4 files are NOT MEASURED (no declared surface covers them), so it decided nothing. - **Export census:** the diff adds 0 and removes 0 `export` lines in `packages/lint/src`, tests excluded. The positive control `5e470f8c1c` adds 1. `index.ts`, `runtime.ts` and `package.json` are unchanged. - So no export is added, and the arm is `no (narrowing)`. The changeset is still BREAKING, because metadata that passed `os validate` can now fail. ## Changeset `.changeset/21419-cube-measure-aggregate-field-type-refused.md`: `@objectstack/lint` `minor`, `fix(lint)!`, a **BREAKING** banner, `Clause-②: no (narrowing)`, and exactly one ADR-0087 marker: `not-required (no-migration-prescription)`. `check-adr-0087-registration` accepts it and reads it as `[BREAKING+bang+clause-②-narrowing]`. ## Tests `src/validate-dataset-measure-aggregates.test.ts` gains 6 tests in a new block, and 1 test is rewritten. - The rewritten test is the #21082 test that pinned "judges only `count_distinct`". It now pins that `count` over a `json` column is accepted and `sum` / `avg` / `min` / `max` over it are refused. - The 6 new tests cover: - the measured instance and its control; - per-row refusals and acceptances over `text` and `datetime`; - the enumeration pin; - the reference tier and the join tier giving opposite verdicts on one path; - the skips: expression types, a prototype key, no or non-string `type`, `'*'`, an unresolved column, an unfollowable hop, an untyped column, a cube naming no object; - report order, and `runAuthoringRules` on all three commands. Runs at `1de24da8fb`, the final commit, which includes the merge of `origin/main` `68c5ab7eba`: - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2`: 119 files, 5592 tests passed. - `pnpm --filter @objectstack/lint typecheck`: exit 0. `tsc -p tsconfig.test.json --listFiles` includes the test file. - eslint, narrowed and proven: the population is the 2 changed `.ts` files. The `.mdx` and `.md` fall outside the config's `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob, and `--print-config` resolves a config for both `.ts` files. `--format json` reports 2 files, 0 errors and 0 warnings. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move a verdict on an untouched file. **Ablations** ran on the committed tree through `scripts/ablation-replace.mjs`. Each restore was proven by its blob hash matching HEAD and an empty `git diff HEAD`. | ablation | result | |---|---| | A1: the guard reverted to `count_distinct` only | 7 red / 45 green: the 6 new tests and the rewritten one | | A2: the `max` row dropped on cube measures | 4 red / 48 green, including the enumeration pin on `cube max(text)` | ## Gates - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `1de24da8fb` derived 90 commands, and all 90 were run. - `check:skill-examples` first exited 3: PREREQUISITE NOT MET, because `client-react` had no `.d.ts`. - `check:dual-build-cjs-loads` first exited 3: 7 packages had no `dist`. - `check:dts-closure` first exited 1, because of this worktree's own `OS_SKIP_DTS` builds. - After those packages were built with declarations, all three exit 0. - `--ran` reconciliation: 90 derived, 90 run, 0 NOT-MEASURED (a derived zero: every line recorded its exit code), 0 UNRUN. - Also run, as the roster gates under a changed path: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. All exit 0. - `main` moved 5 commits after the merge (`3a6d92f78b`). None of them touches `packages/lint`, the docs page or either cube door. The one `service-analytics` change is the NativeSQL boolean-comparand door. CI's merge ref tests the combined state. ## Acceptance notes - **ADR-0087 disposition: the two landed precedents disagree.** The query-time cube door's changeset declared `not-required (already-registered dataset-measure-selecting-aggregate-field-type-refused, dataset-measure-aggregate-field-type-refused)` for these same pairs. This rule's own cube leg declared `no-migration-prescription`, on the ground that those two entries describe `DatasetMeasureSchema` rows. This PR follows the rule's own leg. Either marker passes the gate; if the seat prefers the other, the change is the one comment line. - **A field type outside `FieldType`.** `acceptsDeclaration` is fail-closed on vocabulary, so the lint would refuse even `count` over such a column, where both cube doors stand down. `os validate` cannot reach that case: `defineStack` refuses the field first (measured: `objects.0.fields.amount.type: Invalid field type 'integer'`, exit 1, no author-time rule runs). The dataset leg has the same shape. - **The field-consumer advisory does not credit a relationship-path leaf** (`validate-field-consumers.ts`). This was seen while measuring and is reported to the seat, not fixed here. `os validate` on the scalar control flags `fx_account.revenue` and `fx_ledger.account` as "inert — no site of any kind names it". That is wrong: the cube measure `sum_rev` reads `account.revenue`. A dataset measure over `account.revenue` with `include: ['account']` gets the same inert verdict on `fx_account.revenue`. - **Runtime write door, unchanged.** The runtime `analytics_cube` write door still dispatches no authoring rule, as PR #21416 recorded. 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 c2c21f3 commit d70353f

4 files changed

Lines changed: 259 additions & 31 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` measure whose aggregate the aggregate × field-type table refuses for its column, 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 cube measure pairs the analytics door already refuses with 400 INVALID_FIELD at query time: an analyticsCubes measure whose type is sum, avg, min or max over a column whose declared type that row of AGGREGATE_FIELD_TYPE_COMPATIBILITY does not accept. The table is not edited, and no authorable key, spelling, export or stored shape moves: CubeSchema keeps parsing every measure, no stored row is read or rewritten, and which numeric field an author meant to add up, average or order by is not something a ledger entry can rewrite. The other categories are closed on facts: the package publishes (not unpublished); the dataset-measure-aggregate-field-type-refused and dataset-measure-selecting-aggregate-field-type-refused entries describe DatasetMeasureSchema rows, and this rule's existing cube leg declared this same category on that ground (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. A cube measure's `type` is its aggregate, and the analytics door judges every cube measure against `AGGREGATE_FIELD_TYPE_COMPATIBILITY` before any SQL is built: a pair the table refuses is answered `400 INVALID_FIELD`. The authoring check judged only a cube's `count_distinct` measures, so a cube `sum` over a `text` column, for one, passed every command and was refused on its first query. The `measure-aggregate-field-type-refused` id (gating, `error`) now judges every cube measure exactly as it judges a dataset measure. 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 `measures` entry of `type` `sum`, `avg`, `min` or `max` whose `sql` column is declared with a type outside that aggregate's row of `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec/data`). The four rows accept the numeric and boolean types, `min` and `max` the temporal types too, and `sum` does not accept `percent`; so a text, option, reference, file or structured-JSON column, among others, is refused under all four, and a `date`, `datetime` or `time` column under `sum` and `avg`. The column is the measure'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`).
14+
15+
**What an author sees now.** The finding names the cube, the measure, the column, the object that declares it and its type, the types the aggregate accepts and the aggregates the column's type accepts, and says the analytics door refuses the pair with `400 INVALID_FIELD`. It is located at `analyticsCubes[N].measures.KEY.type`, where `KEY` is the measure's key. A quantity that must be added up, averaged or ordered has to be stored as a numeric or temporal field and aggregated as one; `count` accepts every column.
16+
17+
**Unchanged.** Every dataset finding and every cube dimension finding; a cube `count` measure over any column; a cube `count_distinct` measure, judged as before; a measure of an expression type (`number`, `string`, `boolean`); the row wildcard `'*'`; a measure 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.

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -236,13 +236,18 @@ Both checks also judge the members of an analytics cube (`analyticsCubes`),
236236
because an authored cube is queried through the same analytics door. A cube
237237
dimension whose `sql` column is declared with a structured-JSON type or as a
238238
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
239+
`analyticsCubes[N].dimensions.<key>.sql`. A cube measure is judged exactly as a
240+
dataset measure is, its `type` being the aggregate: a pair outside the table is
241+
refused (`measure-aggregate-field-type-refused`, at
242+
`analyticsCubes[N].measures.<key>.type`). That covers `sum`, `avg`, `min` or
243+
`max` over a column whose type their row does not accept (a `sum` over a `text`
244+
column, say), and `count_distinct` over a JSON-stored column; the analytics
245+
door refuses each of them with `400 INVALID_FIELD` when a query names the
246+
measure. `count` is accepted over every type. The column is read where the door
242247
reads it: on the object the cube's `sql` names, or, for a relationship path,
243248
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.
249+
else the lookup's `reference`. A measure of an expression type (`number`,
250+
`string`, `boolean`) and the row wildcard `'*'` are not judged by this check.
246251

247252
### 7. Navigation exposing objects nobody can read
248253

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

Lines changed: 199 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import { describe, expect, it } from 'vitest';
1616
import {
1717
AGGREGATE_FIELD_TYPE_COMPATIBILITY,
18+
AggregationMetricType,
1819
BOOLEAN_VALUE_TYPES,
1920
FieldType,
2021
MULTI_CAPABLE_TYPES,
@@ -656,12 +657,14 @@ const cubeStack = (
656657
{
657658
name: 'fx_account',
658659
sharingModel: 'private',
659-
fields: { name: { type: 'text' }, hq: { type: 'json' }, region: { type: 'text' } },
660+
// [#21419] `revenue` is `number` here and `text` on `fx_branch`, so the
661+
// two hop tiers give a cube `sum` opposite verdicts.
662+
fields: { name: { type: 'text' }, hq: { type: 'json' }, region: { type: 'text' }, revenue: { type: 'number' } },
660663
},
661664
{
662665
name: 'fx_branch',
663666
sharingModel: 'private',
664-
fields: { name: { type: 'text' }, hq: { type: 'text' }, region: { type: 'json' } },
667+
fields: { name: { type: 'text' }, hq: { type: 'text' }, region: { type: 'json' }, revenue: { type: 'text' } },
665668
},
666669
],
667670
analyticsCubes: [
@@ -741,9 +744,16 @@ describe('the cube leg — an analyticsCubes member the analytics door refuses i
741744
expect(validateDatasetMeasureAggregates(stack)).toEqual([]);
742745
});
743746

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+
// [#21419] Every row of the table is judged on a cube measure (the sweep is
748+
// in the next block): over the same json column `count` reads no value and
749+
// is accepted, and every other row refuses it.
750+
it('judges every row of the table on a cube measure: count reads no value, and the other rows refuse a json column', () => {
751+
expect(findings(cubeStack({ measures: { m: measure('count', 'meta') } }))).toEqual([]);
752+
for (const type of ['sum', 'avg', 'min', 'max']) {
753+
expect(
754+
findings(cubeStack({ measures: { m: measure(type, 'meta') } })).map((f) => f.path),
755+
type,
756+
).toEqual(['analyticsCubes[0].measures.m.type']);
747757
}
748758
});
749759

@@ -859,3 +869,187 @@ describe('the cube leg — an analyticsCubes member the analytics door refuses i
859869
).toEqual([]);
860870
});
861871
});
872+
873+
// ───────────────────────────────────────────────────────────────────────────
874+
// [#21419] The cube leg judges EVERY cube measure through `acceptsDeclaration`
875+
// — the dataset measure's own verdict — on the column the door reads. The
876+
// doors are `service-analytics`' `cube-measure-field-type-door.ts` for the
877+
// `count` / `sum` / `avg` / `min` / `max` rows (the declared TYPE against the
878+
// table; the `multiple` flag is not read) and `structured-json-dimension-door.ts`
879+
// for `count_distinct` (the row AND `isMultiValueField`). Until this leg a cube
880+
// `sum` over a `text` column passed `os validate` (measured:
881+
// `oos-cube-sum-text`, exit 0).
882+
// ───────────────────────────────────────────────────────────────────────────
883+
884+
/**
885+
* The cube doors' verdict on a measure of `type` over a column declared as
886+
* `shape`, as the two door modules state their rules — read off the spec's
887+
* table and predicates, never a retyped list:
888+
*
889+
* - a `type` that is no row of the table (the expression metric types) is
890+
* judged by neither door;
891+
* - `count_distinct` — `structured-json-dimension-door.ts`: refused when the
892+
* row refuses the type OR the declaration is multi-value;
893+
* - every other row — `cube-measure-field-type-door.ts`: refused when the row
894+
* refuses the declared type, whatever the `multiple` flag says.
895+
*/
896+
const cubeDoorRefuses = (type: string, shape: { type: string; multiple: boolean }): boolean => {
897+
if (!Object.prototype.hasOwnProperty.call(AGGREGATE_FIELD_TYPE_COMPATIBILITY, type)) return false;
898+
if (type === 'count_distinct') {
899+
return !isAggregateCompatibleWithFieldType(type, shape.type) || isMultiValueField(shape);
900+
}
901+
return !isAggregateCompatibleWithFieldType(type, shape.type);
902+
};
903+
904+
describe('the cube leg — every cube measure is judged by the aggregate × field-type table, as the cube door judges it', () => {
905+
// ⭐ The card's measured instance and its control, one pair.
906+
it('refuses oos-cube-sum-text — a cube sum over a text column — and accepts sum over a number one', () => {
907+
const found = validateDatasetMeasureAggregates(cubeStack({ measures: { sum_name: measure('sum', 'name') } }));
908+
expect(found).toHaveLength(1);
909+
const issue = found[0];
910+
expect(issue.severity).toBe('error');
911+
expect(issue.rule).toBe(RULE);
912+
expect(issue.path).toBe('analyticsCubes[0].measures.sum_name.type');
913+
expect(issue.where).toBe('cube "fx_cube" › measure "sum_name"');
914+
expect(issue.message).toContain('aggregate "sum" to field "name"');
915+
expect(issue.message).toContain('object "fx_ledger" declares as `text`');
916+
expect(issue.message).toContain(`"sum" accepts: ${AGGREGATE_FIELD_TYPE_COMPATIBILITY.sum.join(', ')}.`);
917+
// The door that refuses it later is the cube's, never the dataset compile leg.
918+
expect(issue.hint).toContain('on the cube with `400 INVALID_FIELD`');
919+
expect(issue.hint).not.toContain('DATASET_INVALID');
920+
921+
expect(
922+
validateDatasetMeasureAggregates(
923+
cubeStack({ measures: { sum_amount: measure('sum', 'amount') } }, {}, { amount: { type: 'number' } }),
924+
),
925+
).toEqual([]);
926+
});
927+
928+
it('refuses avg / min / max over that text column, and sum / avg over a datetime one; accepts min / max over the datetime', () => {
929+
const datetime = { opened_at: { type: 'datetime' } };
930+
for (const [type, column] of [['avg', 'name'], ['min', 'name'], ['max', 'name'], ['sum', 'opened_at'], ['avg', 'opened_at']]) {
931+
const [issue] = findings(cubeStack({ measures: { m: measure(type, column) } }, {}, datetime));
932+
expect(issue?.path, `${type}(${column})`).toBe('analyticsCubes[0].measures.m.type');
933+
expect(issue?.message, `${type}(${column})`).toContain(`aggregate "${type}" to field "${column}"`);
934+
}
935+
for (const type of ['min', 'max', 'count', 'count_distinct']) {
936+
expect(findings(cubeStack({ measures: { m: measure(type, 'opened_at') } }, {}, datetime)), type).toEqual([]);
937+
}
938+
});
939+
940+
// ⭐ The enumeration pin: the whole surface, against the doors' rules.
941+
it('agrees with the cube doors on every cube measure type × every declared FieldType, flagged and not', () => {
942+
const rows = AGGREGATES;
943+
const metricTypes: readonly string[] = AggregationMetricType.options;
944+
// Every row of the table is a `type` a cube measure can write, so the sweep
945+
// below reaches each row through a real cube measure, not a fiction.
946+
for (const row of rows) expect(metricTypes, `row ${row}`).toContain(row);
947+
// The population is every cube measure `type` AND every row: a row or a
948+
// metric type added later is swept here the day it lands.
949+
const types = [...new Set([...metricTypes, ...rows])];
950+
951+
const refusedBy = new Map<string, number>();
952+
const acceptedBy = new Map<string, number>();
953+
for (const type of types) {
954+
for (const fieldType of FieldType.options) {
955+
for (const multiple of [false, true]) {
956+
const def = multiple ? { type: fieldType, multiple: true } : { type: fieldType };
957+
const stack = cubeStack({ measures: { probe_measure: measure(type, 'probe') } }, {}, { probe: def });
958+
const fires = findings(stack).length > 0;
959+
expect(fires, `cube ${type}(${fieldType}${multiple ? ', multiple' : ''})`).toBe(
960+
cubeDoorRefuses(type, { type: fieldType, multiple }),
961+
);
962+
const tally = fires ? refusedBy : acceptedBy;
963+
tally.set(type, (tally.get(type) ?? 0) + 1);
964+
}
965+
}
966+
}
967+
968+
// Floors per type, so no row can agree vacuously: `count` refuses nothing,
969+
// every other row both refuses and accepts, and a type outside the table
970+
// is never judged.
971+
const perType = FieldType.options.length * 2;
972+
for (const type of types) {
973+
const refused = refusedBy.get(type) ?? 0;
974+
const accepted = acceptedBy.get(type) ?? 0;
975+
expect(refused + accepted, type).toBe(perType);
976+
if (!rows.includes(type) || type === 'count') {
977+
expect(refused, `${type} refuses nothing`).toBe(0);
978+
} else {
979+
expect(refused, `${type} refuses`).toBeGreaterThan(10);
980+
expect(accepted, `${type} accepts`).toBeGreaterThan(5);
981+
}
982+
}
983+
// The expression metric types are in the population and outside the table.
984+
expect(types.filter((t) => !rows.includes(t)).sort()).toEqual(['boolean', 'number', 'string']);
985+
});
986+
987+
it('reads a relationship path on the object the last hop reaches — the lookup\'s reference, or the join the cube declares for it', () => {
988+
// No declared join: the lookup's `reference`, `fx_account`, where `name` is text and `revenue` a number.
989+
const [byReference] = findings(cubeStack({ measures: { s: measure('sum', 'account.name') } }));
990+
expect(byReference?.path).toBe('analyticsCubes[0].measures.s.type');
991+
expect(byReference?.message).toContain('object "fx_account" (reached through this cube\'s join chain)');
992+
expect(findings(cubeStack({ measures: { s: measure('sum', 'account.revenue') } }))).toEqual([]);
993+
994+
// A declared join wins over the reference, as at the door: keyed `account`,
995+
// reaching `fx_branch`, where `revenue` is text.
996+
const joined = { joins: { account: { name: 'fx_branch' } } };
997+
const [byJoin] = findings(cubeStack({ measures: { s: measure('sum', 'account.revenue') } }, joined));
998+
expect(byJoin?.message).toContain('object "fx_branch"');
999+
expect(byJoin?.message).toContain('declares as `text`');
1000+
});
1001+
1002+
it('never hands the predicate a guess: no type, a type outside the table, the row wildcard, an unresolved column', () => {
1003+
const silent = (measures: Record<string, unknown>, ledger: Record<string, unknown> = {}) =>
1004+
expect(validateDatasetMeasureAggregates(cubeStack({ measures }, {}, ledger))).toEqual([]);
1005+
// The expression metric types are outside the table's vocabulary (skip 5).
1006+
silent({ n: measure('number', 'name'), s: measure('string', 'name'), b: measure('boolean', 'name') });
1007+
// A prototype key is not a row.
1008+
silent({ p: measure('toString', 'name') });
1009+
// No `type`, or a non-string one — the schema's to refuse.
1010+
silent({ x: { label: 'X', sql: 'name' }, y: { label: 'Y', type: ['sum'], sql: 'name' } });
1011+
// The row wildcard names no column: whether `'*'` belongs on a non-count
1012+
// measure is a question of its own, not this leg's.
1013+
silent({ s: measure('sum', '*'), a: measure('avg', '*'), lo: measure('min', '*'), hi: measure('max', '*') });
1014+
// A column that does not resolve, a hop the graph cannot follow, an untyped column.
1015+
silent({ s: measure('sum', 'nope'), t: measure('sum', 'ghost.name'), u: measure('sum', 'untyped') }, {
1016+
untyped: { label: 'U' },
1017+
});
1018+
// A cube whose `sql` names no object this stack defines.
1019+
expect(
1020+
validateDatasetMeasureAggregates(cubeStack({ measures: { s: measure('sum', 'name') } }, { sql: 'not_here' })),
1021+
).toEqual([]);
1022+
});
1023+
1024+
it('reports each refused measure once, in the cube\'s key order, after its dimensions', () => {
1025+
const stack = cubeStack({
1026+
dimensions: { meta: dim('meta') },
1027+
measures: {
1028+
sum_name: measure('sum', 'name'),
1029+
count_name: measure('count', 'name'),
1030+
distinct_meta: measure('count_distinct', 'meta'),
1031+
max_stage: measure('max', 'stage'),
1032+
},
1033+
});
1034+
expect(validateDatasetMeasureAggregates(stack).map((f) => [f.rule, f.path])).toEqual([
1035+
[DIMENSION_RULE, 'analyticsCubes[0].dimensions.meta.sql'],
1036+
[RULE, 'analyticsCubes[0].measures.sum_name.type'],
1037+
[RULE, 'analyticsCubes[0].measures.distinct_meta.type'],
1038+
[RULE, 'analyticsCubes[0].measures.max_stage.type'],
1039+
]);
1040+
});
1041+
1042+
it('fires through runAuthoringRules on all three commands, and only on the refused measure', () => {
1043+
const stack = cubeStack({ measures: { sum_name: measure('sum', 'name') } });
1044+
for (const command of ['validate', 'build', 'lint'] as const) {
1045+
const found = runAuthoringRules(command, { normalized: stack, parsed: stack }).filter((f) => f.rule === RULE);
1046+
expect(found.map((f) => [f.path, f.severity]), command).toEqual([
1047+
['analyticsCubes[0].measures.sum_name.type', 'error'],
1048+
]);
1049+
}
1050+
const control = cubeStack({ measures: { count_name: measure('count', 'name') } });
1051+
expect(
1052+
runAuthoringRules('lint', { normalized: control, parsed: control }).filter((f) => f.rule === RULE),
1053+
).toEqual([]);
1054+
});
1055+
});

0 commit comments

Comments
 (0)