Skip to content

Commit 016aebf

Browse files
committed
refactor(cube-model): simplify pass over the exporter source (FR-044)
Collapse the mirrored join arms, share the YAML file framing and the grains tail, derive the unmappable-dimension message from the dispatch sets, and make four helpers with no outside importers module-private. No output byte changes: the corpus, canonical golden and reference byte-identical tests are unchanged.
1 parent 6ff759e commit 016aebf

7 files changed

Lines changed: 76 additions & 44 deletions

File tree

‎server/typescript/packages/codegen-ts/src/cube/build-cube-model.ts‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
resolveObjectRef,
4040
resolveTableName,
4141
resolveTableSchema,
42+
stripPackage,
4243
type MetaData,
4344
type MetaDimension,
4445
type MetaField,
@@ -74,14 +75,15 @@ import {
7475
ERR_CUBE_UNMAPPABLE_DIMENSION,
7576
ERR_CUBE_UNMAPPABLE_JOIN,
7677
ERR_CUBE_UNMAPPABLE_REPORT,
78+
ERR_CUBE_UNSUPPORTED_DIALECT,
7779
} from "./cube-errors.js";
7880
import {
81+
defaultedRawMemberName,
7982
dimensionColumn,
8083
docOf,
8184
grainsOf,
8285
measureMembers,
8386
memberKey,
84-
rawMeasureName,
8587
segmentSpec,
8688
type MemberContext,
8789
} from "./cube-members.js";
@@ -162,8 +164,7 @@ interface SpinePlan {
162164

163165
/** Entity short names along a `@via` path, for messages. */
164166
function pathText(from: MetaObject, path: Path): string {
165-
const short = (key: string): string => (key.includes("::") ? key.slice(key.lastIndexOf("::") + 2) : key);
166-
return [from.name, ...path.map((s) => short(s.targetEntity))].join(" -> ");
167+
return [from.name, ...path.map((s) => stripPackage(s.targetEntity))].join(" -> ");
167168
}
168169

169170
interface CubeDraft {
@@ -200,7 +201,10 @@ interface CubeDraft {
200201
export function buildCubeModel(root: MetaRoot, options: CubeModelOptions): CubeModel {
201202
// CubeDialect admits only these; a caller outside the type system gets a refusal, not SQLite SQL.
202203
if (options.dialect !== "postgres" && options.dialect !== "mysql") {
203-
throw new Error(`buildCubeModel: dialect '${String(options.dialect)}' is not a Cube data source this exporter writes (postgres, mysql).`);
204+
throw new CubeModelError(
205+
ERR_CUBE_UNSUPPORTED_DIALECT,
206+
`dialect '${String(options.dialect)}' is not a Cube data source this exporter writes (postgres, mysql).`,
207+
);
204208
}
205209
return new CubeModelBuilder(root, options).build();
206210
}
@@ -215,7 +219,7 @@ export function hasReportingVocabulary(obj: MetaObject): boolean {
215219
}
216220

217221
function hopLabel(node: MetaData): string {
218-
const kind = node.type === TYPE_IDENTITY ? "identity.reference" : `relationship.${node.subType}`;
222+
const kind = node.type === TYPE_IDENTITY ? `${TYPE_IDENTITY}.${IDENTITY_SUBTYPE_REFERENCE}` : `${TYPE_RELATIONSHIP}.${node.subType}`;
219223
return `${kind} '${memberKey(node)}'`;
220224
}
221225

@@ -275,7 +279,7 @@ class CubeModelBuilder {
275279
private readonly options: CubeModelOptions,
276280
) {
277281
this.d = options.dialect;
278-
this.mc = { root, dialect: options.dialect, extract: { columnNamingStrategy: options.columnNamingStrategy } };
282+
this.mc = { root, dialect: this.d, extract: { columnNamingStrategy: options.columnNamingStrategy } };
279283
}
280284

281285
build(): CubeModel {
@@ -871,8 +875,9 @@ class CubeModelBuilder {
871875
const needed = new Set<string>();
872876
const need = (m: MetaMeasure): void => {
873877
needed.add(m.name);
874-
// ADR-0039: defaultValue() resolves, as measureMembers reads it.
875-
if (!m.isRatio() && m.defaultValue() !== undefined) needed.add(rawMeasureName(m.name));
878+
// The `<m>Raw` twin is earned by one rule, in `defaultedRawMemberName`, shared with measureMembers.
879+
const raw = defaultedRawMemberName(m);
880+
if (raw !== undefined) needed.add(raw);
876881
};
877882
for (const f of shape.fields) {
878883
const m = f.measure;
@@ -953,9 +958,9 @@ class CubeModelBuilder {
953958
const pairs = fkFields.map((fk, i) => {
954959
const fkCol = joinColumnFor(fkEntity, fk, this.mc.extract);
955960
const keyCol = joinColumnFor(keyEntity, keyFields[i]!, this.mc.extract);
956-
return h.fkOnSelf
957-
? `${cubeColumn(fkCol, this.d, renderer)} = ${joinedColumn(h.joined.name, keyCol, this.d, renderer)}`
958-
: `${cubeColumn(keyCol, this.d, renderer)} = ${joinedColumn(h.joined.name, fkCol, this.d, renderer)}`;
961+
// One predicate shape, both directions: self's column left, the joined cube's column right.
962+
const [selfCol, joinedCol]: [string, string] = h.fkOnSelf ? [fkCol, keyCol] : [keyCol, fkCol];
963+
return `${cubeColumn(selfCol, this.d, renderer)} = ${joinedColumn(h.joined.name, joinedCol, this.d, renderer)}`;
959964
});
960965
return { name: h.joined.name, relationship: h.relationship, sql: pairs.join(" AND ") };
961966
}

‎server/typescript/packages/codegen-ts/src/cube/cube-members.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ const STRING_SUBTYPES: ReadonlySet<string> = new Set([
6767
FIELD_SUBTYPE_INET,
6868
]);
6969

70+
// The subtypes `dimensionOfField` maps, in the order the refusal below lists them — derived from
71+
// the dispatch sets and checks, so the message cannot go stale when a mapping is added or removed.
72+
const MAPPED_DIMENSION_SUBTYPES: readonly string[] = [
73+
...STRING_SUBTYPES, ...NUMBER_SUBTYPES, FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIMESTAMP,
74+
];
75+
7076
/** `<owner resolution key>.<name>`: a member's address, as `@of` and `@via` spell it. */
7177
export function memberKey(node: MetaData): string {
7278
const owner = node.parent;
@@ -133,8 +139,8 @@ export function dimensionColumn(
133139
throw new CubeModelError(
134140
ERR_CUBE_UNMAPPABLE_DIMENSION,
135141
`${where} reads ${read} (field.${field.subType}), and the exporter maps no Cube dimension type for ` +
136-
`field.${field.subType}: it maps string, enum, uuid, time, uri, inet, int, long, double, float, decimal, ` +
137-
`currency, boolean, date and timestamp. Group by a field of one of those subtypes, or remove the dimension.`,
142+
`field.${field.subType}: it maps ${MAPPED_DIMENSION_SUBTYPES.slice(0, -1).join(", ")} and ` +
143+
`${MAPPED_DIMENSION_SUBTYPES.at(-1)}. Group by a field of one of those subtypes, or remove the dimension.`,
138144
);
139145
}
140146

@@ -143,6 +149,15 @@ export function rawMeasureName(measure: string): string {
143149
return `${measure}Raw`;
144150
}
145151

152+
/**
153+
* The `<m>Raw` twin a measure earns: only a non-ratio `measure.aggregate` with `@default` gets one.
154+
* `measureMembers` emits under this name and the facts cube keeps it — one rule, two consumers.
155+
*/
156+
export function defaultedRawMemberName(m: MetaMeasure): string | undefined {
157+
// ADR-0039: defaultValue() resolves, so a measure inherited through extends keeps its default.
158+
return !m.isRatio() && m.defaultValue() !== undefined ? rawMeasureName(m.name) : undefined;
159+
}
160+
146161
/**
147162
* One measure as Cube members. `raw` is present for a measure.aggregate with `@default`: it is
148163
* the Table D aggregate, `public: false`, under `<m>Raw`, and `measure` reads it through
@@ -173,7 +188,8 @@ export function measureMembers(entity: MetaObject, m: MetaMeasure, where: string
173188
}
174189
const aggregate = aggregateSpec(entity, m, where, mc);
175190
if (n === undefined) return { measure: { name: m.name, ...aggregate, ...docOf(m) } };
176-
const raw = rawMeasureName(m.name);
191+
// Past the ratio branch, this measure satisfies the helper's rule: its twin name exists.
192+
const raw = defaultedRawMemberName(m)!;
177193
return {
178194
raw: { name: raw, ...aggregate, public: false },
179195
measure: { name: m.name, sql: withDefault(memberRef(raw), n), type: "number", ...docOf(m) },

‎server/typescript/packages/codegen-ts/src/cube/cube-model-spec.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010

1111
import type { TimeGrain } from "@metaobjectsdev/metadata";
1212

13+
/** The generator's name, as diagnostics and the catalog print it — and the note in every emitted file's header. */
14+
export const CUBE_MODEL_GENERATOR_NAME = "cube-model";
15+
1316
/** Cube's data sources this exporter writes SQL for (open question 3). */
1417
export type CubeDialect = "postgres" | "mysql";
1518

‎server/typescript/packages/codegen-ts/src/cube/cube-reports.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export interface ReportContribution {
3434
}
3535

3636
/** How many grouping columns a rollup has: its attribute dimensions plus its time dimensions. */
37-
export function rollupDimensionCount(rollup: CubeRollupSpec): number {
37+
function rollupDimensionCount(rollup: CubeRollupSpec): number {
3838
const times = rollup.timeDimensions !== undefined ? rollup.timeDimensions.length : rollup.timeDimension !== undefined ? 1 : 0;
3939
return rollup.dimensions.length + times;
4040
}
@@ -87,12 +87,12 @@ export function coarsestFirst(rollups: readonly CubeRollupSpec[]): CubeRollupSpe
8787
}
8888

8989
/** `RecentPrograms` → `recentProgramsScope`: the segment a report's `@filter` becomes (Table G). */
90-
export function scopeSegmentName(reportName: string): string {
90+
function scopeSegmentName(reportName: string): string {
9191
return `${reportName.charAt(0).toLowerCase()}${reportName.slice(1)}Scope`;
9292
}
9393

9494
/** True when a lowered clause compares against a relative date anywhere inside it. */
95-
export function hasRelativeDate(clause: ViewFilterClause | undefined): boolean {
95+
function hasRelativeDate(clause: ViewFilterClause | undefined): boolean {
9696
if (clause === undefined) return false;
9797
switch (clause.kind) {
9898
case "and":

‎server/typescript/packages/codegen-ts/src/cube/cube-template.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
// inside one, so `endraw` there is plain text.
1515

1616
/** `{{`, `{%` or `{#`: text holding one is raw-wrapped for Jinja. */
17-
export const JINJA_OPENER = /\{[{%#]/;
17+
const JINJA_OPENER = /\{[{%#]/;
1818

1919
/** The word that would end a `{% raw %}` block early. */
2020
export const JINJA_RAW_END = "endraw";

‎server/typescript/packages/codegen-ts/src/cube/cube-yaml.ts‎

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,18 @@
3030

3131
import { GENERATED_HEADER } from "../constants.js";
3232
import { CubeModelError, ERR_CUBE_UNESCAPABLE_LITERAL } from "./cube-errors.js";
33-
import type {
34-
CubeDimensionSpec,
35-
CubeJoinSpec,
36-
CubeMeasureSpec,
37-
CubeRollupSpec,
38-
CubeSegmentSpec,
39-
CubeSpec,
40-
CubeViewCubeSpec,
41-
CubeViewIncludeSpec,
42-
CubeViewSpec,
33+
import {
34+
CUBE_MODEL_GENERATOR_NAME,
35+
type CubeDimensionMeta,
36+
type CubeDimensionSpec,
37+
type CubeJoinSpec,
38+
type CubeMeasureSpec,
39+
type CubeRollupSpec,
40+
type CubeSegmentSpec,
41+
type CubeSpec,
42+
type CubeViewCubeSpec,
43+
type CubeViewIncludeSpec,
44+
type CubeViewSpec,
4345
} from "./cube-model-spec.js";
4446
import { escapeCubeTemplate, jinjaSafe, JINJA_RAW_END } from "./cube-template.js";
4547

@@ -48,9 +50,6 @@ const INDENT = " ";
4850
/** A mapping or a list item as lines, relative to its own indent. */
4951
type Lines = string[];
5052

51-
/** The text after the header, naming the generator that wrote the file. */
52-
const HEADER_NOTE = "cube-model";
53-
5453
/** A member reference inside a rollup: the member on the cube the rollup belongs to. */
5554
function cubeRef(member: string): string {
5655
return `CUBE.${member}`;
@@ -145,12 +144,16 @@ function renderDimension(dimension: CubeDimensionSpec, cube: string): Lines {
145144
];
146145
if (dimension.primaryKey !== undefined) out.push(`primary_key: ${dimension.primaryKey}`);
147146
out.push(...documentation(dimension, at));
148-
if (dimension.meta !== undefined && dimension.meta.grains.length > 0) {
149-
out.push("meta:", ...indented(flowList("grains", dimension.meta.grains)));
150-
}
147+
out.push(...grainsLines(dimension.meta));
151148
return out;
152149
}
153150

151+
/** The `meta:` block, present only when the member carries grains (empty grains emit nothing). */
152+
function grainsLines(meta: CubeDimensionMeta | undefined): Lines {
153+
if (meta === undefined || meta.grains.length === 0) return [];
154+
return ["meta:", ...indented(flowList("grains", meta.grains))];
155+
}
156+
154157
function renderMeasure(measure: CubeMeasureSpec, cube: string): Lines {
155158
const at = `cube '${cube}' measure '${measure.name}'`;
156159
const filters = (measure.filters ?? []).map((f): Lines => [`sql: ${sqlScalar(f.sql)}`]);
@@ -212,9 +215,7 @@ function renderViewInclude(include: CubeViewIncludeSpec, view: string): Lines {
212215
const out: Lines = [`name: ${nameScalar(include.name)}`];
213216
if (include.alias !== undefined) out.push(`alias: ${nameScalar(include.alias)}`);
214217
out.push(...documentation(include, at));
215-
if (include.meta !== undefined && include.meta.grains.length > 0) {
216-
out.push("meta:", ...indented(flowList("grains", include.meta.grains)));
217-
}
218+
out.push(...grainsLines(include.meta));
218219
return out;
219220
}
220221

@@ -237,8 +238,7 @@ export function renderCubeViewYaml(view: CubeViewSpec): string {
237238
...documentation(view, at),
238239
...blockList("cubes", view.cubes.map((entry) => renderViewCube(entry, view.name))),
239240
];
240-
const lines = [`# ${GENERATED_HEADER} — ${HEADER_NOTE}`, "views:", ...indented(listItem(body))];
241-
return `${lines.join("\n")}\n`;
241+
return wrapFile("views:", body);
242242
}
243243

244244
/**
@@ -247,6 +247,11 @@ export function renderCubeViewYaml(view: CubeViewSpec): string {
247247
* (it holds a Jinja opener) and holds `endraw`.
248248
*/
249249
export function renderCubeYaml(cube: CubeSpec): string {
250-
const lines = [`# ${GENERATED_HEADER} — ${HEADER_NOTE}`, "cubes:", ...indented(listItem(renderCube(cube)))];
250+
return wrapFile("cubes:", renderCube(cube));
251+
}
252+
253+
/** The framing both file kinds share: header line, one top-level key, its items indented, trailing newline. */
254+
function wrapFile(rootKey: string, item: Lines): string {
255+
const lines = [`# ${GENERATED_HEADER} — ${CUBE_MODEL_GENERATOR_NAME}`, rootKey, ...indented(listItem(item))];
251256
return `${lines.join("\n")}\n`;
252257
}

‎server/typescript/packages/codegen-ts/src/generators/cube-model.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,13 @@ import { DEFAULT_COLUMN_NAMING_STRATEGY, type MetaObject } from "@metaobjectsdev
3232
import type { EmittedFile, GenContext, Generator } from "../generator.js";
3333
import { buildCubeModel, hasReportingVocabulary } from "../cube/build-cube-model.js";
3434
import { CubeModelError, ERR_CUBE_UNSUPPORTED_DIALECT } from "../cube/cube-errors.js";
35-
import { JOIN_PATH_SEPARATOR, type CubeDialect, type CubeModel, type CubeSpec } from "../cube/cube-model-spec.js";
35+
import {
36+
CUBE_MODEL_GENERATOR_NAME,
37+
JOIN_PATH_SEPARATOR,
38+
type CubeDialect,
39+
type CubeModel,
40+
type CubeSpec,
41+
} from "../cube/cube-model-spec.js";
3642
import { renderCubeViewYaml, renderCubeYaml } from "../cube/cube-yaml.js";
3743

3844
export interface CubeModelGeneratorOptions {
@@ -50,9 +56,6 @@ export interface CubeModelGeneratorOptions {
5056
readonly target?: string;
5157
}
5258

53-
/** The generator's name, as diagnostics and the catalog print it. */
54-
export const CUBE_MODEL_GENERATOR_NAME = "cube-model";
55-
5659
/** Where a cube's file lands, relative to the target's outDir (Table A). */
5760
const CUBE_FILE_DIR = "model/cubes";
5861

0 commit comments

Comments
 (0)