Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/23235-export-group-key-classifier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@objectstack/lint": minor
---

feat(lint): export the dataset dimension leg's group-key classifier, so a consumer asks the lint instead of composing the analytics door's two predicates itself (#23235)

Clause-②: yes

`@objectstack/lint` now exports `groupKeyClassOf(shape)` and its result type `GroupKeyClass`:

- `groupKeyClassOf(shape)` answers "may a dataset or cube dimension group by a column declared this way?". `null` means yes: the column stores one scalar value, and the analytics door serves it as a group key. Otherwise it names the JSON-stored class the door refuses with `400 INVALID_FIELD`: `'structured-json'` (a type in `STRUCTURED_JSON_TYPES`) or `'multi-value'` (`isMultiValueField`: `multiselect`, `checkboxes`, `tags`, or a multi-capable type such as `select` declared `multiple: true`). The two spec predicates are applied in the door's order.
- `shape` is `ValueShapeFieldDef` from `@objectstack/spec/data`, the slice of a field definition the predicates read (`type`, `multiple`). A declared `Field` passes as it is. The classifier reads no object graph: for a dotted path, pass the leaf field's definition.

A consumer that derives dataset dimensions keeps a field as a group key when `groupKeyClassOf(field) === null`, and gets the lint's own verdict. The `dimension-json-stored-field-refused` rule (dataset dimensions and cube dimensions) keeps calling the same function; on the rule's own fixtures, every declared field type with and without `multiple: true` and every column of the cube fixture, the export and the rule agree on the verdict and the class. The rule's findings, paths and texts are unchanged.

No export is removed, and no accepted or refused shape changes.
6 changes: 5 additions & 1 deletion packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,12 @@ export {
validateDatasetMeasureAggregates,
MEASURE_AGGREGATE_FIELD_TYPE_REFUSED,
DIMENSION_JSON_STORED_FIELD_REFUSED,
// [#23235] The dimension leg's group-key verdict on its own, so a consumer
// deriving dataset dimensions (cloud's AI build) asks the door's two
// predicates in the door's order here rather than composing them itself.
groupKeyClassOf,
} from './validate-dataset-measure-aggregates.js';
export type { DatasetMeasureAggregateFinding } from './validate-dataset-measure-aggregates.js';
export type { DatasetMeasureAggregateFinding, GroupKeyClass } from './validate-dataset-measure-aggregates.js';

export { validateStackExpressions, fieldRuleRootIssue, FIELD_RULE_BOUND_ROOTS } from './validate-expressions.js';
export type { ExprIssue } from './validate-expressions.js';
Expand Down
99 changes: 99 additions & 0 deletions packages/lint/src/validate-dataset-measure-aggregates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,19 @@ import {
STRUCTURED_JSON_TYPES,
isAggregateCompatibleWithFieldType,
isMultiValueField,
type Field,
} from '@objectstack/spec/data';

import { runAuthoringRules } from './authoring-rules.js';
import { explainRule } from './rule-explanations.js';
import {
DIMENSION_JSON_STORED_FIELD_REFUSED,
MEASURE_AGGREGATE_FIELD_TYPE_REFUSED,
groupKeyClassOf as moduleGroupKeyClassOf,
validateDatasetMeasureAggregates as validateDatasetMeasureAggregatesUnrecorded,
} from './validate-dataset-measure-aggregates.js';
// [#23235] The classifier as a consumer reaches it: the package barrel.
import { groupKeyClassOf, type GroupKeyClass } from './index.js';

// [#22161] Each finding of the two ids is one verdict sentence; the reasoning
// it used to carry is the id's `os explain` entry. Every call below records
Expand Down Expand Up @@ -1089,6 +1093,101 @@ describe('the cube leg — every cube measure is judged by the aggregate × fiel
});
});

