Skip to content

Commit 1603967

Browse files
dmealingclaude
andcommitted
feat(codegen-ts): emitAbstractShapes config knob (cross-port parity)
Adds the emitAbstractShapes option (default true) to MetaobjectsGenConfig + NormalizedMetaobjectsGenConfig, threaded through normalizeConfig and makeRenderContext into RenderContext. When false, the entity-file generator emits no file for abstract entities (their shape is the only thing they emit; instance/write generators already skip abstract unconditionally via instance-artifacts). Default true preserves existing behavior. Completes the optional TS parity (Unit 5) for the cross-port abstract-codegen knob. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b1c7b79 commit 1603967

5 files changed

Lines changed: 88 additions & 2 deletions

File tree

server/typescript/packages/codegen-ts/src/generators/entity-file.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { perEntity, type Generator, type GeneratorFactory } from "../generator.j
33
import { renderEntityFile } from "../templates/entity-file.js";
44
import { formatTs } from "../format.js";
55
import { entityOutputPath } from "../import-path.js";
6+
import { isAbstract } from "../instance-artifacts.js";
67

78
export interface EntityFileOpts {
89
filter?: (entity: MetaObject) => boolean;
@@ -33,6 +34,12 @@ export const entityFile = function entityFile(opts?: EntityFileOpts): Generator
3334
if (!ctx.renderContext) {
3435
throw new Error("entity-file: renderContext is required (provided by runGen)");
3536
}
37+
// Abstract entities contribute shape only. When emitAbstractShapes is off
38+
// (cross-port knob; default on) the entity-file generator emits nothing for
39+
// them. Instance/write generators skip abstract unconditionally elsewhere.
40+
if (isAbstract(entity) && !ctx.renderContext.emitAbstractShapes) {
41+
return [];
42+
}
3643
return {
3744
path: entityOutputPath(ctx.config.outputLayout ?? "flat", entity.package, `${entity.name}.ts`),
3845
content: await formatTs(renderEntityFile(entity, ctx.renderContext, { allowlists })),

server/typescript/packages/codegen-ts/src/metaobjects-config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@ export interface MetaobjectsGenConfig extends ResolvedGenConfig {
3737
columnNamingStrategy?: ColumnNamingStrategy;
3838
/** Path prefix applied to generated route registrations + hook fetch URLs. Defaults to "". */
3939
apiPrefix?: string;
40+
/**
41+
* Whether abstract entities (`@isAbstract: true`) emit their shape artifact
42+
* (the type-only interface / value-object file from the entity-file
43+
* generator). Defaults to `true`. Instance/write artifacts (forms, CRUD/read
44+
* hooks, grids) are NEVER emitted for abstract entities regardless of this
45+
* flag — that invariant lives in `instance-artifacts.ts`. This knob only
46+
* governs the shape, mirroring the cross-port `emitAbstractShapes` option.
47+
*/
48+
emitAbstractShapes?: boolean;
4049
/** Named output destinations. Generators reference one via `target`. */
4150
targets?: Record<string, TargetConfig>;
4251
/** importBase for the default target (top-level outDir). */
@@ -57,6 +66,7 @@ export interface MetaobjectsGenConfig extends ResolvedGenConfig {
5766
export interface NormalizedMetaobjectsGenConfig extends Omit<MetaobjectsGenConfig, "targets"> {
5867
columnNamingStrategy: ColumnNamingStrategy;
5968
apiPrefix: string;
69+
emitAbstractShapes: boolean;
6070
outputLayout: OutputLayout;
6171
targets: Record<string, ResolvedTarget>;
6272
}
@@ -98,6 +108,7 @@ export function normalizeConfig(config: MetaobjectsGenConfig): NormalizedMetaobj
98108
...config,
99109
columnNamingStrategy: config.columnNamingStrategy ?? DEFAULT_COLUMN_NAMING_STRATEGY,
100110
apiPrefix: config.apiPrefix ?? "",
111+
emitAbstractShapes: config.emitAbstractShapes ?? true,
101112
outputLayout: config.outputLayout ?? "flat",
102113
targets: resolveTargets(config),
103114
};

server/typescript/packages/codegen-ts/src/render-context.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export interface RenderContext {
3939
columnNamingStrategy: ColumnNamingStrategy;
4040
/** Path prefix applied to generated route registrations + hook fetch URLs. Defaults to "". */
4141
apiPrefix: string;
42+
/** Whether abstract entities emit their shape artifact (type-only interface / value-object file). Defaults to true. Instance/write artifacts are never emitted for abstract entities regardless. */
43+
emitAbstractShapes: boolean;
4244
/** Output layout mode: "flat" (default) — all files in outDir; "package" — sub-paths from entity metadata package. */
4345
outputLayout: OutputLayout;
4446
/** The target THIS generator emits to (drives path layout + same-target imports). */
@@ -53,11 +55,12 @@ export interface RenderContext {
5355
}
5456

5557
/** Optional shape — `extStyle`, `omImport`, `columnNamingStrategy`, `apiPrefix`, `outputLayout`, and `packageOf` default if omitted. `packageOf` defaults to an empty Map (correct for flat layout; `runGen` always provides the real map). */
56-
export type RenderContextInput = Omit<RenderContext, "extStyle" | "omImport" | "columnNamingStrategy" | "apiPrefix" | "outputLayout" | "packageOf" | "selfTarget" | "entityModuleTarget"> & {
58+
export type RenderContextInput = Omit<RenderContext, "extStyle" | "omImport" | "columnNamingStrategy" | "apiPrefix" | "emitAbstractShapes" | "outputLayout" | "packageOf" | "selfTarget" | "entityModuleTarget"> & {
5759
extStyle?: ExtStyle;
5860
omImport?: string;
5961
columnNamingStrategy?: ColumnNamingStrategy;
6062
apiPrefix?: string;
63+
emitAbstractShapes?: boolean;
6164
outputLayout?: OutputLayout;
6265
packageOf?: Map<string, string | undefined>;
6366
selfTarget?: ResolvedTarget;
@@ -85,6 +88,7 @@ export function makeRenderContext(opts: RenderContextInput): RenderContext {
8588
omImport: opts.omImport ?? "../index",
8689
columnNamingStrategy: opts.columnNamingStrategy ?? "snake_case",
8790
apiPrefix: opts.apiPrefix ?? "",
91+
emitAbstractShapes: opts.emitAbstractShapes ?? true,
8892
outputLayout,
8993
packageOf: opts.packageOf ?? new Map(),
9094
selfTarget: defaultTarget,

server/typescript/packages/codegen-ts/src/runner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
155155
extStyle: config.extStyle,
156156
columnNamingStrategy: config.columnNamingStrategy,
157157
apiPrefix: config.apiPrefix,
158+
emitAbstractShapes: config.emitAbstractShapes,
158159
outputLayout: selfTarget.outputLayout,
159160
pkMap,
160161
relationMap,

server/typescript/packages/codegen-ts/test/generators/entity-file.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { buildPkMap } from "../../src/pk-resolver.js";
66
import { buildRelationMap } from "../../src/relation-resolver.js";
77
import { makeRenderContext } from "../../src/render-context.js";
88
import type { GenContext } from "../../src/generator.js";
9-
import { MetaDataLoader } from "@metaobjectsdev/metadata";
9+
import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata";
1010
import { FileSource } from "@metaobjectsdev/metadata/core";
1111

1212
const FIXTURE = resolve(import.meta.dir, "..", "fixtures", "single-entity.json");
@@ -101,3 +101,66 @@ describe("entityFile() factory", () => {
101101
expect(files).toEqual([]);
102102
});
103103
});
104+
105+
describe("entityFile() — emitAbstractShapes knob", () => {
106+
// Abstract base (sourceless → shape is a value-object interface) + concrete subclass.
107+
const MODEL = JSON.stringify({
108+
"metadata.root": {
109+
package: "acme",
110+
children: [
111+
{
112+
"object.entity": {
113+
name: "Base",
114+
abstract: true,
115+
children: [
116+
{ "field.int": { name: "id" } },
117+
{ "field.string": { name: "name" } },
118+
{ "identity.primary": { "@fields": "id" } },
119+
],
120+
},
121+
},
122+
{
123+
"object.entity": {
124+
name: "Widget",
125+
extends: "Base",
126+
children: [
127+
{ "source.rdb": { "@table": "widgets" } },
128+
{ "field.string": { name: "sku" } },
129+
],
130+
},
131+
},
132+
],
133+
},
134+
});
135+
136+
async function runWith(emitAbstractShapes: boolean) {
137+
const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(MODEL)]);
138+
if (errors.length > 0) throw new Error(errors.map((e: { message: string }) => e.message).join("\n"));
139+
const entities = root.objects();
140+
const renderContext = makeRenderContext({
141+
dialect: "sqlite", loadedRoot: root, outDir: "/tmp",
142+
dbImport: "../db", extStyle: "none", emitAbstractShapes,
143+
pkMap: buildPkMap(root), relationMap: buildRelationMap(root),
144+
});
145+
const ctx: GenContext = {
146+
entities, loadedRoot: root,
147+
matches: () => true,
148+
config: { outDir: "/tmp", extStyle: "none", dbImport: "../db", dialect: "sqlite" },
149+
renderContext,
150+
warn: () => {},
151+
};
152+
return (await entityFile().generate(ctx)).map((f) => f.path);
153+
}
154+
155+
test("default (on): abstract entity emits its shape file", async () => {
156+
const paths = await runWith(true);
157+
expect(paths).toContain("Base.ts");
158+
expect(paths).toContain("Widget.ts");
159+
});
160+
161+
test("off: abstract entity emits no file; concrete still emits", async () => {
162+
const paths = await runWith(false);
163+
expect(paths).not.toContain("Base.ts");
164+
expect(paths).toContain("Widget.ts");
165+
});
166+
});

0 commit comments

Comments
 (0)