Skip to content

Commit f87cd21

Browse files
dmealingclaude
andcommitted
feat(codegen-ts): built-in scope walks (perEntity/perPackage/perModel) in templateGenerator
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky
1 parent ff4253a commit f87cd21

2 files changed

Lines changed: 126 additions & 3 deletions

File tree

server/typescript/packages/codegen-ts/src/generators/template-generator.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ import type { MetaRoot, MetaObject } from "@metaobjectsdev/metadata";
1717
import { render, type Provider, type RenderFormat } from "@metaobjectsdev/render";
1818
import type { Generator, GenContext, EmittedFile, GeneratorFactory } from "../generator.js";
1919
import { projectProvider } from "../render-engine/framework-provider.js";
20+
import { expandOutputPattern } from "../template-codegen/output-pattern.js";
21+
import {
22+
buildEntityTemplateData,
23+
buildPackageTemplateData,
24+
buildModelTemplateData,
25+
} from "../template-codegen/template-data.js";
26+
27+
/** The three built-in walk scopes (SP-1 §3.1). Same vocabulary as the engine
28+
* helpers perEntity/perPackage/perModel. */
29+
export type TemplateScope = "perEntity" | "perPackage" | "perModel";
2030

2131
export type TemplateFormat = RenderFormat;
2232

@@ -34,8 +44,17 @@ export interface TemplateGeneratorOpts {
3444
name: string;
3545
/** Walk the loaded metadata tree and produce `{ data, outputPath }` tuples
3646
* — one per emitted file. Pattern A (per-entity), pattern B (single
37-
* aggregator), pattern C (mixed), pattern D (filter inline) all fit. */
38-
walk: (root: MetaRoot) => TemplateWalkResult[] | Promise<TemplateWalkResult[]>;
47+
* aggregator), pattern C (mixed), pattern D (filter inline) all fit.
48+
* Mutually exclusive with `scope` — provide exactly one. The power-user
49+
* escape hatch; most consumers declare a `scope` + `outputPattern` instead. */
50+
walk?: (root: MetaRoot) => TemplateWalkResult[] | Promise<TemplateWalkResult[]>;
51+
/** Built-in walk scope (SP-1 §3.1) — declarative alternative to `walk`. The
52+
* generator derives the neutral data dict (template-data.ts) per unit and
53+
* names each file via `outputPattern`. Mutually exclusive with `walk`. */
54+
scope?: TemplateScope;
55+
/** Output path pattern for the built-in `scope` walk: `{name}` `{Name}`
56+
* `{package}` (SP-1 §3.3). Required with `scope`; ignored with `walk`. */
57+
outputPattern?: string;
3958
/** Template reference. Resolved by the configured Provider chain — by
4059
* default the project's `templates/<ref>.mustache` first, then the
4160
* framework defaults at `codegen-ts/templates/<ref>.mustache`. */
@@ -57,10 +76,53 @@ export interface TemplateGeneratorOpts {
5776
target?: string;
5877
}
5978