// ───────────────────────────────────────────────────────────────────────────
// [#23235] `groupKeyClassOf` — the dimension leg's verdict, exported. A
// consumer deriving dataset dimensions asks it instead of composing the
// analytics door's two predicates; these cases hold the export equal to what
// the rule itself decides, on the rule's own fixtures (the dataset leg's
// `dimensionStack` and the cube leg's `cubeStack`).
// ───────────────────────────────────────────────────────────────────────────

/** The words a `dimension-json-stored-field-refused` message names each class with. */
const GROUP_KEY_CLASSES: readonly GroupKeyClass[] = ['structured-json', 'multi-value'];
const CLASS_WORDS: ReadonlyMap<GroupKeyClass, string> = new Map([
['structured-json', 'a structured-JSON value'],
['multi-value', 'a multi-value field'],
]);

/**
* The class the rule's one finding names, or `null` when the rule is silent.
* Anything else — several findings, or a message naming no class or both —
* is `'unreadable'`, which no classifier answer equals.
*/
function classTheRuleNamed(found: ReadonlyArray<{ message: string }>, column: string): GroupKeyClass | null | 'unreadable' {
const [only, ...more] = found;
if (!only) return null;
if (more.length > 0 || !only.message.includes(`"${column}"`)) return 'unreadable';
const named = GROUP_KEY_CLASSES.filter((cls) => only.message.includes(CLASS_WORDS.get(cls) ?? cls));
return named.length === 1 ? named[0] : 'unreadable';
}

describe('[#23235] groupKeyClassOf — the exported classifier and the rule agree on the rule\'s own fixtures', () => {
it('is the function the rule calls, re-exported from the package barrel', () => {
expect(groupKeyClassOf).toBe(moduleGroupKeyClassOf);
});

it('agrees with the dataset leg on every declared FieldType, flagged and not — class included', () => {
const tally: Record<GroupKeyClass | 'served', number> = { 'structured-json': 0, 'multi-value': 0, served: 0 };
for (const fieldType of FieldType.options) {
for (const multiple of [false, true]) {
const def = multiple ? { type: fieldType, multiple: true } : { type: fieldType };
const label = `${fieldType}${multiple ? ', multiple' : ''}`;
const exported = groupKeyClassOf(def);
expect(exported, label).toBe(classTheRuleNamed(dimensionFindings(dimensionStack(def)), 'grouped'));
tally[exported ?? 'served']++;
}
}
// Floors on every side, so neither an always-null nor an always-refusing
// classifier can make the agreement vacuous.
expect(tally['structured-json']).toBe(2 * STRUCTURED_JSON_TYPES.size);
expect(tally['multi-value']).toBe(2 * MULTI_OPTION_TYPES.size + MULTI_CAPABLE_TYPES.size);
expect(tally.served).toBeGreaterThan(50);
});

it('agrees with the dimension leg on its named fixtures', () => {
const fixtures: Array<[Record<string, unknown>, GroupKeyClass | null]> = [
[{ type: 'json' }, 'structured-json'],
[{ type: 'text' }, null],
[{ type: 'multiselect' }, 'multi-value'],
[{ type: 'checkboxes' }, 'multi-value'],
[{ type: 'tags' }, 'multi-value'],
[{ type: 'select', multiple: true }, 'multi-value'],
[{ type: 'select' }, null],
];
for (const [def, expected] of fixtures) {
const label = JSON.stringify(def);
expect(groupKeyClassOf(def as { type: string; multiple?: boolean }), label).toBe(expected);
expect(classTheRuleNamed(dimensionFindings(dimensionStack(def)), 'grouped'), label).toBe(expected);
}
});

it('agrees with the cube leg on every column of the cube fixture', () => {
const stack = cubeStack({});
const ledger = (stack.objects as Array<{ name: string; fields: Record<string, { type: string; multiple?: boolean }> }>)[0];
const columns = Object.entries(ledger.fields).filter(([, def]) => typeof def.type === 'string');
expect(columns.length).toBeGreaterThanOrEqual(6);
const classes = new Set<GroupKeyClass | null>();
for (const [column, def] of columns) {
const exported = groupKeyClassOf(def);
classes.add(exported);
expect(exported, column).toBe(
classTheRuleNamed(cubeDimensionFindings(cubeStack({ dimensions: { probe: dim(column) } })), column),
);
}
// The fixture exercises all three answers.
expect([...classes].sort()).toEqual([null, 'multi-value', 'structured-json'].sort());
});

it('takes a declared Field verbatim — the shape a consumer holds', () => {
const tags: Field = { name: 'labels', type: 'tags' };
const stages: Field = { name: 'stages', type: 'select', multiple: true, options: [{ label: 'Open', value: 'open' }] };
const stage: Field = { name: 'stage', type: 'select', options: [{ label: 'Open', value: 'open' }] };
expect(groupKeyClassOf(tags)).toBe('multi-value');
expect(groupKeyClassOf(stages)).toBe('multi-value');
expect(groupKeyClassOf(stage)).toBeNull();
});
});

