From b781b4a0cad54c660bfb08c8b53c3c13384d080b Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 11 Oct 2026 08:46:43 -0400
Subject: [PATCH 1/6] feat(java): the entity and extractor generators are
ejectable
"Everything should be ejectable." An audit of the stock generators in all five
ports found eight that could not be ejected; this is the Java part, `entity`
(JavaObjectCodeGenerator) and `extractor` (ExtractorCodeGenerator). Every Java
generator can now be ejected except `template` (ADR-0034 Amendment 6).
Why they could not be ejected
- entity compiled only inside its writers' package: it called two protected
naming steps on a writer it does not extend, and named its siblings by bare
name. The package line is the one thing eject rewrites, so a copy did not
compile (39 errors).
- extractor was not a generator. It was a step entity ran, so no
entry could name an owned copy and the drift check had nothing to run.
What the owned copy carries, and what stays in the package
- The owned entity is JavaObjectCodeGenerator.java alone: flavor selection, the
writer factory (createWriter), the ObjectClassBindingProvider source, its
META-INF/services registration, and whether the extractors are written there.
- The class body stays in codegen-base's flavor writers (JavaCodeWriter,
PojoAwareCodeWriter, ValueObjectCodeWriter), now named as supported surface:
subclass one, override a protected step, return it from createWriter. They
are not copied because every generated class's header names the writer class
that wrote it, so a copied writer would change every generated file, and
because a change to the class body is one overridden step.
- JavaCodeWriter gains javaPackageOf(mo) and javaClassNameOf(mo), public views
of the naming steps. The steps stay protected: widening them would break
every subclass that overrides one as protected.
- The owned extractor holds all of its emit.
extractor as a generator of its own
- ExtractorCodeGenerator implements Generator and takes the naming args entity
takes. entity still writes the extractors when no separate extractor is in
the run, so a pom that wires only entity emits byte for byte what it did.
- When one is in , the Maven plugin sets emitExtractors=false on
the others (EmitsObjectExtractors, derived where useNames is), so each file
has one emitter. An explicit value in the pom wins.
Eject
- codegen-base ships the two reference sources. The registry gives both an
eject path, and extractor a wiring note the eject report prints, since a pom
may have no entry for it to change.
- Behaviour change: `-Dnames=entity` and `-Dnames=extractor` are now ejectable
on both JVM ports, so with no port implied by the project's dependencies the
goal asks for -Dport, as it does for `names`. It used to resolve to Kotlin.
Proof
- EjectedBaseGeneratorsCompileTest: both copies compile package-renamed against
the module's public API.
- EntityExtractorEjectRoundTripTest: ejects both through the mojo, compiles
them, and holds the unchanged copies to the packaged generators' output over
the persistence corpus in every flavor; an edit to each copy shows in its
output; -Dlist marks the copies identical or DIFFERS; mvn metaobjects:verify
runs the owned copies and reports what they no longer produce.
- StandaloneExtractorGeneratorTest, EmitExtractorsDerivationTest and a second
compile-gate selection hold the split between the two generators.
The model tier's class header still carries a wall-clock "Generated On:" line,
so the entity comparisons leave that one line out and the drift proof re-runs
a pass that crossed a second. Both go once that line is removed.
Docs: ADR-0034 Amendment 6, own-your-codegen, the Java port page and README
matrix, the codegen skill's Java reference (with regenerated agent-context
goldens), the module README and the CHANGELOG.
---
CHANGELOG.md | 40 ++
README.md | 2 +-
.../metaobjects-codegen/references/java.md | 13 +-
docs/features/own-your-codegen.md | 63 ++-
docs/ports/java.md | 21 +-
.../metaobjects-codegen/references/java.md | 13 +-
.../metaobjects-codegen/references/java.md | 13 +-
server/java/codegen-base/pom.xml | 40 +-
.../generator/EmitsObjectExtractors.java | 51 ++
.../javacode/ExtractorCodeGenerator.java | 76 ++-
.../object/javacode/JavaCodeWriter.java | 23 +
.../javacode/JavaObjectCodeGenerator.java | 48 +-
.../javacode/README-flavored-objects.md | 32 ++
.../EjectedBaseGeneratorsCompileTest.java | 96 ++++
.../StandaloneExtractorGeneratorTest.java | 202 ++++++++
server/java/codegen-spring/pom.xml | 8 +-
.../generator/GeneratorRegistry.java | 75 ++-
.../GeneratorRegistryConformanceTest.java | 15 +-
.../spring/CodegenCompileConformanceTest.java | 19 +
.../spring/NoMagicPhysicalNamesTest.java | 11 +-
.../generator/spring/ReportingInertTest.java | 3 +-
.../mojo/AbstractMetaDataMojo.java | 10 +-
.../com/metaobjects/mojo/EjectSupport.java | 19 +-
.../metaobjects/mojo/MetaDataEjectMojo.java | 4 +
.../metaobjects/mojo/EjectSupportTest.java | 69 +--
.../mojo/EmitExtractorsDerivationTest.java | 96 ++++
.../EntityExtractorEjectRoundTripTest.java | 449 ++++++++++++++++++
...2-codegen-and-docs-surface-architecture.md | 3 +
.../ADR-0034-codegen-scaffold-and-own.md | 81 ++++
29 files changed, 1492 insertions(+), 103 deletions(-)
create mode 100644 server/java/codegen-base/src/main/java/com/metaobjects/generator/EmitsObjectExtractors.java
create mode 100644 server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java
create mode 100644 server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/StandaloneExtractorGeneratorTest.java
create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EmitExtractorsDerivationTest.java
create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EntityExtractorEjectRoundTripTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 514b4743a..425bad058 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,46 @@ here.**
## [Unreleased]
+### Added
+
+- **The Java `entity` and `extractor` generators can be ejected.**
+ `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java` copies
+ `JavaObjectCodeGenerator.java` and `ExtractorCodeGenerator.java` into your `codegen/`
+ module. They were the two Java generators that could not be owned: `entity` compiled only
+ inside its writers' package, and `extractor` was a step inside `entity` that no
+ `` entry could name. An unchanged copy of either emits what the packaged
+ generator emits, `-Dlist` marks the copies `identical` or `DIFFERS`, and
+ `mvn metaobjects:verify` runs them when your pom names them. Every Java generator is now
+ ejectable except `template`. Ruling and reasoning: ADR-0034 Amendment 6.
+ - **The owned `entity` is the generator, not the class body.** The copy holds the flavor
+ selection, the writer factory (`createWriter`), the `ObjectClassBindingProvider` source
+ and its `META-INF/services` registration. The class body stays in
+ `metaobjects-codegen-base`'s flavor writers (`JavaCodeWriter`, `PojoAwareCodeWriter`,
+ `ValueObjectCodeWriter`), which are now supported surface: subclass one, override a
+ `protected` step and return it from `createWriter`. Details:
+ [own-your-codegen](docs/features/own-your-codegen.md#java-entity-and-extractor).
+ - **`extractor` is a generator of its own (Java).** `ExtractorCodeGenerator` can be named
+ in ``, with the same `outputDir`, `pkgPrefix`, `pkgSuffix`, `namePrefix` and
+ `nameSuffix` as your `entity` generator. When it is, the plugin sets
+ `emitExtractors=false` on `entity`, so each `Extractor` has one emitter. Outside
+ Maven, set that arg yourself.
+ - **Nothing changes for a pom that wires only `entity`.** It still writes the extractors,
+ and its output is byte for byte what it was.
+ - New public API in `metaobjects-codegen-base`: `JavaCodeWriter.javaPackageOf(mo)` and
+ `javaClassNameOf(mo)` (the generated type's package and name, as a subclass's naming
+ overrides leave them), the `EmitsObjectExtractors` marker, and
+ `JavaObjectCodeGenerator.ARG_EMIT_EXTRACTORS`.
+
+### Changed
+
+- **`mvn metaobjects:eject -Dnames=entity` and `-Dnames=extractor` need a port when the
+ project does not imply one.** Both names are now ejectable on Java and on Kotlin. A
+ project that declares exactly one of `metaobjects-codegen-spring` and
+ `metaobjects-codegen-kotlin` is unaffected, because the goal infers the port from it.
+ With neither or both declared, the goal used to resolve these two names to Kotlin, the
+ only port that had them; it now refuses with `ejectable on more than one port` and asks
+ for `-Dport=java|kotlin`, as it already did for `names` and `requirement-tests`.
+
### Fixed
- `meta verify --docs` now reports a generated page in the docs root that no current object
diff --git a/README.md b/README.md
index 2ce6d7795..d078fd2d9 100644
--- a/README.md
+++ b/README.md
@@ -159,7 +159,7 @@ first-week wedge plan — and `meta init` picks up from there.
| Metadata dependencies (`dependencies`) | Yes (`meta deps sync`, `path` transport) | Phase 2 | Phase 2 | Phase 2 | Yes (loads the synced snapshot) |
| Runtime metadata (ObjectManager-style) | Yes (`runtime-ts`) | Yes (OMDB) | Yes (via Java OMDB + Exposed) | Roadmap | Yes (ObjectManager) |
| React / Angular UI client (browser) | React: **published** (`@metaobjectsdev/react` + `@metaobjectsdev/tanstack`), codegen + runtime. Angular 18: **source-only by decision** ([ADR-0048](spec/decisions/ADR-0048-angular-tier-source-only.md)) — `@metaobjectsdev/angular` + `@metaobjectsdev/codegen-ts-angular` build in-repo on their own `0.6.x` line but are deliberately not on npm (`npm i @metaobjectsdev/angular` will 404) until they meet the ADR's promotion bar. Consume them from source. | Consumes TS client via REST | Consumes TS client via REST | Consumes TS client via REST | Consumes TS client via REST |
-| Own a generator (`eject`) | Yes (`meta eject`) | Yes (`mvn metaobjects:eject`; `entity` is not ejectable) | Yes (`mvn metaobjects:eject`) | Yes (`dotnet meta eject`) | Yes (`metaobjects eject`) |
+| Own a generator (`eject`) | Yes (`meta eject`) | Yes (`mvn metaobjects:eject`) | Yes (`mvn metaobjects:eject`) | Yes (`dotnet meta eject`) | Yes (`metaobjects eject`) |
| Cross-port REST routes for the client (reference generators) | Generated (`routesFile()` → Fastify) | Generated (`SpringControllerGenerator` → Spring `@RestController`, incl. filter/sort) | Generated (`KotlinSpringControllerGenerator` → Spring `@RestController`, incl. filter/sort) | Generated (`RoutesGenerator` → ASP.NET Minimal API) | Generated (`router_generator` → FastAPI `APIRouter`, incl. filter/sort) |
A "Yes" means the feature is covered by the shared conformance corpora at
diff --git a/agent-context/skills/metaobjects-codegen/references/java.md b/agent-context/skills/metaobjects-codegen/references/java.md
index 0f2a615e4..a50695c15 100644
--- a/agent-context/skills/metaobjects-codegen/references/java.md
+++ b/agent-context/skills/metaobjects-codegen/references/java.md
@@ -159,7 +159,18 @@ separate `metaobjects-codegen-base` module instead.)
| `LlmTraceHelperGenerator` | `TraceHelper.java` per concrete entity — the LLM-trace helper |
| `SpringFilterAllowlistGenerator` | per-entity filter allowlist |
| `SpringNamesGenerator` | `Names.java` — `public abstract class` of `static final String` names that MIRRORS THE METADATA TREE. Every node carries its own `TYPE`/`SUB_TYPE`/`NAME`, so `AuthorNames.NAME` is the OBJECT's name (`"Author"`), **not** a table name, and a physical name sits under the member that says what it is: `SOURCE__TABLE` / `_VIEW` / `_MATERIALIZED_VIEW` / `_PROC` / `_FUNCTION`, from the metamodel's own `@kind`-to-alias map, with `` being `PRIMARY` or `REPLICA` (a write-through entity has a member for each). Plus a `_FIELD`/`_COLUMN` pair per field and a complete `COLUMNS_BY_FIELD`; an `identity.secondary` or `index.lookup` also carries `IDENTITY__INDEX` / `INDEX__INDEX`, while `identity.primary` deliberately carries no index member (migrate names a PK by a dialect-conditional formula this artifact must not restate). **There is no `READ_ONLY`** — it was a derivation over `@kind`, never metadata, so ask `SOURCE__KIND`. Emitted for every object with a declared or inherited primary source, PLUS a fragment for any abstract base such an object extends (columns only, no source). A class whose object extends another one **extends** it (`class CopayAuthNames extends AuthNames`) rather than restating its constants — Java inherits static members, so `CopayAuthNames.SOURCE_PRIMARY_TABLE` and `CopayAuthNames.ID_COLUMN` both resolve through the base. **This port generates no SQL** (the repository is an interface you implement), so nothing generated consumes these — they exist for your implementation. |
-| `JavaObjectCodeGenerator` | module `metaobjects-codegen-base` (`com.metaobjects.generator.direct.object.javacode`), a separate module from the Spring generators above. Flavor-selected via the `flavor` generator arg. `flavor=pojoAware` → `class extends PojoObject` (a concrete `MetaObjectAware` class with a `(MetaObject)` constructor) — its inherited `getMetaData()` back-reference is what breaks a default Jackson/Gson mapper, see "Serializing generated objects" below. `flavor=valueObject` → `class extends ValueObject` (map-backed; less hostile to a default mapper, but still not the sanctioned serialization path). Either concrete flavor also emits a `Extractor` plus a self-registering `ObjectClassBindingProvider`. For a plain default-Jackson-friendly type, use the `codegen-spring` record surface instead — never `pojoAware`. |
+| `JavaObjectCodeGenerator` | module `metaobjects-codegen-base` (`com.metaobjects.generator.direct.object.javacode`), a separate module from the Spring generators above. Flavor-selected via the `flavor` generator arg. `flavor=pojoAware` → `class extends PojoObject` (a concrete `MetaObjectAware` class with a `(MetaObject)` constructor) — its inherited `getMetaData()` back-reference is what breaks a default Jackson/Gson mapper, see "Serializing generated objects" below. `flavor=valueObject` → `class extends ValueObject` (map-backed; less hostile to a default mapper, but still not the sanctioned serialization path). Either concrete flavor also emits a self-registering `ObjectClassBindingProvider`, and a `Extractor` per concrete class unless `ExtractorCodeGenerator` is wired too. For a plain default-Jackson-friendly type, use the `codegen-spring` record surface instead — never `pojoAware`. |
+| `ExtractorCodeGenerator` | also in `metaobjects-codegen-base`. One `Extractor` per concrete object, the same files `JavaObjectCodeGenerator` writes when this is not wired. Wire it as its own `` only to own it or to give it different args: same `outputDir` and `pkgPrefix` / `pkgSuffix` / `namePrefix` / `nameSuffix` as the entity generator, which must run a concrete flavor in the same ``. The plugin then stops the entity generator writing them. |
+
+**Owning one of these two.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java`
+copies `JavaObjectCodeGenerator.java` and `ExtractorCodeGenerator.java` into the
+`codegen/` module (`-Dport` is required: both names are ejectable on both JVM ports).
+The owned entity generator holds the flavor selection, `createWriter`, the binding provider
+source and its `META-INF/services` registration. The class body is not in it: it comes
+from `JavaCodeWriter` / `PojoAwareCodeWriter` / `ValueObjectCodeWriter`, which stay in the
+package. To change a field, a constructor or an accessor, subclass the writer, override the
+`protected` step and return it from `createWriter`; do not copy the writers out. Every
+Java generator can be ejected except `template`.
**Projections (read-only views).** An `object.projection` (read-only `source.rdb`
`@kind: view` child) is served read-only through OMDB at the ObjectManager layer
diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md
index 2938e9472..ef876c096 100644
--- a/docs/features/own-your-codegen.md
+++ b/docs/features/own-your-codegen.md
@@ -536,6 +536,57 @@ from which the goal infers it. `requirement-tests` is one such name:
`KotlinRequirementTestsGenerator.kt`, each with its default rendering. The identity
function, the digest and the renderer and filter hook types stay in the package.
+### Java `entity` and `extractor`
+
+```bash
+mvn metaobjects:eject -Dnames=entity,extractor -Dport=java
+```
+
+Both names exist in both ports, so pass `-Dport`. Their reference source ships in
+`metaobjects-codegen-base`, not `metaobjects-codegen-spring`; your `codegen/` module
+reaches it through the same dependency.
+
+**`entity` copies `JavaObjectCodeGenerator.java`, and that one file is the generator.**
+What it holds is yours to change:
+
+- which flavors exist, and which writer each one gets (`createWriter`);
+- the `ObjectClassBindingProvider` source and its `META-INF/services` registration;
+- whether the `Extractor` classes are written here.
+
+The class body is not in that file. It comes from the flavor writers, which stay in
+`metaobjects-codegen-base` as supported surface for an owned copy to call and subclass:
+
+| Stays in the package | What an owned generator does with it |
+|---|---|
+| `JavaCodeWriter` (interface flavor), `PojoAwareCodeWriter`, `ValueObjectCodeWriter` | Builds one per file in `createWriter`. To change a field, a constructor or an accessor, subclass the writer, override the `protected` step (`writeFields`, `writeConstructor`, `writeGetter`, `writeSetter`, `getGetterMethodName`, ...) and return your subclass. |
+| `JavaCodeWriter.javaPackageOf(mo)` / `javaClassNameOf(mo)` | Names the generated type without writing it. They answer through the same naming steps a subclass overrides, so the binding provider and the extractors follow a rename. |
+| `JavaCodeGenerator`, `BaseObjectCodeGenerator` | The base your copy extends: argument parsing, and the one-file-per-object loop. |
+| `GeneratedFileWriter` | The write guard: a file with no `GENERATED` marker is left alone. |
+
+This is the same split the TypeScript prompt tier uses: the copy owns which objects get
+output, where it lands and how it is composed, and the body composer stays in the
+package. Copying the writers out as well would hand you the whole writer hierarchy to
+keep in step with the reference, when a change to the class body is one overridden step.
+
+**`extractor` copies `ExtractorCodeGenerator.java`, which holds all of its emit.** It is a
+generator of its own: add it to `` with the same `outputDir`, `pkgPrefix`,
+`pkgSuffix`, `namePrefix` and `nameSuffix` as your `entity` generator. An extractor casts
+to the generated flavored class, so `entity` with a concrete `flavor` has to be in the
+same run.
+
+```xml
+
+ com.acme.codegen.ExtractorCodeGenerator
+ ${project.build.directory}/generated-sources/metaobjects
+
+```
+
+`entity` still writes the extractors itself when no separate `extractor` generator is in
+the run, so a pom that wires only `entity` emits what it always did. When one is in
+``, the plugin tells `entity` to leave those files to it (it sets
+`emitExtractors` to `false`), so each file has one emitter. Outside Maven, set
+`emitExtractors=false` on `entity` yourself.
+
### What eject hands over, and what stays core
An owned generator is only yours if the code its output depends on is yours too. The Java
@@ -608,13 +659,15 @@ diff -u -I '^package ' -I '^// metaobjects:owned-runtime' \
Apply the hunks you want by hand. For a generator, diff `reference/java/.java`
against `codegen/src/main/java//.java` the same way, and also ignore
-`RUNTIME_PACKAGE`. Or, if you have no edits of your own (`-Dlist` says `identical`),
-re-eject with `-Dforce`.
+`RUNTIME_PACKAGE`. `entity` and `extractor` are in `metaobjects-codegen-base-$V.jar`
+instead. Or, if you have no edits of your own (`-Dlist` says `identical`), re-eject with
+`-Dforce`.
`-Dport` is needed only when a name exists in both ports and your project's dependencies
-do not say which. Every Kotlin generator is ejectable. In Java, `entity`, `extractor` and
-`template` are not: `entity` shares internal writer classes with `extractor`, and
-`template` is the Mustache primitive, which has no emit logic of its own to own.
+do not say which. Every Kotlin generator is ejectable, and every Java generator except
+`template`. `template` is the Mustache primitive: the template you supply is its emit
+logic, so the generator has none of its own to hand over. That holds in every port that
+ships it.
## C#: `dotnet meta eject`
diff --git a/docs/ports/java.md b/docs/ports/java.md
index d03257fb9..5631eefda 100644
--- a/docs/ports/java.md
+++ b/docs/ports/java.md
@@ -281,11 +281,11 @@ name in every port.
| `value-object` | `SpringValueObjectGenerator` | `metaobjects-codegen-spring` | One Java 21 `record` per concrete `object.value` and per sourceless `object.projection`, in the value object's own package. It carries jakarta bean-validation constraints plus `@Valid` on nested members, so a VO jsonb column POSTs and PATCHes with validation cascading to depth ≥ 2. It is THE Java type for the value object (ADR-0056): `Dto` / `Patch` bind to it, a render helper takes it, and a response parser returns it. The template tier declares no copy, so it needs this generator in the same run. |
| `names` | `SpringNamesGenerator` | `metaobjects-codegen-spring` | One `Names.java` per object with a declared/inherited primary `source.rdb` — `public static final` physical database name constants (table/view name, schema, per-field columns). See "`Names`" below. |
| `requirement-tests` | `JUnitRequirementTestsGenerator` | `metaobjects-codegen-spring` | Per metamodel package that holds a tested requirement, two files in `testPackage`: `Requirements__Witnesses.java` (an interface with one default member per non-skipped test, failing with `unimplemented requirement: ...`) and `Requirements__Test.java` (one JUnit Jupiter `@Test` per requirement, calling that member on your `witnessClass`; a planned or retired requirement is `@Disabled`). Args: `testPackage` and `witnessClass` (required), `grain` (`concern` or `member`), `filter` and `renderer` (class names on your project classpath), `warnUncovered`. Your test classpath needs `org.junit.jupiter:junit-jupiter-api`. A model with no requirement writes nothing. See "Requirement tests" below. |
-| `entity` | `JavaObjectCodeGenerator` | `metaobjects-codegen-base` | Flavor-selected via the `flavor` generator arg (`com.metaobjects.generator.direct.object.javacode`). `flavor=pojoAware` emits `class extends PojoObject` — a concrete `MetaObjectAware` class whose inherited `getMetaData()` back-reference breaks a default Jackson/Gson mapper (see [Serializing generated objects](#serializing-generated-objects) below). `flavor=valueObject` emits a map-backed `class extends ValueObject` instead. Either flavor also emits a `Extractor` and a self-registering `ObjectClassBindingProvider`. For a plain default-Jackson-friendly type, use the `codegen-spring` record surface instead — never `pojoAware`. |
+| `entity` | `JavaObjectCodeGenerator` | `metaobjects-codegen-base` | Flavor-selected via the `flavor` generator arg (`com.metaobjects.generator.direct.object.javacode`). `flavor=pojoAware` emits `class extends PojoObject` — a concrete `MetaObjectAware` class whose inherited `getMetaData()` back-reference breaks a default Jackson/Gson mapper (see [Serializing generated objects](#serializing-generated-objects) below). `flavor=valueObject` emits a map-backed `class extends ValueObject` instead. Either flavor also emits a self-registering `ObjectClassBindingProvider`, and a `Extractor` per concrete class unless the `extractor` generator is wired too. For a plain default-Jackson-friendly type, use the `codegen-spring` record surface instead — never `pojoAware`. |
| `output-parser` | `SpringOutputParserGenerator` | `metaobjects-codegen-spring` | One `Parser` per **responding** `template.prompt` (ADR-0052: one carrying `@responseRef`) — a Jackson-backed throw-only parser returning the `@responseRef` value object's own record (ADR-0056). FR-006 / ADR-0010; the Java sibling of TS's `outputParser()`. See [FR-006 — response parsing](#fr-006--response-parsing) below. |
| `output-prompt` | `SpringOutputPromptGenerator` | `metaobjects-codegen-spring` | One `ResponseFormat` per responding `template.prompt` — a static `renderFormat()` / `renderFormat(PromptOverrides)` pair emitting the output-format prompt fragment (FR-010). The reply's syntax comes from `@responseFormat`, never `@format` (which is the syntax of the rendered prompt BODY). |
| `render-helper` | `SpringRenderHelperGenerator` | `metaobjects-codegen-spring` | One `RenderHelper` per `template.output`, wrapping the JVM `Renderer` with a typed `render(payload, provider)`. `@kind: document` renders `@textRef` to a `String`; `@kind: email` renders subject + html (+ optional text) into an `EmailDocument`. **It also runs the mustache↔payload drift check at BUILD time** — an unresolvable text, or one with a non-warning `Verify` error, fails the build rather than emitting. |
-| `extractor` | `ExtractorCodeGenerator` | `metaobjects-codegen-base` | One `Extractor` wrapping the runtime tolerant extract, turning dirty LLM text into a fully-typed flavored object graph (nested objects + arrays-of-objects populated) in one call. It names `MetaObjectExtractor` (in `metaobjects-om`) by FQN string only, so `codegen-base` keeps no compile dependency on `om` — the reference resolves on the consumer's classpath. |
+| `extractor` | `ExtractorCodeGenerator` | `metaobjects-codegen-base` | One `Extractor` per concrete object, wrapping the runtime tolerant extract and turning dirty LLM text into a fully-typed flavored object graph (nested objects + arrays-of-objects populated) in one call. Args: `outputDir`, and the `pkgPrefix` / `pkgSuffix` / `namePrefix` / `nameSuffix` your `entity` generator uses. Needs `entity` with a concrete `flavor` in the same run, since an extractor casts to the flavored class. It names `MetaObjectExtractor` (in `metaobjects-om`) by FQN string only, so `codegen-base` keeps no compile dependency on `om` — the reference resolves on the consumer's classpath. |
| `trace-helper` | `LlmTraceHelperGenerator` | `metaobjects-codegen-spring` | One `TraceHelper` per concrete entity that transitively `extends` `metaobjects::ai::LlmCallBase` **and** nests a `template.prompt` carrying `@responseRef` — a static `record(...)` that extracts the typed response, builds the `LlmCallBase` trace row, and persists it. Emits nothing for any other entity. |
| `template` | `TemplateScopeGenerator` | `metaobjects-codegen-base` | The generic Mustache primitive: walk the model by a named `scope`, render one shared template per walk result, write each file. The declarative alternative to writing a generator class — see [Declarative template-codegen](#declarative-template-codegen-templatescopegenerator) below. It renders through `render.templategen.TemplateGenerator`, the byte-pinned cross-port factory; that factory takes a walk callback and is not itself a `Generator`, so `TemplateScopeGenerator` is the class a pom names. |
@@ -294,11 +294,14 @@ Every one is independently configurable; the typical starting set is
`SpringControllerGenerator` + `SpringDtoGenerator` + `SpringRepositoryGenerator`
(controller + DTO + repository).
-**One exception: `extractor` is not separately wirable on this port.** It is emitted by
-`entity` — `JavaObjectCodeGenerator` runs `ExtractorCodeGenerator` for every non-abstract
-object it emits — so its output ships whenever `entity` is in your ``, and
-there is no `` entry for it. The other four ports expose `extractor` as a
-standalone generator; Java fuses it, which is why it carries a stable name here at all.
+**`extractor` runs two ways.** Wire only `entity` (with a concrete `flavor`) and it writes
+a `Extractor` beside every non-abstract class itself, as it always has. Add
+`ExtractorCodeGenerator` as a `` of its own, which is how the other four ports
+expose it, and that generator writes them instead: the plugin tells `entity` to leave the
+files to it (`emitExtractors=false`), so each one has a single emitter. You need the
+separate entry only to own the extractor (`mvn metaobjects:eject -Dnames=extractor
+-Dport=java`) or to run it with different args. Outside Maven, set `emitExtractors=false`
+on `entity` yourself.
### Reports
@@ -517,7 +520,9 @@ copy imports them. The model and the other ports' spelling are in
### Authoring your own — two paths
-**Own a generator.** `mvn metaobjects:eject -Dnames=` copies a reference generator into a `codegen/` Maven module under your own package, and prints the module, dependency and `` to wire. See [Own your codegen → Java and Kotlin](../features/own-your-codegen.md#java-and-kotlin-mvn-metaobjectseject).
+**Own a generator.** `mvn metaobjects:eject -Dnames=` copies a reference generator into a `codegen/` Maven module under your own package, and prints the module, dependency and `` to wire. See [Own your codegen → Java and Kotlin](../features/own-your-codegen.md#java-and-kotlin-mvn-metaobjectseject). Every generator in the table above can be ejected except `template`.
+
+**`entity` and `extractor` eject from `metaobjects-codegen-base`.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java` copies `JavaObjectCodeGenerator.java` and `ExtractorCodeGenerator.java` (pass `-Dport`: both names are ejectable on both JVM ports). The owned `entity` holds the flavor selection, the writer factory (`createWriter`), the `ObjectClassBindingProvider` source and its `META-INF/services` registration. The class body stays in the package's flavor writers (`JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter`), which are supported surface: subclass one, override a `protected` step and return it from `createWriter`. The owned `extractor` holds all of its emit. What stays in the package and why is in [Own your codegen → Java `entity` and `extractor`](../features/own-your-codegen.md#java-entity-and-extractor).
**Ejecting hands over the helper runtime too.** The `routes`, `dto` and `repository` output imports helper classes from `com.metaobjects.generator.spring.runtime` (`FilterParser`, `FilterParseResult`, `FilterPredicate`, `PatchValidationException`, `ConstraintErrors`, `RecordComponentNames`). These are helpers, not core. Ejecting one of those generators copies the classes its output imports into `src/main/java//` (default `.runtime`) of the module that compiles the generated code, and the owned generator's output imports that copy. The copies are JDK-only. The owned web tier then builds with no MetaObjects artifact on the application classpath, and you can fix a helper bug in your copy without waiting for a release. A packaged generator that must use the same copy takes `…`. What stays core: the loader and registry, render and extract, OMDB and `metaobjects-om`. `mvn metaobjects:verify` treats the copies as owned code, not stale output. The table, the aggregator rule and a diff recipe for pulling an upstream fix are in [Own your codegen → What eject hands over](../features/own-your-codegen.md#what-eject-hands-over-and-what-stays-core).
diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md
index 0f2a615e4..a50695c15 100644
--- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md
+++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md
@@ -159,7 +159,18 @@ separate `metaobjects-codegen-base` module instead.)
| `LlmTraceHelperGenerator` | `TraceHelper.java` per concrete entity — the LLM-trace helper |
| `SpringFilterAllowlistGenerator` | per-entity filter allowlist |
| `SpringNamesGenerator` | `Names.java` — `public abstract class` of `static final String` names that MIRRORS THE METADATA TREE. Every node carries its own `TYPE`/`SUB_TYPE`/`NAME`, so `AuthorNames.NAME` is the OBJECT's name (`"Author"`), **not** a table name, and a physical name sits under the member that says what it is: `SOURCE__TABLE` / `_VIEW` / `_MATERIALIZED_VIEW` / `_PROC` / `_FUNCTION`, from the metamodel's own `@kind`-to-alias map, with `` being `PRIMARY` or `REPLICA` (a write-through entity has a member for each). Plus a `_FIELD`/`_COLUMN` pair per field and a complete `COLUMNS_BY_FIELD`; an `identity.secondary` or `index.lookup` also carries `IDENTITY__INDEX` / `INDEX__INDEX`, while `identity.primary` deliberately carries no index member (migrate names a PK by a dialect-conditional formula this artifact must not restate). **There is no `READ_ONLY`** — it was a derivation over `@kind`, never metadata, so ask `SOURCE__KIND`. Emitted for every object with a declared or inherited primary source, PLUS a fragment for any abstract base such an object extends (columns only, no source). A class whose object extends another one **extends** it (`class CopayAuthNames extends AuthNames`) rather than restating its constants — Java inherits static members, so `CopayAuthNames.SOURCE_PRIMARY_TABLE` and `CopayAuthNames.ID_COLUMN` both resolve through the base. **This port generates no SQL** (the repository is an interface you implement), so nothing generated consumes these — they exist for your implementation. |
-| `JavaObjectCodeGenerator` | module `metaobjects-codegen-base` (`com.metaobjects.generator.direct.object.javacode`), a separate module from the Spring generators above. Flavor-selected via the `flavor` generator arg. `flavor=pojoAware` → `class extends PojoObject` (a concrete `MetaObjectAware` class with a `(MetaObject)` constructor) — its inherited `getMetaData()` back-reference is what breaks a default Jackson/Gson mapper, see "Serializing generated objects" below. `flavor=valueObject` → `class extends ValueObject` (map-backed; less hostile to a default mapper, but still not the sanctioned serialization path). Either concrete flavor also emits a `Extractor` plus a self-registering `ObjectClassBindingProvider`. For a plain default-Jackson-friendly type, use the `codegen-spring` record surface instead — never `pojoAware`. |
+| `JavaObjectCodeGenerator` | module `metaobjects-codegen-base` (`com.metaobjects.generator.direct.object.javacode`), a separate module from the Spring generators above. Flavor-selected via the `flavor` generator arg. `flavor=pojoAware` → `class extends PojoObject` (a concrete `MetaObjectAware` class with a `(MetaObject)` constructor) — its inherited `getMetaData()` back-reference is what breaks a default Jackson/Gson mapper, see "Serializing generated objects" below. `flavor=valueObject` → `class extends ValueObject` (map-backed; less hostile to a default mapper, but still not the sanctioned serialization path). Either concrete flavor also emits a self-registering `ObjectClassBindingProvider`, and a `Extractor` per concrete class unless `ExtractorCodeGenerator` is wired too. For a plain default-Jackson-friendly type, use the `codegen-spring` record surface instead — never `pojoAware`. |
+| `ExtractorCodeGenerator` | also in `metaobjects-codegen-base`. One `Extractor` per concrete object, the same files `JavaObjectCodeGenerator` writes when this is not wired. Wire it as its own `` only to own it or to give it different args: same `outputDir` and `pkgPrefix` / `pkgSuffix` / `namePrefix` / `nameSuffix` as the entity generator, which must run a concrete flavor in the same ``. The plugin then stops the entity generator writing them. |
+
+**Owning one of these two.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java`
+copies `JavaObjectCodeGenerator.java` and `ExtractorCodeGenerator.java` into the
+`codegen/` module (`-Dport` is required: both names are ejectable on both JVM ports).
+The owned entity generator holds the flavor selection, `createWriter`, the binding provider
+source and its `META-INF/services` registration. The class body is not in it: it comes
+from `JavaCodeWriter` / `PojoAwareCodeWriter` / `ValueObjectCodeWriter`, which stay in the
+package. To change a field, a constructor or an accessor, subclass the writer, override the
+`protected` step and return it from `createWriter`; do not copy the writers out. Every
+Java generator can be ejected except `template`.
**Projections (read-only views).** An `object.projection` (read-only `source.rdb`
`@kind: view` child) is served read-only through OMDB at the ObjectManager layer
diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md
index 0f2a615e4..a50695c15 100644
--- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md
+++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md
@@ -159,7 +159,18 @@ separate `metaobjects-codegen-base` module instead.)
| `LlmTraceHelperGenerator` | `TraceHelper.java` per concrete entity — the LLM-trace helper |
| `SpringFilterAllowlistGenerator` | per-entity filter allowlist |
| `SpringNamesGenerator` | `Names.java` — `public abstract class` of `static final String` names that MIRRORS THE METADATA TREE. Every node carries its own `TYPE`/`SUB_TYPE`/`NAME`, so `AuthorNames.NAME` is the OBJECT's name (`"Author"`), **not** a table name, and a physical name sits under the member that says what it is: `SOURCE__TABLE` / `_VIEW` / `_MATERIALIZED_VIEW` / `_PROC` / `_FUNCTION`, from the metamodel's own `@kind`-to-alias map, with `` being `PRIMARY` or `REPLICA` (a write-through entity has a member for each). Plus a `_FIELD`/`_COLUMN` pair per field and a complete `COLUMNS_BY_FIELD`; an `identity.secondary` or `index.lookup` also carries `IDENTITY__INDEX` / `INDEX__INDEX`, while `identity.primary` deliberately carries no index member (migrate names a PK by a dialect-conditional formula this artifact must not restate). **There is no `READ_ONLY`** — it was a derivation over `@kind`, never metadata, so ask `SOURCE__KIND`. Emitted for every object with a declared or inherited primary source, PLUS a fragment for any abstract base such an object extends (columns only, no source). A class whose object extends another one **extends** it (`class CopayAuthNames extends AuthNames`) rather than restating its constants — Java inherits static members, so `CopayAuthNames.SOURCE_PRIMARY_TABLE` and `CopayAuthNames.ID_COLUMN` both resolve through the base. **This port generates no SQL** (the repository is an interface you implement), so nothing generated consumes these — they exist for your implementation. |
-| `JavaObjectCodeGenerator` | module `metaobjects-codegen-base` (`com.metaobjects.generator.direct.object.javacode`), a separate module from the Spring generators above. Flavor-selected via the `flavor` generator arg. `flavor=pojoAware` → `class extends PojoObject` (a concrete `MetaObjectAware` class with a `(MetaObject)` constructor) — its inherited `getMetaData()` back-reference is what breaks a default Jackson/Gson mapper, see "Serializing generated objects" below. `flavor=valueObject` → `class extends ValueObject` (map-backed; less hostile to a default mapper, but still not the sanctioned serialization path). Either concrete flavor also emits a `Extractor` plus a self-registering `ObjectClassBindingProvider`. For a plain default-Jackson-friendly type, use the `codegen-spring` record surface instead — never `pojoAware`. |
+| `JavaObjectCodeGenerator` | module `metaobjects-codegen-base` (`com.metaobjects.generator.direct.object.javacode`), a separate module from the Spring generators above. Flavor-selected via the `flavor` generator arg. `flavor=pojoAware` → `class extends PojoObject` (a concrete `MetaObjectAware` class with a `(MetaObject)` constructor) — its inherited `getMetaData()` back-reference is what breaks a default Jackson/Gson mapper, see "Serializing generated objects" below. `flavor=valueObject` → `class extends ValueObject` (map-backed; less hostile to a default mapper, but still not the sanctioned serialization path). Either concrete flavor also emits a self-registering `ObjectClassBindingProvider`, and a `Extractor` per concrete class unless `ExtractorCodeGenerator` is wired too. For a plain default-Jackson-friendly type, use the `codegen-spring` record surface instead — never `pojoAware`. |
+| `ExtractorCodeGenerator` | also in `metaobjects-codegen-base`. One `Extractor` per concrete object, the same files `JavaObjectCodeGenerator` writes when this is not wired. Wire it as its own `` only to own it or to give it different args: same `outputDir` and `pkgPrefix` / `pkgSuffix` / `namePrefix` / `nameSuffix` as the entity generator, which must run a concrete flavor in the same ``. The plugin then stops the entity generator writing them. |
+
+**Owning one of these two.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java`
+copies `JavaObjectCodeGenerator.java` and `ExtractorCodeGenerator.java` into the
+`codegen/` module (`-Dport` is required: both names are ejectable on both JVM ports).
+The owned entity generator holds the flavor selection, `createWriter`, the binding provider
+source and its `META-INF/services` registration. The class body is not in it: it comes
+from `JavaCodeWriter` / `PojoAwareCodeWriter` / `ValueObjectCodeWriter`, which stay in the
+package. To change a field, a constructor or an accessor, subclass the writer, override the
+`protected` step and return it from `createWriter`; do not copy the writers out. Every
+Java generator can be ejected except `template`.
**Projections (read-only views).** An `object.projection` (read-only `source.rdb`
`@kind: view` child) is served read-only through OMDB at the ObjectManager layer
diff --git a/server/java/codegen-base/pom.xml b/server/java/codegen-base/pom.xml
index 7af0508bf..05f61039f 100644
--- a/server/java/codegen-base/pom.xml
+++ b/server/java/codegen-base/pom.xml
@@ -91,7 +91,45 @@
-
+
+
+
+ org.apache.maven.plugins
+ maven-resources-plugin
+
+
+ ship-eject-reference-source
+ generate-resources
+
+ copy-resources
+
+
+ ${project.build.outputDirectory}/META-INF/metaobjects/reference/java
+
+
+ src/main/java/com/metaobjects/generator/direct/object/javacode
+
+ JavaObjectCodeGenerator.java
+ ExtractorCodeGenerator.java
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/EmitsObjectExtractors.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/EmitsObjectExtractors.java
new file mode 100644
index 000000000..9ae279029
--- /dev/null
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/EmitsObjectExtractors.java
@@ -0,0 +1,51 @@
+package com.metaobjects.generator;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Marker: this generator EMITS the per-object {@code Extractor} classes, as a
+ * generator of its own in the run.
+ *
+ *
The Java {@code entity} generator has always written those classes itself, beside each
+ * concrete flavored class, and still does when nothing else in the run will. The marker
+ * exists so a RUN can answer the question {@code entity} cannot answer alone: is a
+ * separate extractor generator writing these files? When one is, {@code entity} leaves
+ * them to it. Without that, wiring an owned extractor next to {@code entity} put two
+ * emitters on one path: identical output hid the duplicate, and the first edit to the owned
+ * copy failed the build as an output path collision.
+ *
+ *
Same shape as {@link EmitsPhysicalNameConstants}: the Maven mojo builds the whole
+ * {@link Generator} list before it runs any of it, so that is where the answer is derived,
+ * and an explicit value in the pom always wins.
+ */
+public interface EmitsObjectExtractors {
+
+ /**
+ * The {@code entity} generator arg that says whether it writes the
+ * {@code Extractor} classes itself. Defaults to {@code true}, which is what it
+ * did before the extractor could be wired separately.
+ */
+ String ARG_EMIT_EXTRACTORS = "emitExtractors";
+
+ /** True iff some generator in {@code generators} emits the extractors on its own. */
+ static boolean includesExtractors(Collection extends Generator> generators) {
+ return generators.stream().anyMatch(g -> g instanceof EmitsObjectExtractors);
+ }
+
+ /**
+ * {@code args} with {@link #ARG_EMIT_EXTRACTORS} derived from the run's generator set.
+ *
+ *
An explicit value in {@code args} wins. Absent, it becomes {@code "false"} exactly
+ * when a separate extractor generator is in the run, and is left unset otherwise, so a
+ * run without one behaves as it always has.
+ */
+ static Map deriveEmitExtractors(Map args,
+ Collection extends Generator> generators) {
+ if (args.containsKey(ARG_EMIT_EXTRACTORS) || !includesExtractors(generators)) return args;
+ Map derived = new HashMap<>(args);
+ derived.put(ARG_EMIT_EXTRACTORS, Boolean.toString(false));
+ return derived;
+ }
+}
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/ExtractorCodeGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/ExtractorCodeGenerator.java
index 50833c3ea..2b54f285d 100644
--- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/ExtractorCodeGenerator.java
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/ExtractorCodeGenerator.java
@@ -1,9 +1,25 @@
package com.metaobjects.generator.direct.object.javacode;
+// Eject note (ADR-0034, JVM eject design): this sibling stays in
+// com.metaobjects.generator.direct.object.javacode when this generator is copied out via
+// `mvn metaobjects:eject` and its own package is renamed — an explicit import, not
+// same-package bare-name resolution, is what keeps the ejected copy compiling.
+import com.metaobjects.generator.direct.object.javacode.JavaCodeWriter;
+
+import com.metaobjects.generator.EmitsObjectExtractors;
+import com.metaobjects.generator.GeneratorBase;
+import com.metaobjects.generator.GeneratorException;
+import com.metaobjects.generator.direct.object.BaseObjectCodeGenerator;
import com.metaobjects.generator.util.GeneratedFileWriter;
+import com.metaobjects.generator.util.GeneratorUtil;
+import com.metaobjects.io.util.IOUtil;
+import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.object.MetaObject;
import java.io.File;
import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
/**
* Emits a generated {@code Extractor} — the Task-6 payoff that wraps the Phase-B
@@ -33,12 +49,22 @@
* flavored type. The {@code extractLenient(...)} overload widens through {@code ExtractionResult>}
* to expose the typed {@code ExtractionResult<>}.
*
- *
This is a plain emission helper (direct {@code StringBuilder} emit; no templates) driven
- * by {@link JavaObjectCodeGenerator#execute}. Its emission steps are {@code protected} so a
- * downstream generator can override one step (e.g. {@link #writeExtractMethod}) without
- * reimplementing the whole class.
+ *
Two ways to run it. Wired on its own as a {@code }, {@link #execute}
+ * writes one Extractor per concrete object, named by the same {@code pkgPrefix} /
+ * {@code pkgSuffix} / {@code namePrefix} / {@code nameSuffix} args the {@code entity}
+ * generator takes, so give both the same values. Left out of the run, the {@code entity}
+ * generator ({@link JavaObjectCodeGenerator}) calls {@link #emit} itself for each concrete
+ * flavored class, which is what it has always done. A run that holds both writes each file
+ * once: {@link EmitsObjectExtractors} is how {@code entity} knows to leave them to this.
+ *
+ *
An Extractor casts to the generated flavored class, so it needs the {@code entity}
+ * generator with a concrete {@code flavor} in the same run.
+ *
+ *
Emission is a direct {@code StringBuilder} emit (no templates). Its steps are
+ * {@code protected} so a subclass can override one (e.g. {@link #writeExtractMethod})
+ * without reimplementing the whole class.
*/
-public class ExtractorCodeGenerator {
+public class ExtractorCodeGenerator extends GeneratorBase implements EmitsObjectExtractors {
/** Fully-qualified name of the runtime extract entry point (emitted as a source FQN string). */
public static final String META_OBJECT_EXTRACT_FQN =
@@ -54,6 +80,46 @@ public class ExtractorCodeGenerator {
/** Suffix appended to the flavored class's simple name to form the Extractor class name. */
public static final String EXTRACTOR_SUFFIX = "Extractor";
+ /**
+ * Write one {@code Extractor} per concrete object the filters select. Abstract
+ * objects get none: {@code MetaObject.newInstance()} cannot build one, so an extractor
+ * for it could never return a typed graph.
+ */
+ @Override
+ public void execute(MetaDataLoader loader) {
+ if (!hasArg(ARG_OUTPUTDIR)) {
+ throw new GeneratorException(
+ "You must set a valid output directory with arg '" + ARG_OUTPUTDIR + "'");
+ }
+ File outputDir = getOutputDir();
+ JavaCodeWriter namer = createNamer(loader);
+ try {
+ for (MetaObject mo : GeneratorUtil.getFilteredMetaData(
+ loader, MetaObject.class, getMetaDataFilters())) {
+ if (IOUtil.isAbstract(mo)) {
+ continue;
+ }
+ emit(outputDir, namer.javaPackageOf(mo), namer.javaClassNameOf(mo), mo.getName());
+ }
+ } catch (IOException e) {
+ throw new GeneratorException("Unable to emit generated Extractor: " + e, e);
+ }
+ }
+
+ /**
+ * The writer whose naming hooks say which package and class each Extractor sits beside.
+ * It names; it never writes. Override it when your {@code entity} generator names its
+ * classes through a writer of your own, so both agree.
+ */
+ protected JavaCodeWriter createNamer(MetaDataLoader loader) {
+ JavaCodeWriter namer = new JavaCodeWriter(loader, new PrintWriter(new StringWriter()));
+ namer.withPkgPrefix(getArg(BaseObjectCodeGenerator.ARG_PKGPREFIX, ""))
+ .withPkgSuffix(getArg(BaseObjectCodeGenerator.ARG_PKGSUFFIX, ""))
+ .withNamePrefix(getArg(BaseObjectCodeGenerator.ARG_NAMEPREFIX, ""))
+ .withNameSuffix(getArg(BaseObjectCodeGenerator.ARG_NAMESUFFIX, ""));
+ return namer;
+ }
+
/**
* Emit {@code Extractor.java} into {@code outputDir} under the package directory
* derived from {@code javaPackage}.
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaCodeWriter.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaCodeWriter.java
index 606198783..e1d9d0be1 100644
--- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaCodeWriter.java
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaCodeWriter.java
@@ -218,4 +218,27 @@ protected String getLanguagePackage(MetaObject mo) {
protected String getLanguageNameAttribute() {
return ATTR_JAVANAME;
}
+
+ //////////////////////////////////////////////////////////////////////
+ // Naming, as a generator outside this package reads it
+
+ /**
+ * The Java package the type generated for {@code mo} lands in.
+ *
+ *
The public view of the {@code protected} {@link #getLanguagePackage} hook, so it
+ * answers with whatever a subclass overrode. A generator that has to name the generated
+ * type without writing it (the binding provider and the {@code Extractor} both do)
+ * asks here. Supported surface for an owned generator (ADR-0034 Amendment 6).
+ */
+ public String javaPackageOf(MetaObject mo) {
+ return getLanguagePackage(mo);
+ }
+
+ /**
+ * The simple name of the Java type generated for {@code mo}: the public view of the
+ * {@code protected} {@link #getClassName} hook. See {@link #javaPackageOf}.
+ */
+ public String javaClassNameOf(MetaObject mo) {
+ return getClassName(mo);
+ }
}
\ No newline at end of file
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java
index 6b73ce019..e296281ad 100644
--- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java
@@ -1,5 +1,16 @@
package com.metaobjects.generator.direct.object.javacode;
+// Eject note (ADR-0034, JVM eject design): these siblings stay in
+// com.metaobjects.generator.direct.object.javacode when this generator is copied out via
+// `mvn metaobjects:eject` and its own package is renamed — an explicit import, not
+// same-package bare-name resolution, is what keeps the ejected copy compiling.
+import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator;
+import com.metaobjects.generator.direct.object.javacode.JavaCodeGenerator;
+import com.metaobjects.generator.direct.object.javacode.JavaCodeWriter;
+import com.metaobjects.generator.direct.object.javacode.PojoAwareCodeWriter;
+import com.metaobjects.generator.direct.object.javacode.ValueObjectCodeWriter;
+
+import com.metaobjects.generator.EmitsObjectExtractors;
import com.metaobjects.generator.GeneratorException;
import com.metaobjects.generator.direct.GenerationContext;
import com.metaobjects.generator.direct.object.BaseObjectCodeWriter;
@@ -33,6 +44,16 @@
* emission loop (which already iterates EVERY {@code MetaObject} in the loader, so the
* nested sub-objects referenced via {@code @objectRef} are emitted as their own files) —
* is inherited unchanged from the legacy framework.
+ *
+ *
Owning this generator ({@code mvn metaobjects:eject -Dnames=entity -Dport=java},
+ * ADR-0034 Amendment 6). The copy is this one file, and what it holds is yours to change:
+ * which flavors exist and which writer each one gets ({@link #createWriter}), the
+ * {@code ObjectClassBindingProvider} source and its {@code META-INF/services} registration,
+ * and whether the Extractors are written here. The class BODY is not in this file. It
+ * comes from the flavor writers ({@link JavaCodeWriter}, {@link PojoAwareCodeWriter},
+ * {@link ValueObjectCodeWriter}), which stay in {@code metaobjects-codegen-base} as supported
+ * surface: to change a field, a constructor or an accessor, subclass a writer, override the
+ * {@code protected} step and return your writer from {@link #createWriter}.
*/
public class JavaObjectCodeGenerator extends JavaCodeGenerator {
@@ -48,6 +69,14 @@ public class JavaObjectCodeGenerator extends JavaCodeGenerator {
*/
public static final String FLAVOR_VALUE_OBJECT = "valueObject";
+ /**
+ * Generator argument: {@code false} stops this generator writing the
+ * {@code Extractor} classes. Default {@code true}. The Maven plugin sets it to
+ * {@code false} for you when a separate {@code extractor} generator is in the same
+ * {@code } list, so each file has one emitter.
+ */
+ public static final String ARG_EMIT_EXTRACTORS = EmitsObjectExtractors.ARG_EMIT_EXTRACTORS;
+
/** Read the {@code flavor} argument (empty = legacy interface flavor). */
public String getFlavor() {
return getArg(ARG_FLAVOR, "");
@@ -58,6 +87,11 @@ private boolean isConcreteFlavor() {
return FLAVOR_POJO_AWARE.equals(getFlavor()) || FLAVOR_VALUE_OBJECT.equals(getFlavor());
}
+ /** Whether this generator writes the Extractors itself (see {@link #ARG_EMIT_EXTRACTORS}). */
+ public boolean emitsExtractors() {
+ return Boolean.parseBoolean(getArg(ARG_EMIT_EXTRACTORS, "true"));
+ }
+
// FQN ("pkg::Name") of the generated provider class self-registered via ServiceLoader.
static final String PROVIDER_PACKAGE = "com.metaobjects.generated";
static final String PROVIDER_SIMPLE_NAME = "GeneratedObjectClassBindingProvider";
@@ -99,7 +133,9 @@ protected void parseArgs() {
* self-registering {@link ObjectClassBindingProvider} covering every generated object
* (keyed on the package-folded metadata resolution key {@code pkg::Name}, which is what
* {@link MetaObject#newInstance()} looks up), plus its {@code META-INF/services}
- * registration so the provider is discoverable via {@code ServiceLoader}.
+ * registration so the provider is discoverable via {@code ServiceLoader}. It also writes
+ * a {@code Extractor} beside each concrete class, unless
+ * {@link #ARG_EMIT_EXTRACTORS} says a separate extractor generator does.
*/
@Override
public void execute(MetaDataLoader loader) {
@@ -115,7 +151,7 @@ public void execute(MetaDataLoader loader) {
JavaCodeWriter namer = (JavaCodeWriter)
createWriter(loader, null, new PrintWriter(new java.io.StringWriter()), globalContext);
Map fqnToClass = new LinkedHashMap<>();
- ExtractorCodeGenerator extractorGen = new ExtractorCodeGenerator();
+ ExtractorCodeGenerator extractorGen = emitsExtractors() ? new ExtractorCodeGenerator() : null;
Collection metadata =
GeneratorUtil.getFilteredMetaData(loader, getFilterClass(), getMetaDataFilters());
try {
@@ -128,13 +164,15 @@ public void execute(MetaDataLoader loader) {
if (IOUtil.isAbstract(mo)) {
continue;
}
- String javaPkg = namer.getLanguagePackage(mo);
- String javaClass = namer.getClassName(mo);
+ String javaPkg = namer.javaPackageOf(mo);
+ String javaClass = namer.javaClassNameOf(mo);
String generatedFqn = isNotBlank(javaPkg) ? javaPkg + "." + javaClass : javaClass;
// Key on the package-folded resolution key — NOT the dotted Java FQN.
fqnToClass.put(mo.getName(), generatedFqn);
// Emit a Extractor alongside each CONCRETE flavored class.
- extractorGen.emit(getOutputDir(), javaPkg, javaClass, mo.getName());
+ if (extractorGen != null) {
+ extractorGen.emit(getOutputDir(), javaPkg, javaClass, mo.getName());
+ }
}
} catch (IOException e) {
throw new GeneratorException("Unable to emit generated Extractor: " + e, e);
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md
index 86a8c3590..bf147f02d 100644
--- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md
@@ -26,6 +26,11 @@ For each concrete flavor, the generator also emits, per object:
`orThrow`) and `static ExtractionResult<> extractLenient(MetaDataLoader, String)`
(never-throws), both wrapping the runtime `MetaObjectExtractor.extract`.
+The Extractors have a generator of their own as well, `ExtractorCodeGenerator`, wirable as
+a separate `` with the same `outputDir` and naming args. When one is in the run
+`JavaObjectCodeGenerator` leaves the files to it (`emitExtractors=false`, which the Maven
+plugin sets from the generator list; `EmitsObjectExtractors` is the marker it reads).
+
Abstract objects are skipped for both the binding entry and the Extractor (they cannot be
`newInstance()`-d — honors the abstract-codegen invariant: no instance artifacts for abstracts).
@@ -51,6 +56,27 @@ generator (override `createWriter` to return its writer). Proven by
`DownstreamCustomizationTest` (overrides `getGetterMethodName` → `fetch()` via a
`createWriter` override, compiled + asserted).
+## Owning the generators (`mvn metaobjects:eject`)
+
+`JavaObjectCodeGenerator` (`entity`) and `ExtractorCodeGenerator` (`extractor`) each eject
+as one file, package-renamed, into the adopter's `codegen/` module (ADR-0034 Amendment 6).
+This module's pom ships the two sources under `META-INF/metaobjects/reference/java/`.
+
+What an owned copy may rely on, and so what this package has to keep working for it:
+
+- the flavor writers `JavaCodeWriter`, `PojoAwareCodeWriter` and `ValueObjectCodeWriter`,
+ their public constructors and fluent options, and their `protected` emission steps;
+- `JavaCodeWriter.javaPackageOf(mo)` / `javaClassNameOf(mo)`, the public views of the
+ naming steps (a generator outside this package cannot call the `protected` ones);
+- `JavaCodeGenerator` and `BaseObjectCodeGenerator`, which the owned `entity` extends;
+- `ExtractorCodeGenerator.emit(...)`, which the owned `entity` calls.
+
+The writers are deliberately NOT part of the copy. Each generated class's header names the
+writer class that wrote it, so a writer copied into another package would change every
+generated file. Two rules keep a reference ejectable, and `EjectedBaseGeneratorsCompileTest`
+fails when either is broken: import a sibling class explicitly rather than by its bare
+name, and call nothing on another class that is not `public`.
+
## Deviation list (what is NOT reused as-is, and why)
The default is reuse; these are the accounted-for exceptions.
@@ -76,6 +102,12 @@ The default is reuse; these are the accounted-for exceptions.
`MetaObject` already in the loader, so the inherited flat multi-file loop emits every
referenced sub-object exactly once — no visited-set/cycle guard needed (the spec's
recursive-emission note is superseded by this simpler sound behavior).
+6. **`JavaCodeWriter.javaPackageOf` / `javaClassNameOf` (new, additive).** Public views of
+ the `protected` `getLanguagePackage` / `getClassName` steps, added so a generator in
+ another package (an ejected `JavaObjectCodeGenerator`) can name a generated type. The
+ steps themselves stay `protected`: widening them would break every subclass that
+ overrides one as `protected`. This is the one edit to the otherwise unchanged
+ `JavaCodeWriter`.
## Status / known gaps
diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java
new file mode 100644
index 000000000..d261b0096
--- /dev/null
+++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java
@@ -0,0 +1,96 @@
+package com.metaobjects.generator.direct.object;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import javax.tools.Diagnostic;
+import javax.tools.DiagnosticCollector;
+import javax.tools.JavaCompiler;
+import javax.tools.JavaFileObject;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.StandardLocation;
+import javax.tools.ToolProvider;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Eject proof (a) for the two generators this module ships a reference for: {@code entity}
+ * ({@code JavaObjectCodeGenerator}) and {@code extractor} ({@code ExtractorCodeGenerator}).
+ * Each compiles, package-renamed, against this module's PUBLISHED API and nothing else,
+ * which is what an adopter's {@code codegen/} module has after {@code mvn metaobjects:eject}.
+ *
+ *
The sibling of {@code EjectedGeneratorsCompileTest} in {@code codegen-spring}, and it
+ * reads {@code src/main/java} for the same reason: the eject mojo lives downstream of this
+ * module. If either reference starts using a member that is not public, or a same-package
+ * class by its bare name, this fails with the "cannot find symbol" an adopter's build would
+ * show. That is how {@code entity} failed before: it called two {@code protected} naming
+ * hooks on a writer it did not extend, which only compiles inside the writer's package.
It used to exist only as a step inside {@code entity}: no {@code } entry
+ * could name it, so it could not be owned or drift-checked apart from {@code entity}. These
+ * tests hold the three things that make it a generator in its own right: it writes the same
+ * files {@code entity} writes, {@code entity} can be told to leave them to it, and with
+ * nothing told, {@code entity} still writes them as it always has.
+ */
+public class StandaloneExtractorGeneratorTest {
+
+ private static final String PKG = "acme::shapes";
+
+ /** An abstract base, a concrete subtype, and an object nesting another. */
+ private static final String META = "{ \"metadata.root\": {"
+ + " \"package\": \"" + PKG + "\","
+ + " \"children\": ["
+ + " { \"object.value\": { \"name\": \"Shape\", \"abstract\": true, \"children\": ["
+ + " { \"field.string\": { \"name\": \"label\" } }"
+ + " ]}},"
+ + " { \"object.value\": { \"name\": \"Circle\", \"extends\": \"Shape\", \"children\": ["
+ + " { \"field.double\": { \"name\": \"radius\" } }"
+ + " ]}},"
+ + " { \"object.value\": { \"name\": \"Drawing\", \"children\": ["
+ + " { \"field.string\": { \"name\": \"title\" } },"
+ + " { \"field.object\": { \"name\": \"outline\", \"@objectRef\": \"" + PKG + "::Circle\" } }"
+ + " ]}}"
+ + " ]"
+ + "}}";
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ @After
+ public void resetRegistry() {
+ ObjectClassRegistry.resetGlobal();
+ }
+
+ private MetaDataLoader loadMeta() {
+ MetaDataLoader loader = new MetaDataLoader(
+ LoaderOptions.create(false, false, true),
+ MetaDataLoader.SUBTYPE_MANUAL, "standalone-extractor");
+ loader.init();
+ loader.load(List.of(new InMemoryStringSource(META, "standalone-extractor/meta.json")));
+ return loader;
+ }
+
+ private static Map entityArgs(Path out, String flavor) {
+ Map args = new HashMap<>();
+ args.put("outputDir", out.toString());
+ args.put("type", "class");
+ args.put("flavor", flavor);
+ return args;
+ }
+
+ private static void run(Generator generator, Map args, MetaDataLoader loader) {
+ generator.setArgs(args);
+ generator.execute(loader);
+ }
+
+ /** Every {@code *Extractor.java} under {@code root}, by path relative to it. */
+ private static Map extractors(Path root) throws IOException {
+ Map out = new TreeMap<>();
+ try (Stream walk = Files.walk(root)) {
+ for (Path p : (Iterable) walk.filter(Files::isRegularFile)::iterator) {
+ if (p.getFileName().toString().endsWith("Extractor.java")) {
+ out.put(root.relativize(p).toString().replace('\\', '/'),
+ Files.readString(p, StandardCharsets.UTF_8));
+ }
+ }
+ }
+ return out;
+ }
+
+ @Test
+ public void entityStillWritesTheExtractorsWhenNothingElseDoes() throws Exception {
+ Path out = tmp.newFolder("fused").toPath();
+ run(new JavaObjectCodeGenerator(), entityArgs(out, "pojoAware"), loadMeta());
+
+ assertEquals(List.of("acme/shapes/CircleExtractor.java", "acme/shapes/DrawingExtractor.java"),
+ List.copyOf(extractors(out).keySet()));
+ }
+
+ @Test
+ public void entityLeavesTheExtractorsAloneWhenToldASeparateGeneratorWritesThem() throws Exception {
+ Path out = tmp.newFolder("split").toPath();
+ Map args = entityArgs(out, "pojoAware");
+ args.put(JavaObjectCodeGenerator.ARG_EMIT_EXTRACTORS, "false");
+ run(new JavaObjectCodeGenerator(), args, loadMeta());
+
+ assertTrue("entity wrote extractors it was told to leave: " + extractors(out).keySet(),
+ extractors(out).isEmpty());
+ // The rest of entity's output is untouched by the arg.
+ assertTrue(Files.exists(out.resolve("acme/shapes/Circle.java")));
+ assertTrue(Files.exists(out.resolve(
+ "com/metaobjects/generated/GeneratedObjectClassBindingProvider.java")));
+ }
+
+ @Test
+ public void onItsOwnItWritesTheSameFilesEntityWrites() throws Exception {
+ Path fused = tmp.newFolder("fused").toPath();
+ run(new JavaObjectCodeGenerator(), entityArgs(fused, "valueObject"), loadMeta());
+
+ Path standalone = tmp.newFolder("standalone").toPath();
+ Map args = new HashMap<>();
+ args.put("outputDir", standalone.toString());
+ run(new ExtractorCodeGenerator(), args, loadMeta());
+
+ assertFalse(extractors(fused).isEmpty());
+ assertEquals(extractors(fused), extractors(standalone));
+ // And nothing but extractors: the abstract Shape gets none, and no class is written.
+ try (Stream walk = Files.walk(standalone)) {
+ assertEquals(2, walk.filter(Files::isRegularFile).count());
+ }
+ }
+
+ @Test
+ public void itNamesTheExtractorsByTheSameArgsEntityNamesItsClassesBy() throws Exception {
+ Map naming = new HashMap<>();
+ naming.put("pkgPrefix", "com.example");
+ naming.put("pkgSuffix", "model");
+ naming.put("namePrefix", "My");
+ naming.put("nameSuffix", "Vo");
+
+ Path fused = tmp.newFolder("fused").toPath();
+ Map entity = entityArgs(fused, "pojoAware");
+ entity.putAll(naming);
+ run(new JavaObjectCodeGenerator(), entity, loadMeta());
+
+ Path standalone = tmp.newFolder("standalone").toPath();
+ Map args = new HashMap<>(naming);
+ args.put("outputDir", standalone.toString());
+ run(new ExtractorCodeGenerator(), args, loadMeta());
+
+ assertTrue(extractors(standalone).containsKey(
+ "com/example/acme/shapes/model/MyCircleVoExtractor.java"));
+ assertEquals(extractors(fused), extractors(standalone));
+ }
+
+ @Test
+ public void itRefusesToRunWithNoOutputDirectory() {
+ try {
+ run(new ExtractorCodeGenerator(), new HashMap<>(), loadMeta());
+ fail("expected a GeneratorException naming the missing outputDir");
+ } catch (GeneratorException e) {
+ assertTrue(e.getMessage(), e.getMessage().contains("outputDir"));
+ }
+ }
+
+ @Test
+ public void aRunDerivesTheArgFromTheGeneratorsInIt() {
+ Map args = Map.of("outputDir", "out");
+
+ // No separate extractor in the run: the arg stays unset, so entity keeps its default.
+ assertFalse(EmitsObjectExtractors.deriveEmitExtractors(
+ args, List.of(new JavaObjectCodeGenerator()))
+ .containsKey(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS));
+
+ // A separate extractor in the run: entity is told to leave the files to it.
+ assertEquals("false", EmitsObjectExtractors.deriveEmitExtractors(
+ args, List.of(new JavaObjectCodeGenerator(), new ExtractorCodeGenerator()))
+ .get(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS));
+
+ // An explicit value in the pom wins over the derivation.
+ Map pinned = new HashMap<>(args);
+ pinned.put(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS, "true");
+ assertEquals("true", EmitsObjectExtractors.deriveEmitExtractors(
+ pinned, List.of(new JavaObjectCodeGenerator(), new ExtractorCodeGenerator()))
+ .get(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS));
+ }
+}
diff --git a/server/java/codegen-spring/pom.xml b/server/java/codegen-spring/pom.xml
index 0da498c86..a350c68fa 100644
--- a/server/java/codegen-spring/pom.xml
+++ b/server/java/codegen-spring/pom.xml
@@ -160,10 +160,10 @@
resource IS the source, one file, one owner. See
com.metaobjects.generator.GeneratorRegistry.EJECT_RESOURCE_ROOT.
- Deliberately excludes "extractor" (ExtractorCodeGenerator — fused into
- "entity", not separately wirable) and "template" (TemplateScopeGenerator —
- a generic declarative primitive with no emit logic to own); see
- GeneratorRegistry's javadoc on those two entries. -->
+ "entity" and "extractor" are not in this list because codegen-base owns
+ them: its pom ships their source the same way. "template"
+ (TemplateScopeGenerator) ships nowhere: it is a generic declarative
+ primitive with no emit logic to own; see GeneratorRegistry. -->
org.apache.maven.pluginsmaven-resources-plugin
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java
index deaa58c26..a20c1cae0 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java
@@ -93,6 +93,7 @@ public static final class GeneratorInfo {
private final Layer layer;
private final String ejectResourcePath;
private final List ejectRuntime;
+ private final String ejectNote;
public GeneratorInfo(String stableName, String classname, String description,
Tier tier, Layer layer) {
@@ -109,11 +110,9 @@ public GeneratorInfo(String stableName, String classname, String description,
* as shipped inside the {@code codegen-*} jar that owns it (e.g. {@code
* META-INF/metaobjects/reference/java/SpringNamesGenerator.java}), or {@code
* null} when this generator is not ejectable — {@code mvn metaobjects:eject}
- * reads this field to resolve {@code -Dnames}. See {@link
- * docs/superpowers/specs/2026-09-22-eject-in-every-port-design.md the JVM
- * eject design} for why some registered generators (the "extractor" that is
- * fused into "entity"; "template", a generic declarative primitive with no
- * emit logic to own) deliberately carry no eject source.
+ * reads this field to resolve {@code -Dnames}. One registered generator carries
+ * none: "template", the Mustache primitive, which has no emit logic to own
+ * (ADR-0034 Amendment 6).
* @param ejectRuntime simple names of the helper-runtime classes this generator's
* OUTPUT imports (from {@link #RUNTIME_PACKAGE}), closed over their own references
* to each other. {@code mvn metaobjects:eject} copies their source (shipped under
@@ -123,6 +122,18 @@ public GeneratorInfo(String stableName, String classname, String description,
public GeneratorInfo(String stableName, String classname, String description,
Tier tier, Layer layer, String ejectResourcePath,
List ejectRuntime) {
+ this(stableName, classname, description, tier, layer, ejectResourcePath,
+ ejectRuntime, null);
+ }
+
+ /**
+ * @param ejectNote wiring the generic eject report cannot know, printed after it;
+ * {@code null} for a generator whose only wiring is the {@code }
+ * change. {@code {owned}} in it is replaced by the owned copy's class name.
+ */
+ public GeneratorInfo(String stableName, String classname, String description,
+ Tier tier, Layer layer, String ejectResourcePath,
+ List ejectRuntime, String ejectNote) {
this.stableName = stableName;
this.classname = classname;
this.description = description;
@@ -130,6 +141,7 @@ public GeneratorInfo(String stableName, String classname, String description,
this.layer = layer;
this.ejectResourcePath = ejectResourcePath;
this.ejectRuntime = List.copyOf(ejectRuntime);
+ this.ejectNote = ejectNote;
}
/** Canonical cross-port stable name (the manifest key). */
@@ -169,6 +181,12 @@ public List ejectRuntime() {
return ejectRuntime;
}
+ /** Wiring to print after the generic eject report, or {@code null} (see the
+ * constructor). */
+ public String ejectNote() {
+ return ejectNote;
+ }
+
@Override
public String toString() {
return "GeneratorInfo{" + stableName + " -> " + classname
@@ -209,17 +227,14 @@ private static String ejectPath(Class> impl) {
private static Map buildRegistry() {
Map m = new LinkedHashMap<>();
- // "entity" stays registered but is NOT ejectable on Java: JavaObjectCodeGenerator
- // lives in codegen-base (not this module), extends a same-package superclass
- // (JavaCodeGenerator), and directly instantiates the FUSED, non-ejectable
- // ExtractorCodeGenerator plus other same-package writer helpers
- // (PojoAwareCodeWriter / ValueObjectCodeWriter). Ejecting it would mean widening a
- // whole internal writer hierarchy to public — a materially bigger surface than the
- // other ten generators, for a generator this port already documents as fused to a
- // non-ejectable neighbor. Kotlin's "entity" (KotlinEntityGenerator) has no such
- // entanglement and IS ejectable — see GeneratorRegistry.kt.
+ // "entity" and "extractor" live in codegen-base, which ships their reference source
+ // (its pom, the same execution as this module's). The owned copy of "entity" is the
+ // generator: flavor selection, the writer factory, the binding provider and its
+ // services file. The class body stays in codegen-base's flavor writers, which are
+ // supported surface an owned copy calls and subclasses (ADR-0034 Amendment 6).
register(m, "entity", JavaObjectCodeGenerator.class.getName(),
- "Per-entity Java model/class (table-backed or value object).", Tier.NATIVE, Layer.MODEL);
+ "Per-entity Java model/class (table-backed or value object).", Tier.NATIVE, Layer.MODEL,
+ ejectPath(JavaObjectCodeGenerator.class));
register(m, "routes", SpringControllerGenerator.class.getName(),
"Per-entity Spring @RestController endpoint surface.", Tier.NATIVE, Layer.API,
ejectPath(SpringControllerGenerator.class), ROUTES_RUNTIME);
@@ -236,13 +251,21 @@ private static Map buildRegistry() {
register(m, "render-helper", SpringRenderHelperGenerator.class.getName(),
"Per-renderable-template render helper (typed wrappers; document/email for a template.output, the document shape for a template.prompt).", Tier.NATIVE, Layer.CAPABILITY,
ejectPath(SpringRenderHelperGenerator.class));
- // "extractor" stays registered (discoverable, cross-port symmetric) but is NOT
- // ejectable: it is fused into "entity"'s own emission (JavaObjectCodeGenerator),
- // not a separately wirable generator with its own emit logic to own.
+ // "extractor" is a generator of its own AND a step "entity" runs when no separate
+ // one is in the run (EmitsObjectExtractors), so an existing pom that wires only
+ // "entity" emits what it always did. The note is what eject prints: unlike every
+ // other generator there may be no entry to change, only one to add.
register(m, "extractor", ExtractorCodeGenerator.class.getName(),
- "Per-template typed extract helper: tolerant recovery of the typed response from "
- + "dirty model text; throws only when a @required field is lost. FUSED into `entity` on this "
- + "port — emitted by JavaObjectCodeGenerator, not separately wirable.", Tier.NATIVE, Layer.CAPABILITY);
+ "Per-object typed Extractor: tolerant recovery of the typed, flavored object "
+ + "graph from dirty model text; throws only when a @required field is lost. `entity` "
+ + "writes these itself with a concrete flavor, unless this generator is in "
+ + " too.", Tier.NATIVE, Layer.CAPABILITY,
+ ejectPath(ExtractorCodeGenerator.class), List.of(),
+ "extractor: `entity` writes the Extractor classes itself unless a separate "
+ + "extractor generator is in . If yours has no entry for one, add "
+ + "{owned} with the same outputDir, "
+ + "pkgPrefix, pkgSuffix, namePrefix and nameSuffix args as your entity generator. "
+ + "`entity` then leaves those files to it.");
// "template" stays registered but is NOT ejectable: it is already a generic,
// declaratively-configured primitive (Mustache template + output pattern) with no
// emit LOGIC in it to own — you customize it via args/template-spec, not by editing
@@ -294,8 +317,16 @@ private static void register(Map m, String stableName,
private static void register(Map m, String stableName,
String classname, String description, Tier tier, Layer layer,
String ejectResourcePath, List ejectRuntime) {
+ register(m, stableName, classname, description, tier, layer, ejectResourcePath,
+ ejectRuntime, null);
+ }
+
+ private static void register(Map m, String stableName,
+ String classname, String description, Tier tier, Layer layer,
+ String ejectResourcePath, List ejectRuntime,
+ String ejectNote) {
if (m.put(stableName, new GeneratorInfo(stableName, classname, description, tier, layer,
- ejectResourcePath, ejectRuntime)) != null) {
+ ejectResourcePath, ejectRuntime, ejectNote)) != null) {
throw new IllegalStateException("duplicate generator stable name: " + stableName);
}
}
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/GeneratorRegistryConformanceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/GeneratorRegistryConformanceTest.java
index 4e2f3abba..5e66fba6f 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/GeneratorRegistryConformanceTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/GeneratorRegistryConformanceTest.java
@@ -47,17 +47,16 @@ public class GeneratorRegistryConformanceTest {
* {@link #everyRegisteredClassnameLoadsAndIsAWirableGenerator()} asserts the set matches
* EXACTLY, so a NEW one fails just as loudly as one that has quietly been fixed.
*
- *
{@code extractor} → {@code ExtractorCodeGenerator} is an emission HELPER driven by
- * {@code JavaObjectCodeGenerator.execute}: its output SHIPS whenever {@code entity} runs
- * — every non-abstract object gets an extractor — it is simply not independently wirable
- * in a {@code } entry the way the other four ports expose it. A fusion, not a
- * gap; {@code docs/ports/java.md} says so where an adopter will look.
+ *
Empty. {@code extractor} was the one member while {@code ExtractorCodeGenerator} was
+ * only a step inside {@code entity}; it is a generator of its own now, as on the other
+ * four ports, and {@code entity} still writes its files when no separate one is in the
+ * run.
*
*
Shared with {@code NoMagicPhysicalNamesTest}, which must skip exactly these rows
* when it builds a suite off the registry. ONE definition, so the two cannot drift into
* disagreeing about which rows are runnable.
*/
- public static final Set FUSED_NOT_WIRABLE = Set.of("extractor");
+ public static final Set NOT_WIRABLE = Set.of();
/** Walk up from the test's working dir to the repo root (the dir holding both {@code fixtures/} and {@code server/}). */
private static Path repoRoot() {
@@ -261,8 +260,8 @@ public void everyRegisteredClassnameLoadsAndIsAWirableGenerator() {
assertEquals("every registered classname must resolve on this port's classpath",
new TreeSet(), unloadable);
- assertEquals("registry rows that a entry cannot wire — see FUSED_NOT_WIRABLE",
- new TreeSet<>(FUSED_NOT_WIRABLE), notWirable);
+ assertEquals("registry rows that a entry cannot wire — see NOT_WIRABLE",
+ new TreeSet<>(NOT_WIRABLE), notWirable);
}
/**
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java
index 2ec311da3..97f8bcc00 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java
@@ -1,5 +1,6 @@
package com.metaobjects.generator.spring;
+import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator;
import com.metaobjects.generator.direct.object.javacode.JavaObjectCodeGenerator;
import com.metaobjects.generator.Generator;
import com.metaobjects.generator.util.GeneratedFileWriter;
@@ -143,6 +144,7 @@ private void generateAndCompile(String label, Map> s
private static Generator newGenerator(String stableName) {
switch (stableName) {
case "entity": return new JavaObjectCodeGenerator();
+ case "extractor": return new ExtractorCodeGenerator();
case "value-object": return new SpringValueObjectGenerator();
case "dto": return new SpringDtoGenerator();
case "repository": return new SpringRepositoryGenerator();
@@ -180,6 +182,23 @@ public void theJavaModelTierCompiles() throws Exception {
generateAndCompile("model", selection, 17);
}
+ /**
+ * The same model tier with the extractors written by the {@code extractor} generator
+ * instead of by {@code entity}. One emitter per file is what a pom holding both gets
+ * (the Maven plugin derives the arg), so it is what is compiled here: a standalone
+ * extractor that named a class {@code entity} did not emit would fail this compile.
+ */
+ @Test
+ public void theJavaModelTierCompilesWithTheExtractorsFromTheirOwnGenerator() throws Exception {
+ Map entityOnly = new LinkedHashMap<>(AS_CLASS);
+ entityOnly.put(JavaObjectCodeGenerator.ARG_EMIT_EXTRACTORS, "false");
+ Map> selection = new LinkedHashMap<>();
+ selection.put("entity", entityOnly);
+ selection.put("extractor", PLAIN);
+ selection.put("names", PLAIN);
+ generateAndCompile("model-split", selection, 17, "ProgramExtractor");
+ }
+
/**
* The Spring request/response tier: the DTO/Patch pair, the value-object records they
* bind, the repositories and the filter allowlists, including the read-only surface of
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/NoMagicPhysicalNamesTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/NoMagicPhysicalNamesTest.java
index 01d64937b..7a6f1fcbe 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/NoMagicPhysicalNamesTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/NoMagicPhysicalNamesTest.java
@@ -1,5 +1,6 @@
package com.metaobjects.generator.spring;
+import com.metaobjects.generator.EmitsObjectExtractors;
import com.metaobjects.generator.EmitsPhysicalNameConstants;
import com.metaobjects.generator.Generator;
import com.metaobjects.generator.GeneratorRegistry;
@@ -452,15 +453,17 @@ private Map generate() throws Exception {
}
// A registry entry that cannot be built as a Generator is skipped — but the SET of
// them is PINNED, because silently dropping entries is how a generator escapes this
- // gate. The pin is GeneratorRegistryConformanceTest.FUSED_NOT_WIRABLE, which owns
+ // gate. The pin is GeneratorRegistryConformanceTest.NOT_WIRABLE, which owns
// the question and states the reason; asserting it here too means this suite cannot
// quietly start covering less than that ruling says it does.
assertEquals("registry entries that are not Generators",
- new TreeSet<>(GeneratorRegistryConformanceTest.FUSED_NOT_WIRABLE), notGenerators);
+ new TreeSet<>(GeneratorRegistryConformanceTest.NOT_WIRABLE), notGenerators);
// Derive `useNames` from the suite through the SHIPPED helper — the identical call
// AbstractMetaDataMojo.buildGenerators makes. Re-implementing it here would leave
- // the gate measuring the test's own logic.
- Map runArgs = EmitsPhysicalNameConstants.deriveUseNames(args, suite);
+ // the gate measuring the test's own logic. The same goes for who writes the
+ // extractors: the registry holds both `entity` and `extractor`, as a pom may.
+ Map runArgs = EmitsObjectExtractors.deriveEmitExtractors(
+ EmitsPhysicalNameConstants.deriveUseNames(args, suite), suite);
for (Generator g : suite) {
g.setArgs(new HashMap<>(runArgs));
g.execute(loader);
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java
index da873f6d7..68dab1c96 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java
@@ -204,7 +204,6 @@ public void exactlyTheseGeneratorsCannotRunFromABareModel() throws Exception {
// Each is compared above on its error message alone, which proves nothing about its
// output. Pinned by name so a generator that starts throwing cannot drop out
// silently; the list may only shrink.
- // extractor not a Generator at all: fused into `entity`, which emits it
// template needs a `template` arg (no default template exists)
List threw = new ArrayList<>();
StringBuilder why = new StringBuilder();
@@ -216,7 +215,7 @@ public void exactlyTheseGeneratorsCannotRunFromABareModel() throws Exception {
}
}
threw.sort(null);
- assertEquals(why.toString(), List.of("extractor", "template"), threw);
+ assertEquals(why.toString(), List.of("template"), threw);
}
@Test
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
index 9d5ea6da2..96a6f3339 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
@@ -14,6 +14,7 @@
import com.metaobjects.loader.LoaderConfigurable;
import com.metaobjects.loader.LoaderConfigurationConstants;
import com.metaobjects.loader.LoaderOptions;
+import com.metaobjects.generator.EmitsObjectExtractors;
import com.metaobjects.generator.EmitsPhysicalNameConstants;
import com.metaobjects.loader.MetaDataLoader;
import org.apache.maven.plugin.AbstractMojo;
@@ -199,9 +200,16 @@ protected List buildGenerators(ClassLoader projectClassLoader,
// aggregation point that makes it safe to default from the run instead: the same
// decision TypeScript makes from its `emitsNames` generator marker and C# from
// GeneratorRegistry.IncludesNames. An explicit `` in the pom still wins.
+ //
+ // The same aggregation answers a second question for the Java `entity` generator: is
+ // a separate `extractor` generator in this run? `entity` writes the Extractor
+ // classes itself unless one is, so a run holding both has one emitter per file
+ // rather than two that agree until the owned one is edited.
for ( int i = 0; i < generatorImpls.size(); i++ ) {
+ Map derived =
+ EmitsPhysicalNameConstants.deriveUseNames( argsByGenerator.get(i), generatorImpls );
generatorImpls.get(i).setArgs(
- EmitsPhysicalNameConstants.deriveUseNames( argsByGenerator.get(i), generatorImpls ) );
+ EmitsObjectExtractors.deriveEmitExtractors( derived, generatorImpls ) );
}
return generatorImpls;
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/EjectSupport.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/EjectSupport.java
index 025845f7e..31957b410 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/EjectSupport.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/EjectSupport.java
@@ -62,6 +62,9 @@ public static final class Entry {
* eject copies alongside it (empty for every Kotlin generator — see
* {@link #allEntries()}). */
public final List runtime;
+ /** Wiring the generic eject report cannot know, with {@code {owned}} standing for the
+ * owned copy's class name, or {@code null} (see {@link #wiringNote}). */
+ public final String note;
public Entry(Port port, String stableName, String classname, String description, String resourcePath) {
this(port, stableName, classname, description, resourcePath, List.of());
@@ -69,12 +72,24 @@ public Entry(Port port, String stableName, String classname, String description,
public Entry(Port port, String stableName, String classname, String description,
String resourcePath, List runtime) {
+ this(port, stableName, classname, description, resourcePath, runtime, null);
+ }
+
+ public Entry(Port port, String stableName, String classname, String description,
+ String resourcePath, List runtime, String note) {
this.port = port;
this.stableName = stableName;
this.classname = classname;
this.description = description;
this.resourcePath = resourcePath;
this.runtime = List.copyOf(runtime);
+ this.note = note;
+ }
+
+ /** {@link #note} with the owned copy's class name filled in, or {@code null} when
+ * this generator's only wiring is the {@code } change. */
+ public String wiringNote(String ownedPackage) {
+ return note == null ? null : note.replace("{owned}", ownedPackage + "." + simpleName());
}
/** The simple class name — also the filename stem the ejected copy is written as. */
@@ -87,7 +102,7 @@ public String simpleName() {
/**
* Every generator BOTH registries declare, ejectable or not (an entry with a {@code
* null} {@link Entry#resourcePath} is registered but not ejectable — see {@code
- * GeneratorRegistry}'s "extractor"/"template" javadoc). Insertion order: Java first,
+ * GeneratorRegistry}'s "template" entry). Insertion order: Java first,
* then Kotlin, each in its own registry's declared order.
*
*
Only Java entries carry helper runtime. The Kotlin generators emit their filter
@@ -100,7 +115,7 @@ public static List allEntries() {
List out = new ArrayList<>();
for (GeneratorRegistry.GeneratorInfo info : GeneratorRegistry.list().values()) {
out.add(new Entry(Port.JAVA, info.stableName(), info.classname(), info.description(),
- info.ejectResourcePath(), info.ejectRuntime()));
+ info.ejectResourcePath(), info.ejectRuntime(), info.ejectNote()));
}
for (Map.Entry e : GeneratorRegistryKt.getGENERATOR_REGISTRY().entrySet()) {
GeneratorInfo info = e.getValue();
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataEjectMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataEjectMojo.java
index 58860dc62..72288a7ea 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataEjectMojo.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataEjectMojo.java
@@ -150,6 +150,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {
Path codegenRoot = project.getBasedir().toPath().resolve(CODEGEN_DIR);
Set portsTouched = new LinkedHashSet<>();
List classnameChanges = new ArrayList<>();
+ List wiringNotes = new ArrayList<>();
List entries = new ArrayList<>();
for (EjectSupport.Resolution r : resolved) entries.add(r.entry);
@@ -184,6 +185,8 @@ public void execute() throws MojoExecutionException, MojoFailureException {
reportEject(e, target, rewritten, write(pkgDir, target, rewritten, force));
classnameChanges.add(" " + e.stableName + ": " + e.classname + " -> "
+ pkg + "." + e.simpleName());
+ String note = e.wiringNote(pkg);
+ if (note != null) wiringNotes.add(note);
}
boolean pomWritten = writeCodegenPomIfNeeded(codegenRoot, pkg, portsTouched);
@@ -193,6 +196,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {
}
printWiring(pomWritten, classnameChanges);
+ for (String note : wiringNotes) getLog().info(note);
if (!runtime.isEmpty()) {
printRuntimeWiring(runtimeRoot, runtimePkg, requested, codegenRoot);
}
diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java
index fe0c23754..e1ec7073a 100644
--- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java
+++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java
@@ -34,46 +34,51 @@ public void everyEntryHasAPortAndAStableName() {
}
@Test
- public void javasFusedExtractorGenericTemplateAndEntangledEntityAreRegisteredButNotEjectable() {
- List all = EjectSupport.allEntries();
- boolean sawJavaExtractor = false, sawJavaTemplate = false, sawJavaEntity = false;
- for (EjectSupport.Entry e : all) {
- if (e.port == EjectSupport.Port.JAVA && e.stableName.equals("extractor")) {
- sawJavaExtractor = true;
- assertNull("extractor is fused into entity — not ejectable on Java", e.resourcePath);
- }
- if (e.port == EjectSupport.Port.JAVA && e.stableName.equals("template")) {
- sawJavaTemplate = true;
- assertNull("template is a generic declarative primitive — not ejectable", e.resourcePath);
- }
- if (e.port == EjectSupport.Port.JAVA && e.stableName.equals("entity")) {
- sawJavaEntity = true;
- assertNull("entity lives in codegen-base and is entangled with the fused "
- + "extractor plus a same-package writer hierarchy — not ejectable on Java",
- e.resourcePath);
- }
+ public void onJavaOnlyTheGenericTemplateIsRegisteredButNotEjectable() {
+ java.util.Set notEjectable = new java.util.TreeSet<>();
+ for (EjectSupport.Entry e : EjectSupport.allEntries()) {
+ if (e.port == EjectSupport.Port.JAVA && e.resourcePath == null) notEjectable.add(e.stableName);
}
- assertTrue(sawJavaExtractor);
- assertTrue(sawJavaTemplate);
- assertTrue(sawJavaEntity);
+ // PINNED: template is the Mustache primitive, with no emit logic to own. entity and
+ // extractor were the other two until ADR-0034 Amendment 6; a generator that joins
+ // this set has to be ruled on, not slipped in.
+ assertEquals(java.util.Set.of("template"), notEjectable);
}
@Test
- public void kotlinsEntityIsEjectableAndUnambiguousSinceJavasIsNot() {
+ public void entityAndExtractorAreEjectableOnBothPortsAndSoNeedAPort() {
Map> catalog = EjectSupport.ejectableByStableName();
- assertEquals(1, catalog.get("entity").size());
- EjectSupport.Resolution r = EjectSupport.resolve("entity", catalog, null, null);
- assertTrue(r.ok());
- assertEquals(EjectSupport.Port.KOTLIN, r.entry.port);
+ for (String name : List.of("entity", "extractor")) {
+ assertEquals(name, 2, catalog.get(name).size());
+ EjectSupport.Resolution noHint = EjectSupport.resolve(name, catalog, null, null);
+ assertFalse(name, noHint.ok());
+ assertTrue(noHint.error, noHint.error.contains("-Dport"));
+
+ EjectSupport.Resolution java = EjectSupport.resolve(name, catalog, EjectSupport.Port.JAVA, null);
+ assertTrue(name, java.ok());
+ assertEquals(EjectSupport.Port.JAVA, java.entry.port);
+ assertTrue(java.entry.classname,
+ java.entry.classname.startsWith("com.metaobjects.generator.direct.object.javacode."));
+
+ EjectSupport.Resolution kotlin = EjectSupport.resolve(name, catalog, EjectSupport.Port.KOTLIN, null);
+ assertTrue(name, kotlin.ok());
+ assertEquals(EjectSupport.Port.KOTLIN, kotlin.entry.port);
+ }
}
@Test
- public void kotlinsExtractorIsEjectable() {
- List all = EjectSupport.allEntries();
- boolean found = all.stream().anyMatch(e ->
- e.port == EjectSupport.Port.KOTLIN && e.stableName.equals("extractor")
- && e.resourcePath != null);
- assertTrue("Kotlin's extractor is a standalone generator, unlike Java's fused one", found);
+ public void onlyTheJavaExtractorCarriesAWiringNoteAndItNamesTheOwnedClass() {
+ for (EjectSupport.Entry e : EjectSupport.allEntries()) {
+ boolean javaExtractor = e.port == EjectSupport.Port.JAVA && e.stableName.equals("extractor");
+ if (!javaExtractor) {
+ assertNull(e.port.id + " " + e.stableName, e.wiringNote("com.acme.codegen"));
+ continue;
+ }
+ String note = e.wiringNote("com.acme.codegen");
+ assertTrue(note, note.contains(
+ "com.acme.codegen.ExtractorCodeGenerator"));
+ assertFalse(note, note.contains("{owned}"));
+ }
}
@Test
diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EmitExtractorsDerivationTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EmitExtractorsDerivationTest.java
new file mode 100644
index 000000000..9394a596f
--- /dev/null
+++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EmitExtractorsDerivationTest.java
@@ -0,0 +1,96 @@
+package com.metaobjects.mojo;
+
+import com.metaobjects.generator.EmitsObjectExtractors;
+import com.metaobjects.generator.Generator;
+import com.metaobjects.loader.MetaDataLoader;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+
+/**
+ * The mojo tells the Java {@code entity} generator when a separate {@code extractor}
+ * generator is in the run, so each {@code Extractor} has one emitter.
+ *
+ *
{@link UseNamesDerivationTest} holds the same seam for the names artifact, and for the
+ * same reason: each generator's own tests construct it directly and never reach
+ * {@link AbstractMetaDataMojo#buildGenerators}, so the derivation could be dropped there
+ * with nothing failing. What would fail is an adopter's build, on the first edit to an owned
+ * extractor, as an output path collision with the copy {@code entity} still wrote.
+ */
+public class EmitExtractorsDerivationTest {
+
+ /** Records the args the mojo hands it, so the derivation is observable. */
+ public static class RecordingGenerator implements Generator {
+ static final List
*
*
An Extractor casts to the generated flavored class, so it needs the {@code entity}
- * generator with a concrete {@code flavor} in the same run.
+ * generator with a concrete {@code flavor} in the same run, selecting the same objects: give
+ * both generators the same {@code }. An Extractor for an object {@code entity} wrote
+ * no class for does not compile.
*
*
Emission is a direct {@code StringBuilder} emit (no templates). Its steps are
* {@code protected} so a subclass can override one (e.g. {@link #writeExtractMethod})
diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/StandaloneExtractorGeneratorTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/StandaloneExtractorGeneratorTest.java
index 08b96b5b5..da0ff4b15 100644
--- a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/StandaloneExtractorGeneratorTest.java
+++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/StandaloneExtractorGeneratorTest.java
@@ -168,6 +168,26 @@ public void itNamesTheExtractorsByTheSameArgsEntityNamesItsClassesBy() throws Ex
assertEquals(extractors(fused), extractors(standalone));
}
+ @Test
+ public void givenEntitysFiltersItWritesAnExtractorForExactlyTheClassesEntityWrites() throws Exception {
+ List filters = List.of(PKG + "::Drawing");
+
+ Path fused = tmp.newFolder("fused").toPath();
+ JavaObjectCodeGenerator entity = new JavaObjectCodeGenerator();
+ entity.setFilters(filters);
+ run(entity, entityArgs(fused, "pojoAware"), loadMeta());
+
+ Path standalone = tmp.newFolder("standalone").toPath();
+ ExtractorCodeGenerator extractor = new ExtractorCodeGenerator();
+ extractor.setFilters(filters);
+ Map args = new HashMap<>();
+ args.put("outputDir", standalone.toString());
+ run(extractor, args, loadMeta());
+
+ assertEquals(List.of("acme/shapes/DrawingExtractor.java"), List.copyOf(extractors(standalone).keySet()));
+ assertEquals(extractors(fused), extractors(standalone));
+ }
+
@Test
public void itRefusesToRunWithNoOutputDirectory() {
try {
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java
index a20c1cae0..a0b63c46b 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java
@@ -264,8 +264,9 @@ private static Map buildRegistry() {
"extractor: `entity` writes the Extractor classes itself unless a separate "
+ "extractor generator is in . If yours has no entry for one, add "
+ "{owned} with the same outputDir, "
- + "pkgPrefix, pkgSuffix, namePrefix and nameSuffix args as your entity generator. "
- + "`entity` then leaves those files to it.");
+ + "pkgPrefix, pkgSuffix, namePrefix and nameSuffix args, and the same , as "
+ + "your entity generator: an extractor for an object entity wrote no class for does "
+ + "not compile. `entity` then leaves those files to it.");
// "template" stays registered but is NOT ejectable: it is already a generic,
// declaratively-configured primitive (Mustache template + output pattern) with no
// emit LOGIC in it to own — you customize it via args/template-spec, not by editing
diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java
index a5cf5b230..0a5123ce6 100644
--- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java
+++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java
@@ -107,6 +107,9 @@ private Map args(Path outDir) {
a.put("outputDir", outDir.toString());
a.put("packageName", "fitness");
a.put("templateRoot", canonical.resolve("prompts").toString());
+ // Required by the entity generator (JavaObjectCodeGenerator), ignored by the rest.
+ a.put("type", "class");
+ a.put("flavor", "pojoAware");
return a;
}
From 573cd61f68d39f2bf6814b3118509ef30b5da00c Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 11 Oct 2026 09:39:30 -0400
Subject: [PATCH 3/6] refactor(java): simplify pass on the ejectable entity and
extractor change
- The eject mojo reads each wiring note from the resolved entries when it
prints, instead of collecting them in a second list during the copy loop.
- EjectedBaseGeneratorsCompileTest compiles through JavaCompileTestSupport, the
module's shared compile helper, which also dumps the sources on failure.
- EmitExtractorsDerivationTest reuses UseNamesDerivationTest's recording
generator instead of carrying a copy of it.
- A literal "false" and an imported StringWriter; no output changes.
---
.../generator/EmitsObjectExtractors.java | 2 +-
.../javacode/JavaObjectCodeGenerator.java | 3 +-
.../EjectedBaseGeneratorsCompileTest.java | 42 +++++--------------
.../metaobjects/mojo/MetaDataEjectMojo.java | 9 ++--
.../mojo/EmitExtractorsDerivationTest.java | 31 +++++---------
5 files changed, 29 insertions(+), 58 deletions(-)
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/EmitsObjectExtractors.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/EmitsObjectExtractors.java
index 9ae279029..188fa88d1 100644
--- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/EmitsObjectExtractors.java
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/EmitsObjectExtractors.java
@@ -45,7 +45,7 @@ static Map deriveEmitExtractors(Map args,
Collection extends Generator> generators) {
if (args.containsKey(ARG_EMIT_EXTRACTORS) || !includesExtractors(generators)) return args;
Map derived = new HashMap<>(args);
- derived.put(ARG_EMIT_EXTRACTORS, Boolean.toString(false));
+ derived.put(ARG_EMIT_EXTRACTORS, "false");
return derived;
}
}
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java
index e296281ad..3ff95fdc7 100644
--- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java
@@ -24,6 +24,7 @@
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
+import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Collection;
@@ -149,7 +150,7 @@ public void execute(MetaDataLoader loader) {
// to its generated fully-qualified Java class name, reusing the SAME naming hooks
// the writer used so the binding targets the class that was actually emitted.
JavaCodeWriter namer = (JavaCodeWriter)
- createWriter(loader, null, new PrintWriter(new java.io.StringWriter()), globalContext);
+ createWriter(loader, null, new PrintWriter(new StringWriter()), globalContext);
Map fqnToClass = new LinkedHashMap<>();
ExtractorCodeGenerator extractorGen = emitsExtractors() ? new ExtractorCodeGenerator() : null;
Collection metadata =
diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java
index d261b0096..6f18a48b0 100644
--- a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java
+++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java
@@ -1,22 +1,14 @@
package com.metaobjects.generator.direct.object;
+import com.metaobjects.generator.util.JavaCompileTestSupport;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
-import javax.tools.Diagnostic;
-import javax.tools.DiagnosticCollector;
-import javax.tools.JavaCompiler;
-import javax.tools.JavaFileObject;
-import javax.tools.StandardJavaFileManager;
-import javax.tools.StandardLocation;
-import javax.tools.ToolProvider;
-import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -60,37 +52,23 @@ public void entityAndExtractorCompilePackageRenamed() throws IOException {
Path srcDir = Path.of("src/main/java/com/metaobjects/generator/direct/object/javacode");
assertTrue("expected " + srcDir.toAbsolutePath() + " to exist", Files.isDirectory(srcDir));
- Path outDir = tempFolder.newFolder("owned").toPath().resolve("com/acme/owned");
+ Path ownedRoot = tempFolder.newFolder("owned").toPath();
+ Path outDir = ownedRoot.resolve("com/acme/owned");
Files.createDirectories(outDir);
- List sources = new ArrayList<>();
for (String simpleName : EJECTABLE_SIMPLE_NAMES) {
Path original = srcDir.resolve(simpleName + ".java");
assertTrue("expected " + original + " to exist", Files.exists(original));
String rewritten = rewritePackage(
Files.readString(original, StandardCharsets.UTF_8), "com.acme.owned");
- Path copy = outDir.resolve(simpleName + ".java");
- Files.writeString(copy, rewritten, StandardCharsets.UTF_8);
- sources.add(copy.toFile());
+ Files.writeString(outDir.resolve(simpleName + ".java"), rewritten,
+ StandardCharsets.UTF_8);
}
- JavaCompiler javac = ToolProvider.getSystemJavaCompiler();
- DiagnosticCollector diagnostics = new DiagnosticCollector<>();
- Path classesOut = tempFolder.newFolder("classes").toPath();
- try (StandardJavaFileManager fm = javac.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)) {
- fm.setLocation(StandardLocation.CLASS_OUTPUT, List.of(classesOut.toFile()));
- Iterable extends JavaFileObject> units = fm.getJavaFileObjectsFromFiles(sources);
- List options = List.of("-classpath", System.getProperty("java.class.path"));
- boolean ok = javac.getTask(null, fm, diagnostics, options, null, units).call();
-
- StringBuilder errs = new StringBuilder();
- for (Diagnostic extends JavaFileObject> d : diagnostics.getDiagnostics()) {
- if (d.getKind() == Diagnostic.Kind.ERROR) {
- errs.append(d.toString()).append('\n');
- }
- }
- assertTrue("ejected+package-renamed generators failed to compile:\n" + errs,
- ok && errs.length() == 0);
- }
+ // In-process compile against this module's published API and the test classpath —
+ // exactly what an adopter's codegen/ module holds after eject. The shared
+ // codegen-base compile helper, which dumps the sources on failure.
+ JavaCompileTestSupport.compileGenerated(ownedRoot,
+ tempFolder.newFolder("classes").toPath());
}
}
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataEjectMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataEjectMojo.java
index 72288a7ea..5bba11059 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataEjectMojo.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataEjectMojo.java
@@ -150,7 +150,6 @@ public void execute() throws MojoExecutionException, MojoFailureException {
Path codegenRoot = project.getBasedir().toPath().resolve(CODEGEN_DIR);
Set portsTouched = new LinkedHashSet<>();
List classnameChanges = new ArrayList<>();
- List wiringNotes = new ArrayList<>();
List entries = new ArrayList<>();
for (EjectSupport.Resolution r : resolved) entries.add(r.entry);
@@ -185,8 +184,6 @@ public void execute() throws MojoExecutionException, MojoFailureException {
reportEject(e, target, rewritten, write(pkgDir, target, rewritten, force));
classnameChanges.add(" " + e.stableName + ": " + e.classname + " -> "
+ pkg + "." + e.simpleName());
- String note = e.wiringNote(pkg);
- if (note != null) wiringNotes.add(note);
}
boolean pomWritten = writeCodegenPomIfNeeded(codegenRoot, pkg, portsTouched);
@@ -196,7 +193,11 @@ public void execute() throws MojoExecutionException, MojoFailureException {
}
printWiring(pomWritten, classnameChanges);
- for (String note : wiringNotes) getLog().info(note);
+ // Wiring a generator's entry adds to the generic report, in the order requested.
+ for (EjectSupport.Resolution r : resolved) {
+ String note = r.entry.wiringNote(pkg);
+ if (note != null) getLog().info(note);
+ }
if (!runtime.isEmpty()) {
printRuntimeWiring(runtimeRoot, runtimePkg, requested, codegenRoot);
}
diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EmitExtractorsDerivationTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EmitExtractorsDerivationTest.java
index 9394a596f..f739c6e62 100644
--- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EmitExtractorsDerivationTest.java
+++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EmitExtractorsDerivationTest.java
@@ -1,12 +1,9 @@
package com.metaobjects.mojo;
import com.metaobjects.generator.EmitsObjectExtractors;
-import com.metaobjects.generator.Generator;
-import com.metaobjects.loader.MetaDataLoader;
import org.junit.Before;
import org.junit.Test;
-import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -26,25 +23,19 @@
*/
public class EmitExtractorsDerivationTest {
- /** Records the args the mojo hands it, so the derivation is observable. */
- public static class RecordingGenerator implements Generator {
- static final List> SEEN = new ArrayList<>();
-
- @Override public Generator setArgs(Map args) { SEEN.add(args); return this; }
- @Override public Generator setFilters(List filters) { return this; }
- @Override public Generator setScripts(List scripts) { return this; }
- @Override public void execute(MetaDataLoader loader) { }
- }
-
- /** The same, but declaring that it writes the extractors as a generator of its own. */
- public static class RecordingExtractorGenerator extends RecordingGenerator
+ /**
+ * Declares that it writes the extractors as a generator of its own. The arg recorder is
+ * the sibling names test's shared {@code RecordingGenerator} — same package, same seam,
+ * one recorder rather than a copy that can drift from the other.
+ */
+ public static class RecordingExtractorGenerator extends UseNamesDerivationTest.RecordingGenerator
implements EmitsObjectExtractors { }
private MetaDataGeneratorMojo mojo;
@Before
public void setUp() {
- RecordingGenerator.SEEN.clear();
+ UseNamesDerivationTest.RecordingGenerator.SEEN.clear();
mojo = new MetaDataGeneratorMojo();
mojo.setLoader(new LoaderParam());
}
@@ -59,13 +50,13 @@ private static GeneratorParam param(Class> impl, Map args) {
private List> run(GeneratorParam... params) {
mojo.setGenerators(List.of(params));
mojo.buildGenerators(getClass().getClassLoader(), null);
- return RecordingGenerator.SEEN;
+ return UseNamesDerivationTest.RecordingGenerator.SEEN;
}
@Test
public void a_separate_extractor_in_the_run_tells_every_generator_not_to_write_them() {
List> seen = run(
- param(RecordingGenerator.class, null),
+ param(UseNamesDerivationTest.RecordingGenerator.class, null),
param(RecordingExtractorGenerator.class, null));
assertEquals(2, seen.size());
@@ -76,7 +67,7 @@ public void a_separate_extractor_in_the_run_tells_every_generator_not_to_write_t
@Test
public void a_run_without_one_leaves_the_arg_unset_so_entity_writes_them_as_before() {
- List> seen = run(param(RecordingGenerator.class, null));
+ List> seen = run(param(UseNamesDerivationTest.RecordingGenerator.class, null));
assertEquals(1, seen.size());
assertFalse(seen.get(0).containsKey(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS));
@@ -88,7 +79,7 @@ public void an_explicit_pom_value_wins_over_the_derivation() {
pinned.put(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS, "true");
List> seen = run(
- param(RecordingGenerator.class, pinned),
+ param(UseNamesDerivationTest.RecordingGenerator.class, pinned),
param(RecordingExtractorGenerator.class, null));
assertEquals("true", seen.get(0).get(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS));
From 0e37a95bf42bbc5258a688986521d0f2bf5ee318 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 11 Oct 2026 10:07:17 -0400
Subject: [PATCH 4/6] no-mistakes(review): eject entity writers, deterministic
headers, single extractor emitter
Assisted-by: no-mistakes:claude:claude-opus-5-5
---
CHANGELOG.md | 60 ++++---
.../metaobjects-codegen/references/java.md | 19 ++-
docs/features/own-your-codegen.md | 73 ++++----
docs/ports/java.md | 7 +-
.../metaobjects-codegen/references/java.md | 19 ++-
.../metaobjects-codegen/references/java.md | 19 ++-
server/java/codegen-base/pom.xml | 23 ++-
.../generator/EmitsObjectExtractors.java | 13 +-
.../direct/object/BaseObjectCodeWriter.java | 2 -
.../javacode/ExtractorCodeGenerator.java | 13 +-
.../object/javacode/JavaCodeWriter.java | 2 +-
.../javacode/JavaObjectCodeGenerator.java | 48 +++---
.../javacode/README-flavored-objects.md | 56 +++---
.../EjectedBaseGeneratorsCompileTest.java | 83 ++++++---
.../StandaloneExtractorGeneratorTest.java | 7 +-
.../generator/GeneratorRegistry.java | 63 ++++++-
.../spring/CodegenCompileConformanceTest.java | 3 +-
.../generator/spring/ReportingInertTest.java | 7 +-
.../com/metaobjects/mojo/EjectSupport.java | 68 +++++++-
.../metaobjects/mojo/MetaDataEjectMojo.java | 78 ++++++---
.../metaobjects/mojo/EjectSupportTest.java | 28 +++
.../mojo/EmitExtractorsDerivationTest.java | 20 ++-
.../EntityExtractorEjectRoundTripTest.java | 161 ++++++++++++------
.../ADR-0034-codegen-scaffold-and-own.md | 104 ++++++-----
24 files changed, 664 insertions(+), 312 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 153cbbb75..190b22d16 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,35 +13,53 @@ here.**
### Added
- **The Java `entity` and `extractor` generators can be ejected.**
- `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java` copies
- `JavaObjectCodeGenerator.java` and `ExtractorCodeGenerator.java` into your `codegen/`
- module. They were the two Java generators that could not be owned: `entity` compiled only
- inside its writers' package, and `extractor` was a step inside `entity` that no
- `` entry could name. An unchanged copy of either emits what the packaged
- generator emits, `-Dlist` marks the copies `identical` or `DIFFERS`, and
- `mvn metaobjects:verify` runs them when your pom names them. Every Java generator is now
- ejectable except `template`. Ruling and reasoning: ADR-0034 Amendment 6.
- - **The owned `entity` is the generator, not the class body.** The copy holds the flavor
- selection, the writer factory (`createWriter`), the `ObjectClassBindingProvider` source
- and its `META-INF/services` registration. The class body stays in
- `metaobjects-codegen-base`'s flavor writers (`JavaCodeWriter`, `PojoAwareCodeWriter`,
- `ValueObjectCodeWriter`), which are now supported surface: subclass one, override a
- `protected` step and return it from `createWriter`. Details:
+ `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java` copies each generator, with
+ the rest of its emit, into your `codegen/` module. They were the two Java generators that
+ could not be owned: `entity`'s class body is emitted by writer classes outside the
+ generator, and `extractor` was a step inside `entity` that no `` entry could
+ name. Unchanged copies emit what the packaged generator emits, `-Dlist` marks them
+ `identical` or `DIFFERS`, and `mvn metaobjects:verify` runs them when your pom names them.
+ Every Java generator is now ejectable except `template`. Ruling and reasoning: ADR-0034
+ Amendment 6.
+ - **The owned `entity` includes the class body.** Eject copies seven files into one
+ package: `JavaObjectCodeGenerator`, the two bases it extends (`JavaCodeGenerator`,
+ `BaseObjectCodeGenerator`), and the four writers that emit each class
+ (`BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`,
+ `ValueObjectCodeWriter`). The copies use one another, never the packaged classes: where
+ one imports another, eject rewrites that import along with the package line. You change
+ a field, a constructor or an accessor by editing the writer, with nothing to subclass.
+ `-Dlist` gives `entity` one verdict over all seven files. Details:
[own-your-codegen](docs/features/own-your-codegen.md#java-entity-and-extractor).
- **`extractor` is a generator of its own (Java).** `ExtractorCodeGenerator` can be named
in ``, with the same `outputDir`, `pkgPrefix`, `pkgSuffix`, `namePrefix`,
`nameSuffix` and `` as your `entity` generator. When it is, the plugin sets
- `emitExtractors=false` on `entity`, so each `Extractor` has one emitter. Outside
- Maven, set that arg yourself.
- - **Nothing changes for a pom that wires only `entity`.** It still writes the extractors,
- and its output is byte for byte what it was.
- - New public API in `metaobjects-codegen-base`: `JavaCodeWriter.javaPackageOf(mo)` and
+ `emitExtractors=false` on `entity`, so each `Extractor` has one emitter; an
+ `emitExtractors=true` in the pom does not override that. Outside Maven, set the arg
+ yourself. Ejecting it also copies `JavaCodeWriter` and `BaseObjectCodeWriter`, where it
+ reads class names from. In the same package as an owned `entity`, the two share that
+ one copy.
+ - **A pom that wires only `entity` still writes the extractors.** Its class files change
+ once, in the header comment only: see **Changed** below.
+ - New public API: in `metaobjects-codegen-base`, `JavaCodeWriter.javaPackageOf(mo)` and
`javaClassNameOf(mo)` (the generated type's package and name, as a subclass's naming
- overrides leave them), the `EmitsObjectExtractors` marker, and
- `JavaObjectCodeGenerator.ARG_EMIT_EXTRACTORS`.
+ overrides leave them) and the `EmitsObjectExtractors` marker, whose
+ `ARG_EMIT_EXTRACTORS` names the `emitExtractors` arg; in `metaobjects-codegen-spring`,
+ `GeneratorRegistry.GeneratorInfo.ejectWith()`, the classes eject copies with a
+ generator.
### Changed
+- **Java classes written by `entity` lose two header lines, and `mvn metaobjects:verify` no
+ longer depends on the clock.** The header comment of every class `JavaObjectCodeGenerator`
+ writes carried `ObjectCodeWriter:` (the writer's class name) and `Generated On:` (the
+ wall-clock second of the run). Both lines are removed. **Expect a one-time diff in every
+ regenerated Java class file, in the header comment only.** The timestamp meant
+ `mvn metaobjects:verify`, which compares bytes, reported `[content-differs]` for every
+ class file unless it ran in the same second as the generate it checked. Generated output
+ now holds nothing that varies between runs, so regenerating an unchanged model writes the
+ same bytes and verify passes whenever it runs. The writer's class name is dropped, not
+ replaced by a stable name: it would differ between a packaged writer and an owned copy of
+ it, and dropping the line is the smaller change.
- **`mvn metaobjects:eject -Dnames=entity` and `-Dnames=extractor` need a port when the
project does not imply one.** Both names are now ejectable on Java and on Kotlin. A
project that declares exactly one of `metaobjects-codegen-spring` and
diff --git a/agent-context/skills/metaobjects-codegen/references/java.md b/agent-context/skills/metaobjects-codegen/references/java.md
index 0995d96a4..160a3e6b0 100644
--- a/agent-context/skills/metaobjects-codegen/references/java.md
+++ b/agent-context/skills/metaobjects-codegen/references/java.md
@@ -163,14 +163,17 @@ separate `metaobjects-codegen-base` module instead.)
| `ExtractorCodeGenerator` | also in `metaobjects-codegen-base`. One `Extractor` per concrete object, the same files `JavaObjectCodeGenerator` writes when this is not wired. Wire it as its own `` only to own it or to give it different args: same `outputDir`, `pkgPrefix` / `pkgSuffix` / `namePrefix` / `nameSuffix` and `` as the entity generator, which must run a concrete flavor in the same `` (an extractor for an object the entity generator wrote no class for does not compile). The plugin then stops the entity generator writing them. |
**Owning one of these two.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java`
-copies `JavaObjectCodeGenerator.java` and `ExtractorCodeGenerator.java` into the
-`codegen/` module (`-Dport` is required: both names are ejectable on both JVM ports).
-The owned entity generator holds the flavor selection, `createWriter`, the binding provider
-source and its `META-INF/services` registration. The class body is not in it: it comes
-from `JavaCodeWriter` / `PojoAwareCodeWriter` / `ValueObjectCodeWriter`, which stay in the
-package. To change a field, a constructor or an accessor, subclass the writer, override the
-`protected` step and return it from `createWriter`; do not copy the writers out. Every
-Java generator can be ejected except `template`.
+copies each generator into the `codegen/` module with the rest of its emit (`-Dport` is
+required: both names are ejectable on both JVM ports). For `entity` that is seven files in
+one package: `JavaObjectCodeGenerator` (flavor selection, `createWriter`, the binding
+provider source and its `META-INF/services` registration), the bases it extends
+(`JavaCodeGenerator`, `BaseObjectCodeGenerator`), and the writers that emit the class body
+(`BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter`).
+The copies use one another, not the packaged classes. To change a field, a constructor or
+an accessor, edit the owned writer; do not subclass a packaged one. `extractor` copies
+`ExtractorCodeGenerator` with `JavaCodeWriter` and `BaseObjectCodeWriter`, and shares them
+with `entity` when both are in one package. Every Java generator can be ejected except
+`template`.
**Projections (read-only views).** An `object.projection` (read-only `source.rdb`
`@kind: view` child) is served read-only through OMDB at the ObjectManager layer
diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md
index 0f2051843..3d7e97f1b 100644
--- a/docs/features/own-your-codegen.md
+++ b/docs/features/own-your-codegen.md
@@ -518,7 +518,9 @@ The JVM plugin loads generator classes from your project's classpath, but only f
classes compiled **before** `generate-sources` runs, and the plugin's own copy of a class
always wins over one with the same name. So eject puts each copy into a separate
`codegen/` Maven module, under your own package (`-Dpackage`, default
-`.codegen`). The package declaration is the only line it changes. On first use
+`.codegen`). The package declaration is the only line it changes, except for a
+generator that is copied with other classes (Java `entity` and `extractor`, below): there
+the imports those copies have of one another change too. On first use
it writes `codegen/pom.xml`, which depends on `metaobjects-codegen-spring` or
`metaobjects-codegen-kotlin` at the plugin's version.
@@ -546,33 +548,39 @@ Both names exist in both ports, so pass `-Dport`. Their reference source ships i
`metaobjects-codegen-base`, not `metaobjects-codegen-spring`; your `codegen/` module
reaches it through the same dependency.
-**`entity` copies `JavaObjectCodeGenerator.java`, and that one file is the generator.**
-What it holds is yours to change:
+**`entity` copies seven files, and together they are the generator.**
+`JavaObjectCodeGenerator` does not emit a class body itself. A writer does, one per
+flavor, so the writers come with it:
-- which flavors exist, and which writer each one gets (`createWriter`);
-- the `ObjectClassBindingProvider` source and its `META-INF/services` registration;
-- whether the `Extractor` classes are written here.
-
-The class body is not in that file. It comes from the flavor writers, which stay in
-`metaobjects-codegen-base` as supported surface for an owned copy to call and subclass:
-
-| Stays in the package | What an owned generator does with it |
+| Copied file | What it holds |
|---|---|
-| `JavaCodeWriter` (interface flavor), `PojoAwareCodeWriter`, `ValueObjectCodeWriter` | Builds one per file in `createWriter`. To change a field, a constructor or an accessor, subclass the writer, override the `protected` step (`writeFields`, `writeConstructor`, `writeGetter`, `writeSetter`, `getGetterMethodName`, ...) and return your subclass. |
-| `JavaCodeWriter.javaPackageOf(mo)` / `javaClassNameOf(mo)` | Names the generated type without writing it. They answer through the same naming steps a subclass overrides, so the binding provider and the extractors follow a rename. |
-| `JavaCodeGenerator`, `BaseObjectCodeGenerator` | The base your copy extends: argument parsing, and the one-file-per-object loop. |
-| `GeneratedFileWriter` | The write guard: a file with no `GENERATED` marker is left alone. |
-
-This is the same split the TypeScript prompt tier uses: the copy owns which objects get
-output, where it lands and how it is composed, and the body composer stays in the
-package. Copying the writers out as well would hand you the whole writer hierarchy to
-keep in step with the reference, when a change to the class body is one overridden step.
-
-**`extractor` copies `ExtractorCodeGenerator.java`, which holds all of its emit.** It is a
-generator of its own: add it to `` with the same `outputDir`, `pkgPrefix`,
-`pkgSuffix`, `namePrefix` and `nameSuffix` as your `entity` generator, and the same
-`` if that one has any. An extractor casts to the generated flavored class, so
-`entity` with a concrete `flavor` has to be in the same run and has to select the same
+| `JavaObjectCodeGenerator` | Which flavors exist and which writer each one gets (`createWriter`); the `ObjectClassBindingProvider` source and its `META-INF/services` registration; whether the `Extractor` classes are written here. |
+| `JavaCodeGenerator`, `BaseObjectCodeGenerator` | The generator's arguments, and the directory and file name each class lands in. |
+| `BaseObjectCodeWriter` | The class header comment, and the per-field loop every flavor shares. |
+| `JavaCodeWriter` | The Java type of each field; accessor, parameter, class and package names; the body of the interface flavor. |
+| `PojoAwareCodeWriter`, `ValueObjectCodeWriter` | The fields, the constructor and the accessor bodies of the two concrete flavors. |
+
+All seven land in one package and use one another, never the packaged classes: eject
+rewrites the package line of each and, where one imports another, that import. To change a
+field, a constructor or an accessor, edit the writer. You subclass nothing. `-Dlist` gives
+`entity` one verdict over all seven files.
+
+What stays in the package holds no emit: `MultiFileDirectGeneratorBase` (the
+one-file-per-object loop), `FileDirectWriter` (indentation and `println`) and
+`GeneratedFileWriter` (the write guard: a file with no `GENERATED` marker is left alone).
+The owned `entity` still calls the packaged `ExtractorCodeGenerator` to write the
+extractors, until you wire an `extractor` generator of your own.
+
+**`extractor` copies three files.** `ExtractorCodeGenerator.java` holds its emit.
+`JavaCodeWriter` and `BaseObjectCodeWriter` come with it because that is where it reads the
+package and class name of the type each extractor casts to. Eject both generators into the
+same package, which is the default, and they share the one `JavaCodeWriter`: a naming
+change made there moves the classes and their extractors together.
+
+`extractor` is a generator of its own: add it to `` with the same `outputDir`,
+`pkgPrefix`, `pkgSuffix`, `namePrefix` and `nameSuffix` as your `entity` generator, and the
+same `` if that one has any. An extractor casts to the generated flavored class,
+so `entity` with a concrete `flavor` has to be in the same run and has to select the same
objects: an extractor for an object `entity` wrote no class for does not compile.
```xml
@@ -584,9 +592,9 @@ objects: an extractor for an object `entity` wrote no class for does not compile
`entity` still writes the extractors itself when no separate `extractor` generator is in
the run, so a pom that wires only `entity` emits what it always did. When one is in
-``, the plugin tells `entity` to leave those files to it (it sets
-`emitExtractors` to `false`), so each file has one emitter. Outside Maven, set
-`emitExtractors=false` on `entity` yourself.
+``, the plugin tells `entity` to leave those files to it: it sets
+`emitExtractors` to `false`, whatever the pom says, so each file has one emitter. Outside
+Maven, set `emitExtractors=false` on `entity` yourself.
### What eject hands over, and what stays core
@@ -660,9 +668,10 @@ diff -u -I '^package ' -I '^// metaobjects:owned-runtime' \
Apply the hunks you want by hand. For a generator, diff `reference/java/.java`
against `codegen/src/main/java//.java` the same way, and also ignore
-`RUNTIME_PACKAGE`. `entity` and `extractor` are in `metaobjects-codegen-base-$V.jar`
-instead. Or, if you have no edits of your own (`-Dlist` says `identical`), re-eject with
-`-Dforce`.
+`RUNTIME_PACKAGE`. `entity` and `extractor`, and the classes copied with them, are in
+`metaobjects-codegen-base-$V.jar` instead; for those also pass `-I '^import '`, because
+eject pointed the copies' imports of one another at your package. Or, if you have no edits
+of your own (`-Dlist` says `identical`), re-eject with `-Dforce`.
`-Dport` is needed only when a name exists in both ports and your project's dependencies
do not say which. Every Kotlin generator is ejectable, and every Java generator except
diff --git a/docs/ports/java.md b/docs/ports/java.md
index e44fb02af..aee25b5b7 100644
--- a/docs/ports/java.md
+++ b/docs/ports/java.md
@@ -298,7 +298,8 @@ Every one is independently configurable; the typical starting set is
a `Extractor` beside every non-abstract class itself, as it always has. Add
`ExtractorCodeGenerator` as a `` of its own, which is how the other four ports
expose it, and that generator writes them instead: the plugin tells `entity` to leave the
-files to it (`emitExtractors=false`), so each one has a single emitter. You need the
+files to it (`emitExtractors=false`, whatever the pom says), so each one has a single
+emitter. You need the
separate entry only to own the extractor (`mvn metaobjects:eject -Dnames=extractor
-Dport=java`) or to run it with different args. Outside Maven, set `emitExtractors=false`
on `entity` yourself.
@@ -522,7 +523,9 @@ copy imports them. The model and the other ports' spelling are in
**Own a generator.** `mvn metaobjects:eject -Dnames=` copies a reference generator into a `codegen/` Maven module under your own package, and prints the module, dependency and `` to wire. See [Own your codegen → Java and Kotlin](../features/own-your-codegen.md#java-and-kotlin-mvn-metaobjectseject). Every generator in the table above can be ejected except `template`.
-**`entity` and `extractor` eject from `metaobjects-codegen-base`.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java` copies `JavaObjectCodeGenerator.java` and `ExtractorCodeGenerator.java` (pass `-Dport`: both names are ejectable on both JVM ports). The owned `entity` holds the flavor selection, the writer factory (`createWriter`), the `ObjectClassBindingProvider` source and its `META-INF/services` registration. The class body stays in the package's flavor writers (`JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter`), which are supported surface: subclass one, override a `protected` step and return it from `createWriter`. The owned `extractor` holds all of its emit. What stays in the package and why is in [Own your codegen → Java `entity` and `extractor`](../features/own-your-codegen.md#java-entity-and-extractor).
+**`entity` and `extractor` eject from `metaobjects-codegen-base`, each with the rest of its emit.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java` (pass `-Dport`: both names are ejectable on both JVM ports). `entity` copies seven files into one package: `JavaObjectCodeGenerator`, the bases it extends (`JavaCodeGenerator`, `BaseObjectCodeGenerator`) and the four writers that emit each class body (`BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter`). The copies use one another, never the packaged classes, so you change a field, a constructor or an accessor by editing the writer. `extractor` copies `ExtractorCodeGenerator` with `JavaCodeWriter` and `BaseObjectCodeWriter`, where it reads class names from; in the same package as `entity` the two share that one copy. The file-by-file table is in [Own your codegen → Java `entity` and `extractor`](../features/own-your-codegen.md#java-entity-and-extractor).
+
+**Class headers changed once, and `mvn metaobjects:verify` no longer depends on the clock.** The header comment of every class `entity` writes used to carry an `ObjectCodeWriter:` line (the writer's class name) and a `Generated On:` line (the wall-clock second of the run). Both are gone, so the first regeneration after upgrading changes every generated class file in its header comment and nowhere else. Because of the timestamp, `mvn metaobjects:verify` reported `[content-differs]` for every one of those files unless it ran in the same second as the generate it was checking. Output now holds nothing that varies between runs: regenerating an unchanged model writes the same bytes, and verify passes whenever it runs.
**Ejecting hands over the helper runtime too.** The `routes`, `dto` and `repository` output imports helper classes from `com.metaobjects.generator.spring.runtime` (`FilterParser`, `FilterParseResult`, `FilterPredicate`, `PatchValidationException`, `ConstraintErrors`, `RecordComponentNames`). These are helpers, not core. Ejecting one of those generators copies the classes its output imports into `src/main/java//` (default `.runtime`) of the module that compiles the generated code, and the owned generator's output imports that copy. The copies are JDK-only. The owned web tier then builds with no MetaObjects artifact on the application classpath, and you can fix a helper bug in your copy without waiting for a release. A packaged generator that must use the same copy takes `…`. What stays core: the loader and registry, render and extract, OMDB and `metaobjects-om`. `mvn metaobjects:verify` treats the copies as owned code, not stale output. The table, the aggregator rule and a diff recipe for pulling an upstream fix are in [Own your codegen → What eject hands over](../features/own-your-codegen.md#what-eject-hands-over-and-what-stays-core).
diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md
index 0995d96a4..160a3e6b0 100644
--- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md
+++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md
@@ -163,14 +163,17 @@ separate `metaobjects-codegen-base` module instead.)
| `ExtractorCodeGenerator` | also in `metaobjects-codegen-base`. One `Extractor` per concrete object, the same files `JavaObjectCodeGenerator` writes when this is not wired. Wire it as its own `` only to own it or to give it different args: same `outputDir`, `pkgPrefix` / `pkgSuffix` / `namePrefix` / `nameSuffix` and `` as the entity generator, which must run a concrete flavor in the same `` (an extractor for an object the entity generator wrote no class for does not compile). The plugin then stops the entity generator writing them. |
**Owning one of these two.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java`
-copies `JavaObjectCodeGenerator.java` and `ExtractorCodeGenerator.java` into the
-`codegen/` module (`-Dport` is required: both names are ejectable on both JVM ports).
-The owned entity generator holds the flavor selection, `createWriter`, the binding provider
-source and its `META-INF/services` registration. The class body is not in it: it comes
-from `JavaCodeWriter` / `PojoAwareCodeWriter` / `ValueObjectCodeWriter`, which stay in the
-package. To change a field, a constructor or an accessor, subclass the writer, override the
-`protected` step and return it from `createWriter`; do not copy the writers out. Every
-Java generator can be ejected except `template`.
+copies each generator into the `codegen/` module with the rest of its emit (`-Dport` is
+required: both names are ejectable on both JVM ports). For `entity` that is seven files in
+one package: `JavaObjectCodeGenerator` (flavor selection, `createWriter`, the binding
+provider source and its `META-INF/services` registration), the bases it extends
+(`JavaCodeGenerator`, `BaseObjectCodeGenerator`), and the writers that emit the class body
+(`BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter`).
+The copies use one another, not the packaged classes. To change a field, a constructor or
+an accessor, edit the owned writer; do not subclass a packaged one. `extractor` copies
+`ExtractorCodeGenerator` with `JavaCodeWriter` and `BaseObjectCodeWriter`, and shares them
+with `entity` when both are in one package. Every Java generator can be ejected except
+`template`.
**Projections (read-only views).** An `object.projection` (read-only `source.rdb`
`@kind: view` child) is served read-only through OMDB at the ObjectManager layer
diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md
index 0995d96a4..160a3e6b0 100644
--- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md
+++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md
@@ -163,14 +163,17 @@ separate `metaobjects-codegen-base` module instead.)
| `ExtractorCodeGenerator` | also in `metaobjects-codegen-base`. One `Extractor` per concrete object, the same files `JavaObjectCodeGenerator` writes when this is not wired. Wire it as its own `` only to own it or to give it different args: same `outputDir`, `pkgPrefix` / `pkgSuffix` / `namePrefix` / `nameSuffix` and `` as the entity generator, which must run a concrete flavor in the same `` (an extractor for an object the entity generator wrote no class for does not compile). The plugin then stops the entity generator writing them. |
**Owning one of these two.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java`
-copies `JavaObjectCodeGenerator.java` and `ExtractorCodeGenerator.java` into the
-`codegen/` module (`-Dport` is required: both names are ejectable on both JVM ports).
-The owned entity generator holds the flavor selection, `createWriter`, the binding provider
-source and its `META-INF/services` registration. The class body is not in it: it comes
-from `JavaCodeWriter` / `PojoAwareCodeWriter` / `ValueObjectCodeWriter`, which stay in the
-package. To change a field, a constructor or an accessor, subclass the writer, override the
-`protected` step and return it from `createWriter`; do not copy the writers out. Every
-Java generator can be ejected except `template`.
+copies each generator into the `codegen/` module with the rest of its emit (`-Dport` is
+required: both names are ejectable on both JVM ports). For `entity` that is seven files in
+one package: `JavaObjectCodeGenerator` (flavor selection, `createWriter`, the binding
+provider source and its `META-INF/services` registration), the bases it extends
+(`JavaCodeGenerator`, `BaseObjectCodeGenerator`), and the writers that emit the class body
+(`BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter`).
+The copies use one another, not the packaged classes. To change a field, a constructor or
+an accessor, edit the owned writer; do not subclass a packaged one. `extractor` copies
+`ExtractorCodeGenerator` with `JavaCodeWriter` and `BaseObjectCodeWriter`, and shares them
+with `entity` when both are in one package. Every Java generator can be ejected except
+`template`.
**Projections (read-only views).** An `object.projection` (read-only `source.rdb`
`@kind: view` child) is served read-only through OMDB at the ObjectManager layer
diff --git a/server/java/codegen-base/pom.xml b/server/java/codegen-base/pom.xml
index 05f61039f..7d585377d 100644
--- a/server/java/codegen-base/pom.xml
+++ b/server/java/codegen-base/pom.xml
@@ -99,11 +99,13 @@
Copied at BUILD TIME from the src/main/java files the compiler just compiled,
so the resource IS the source: one file, one owner, nothing to drift.
- These two are "entity" and "extractor". The flavor writers beside them
- (JavaCodeWriter, PojoAwareCodeWriter, ValueObjectCodeWriter) are NOT shipped:
- they are the supported surface an owned copy calls, not part of the copy
- (ADR-0034 Amendment 6). "template" (TemplateScopeGenerator) is not shipped
- either: it is the Mustache primitive and has no emit logic of its own. -->
+ These are "entity" (JavaObjectCodeGenerator) and "extractor"
+ (ExtractorCodeGenerator), and the rest of their emit, which eject copies
+ with them into the same owned package (GeneratorInfo.ejectWith(), ADR-0034
+ Amendment 6): the generator bases and the writers that emit the class body
+ for "entity", and the writer "extractor" reads its names from. "template"
+ (TemplateScopeGenerator) is not shipped: it is the Mustache primitive and
+ has no emit logic of its own. -->
org.apache.maven.pluginsmaven-resources-plugin
@@ -122,6 +124,17 @@
JavaObjectCodeGenerator.javaExtractorCodeGenerator.java
+ JavaCodeGenerator.java
+ JavaCodeWriter.java
+ PojoAwareCodeWriter.java
+ ValueObjectCodeWriter.java
+
+
+
+ src/main/java/com/metaobjects/generator/direct/object
+
+ BaseObjectCodeGenerator.java
+ BaseObjectCodeWriter.java
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/EmitsObjectExtractors.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/EmitsObjectExtractors.java
index 188fa88d1..fabd25c7c 100644
--- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/EmitsObjectExtractors.java
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/EmitsObjectExtractors.java
@@ -17,8 +17,9 @@
* copy failed the build as an output path collision.
*
*
Same shape as {@link EmitsPhysicalNameConstants}: the Maven mojo builds the whole
- * {@link Generator} list before it runs any of it, so that is where the answer is derived,
- * and an explicit value in the pom always wins.
+ * {@link Generator} list before it runs any of it, so that is where the answer is derived.
+ * Unlike {@code useNames} it is not a preference a pom can pin the other way: with a separate
+ * extractor generator in the run, {@code entity} writing them too is the duplicate.
*/
public interface EmitsObjectExtractors {
@@ -37,13 +38,13 @@ static boolean includesExtractors(Collection extends Generator> generators) {
/**
* {@code args} with {@link #ARG_EMIT_EXTRACTORS} derived from the run's generator set.
*
- *
An explicit value in {@code args} wins. Absent, it becomes {@code "false"} exactly
- * when a separate extractor generator is in the run, and is left unset otherwise, so a
- * run without one behaves as it always has.
+ *
It is {@code "false"} whenever a separate extractor generator is in the run,
+ * whatever {@code args} said, so each file has one emitter. Otherwise {@code args} is
+ * returned as it came, so a run without one behaves as it always has.
*/
static Map deriveEmitExtractors(Map args,
Collection extends Generator> generators) {
- if (args.containsKey(ARG_EMIT_EXTRACTORS) || !includesExtractors(generators)) return args;
+ if (!includesExtractors(generators)) return args;
Map derived = new HashMap<>(args);
derived.put(ARG_EMIT_EXTRACTORS, "false");
return derived;
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/BaseObjectCodeWriter.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/BaseObjectCodeWriter.java
index 65dadceb6..c722fdb4f 100644
--- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/BaseObjectCodeWriter.java
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/BaseObjectCodeWriter.java
@@ -218,11 +218,9 @@ public String writeObject(MetaObject mo) throws GeneratorIOException {
// Deleting this line is the documented gesture for taking ownership of the file.
List docs = Arrays.asList(
"GENERATED — regenerated from metadata. Delete this line to own this file.",
- "ObjectCodeWriter: " + getClass().getName(),
"MetaObject: " + mo.getName(),
"SuperObject: " + (superObject != null ? superObject.getName() : ""),
"MetaDataLoader: " + getLoader().toString(),
- "Generated On: " + (new Date()).toString(),
"Context: " + (context != null ? "initialized" : "null")
);
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/ExtractorCodeGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/ExtractorCodeGenerator.java
index 37af11051..5cf4fc1ba 100644
--- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/ExtractorCodeGenerator.java
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/ExtractorCodeGenerator.java
@@ -1,11 +1,8 @@
package com.metaobjects.generator.direct.object.javacode;
-// Eject note (ADR-0034, JVM eject design): this sibling stays in
-// com.metaobjects.generator.direct.object.javacode when this generator is copied out via
-// `mvn metaobjects:eject` and its own package is renamed — an explicit import, not
-// same-package bare-name resolution, is what keeps the ejected copy compiling.
-import com.metaobjects.generator.direct.object.javacode.JavaCodeWriter;
-
+// Eject note (ADR-0034 Amendment 6): `mvn metaobjects:eject -Dnames=extractor` copies this
+// file together with JavaCodeWriter and BaseObjectCodeWriter, into one package. JavaCodeWriter,
+// named bare below, is that copy: the names an owned extractor writes come from owned code.
import com.metaobjects.generator.EmitsObjectExtractors;
import com.metaobjects.generator.GeneratorBase;
import com.metaobjects.generator.GeneratorException;
@@ -110,8 +107,8 @@ loader, MetaObject.class, getMetaDataFilters())) {
/**
* The writer whose naming hooks say which package and class each Extractor sits beside.
- * It names; it never writes. Override it when your {@code entity} generator names its
- * classes through a writer of your own, so both agree.
+ * It names; it never writes. It has to be the writer your {@code entity} generator names
+ * its classes through, so both agree: ejected into one package, the two share one copy.
*/
protected JavaCodeWriter createNamer(MetaDataLoader loader) {
JavaCodeWriter namer = new JavaCodeWriter(loader, new PrintWriter(new StringWriter()));
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaCodeWriter.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaCodeWriter.java
index e1d9d0be1..9ed7ae276 100644
--- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaCodeWriter.java
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaCodeWriter.java
@@ -228,7 +228,7 @@ protected String getLanguageNameAttribute() {
*
The public view of the {@code protected} {@link #getLanguagePackage} hook, so it
* answers with whatever a subclass overrode. A generator that has to name the generated
* type without writing it (the binding provider and the {@code Extractor} both do)
- * asks here. Supported surface for an owned generator (ADR-0034 Amendment 6).
+ * asks here.
*/
public String javaPackageOf(MetaObject mo) {
return getLanguagePackage(mo);
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java
index 3ff95fdc7..bf48b372e 100644
--- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java
@@ -1,14 +1,11 @@
package com.metaobjects.generator.direct.object.javacode;
-// Eject note (ADR-0034, JVM eject design): these siblings stay in
-// com.metaobjects.generator.direct.object.javacode when this generator is copied out via
-// `mvn metaobjects:eject` and its own package is renamed — an explicit import, not
-// same-package bare-name resolution, is what keeps the ejected copy compiling.
+// Eject note (ADR-0034 Amendment 6): `mvn metaobjects:eject -Dnames=entity` copies this file
+// together with JavaCodeGenerator, BaseObjectCodeGenerator and the four writers, into one
+// package. The classes named bare below are those copies. ExtractorCodeGenerator is the
+// `extractor` generator and is NOT part of this copy, so it is imported by name: that is
+// what keeps the ejected copy compiling, and calling the packaged one.
import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator;
-import com.metaobjects.generator.direct.object.javacode.JavaCodeGenerator;
-import com.metaobjects.generator.direct.object.javacode.JavaCodeWriter;
-import com.metaobjects.generator.direct.object.javacode.PojoAwareCodeWriter;
-import com.metaobjects.generator.direct.object.javacode.ValueObjectCodeWriter;
import com.metaobjects.generator.EmitsObjectExtractors;
import com.metaobjects.generator.GeneratorException;
@@ -47,14 +44,14 @@
* is inherited unchanged from the legacy framework.
*
*
Owning this generator ({@code mvn metaobjects:eject -Dnames=entity -Dport=java},
- * ADR-0034 Amendment 6). The copy is this one file, and what it holds is yours to change:
- * which flavors exist and which writer each one gets ({@link #createWriter}), the
- * {@code ObjectClassBindingProvider} source and its {@code META-INF/services} registration,
- * and whether the Extractors are written here. The class BODY is not in this file. It
- * comes from the flavor writers ({@link JavaCodeWriter}, {@link PojoAwareCodeWriter},
- * {@link ValueObjectCodeWriter}), which stay in {@code metaobjects-codegen-base} as supported
- * surface: to change a field, a constructor or an accessor, subclass a writer, override the
- * {@code protected} step and return your writer from {@link #createWriter}.
+ * ADR-0034 Amendment 6). The copy is this file and the rest of its emit, in one package:
+ * {@link JavaCodeGenerator} and {@code BaseObjectCodeGenerator} (arguments, and where each
+ * file lands), and the writers that emit the class body ({@code BaseObjectCodeWriter},
+ * {@link JavaCodeWriter}, {@link PojoAwareCodeWriter}, {@link ValueObjectCodeWriter}). This
+ * file holds which flavors exist and which writer each one gets ({@link #createWriter}),
+ * the {@code ObjectClassBindingProvider} source and its {@code META-INF/services}
+ * registration, and whether the Extractors are written here. To change a field, a
+ * constructor or an accessor, edit the writer.
*/
public class JavaObjectCodeGenerator extends JavaCodeGenerator {
@@ -70,14 +67,6 @@ public class JavaObjectCodeGenerator extends JavaCodeGenerator {
*/
public static final String FLAVOR_VALUE_OBJECT = "valueObject";
- /**
- * Generator argument: {@code false} stops this generator writing the
- * {@code Extractor} classes. Default {@code true}. The Maven plugin sets it to
- * {@code false} for you when a separate {@code extractor} generator is in the same
- * {@code } list, so each file has one emitter.
- */
- public static final String ARG_EMIT_EXTRACTORS = EmitsObjectExtractors.ARG_EMIT_EXTRACTORS;
-
/** Read the {@code flavor} argument (empty = legacy interface flavor). */
public String getFlavor() {
return getArg(ARG_FLAVOR, "");
@@ -88,9 +77,14 @@ private boolean isConcreteFlavor() {
return FLAVOR_POJO_AWARE.equals(getFlavor()) || FLAVOR_VALUE_OBJECT.equals(getFlavor());
}
- /** Whether this generator writes the Extractors itself (see {@link #ARG_EMIT_EXTRACTORS}). */
+ /**
+ * Whether this generator writes the Extractors itself: the
+ * {@link EmitsObjectExtractors#ARG_EMIT_EXTRACTORS} arg, default {@code true}. The Maven
+ * plugin sets it to {@code false} when a separate {@code extractor} generator is in the
+ * same {@code } list, so each file has one emitter.
+ */
public boolean emitsExtractors() {
- return Boolean.parseBoolean(getArg(ARG_EMIT_EXTRACTORS, "true"));
+ return Boolean.parseBoolean(getArg(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS, "true"));
}
// FQN ("pkg::Name") of the generated provider class self-registered via ServiceLoader.
@@ -136,7 +130,7 @@ protected void parseArgs() {
* {@link MetaObject#newInstance()} looks up), plus its {@code META-INF/services}
* registration so the provider is discoverable via {@code ServiceLoader}. It also writes
* a {@code Extractor} beside each concrete class, unless
- * {@link #ARG_EMIT_EXTRACTORS} says a separate extractor generator does.
+ * {@link EmitsObjectExtractors#ARG_EMIT_EXTRACTORS} says a separate extractor generator does.
*/
@Override
public void execute(MetaDataLoader loader) {
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md
index bf147f02d..48583974c 100644
--- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md
@@ -58,24 +58,32 @@ generator (override `createWriter` to return its writer). Proven by
## Owning the generators (`mvn metaobjects:eject`)
-`JavaObjectCodeGenerator` (`entity`) and `ExtractorCodeGenerator` (`extractor`) each eject
-as one file, package-renamed, into the adopter's `codegen/` module (ADR-0034 Amendment 6).
-This module's pom ships the two sources under `META-INF/metaobjects/reference/java/`.
-
-What an owned copy may rely on, and so what this package has to keep working for it:
-
-- the flavor writers `JavaCodeWriter`, `PojoAwareCodeWriter` and `ValueObjectCodeWriter`,
- their public constructors and fluent options, and their `protected` emission steps;
-- `JavaCodeWriter.javaPackageOf(mo)` / `javaClassNameOf(mo)`, the public views of the
- naming steps (a generator outside this package cannot call the `protected` ones);
-- `JavaCodeGenerator` and `BaseObjectCodeGenerator`, which the owned `entity` extends;
-- `ExtractorCodeGenerator.emit(...)`, which the owned `entity` calls.
-
-The writers are deliberately NOT part of the copy. Each generated class's header names the
-writer class that wrote it, so a writer copied into another package would change every
-generated file. Two rules keep a reference ejectable, and `EjectedBaseGeneratorsCompileTest`
-fails when either is broken: import a sibling class explicitly rather than by its bare
-name, and call nothing on another class that is not `public`.
+`JavaObjectCodeGenerator` (`entity`) and `ExtractorCodeGenerator` (`extractor`) eject into
+the adopter's `codegen/` module, each with the rest of its emit (ADR-0034 Amendment 6).
+This module's pom ships the sources under `META-INF/metaobjects/reference/java/`, and
+`GeneratorRegistry` (in `codegen-spring`) declares which classes go with which generator.
+
+| Generator | Copied with it, into the same owned package |
+|---|---|
+| `entity` | `JavaCodeGenerator`, `BaseObjectCodeGenerator`, `BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter` |
+| `extractor` | `JavaCodeWriter`, `BaseObjectCodeWriter` |
+
+Eject rewrites each file's package line and, where one copied class imports another, that
+import. So the owned generator extends and calls the owned copies: an adopter changes the
+class body by editing a writer, not by subclassing one. What a copy may still rely on in
+this module, and so what has to keep working for it:
+
+- `MultiFileDirectGeneratorBase` and `FileDirectWriter`, which the copies extend;
+- `GenerationContext`, `GeneratorUtil`, `JavaIdentifiers` and `GeneratedFileWriter`;
+- `ExtractorCodeGenerator.emit(...)`, which an owned `entity` calls until an `extractor`
+ generator is wired beside it.
+
+Two rules keep a copy set ejectable, and `EjectedBaseGeneratorsCompileTest` fails when
+either is broken. A class in a copy set may name another class of this package by its bare
+name only if that class is in the same set; anything else (the owned `entity` naming
+`ExtractorCodeGenerator`) is imported explicitly. And a copied class calls nothing that is
+not `public` on a class left behind. A new class the writers come to depend on is added to
+the set in three places: this module's pom, `GeneratorRegistry`, and that test.
## Deviation list (what is NOT reused as-is, and why)
@@ -103,11 +111,13 @@ The default is reuse; these are the accounted-for exceptions.
referenced sub-object exactly once — no visited-set/cycle guard needed (the spec's
recursive-emission note is superseded by this simpler sound behavior).
6. **`JavaCodeWriter.javaPackageOf` / `javaClassNameOf` (new, additive).** Public views of
- the `protected` `getLanguagePackage` / `getClassName` steps, added so a generator in
- another package (an ejected `JavaObjectCodeGenerator`) can name a generated type. The
- steps themselves stay `protected`: widening them would break every subclass that
- overrides one as `protected`. This is the one edit to the otherwise unchanged
- `JavaCodeWriter`.
+ the `protected` `getLanguagePackage` / `getClassName` steps, so a generator can name a
+ generated type whichever package its writer is in. The steps themselves stay
+ `protected`: widening them would break every subclass that overrides one as
+ `protected`.
+7. **Class header (changed).** `BaseObjectCodeWriter` no longer writes the
+ `ObjectCodeWriter:` and `Generated On:` header lines. Generated output holds nothing
+ that varies between runs or between a packaged writer and an owned copy of it.
## Status / known gaps
diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java
index 6f18a48b0..cce3db6f2 100644
--- a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java
+++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java
@@ -18,50 +18,71 @@
/**
* Eject proof (a) for the two generators this module ships a reference for: {@code entity}
* ({@code JavaObjectCodeGenerator}) and {@code extractor} ({@code ExtractorCodeGenerator}).
- * Each compiles, package-renamed, against this module's PUBLISHED API and nothing else,
- * which is what an adopter's {@code codegen/} module has after {@code mvn metaobjects:eject}.
+ * Each one's copy set (the generator and the classes that hold the rest of its emit)
+ * compiles in ONE package of the adopter's, against this module's PUBLISHED API and nothing
+ * else, which is what a {@code codegen/} module has after {@code mvn metaobjects:eject}.
*
*
The sibling of {@code EjectedGeneratorsCompileTest} in {@code codegen-spring}, and it
* reads {@code src/main/java} for the same reason: the eject mojo lives downstream of this
- * module. If either reference starts using a member that is not public, or a same-package
- * class by its bare name, this fails with the "cannot find symbol" an adopter's build would
- * show. That is how {@code entity} failed before: it called two {@code protected} naming
- * hooks on a writer it did not extend, which only compiles inside the writer's package.
+ * module, where {@code EntityExtractorEjectRoundTripTest} runs the real one. A copy set is
+ * closed when this compiles: a class in it that names a same-package class left out of the
+ * set, or calls a member of one that is not public, fails here with the "cannot find
+ * symbol" an adopter's build would show.
*/
public class EjectedBaseGeneratorsCompileTest {
@Rule
public TemporaryFolder tempFolder = new TemporaryFolder();
- /** The simple names this module's pom ships under META-INF/metaobjects/reference/java. */
- private static final List EJECTABLE_SIMPLE_NAMES = List.of(
- "JavaObjectCodeGenerator",
- "ExtractorCodeGenerator"
- );
+ private static final String REFERENCE_PACKAGE = "com.metaobjects.generator.direct.object";
+ private static final String OWNED_PACKAGE = "com.acme.owned";
+
+ /** What eject copies for {@code entity}, as class names under {@link #REFERENCE_PACKAGE}. */
+ private static final List ENTITY = List.of(
+ "javacode.JavaObjectCodeGenerator",
+ "javacode.JavaCodeGenerator",
+ "BaseObjectCodeGenerator",
+ "BaseObjectCodeWriter",
+ "javacode.JavaCodeWriter",
+ "javacode.PojoAwareCodeWriter",
+ "javacode.ValueObjectCodeWriter");
+
+ /** What eject copies for {@code extractor}. */
+ private static final List EXTRACTOR = List.of(
+ "javacode.ExtractorCodeGenerator",
+ "javacode.JavaCodeWriter",
+ "BaseObjectCodeWriter");
private static final Pattern PACKAGE_LINE = Pattern.compile("(?m)^package\\s+[\\w.]+\\s*;\\s*$");
- private static String rewritePackage(String source, String newPackage) {
+ private static String simpleName(String className) {
+ return className.substring(className.lastIndexOf('.') + 1);
+ }
+
+ /** The two edits eject makes: the package line, and imports of the classes copied along. */
+ private static String owned(String source, List copySet) {
Matcher m = PACKAGE_LINE.matcher(source);
assertTrue("expected a package line", m.find());
- return new StringBuilder(source).replace(m.start(), m.end(), "package " + newPackage + ";").toString();
+ String out = new StringBuilder(source)
+ .replace(m.start(), m.end(), "package " + OWNED_PACKAGE + ";").toString();
+ for (String className : copySet) {
+ out = out.replace("import " + REFERENCE_PACKAGE + "." + className + ";",
+ "import " + OWNED_PACKAGE + "." + simpleName(className) + ";");
+ }
+ return out;
}
- @Test
- public void entityAndExtractorCompilePackageRenamed() throws IOException {
- Path srcDir = Path.of("src/main/java/com/metaobjects/generator/direct/object/javacode");
- assertTrue("expected " + srcDir.toAbsolutePath() + " to exist", Files.isDirectory(srcDir));
-
- Path ownedRoot = tempFolder.newFolder("owned").toPath();
- Path outDir = ownedRoot.resolve("com/acme/owned");
+ private void compilesInOnePackage(String label, List copySet) throws IOException {
+ Path srcRoot = Path.of("src/main/java", REFERENCE_PACKAGE.replace('.', '/'));
+ Path ownedRoot = tempFolder.newFolder(label).toPath();
+ Path outDir = ownedRoot.resolve(OWNED_PACKAGE.replace('.', '/'));
Files.createDirectories(outDir);
- for (String simpleName : EJECTABLE_SIMPLE_NAMES) {
- Path original = srcDir.resolve(simpleName + ".java");
+ for (String className : copySet) {
+ Path original = srcRoot.resolve(className.replace('.', '/') + ".java");
assertTrue("expected " + original + " to exist", Files.exists(original));
- String rewritten = rewritePackage(
- Files.readString(original, StandardCharsets.UTF_8), "com.acme.owned");
- Files.writeString(outDir.resolve(simpleName + ".java"), rewritten,
+ Files.writeString(outDir.resolve(simpleName(className) + ".java"),
+ owned(Files.readString(original, StandardCharsets.UTF_8), copySet),
StandardCharsets.UTF_8);
}
@@ -69,6 +90,16 @@ public void entityAndExtractorCompilePackageRenamed() throws IOException {
// exactly what an adopter's codegen/ module holds after eject. The shared
// codegen-base compile helper, which dumps the sources on failure.
JavaCompileTestSupport.compileGenerated(ownedRoot,
- tempFolder.newFolder("classes").toPath());
+ tempFolder.newFolder(label + "-classes").toPath());
+ }
+
+ @Test
+ public void entityCompilesWithItsWritersInOneOwnedPackage() throws IOException {
+ compilesInOnePackage("entity", ENTITY);
+ }
+
+ @Test
+ public void extractorCompilesWithItsNamingWriterInOneOwnedPackage() throws IOException {
+ compilesInOnePackage("extractor", EXTRACTOR);
}
}
diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/StandaloneExtractorGeneratorTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/StandaloneExtractorGeneratorTest.java
index da0ff4b15..8a8ab7696 100644
--- a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/StandaloneExtractorGeneratorTest.java
+++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/StandaloneExtractorGeneratorTest.java
@@ -116,7 +116,7 @@ public void entityStillWritesTheExtractorsWhenNothingElseDoes() throws Exception
public void entityLeavesTheExtractorsAloneWhenToldASeparateGeneratorWritesThem() throws Exception {
Path out = tmp.newFolder("split").toPath();
Map args = entityArgs(out, "pojoAware");
- args.put(JavaObjectCodeGenerator.ARG_EMIT_EXTRACTORS, "false");
+ args.put(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS, "false");
run(new JavaObjectCodeGenerator(), args, loadMeta());
assertTrue("entity wrote extractors it was told to leave: " + extractors(out).keySet(),
@@ -212,10 +212,11 @@ public void aRunDerivesTheArgFromTheGeneratorsInIt() {
args, List.of(new JavaObjectCodeGenerator(), new ExtractorCodeGenerator()))
.get(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS));
- // An explicit value in the pom wins over the derivation.
+ // An explicit "true" does not bring the second emitter back: with a separate
+ // extractor in the run, entity writing them too is the duplicate.
Map pinned = new HashMap<>(args);
pinned.put(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS, "true");
- assertEquals("true", EmitsObjectExtractors.deriveEmitExtractors(
+ assertEquals("false", EmitsObjectExtractors.deriveEmitExtractors(
pinned, List.of(new JavaObjectCodeGenerator(), new ExtractorCodeGenerator()))
.get(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS));
}
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java
index a0b63c46b..5e52ac2ce 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java
@@ -1,7 +1,13 @@
package com.metaobjects.generator;
+import com.metaobjects.generator.direct.object.BaseObjectCodeGenerator;
+import com.metaobjects.generator.direct.object.BaseObjectCodeWriter;
import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator;
+import com.metaobjects.generator.direct.object.javacode.JavaCodeGenerator;
+import com.metaobjects.generator.direct.object.javacode.JavaCodeWriter;
import com.metaobjects.generator.direct.object.javacode.JavaObjectCodeGenerator;
+import com.metaobjects.generator.direct.object.javacode.PojoAwareCodeWriter;
+import com.metaobjects.generator.direct.object.javacode.ValueObjectCodeWriter;
import com.metaobjects.generator.spring.JUnitRequirementTestsGenerator;
import com.metaobjects.generator.spring.LlmTraceHelperGenerator;
import com.metaobjects.generator.spring.SpringControllerGenerator;
@@ -94,6 +100,7 @@ public static final class GeneratorInfo {
private final String ejectResourcePath;
private final List ejectRuntime;
private final String ejectNote;
+ private final List ejectWith;
public GeneratorInfo(String stableName, String classname, String description,
Tier tier, Layer layer) {
@@ -134,6 +141,22 @@ public GeneratorInfo(String stableName, String classname, String description,
public GeneratorInfo(String stableName, String classname, String description,
Tier tier, Layer layer, String ejectResourcePath,
List ejectRuntime, String ejectNote) {
+ this(stableName, classname, description, tier, layer, ejectResourcePath,
+ ejectRuntime, ejectNote, List.of());
+ }
+
+ /**
+ * @param ejectWith fully-qualified names of the classes that hold the rest of this
+ * GENERATOR's emit (its bases and writers), closed over their references to each
+ * other. {@code mvn metaobjects:eject} copies each one's source (shipped at
+ * {@link #EJECT_RESOURCE_ROOT}{@code .java}) into the same owned
+ * package as the generator and points their imports of one another at the copies,
+ * so an owned generator extends and calls owned code. Not {@code ejectRuntime}:
+ * that is what the generated OUTPUT imports.
+ */
+ public GeneratorInfo(String stableName, String classname, String description,
+ Tier tier, Layer layer, String ejectResourcePath,
+ List ejectRuntime, String ejectNote, List ejectWith) {
this.stableName = stableName;
this.classname = classname;
this.description = description;
@@ -142,6 +165,7 @@ public GeneratorInfo(String stableName, String classname, String description,
this.ejectResourcePath = ejectResourcePath;
this.ejectRuntime = List.copyOf(ejectRuntime);
this.ejectNote = ejectNote;
+ this.ejectWith = List.copyOf(ejectWith);
}
/** Canonical cross-port stable name (the manifest key). */
@@ -187,6 +211,12 @@ public String ejectNote() {
return ejectNote;
}
+ /** Fully-qualified names of the classes copied with this generator into its owned
+ * package, the rest of its emit (see the constructor). */
+ public List ejectWith() {
+ return ejectWith;
+ }
+
@Override
public String toString() {
return "GeneratorInfo{" + stableName + " -> " + classname
@@ -201,6 +231,17 @@ public String toString() {
"ConstraintErrors", "FilterParseResult", "FilterParser", "FilterPredicate",
"PatchValidationException", "RecordComponentNames");
+ /** The writer a generated type's package and class name are read from, and its base. */
+ private static final List OBJECT_NAMING = List.of(
+ JavaCodeWriter.class.getName(), BaseObjectCodeWriter.class.getName());
+
+ /** The rest of "entity"'s emit: the bases that parse its arguments and place each file,
+ * and the writers that emit the class body, one per flavor. */
+ private static final List ENTITY_EMIT = List.of(
+ JavaCodeGenerator.class.getName(), BaseObjectCodeGenerator.class.getName(),
+ BaseObjectCodeWriter.class.getName(), JavaCodeWriter.class.getName(),
+ PojoAwareCodeWriter.class.getName(), ValueObjectCodeWriter.class.getName());
+
private static final Map REGISTRY = buildRegistry();
/** Classpath root every ejectable Java generator's reference source is shipped under
@@ -228,13 +269,12 @@ private static String ejectPath(Class> impl) {
private static Map buildRegistry() {
Map m = new LinkedHashMap<>();
// "entity" and "extractor" live in codegen-base, which ships their reference source
- // (its pom, the same execution as this module's). The owned copy of "entity" is the
- // generator: flavor selection, the writer factory, the binding provider and its
- // services file. The class body stays in codegen-base's flavor writers, which are
- // supported surface an owned copy calls and subclasses (ADR-0034 Amendment 6).
+ // (its pom, the same execution as this module's). The class body "entity" writes is
+ // emitted by its writers, so the owned copy is the generator AND those writers, with
+ // the two bases between it and the file loop (ADR-0034 Amendment 6).
register(m, "entity", JavaObjectCodeGenerator.class.getName(),
"Per-entity Java model/class (table-backed or value object).", Tier.NATIVE, Layer.MODEL,
- ejectPath(JavaObjectCodeGenerator.class));
+ ejectPath(JavaObjectCodeGenerator.class), List.of(), null, ENTITY_EMIT);
register(m, "routes", SpringControllerGenerator.class.getName(),
"Per-entity Spring @RestController endpoint surface.", Tier.NATIVE, Layer.API,
ejectPath(SpringControllerGenerator.class), ROUTES_RUNTIME);
@@ -266,7 +306,8 @@ private static Map buildRegistry() {
+ "{owned} with the same outputDir, "
+ "pkgPrefix, pkgSuffix, namePrefix and nameSuffix args, and the same , as "
+ "your entity generator: an extractor for an object entity wrote no class for does "
- + "not compile. `entity` then leaves those files to it.");
+ + "not compile. `entity` then leaves those files to it.",
+ OBJECT_NAMING);
// "template" stays registered but is NOT ejectable: it is already a generic,
// declaratively-configured primitive (Mustache template + output pattern) with no
// emit LOGIC in it to own — you customize it via args/template-spec, not by editing
@@ -326,8 +367,16 @@ private static void register(Map m, String stableName,
String classname, String description, Tier tier, Layer layer,
String ejectResourcePath, List ejectRuntime,
String ejectNote) {
+ register(m, stableName, classname, description, tier, layer, ejectResourcePath,
+ ejectRuntime, ejectNote, List.of());
+ }
+
+ private static void register(Map m, String stableName,
+ String classname, String description, Tier tier, Layer layer,
+ String ejectResourcePath, List ejectRuntime,
+ String ejectNote, List ejectWith) {
if (m.put(stableName, new GeneratorInfo(stableName, classname, description, tier, layer,
- ejectResourcePath, ejectRuntime, ejectNote)) != null) {
+ ejectResourcePath, ejectRuntime, ejectNote, ejectWith)) != null) {
throw new IllegalStateException("duplicate generator stable name: " + stableName);
}
}
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java
index 97f8bcc00..c5f693f39 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java
@@ -1,5 +1,6 @@
package com.metaobjects.generator.spring;
+import com.metaobjects.generator.EmitsObjectExtractors;
import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator;
import com.metaobjects.generator.direct.object.javacode.JavaObjectCodeGenerator;
import com.metaobjects.generator.Generator;
@@ -191,7 +192,7 @@ public void theJavaModelTierCompiles() throws Exception {
@Test
public void theJavaModelTierCompilesWithTheExtractorsFromTheirOwnGenerator() throws Exception {
Map entityOnly = new LinkedHashMap<>(AS_CLASS);
- entityOnly.put(JavaObjectCodeGenerator.ARG_EMIT_EXTRACTORS, "false");
+ entityOnly.put(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS, "false");
Map> selection = new LinkedHashMap<>();
selection.put("entity", entityOnly);
selection.put("extractor", PLAIN);
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java
index 68dab1c96..840ef8209 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java
@@ -72,8 +72,6 @@ public class ReportingInertTest extends SharedRegistryTestBase {
"repository", "acme/shop/StoreTotalsRepository.java",
"filter-allowlist", "acme/shop/StoreTotalsFilterAllowlist.java",
"routes", "acme/shop/StoreTotalsController.java");
- private static final java.util.regex.Pattern GENERATED_ON =
- java.util.regex.Pattern.compile("Generated On:[^\\n]*");
private static Path model(String variant) {
return SpringTestFixtures.findCorpusRoot().getParent()
@@ -121,10 +119,7 @@ private Map emit(String variant, List suite) thro
}
try (Stream s = Files.walk(outDir)) {
for (Path p : s.filter(Files::isRegularFile).collect(Collectors.toList())) {
- // The model tier stamps a wall-clock "Generated On:" line into every class
- // header; two runs a second apart differ there and nowhere else by design.
- files.put(outDir.relativize(p).toString(),
- GENERATED_ON.matcher(Files.readString(p, StandardCharsets.UTF_8)).replaceAll("Generated On: