Skip to content

Commit e0ffe7b

Browse files
dmealingclaude
andcommitted
Merge abstract-codegen-followups: TS emitAbstractShapes knob + Java registry bootstrap fix
- feat(codegen-ts): emitAbstractShapes config knob (default true) — completes the optional TS parity (Unit 5) for the cross-port abstract-codegen knob. - fix(java-metadata): break a class-init cycle in the registry bootstrap by removing redundant self-registering static{} blocks from MetaData/MetaRoot (registration flows solely through CoreTypeMetaDataProvider). Fixes a latent provider-load failure that left the registry with only field.base. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents cc08aa0 + 486b02b commit e0ffe7b

8 files changed

Lines changed: 121 additions & 33 deletions

File tree

server/java/codegen-base/src/test/java/com/metaobjects/generator/util/GeneratorUtilAbstractTest.java

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,23 @@
2121
*/
2222
public class GeneratorUtilAbstractTest extends GeneratorTestBase {
2323

24-
// Deliberately field-free: the accessor reads the object's own `abstract`
25-
// flag, so the model only needs `object.entity` (a registered type). Avoiding
26-
// field subtypes keeps this unit test independent of the metadata-module
27-
// provider-bootstrap path (which other codegen-base tests don't exercise).
24+
// Field-bearing on purpose: loading field.long/field.string forces the
25+
// metadata provider-bootstrap path. This is also a regression guard for the
26+
// class-init cycle where MetaData's static{} block bootstrapped the registry
27+
// during its own <clinit>, leaving subclass loggers null and the field
28+
// subtypes unregistered (only field.base). See the removal of the redundant
29+
// self-registration static{} blocks in MetaData/MetaRoot.
2830
private static final String MODEL = "{\n" +
2931
" \"metadata.root\": {\n" +
3032
" \"package\": \"acme\",\n" +
3133
" \"children\": [\n" +
32-
" { \"object.entity\": { \"name\": \"Base\", \"abstract\": true } },\n" +
33-
" { \"object.entity\": { \"name\": \"Concrete\", \"extends\": \"Base\" } }\n" +
34+
" { \"object.entity\": { \"name\": \"Base\", \"abstract\": true, \"children\": [\n" +
35+
" { \"field.long\": { \"name\": \"id\" } }\n" +
36+
" ] } },\n" +
37+
" { \"object.entity\": { \"name\": \"Concrete\", \"extends\": \"Base\", \"children\": [\n" +
38+
" { \"field.string\": { \"name\": \"label\" } },\n" +
39+
" { \"identity.primary\": { \"name\": \"pk\", \"@fields\": [\"id\"] } }\n" +
40+
" ] } }\n" +
3441
" ]\n" +
3542
" }\n" +
3643
"}";

server/java/metadata/src/main/java/com/metaobjects/MetaData.java

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ public class MetaData implements Cloneable, Serializable {
128128
/** Root metadata subtype for the tree-root node (MetaRoot) */
129129
public static final String SUBTYPE_ROOT = "root";
130130

131-
// Unified registry self-registration for root metadata type
131+
// Registered via CoreTypeMetaDataProvider on the ServiceLoader bootstrap.
132132
/**
133133
* Register MetaData as metadata.base with abstract requirements constraints.
134134
* This creates metadata.base which defines metadata file structure and enforces
@@ -245,14 +245,15 @@ public static void registerTypes() {
245245
registerTypes(MetaDataRegistry.getInstance());
246246
}
247247

248-
// Static registration block - automatically registers the root metadata type when class is loaded
249-
static {
250-
try {
251-
registerTypes(MetaDataRegistry.getInstance());
252-
} catch (Exception e) {
253-
log.error("Failed to register root MetaData type during class loading", e);
254-
}
255-
}
248+
// NOTE: metadata.base registration is performed by CoreTypeMetaDataProvider
249+
// (the ServiceLoader bootstrap), invoked on first MetaDataRegistry.getInstance().
250+
// A static{} block here that called getInstance() during MetaData.<clinit> is
251+
// intentionally absent: because the field/object/etc. providers call
252+
// registerTypes() on MetaData subclasses, bootstrapping the registry from this
253+
// base class's initializer created a class-init cycle (a subclass's static
254+
// logger could be observed null, aborting provider load and leaving only
255+
// field.base registered). Registration via the provider is the single source.
256+
256257
// Unified caching strategy
257258
private final CacheStrategy cache = new HybridCache();
258259

@@ -371,7 +372,7 @@ public MetaData(String type, String subType, String name ) {
371372

372373
// ========== ENHANCED TYPE SYSTEM METHODS ==========
373374

374-
// Type definition methods removed - using unified registry with static self-registration
375+
// Type definition methods removed - using unified registry via ServiceLoader providers
375376

376377
/**
377378
* Validate MetaData name during construction

server/java/metadata/src/main/java/com/metaobjects/MetaRoot.java

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -59,30 +59,24 @@ public class MetaRoot extends MetaData {
5959
*/
6060
private transient boolean synthesizedName = false;
6161

62-
// Self-registration of the metadata.root type with the unified registry.
6362
// metadata.root accepts the same top-level children as metadata.base; it is
64-
// the concrete tree-root produced by MetaDataLoader.
65-
// metadata.root self-registers against the singleton on class-load. The same
66-
// registration is also wired into the ServiceLoader bootstrap
67-
// (CoreTypeMetaDataProvider) so isolated registries created via
68-
// MetaDataRegistry.createWithCoreProviders() get it too; the idempotency guard
69-
// in registerTypes() makes the two paths safe.
70-
static {
71-
registerTypes(MetaDataRegistry.getInstance());
72-
}
63+
// the concrete tree-root produced by MetaDataLoader. Its registration is wired
64+
// into the ServiceLoader bootstrap (CoreTypeMetaDataProvider), invoked on first
65+
// MetaDataRegistry.getInstance() — including isolated registries created via
66+
// MetaDataRegistry.createWithCoreProviders(). A self-registering static{} block
67+
// here is intentionally absent: bootstrapping the registry from this class's
68+
// <clinit> created a class-init cycle (see the matching note in MetaData).
7369

7470
/**
7571
* Registers the metadata.root type with the supplied registry.
76-
* Provided for parity with {@link MetaData#registerTypes(MetaDataRegistry)};
77-
* the static initializer already self-registers against the singleton.
72+
* Invoked via CoreTypeMetaDataProvider on the ServiceLoader bootstrap.
7873
*
7974
* @param registry the registry to register with
8075
*/
8176
public static void registerTypes(MetaDataRegistry registry) {
8277
try {
83-
// Idempotent: the static block (singleton) and the ServiceLoader
84-
// bootstrap (CoreTypeMetaDataProvider, for isolated registries) both
85-
// call this; skip if metadata.root is already registered.
78+
// Idempotent: skip if metadata.root is already registered (the
79+
// provider may run against a registry that already has it).
8680
if (registry.isRegistered(TYPE_METADATA, SUBTYPE_ROOT)) {
8781
return;
8882
}

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)