describe('[#22161] one-line verdicts — the two ids', () => {
const IDS = [MEASURE_AGGREGATE_FIELD_TYPE_REFUSED, DIMENSION_JSON_STORED_FIELD_REFUSED];

Expand Down
23 changes: 21 additions & 2 deletions packages/lint/src/validate-dataset-measure-aggregates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,15 +325,34 @@ function declarerOf(verdict: Extract<FieldPathVerdict, { kind: 'ok' }>, baseObje
return verdict.object === baseObject ? `object "${baseObject}"` : `joined object "${verdict.object}"`;
}

/**
* The JSON-stored class of a column the analytics door refuses to group by:
* `'structured-json'` (the {@link STRUCTURED_JSON_TYPES} class) or
* `'multi-value'` (a list stored as JSON, {@link isMultiValueField}).
*/
export type GroupKeyClass = 'structured-json' | 'multi-value';

/**
* [#20890] The class of a column a dimension GROUPS by, or `null` when it
* stores one scalar value — the analytics door's two predicates, in its
* order: {@link STRUCTURED_JSON_TYPES}, then {@link isMultiValueField} (the
* declaration, `multiple` included). ⛔ Never the type alone: a type-only
* reading would refuse `tags` and pass a `select` with `multiple: true`, which
* is the same JSON column.
*
* [#23235] Exported from the package barrel as the one place the door's two
* predicates are applied in the door's order, so a consumer that derives
* group keys outside a config file (cloud's AI build deriving dataset
* dimensions) asks this verdict instead of composing the two predicates
* itself — `null` is a group key the door serves. Both legs of
* `dimension-json-stored-field-refused` (dataset and cube) keep calling it,
* and this file's test holds the export equal to the rule's verdict on the
* rule's own fixtures. The input is the spec's own value-contract slice of a
* field definition (`ValueShapeFieldDef`: `type` and `multiple`, the two keys
* the predicates read), so a declared `Field` passes verbatim; it reads no
* object graph, so resolving a dotted path to its leaf stays the caller's.
*/
function groupKeyClassOf(shape: ValueShapeFieldDef): 'structured-json' | 'multi-value' | null {
export function groupKeyClassOf(shape: ValueShapeFieldDef): GroupKeyClass | null {
if (STRUCTURED_JSON_TYPES.has(shape.type)) return 'structured-json';
return isMultiValueField(shape) ? 'multi-value' : null;
}
Expand Down Expand Up @@ -362,7 +381,7 @@ function jsonStoredDimensionFinding(
site: MemberSite,
leaf: Extract<FieldPathVerdict, { kind: 'ok' }>,
shape: ValueShapeFieldDef,
cls: 'structured-json' | 'multi-value',
cls: GroupKeyClass,
): DatasetMeasureAggregateFinding {
return {
severity: 'error',
Expand Down
Loading