79+
/** Derive a `walk` from a built-in scope + output pattern. Each scope yields the
80+
* neutral data dict for its unit and names the file via the pattern. */
81+
function scopeWalk(
82+
scope: TemplateScope,
83+
pattern: string,
84+
): (root: MetaRoot) => TemplateWalkResult[] {
85+
return (root) => {
86+
const concrete = root.objects().filter((o) => o.isAbstract !== true);
87+
if (scope === "perEntity") {
88+
return concrete.map((e) => ({
89+
data: buildEntityTemplateData(e),
90+
outputPath: expandOutputPattern(pattern, { name: e.name, package: e.package ?? "" }),
91+
}));
92+
}
93+
if (scope === "perPackage") {
94+
const byPkg = new Map<string, MetaObject[]>();
95+
for (const o of concrete) {
96+
const pkg = o.package ?? "";
97+
let bucket = byPkg.get(pkg);
98+
if (bucket === undefined) { bucket = []; byPkg.set(pkg, bucket); }
99+
bucket.push(o);
100+
}
101+
return [...byPkg.keys()].sort().map((pkg) => ({
102+
data: buildPackageTemplateData(pkg, byPkg.get(pkg)!),
103+
outputPath: expandOutputPattern(pattern, { package: pkg }),
104+
}));
105+
}
106+
// perModel — one file over the whole model.
107+
return [{ data: buildModelTemplateData(root), outputPath: expandOutputPattern(pattern, {}) }];
108+
};
109+
}
110+
60111
export const templateGenerator = function templateGenerator(
61112
opts: TemplateGeneratorOpts,
62113
): Generator {
63114
const fmt: TemplateFormat = opts.format ?? "text";
115+
const hasWalk = typeof opts.walk === "function";
116+
const hasScope = opts.scope !== undefined;
117+
if (hasWalk === hasScope) {
118+
throw new Error(
119+
`templateGenerator(${opts.name}): provide exactly one of \`walk\` or (\`scope\` + \`outputPattern\`)`,
120+
);
121+
}
122+
if (hasScope && (opts.outputPattern === undefined || opts.outputPattern === "")) {
123+
throw new Error(`templateGenerator(${opts.name}): \`scope\` requires a non-empty \`outputPattern\``);
124+
}
125+
const walk = hasWalk ? opts.walk! : scopeWalk(opts.scope!, opts.outputPattern!);
64126
const generator: Generator = {
65127
name: opts.name,
66128
async generate(ctx: GenContext): Promise<EmittedFile[]> {
@@ -77,7 +139,7 @@ export const templateGenerator = function templateGenerator(
77139
);
78140
provider = projectProvider(process.cwd());
79141
}
80-
const walkRes = await opts.walk(ctx.loadedRoot);
142+
const walkRes = await walk(ctx.loadedRoot);
81143
const files: EmittedFile[] = [];
82144
for (const { data, outputPath } of walkRes) {
83145
let content: string;
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join, resolve } from "node:path";
5+
import { runGen, defineConfig } from "../../src/index.js";
6+
import { templateGenerator } from "../../src/generators/template-generator.js";
7+
import { MetaDataLoader } from "@metaobjectsdev/metadata";
8+
import { FileSource } from "@metaobjectsdev/metadata/core";
9+
10+
let tmp: string;
11+
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), "tmpl-scope-")); });
12+
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
13+
14+
async function genPerEntity(outDir: string, projectRoot: string) {
15+
const loader = new MetaDataLoader();
16+
const res = await loader.load([new FileSource(resolve(import.meta.dir, "../fixtures/single-entity.json"))]);
17+
expect(res.errors).toEqual([]);
18+
await runGen({
19+
config: defineConfig({
20+
outDir, extStyle: "none", dbImport: "~/db", dialect: "sqlite",
21+
generators: [templateGenerator({
22+
name: "entity-name-list",
23+
template: "scopecheck/entity",
24+
scope: "perEntity",
25+
outputPattern: "{name}.txt",
26+
})],
27+
}),
28+
metadata: res.root,
29+
projectRoot,
30+
});
31+
}
32+
33+
describe("templateGenerator scope=perEntity", () => {
34+
test("emits one file per concrete entity via the named walk", async () => {
35+
const tdir = join(tmp, "templates", "scopecheck");
36+
mkdirSync(tdir, { recursive: true });
37+
writeFileSync(join(tdir, "entity.mustache"), "name={{name}} pkg={{package}}\n");
38+
const outDir = join(tmp, "out");
39+
await genPerEntity(outDir, tmp);
40+
const files = readdirSync(outDir).sort();
41+
expect(files.length).toBeGreaterThan(0);
42+
const first = readFileSync(join(outDir, files[0]!), "utf8");
43+
expect(first).toMatch(/^name=/);
44+
});
45+
});
46+
47+
describe("templateGenerator option validation", () => {
48+
test("throws when both walk and scope are given", () => {
49+
expect(() => templateGenerator({
50+
name: "bad", template: "x", scope: "perEntity", outputPattern: "{name}.txt",
51+
walk: () => [],
52+
})).toThrow(/exactly one/i);
53+
});
54+
test("throws when neither walk nor scope is given", () => {
55+
expect(() => templateGenerator({ name: "bad2", template: "x" })).toThrow(/exactly one/i);
56+
});
57+
test("throws when scope is given without outputPattern", () => {
58+
expect(() => templateGenerator({ name: "bad3", template: "x", scope: "perModel" }))
59+
.toThrow(/outputPattern/i);
60+
});
61+
});

0 commit comments

Comments
 (0)