Skip to content

Commit f3a2fa7

Browse files
authored
feat(java): make entity and extractor generators ejectable (#425)
* 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 <generator> 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 <generators>, 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 <generator> 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. * fix(java): the standalone extractor needs entity's filters; say so and test it Pre-gate review found that nothing told an adopter the separate extractor generator has to select the same objects as entity. Each <generator> merges its own <filters>, so an extractor wired without entity's filters writes a <Name>Extractor for an object entity wrote no class for, which does not compile. - The eject wiring note, ExtractorCodeGenerator's javadoc, own-your-codegen, the Java port page, the codegen skill's Java reference (goldens regenerated) and the CHANGELOG now name <filters> beside the naming args. - StandaloneExtractorGeneratorTest holds that the two generators, given the same filters, select the same objects. - EjectRuntimeRoundTripTest runs every ejectable Java generator off the registry; entity is one now, so its shared args carry entity's required type and flavor. That test also holds that neither generator's output imports a codegen helper, so ejecting them copies no runtime. * 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. * no-mistakes(review): eject entity writers, deterministic headers, single extractor emitter Assisted-by: no-mistakes:claude:claude-opus-5-5 * no-mistakes(review): move changelog to 1.1.0, drop naming views and overloads Assisted-by: no-mistakes:claude:claude-opus-5-5 * no-mistakes(review): document one extractor entry per entity entry Assisted-by: no-mistakes:claude:claude-opus-5-5
1 parent 0d8b09a commit f3a2fa7

30 files changed

Lines changed: 1833 additions & 138 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,39 @@ generator catalog and the Atlas evaluation are 1.2 work.
7171

7272
### Added
7373

74+
- **The Java `entity` and `extractor` generators can be ejected.**
75+
`mvn metaobjects:eject -Dnames=entity,extractor -Dport=java` copies each generator, with
76+
the rest of its emit, into your `codegen/` module. They were the two Java generators that
77+
could not be owned: `entity`'s class body is emitted by writer classes outside the
78+
generator, and `extractor` was a step inside `entity` that no `<generator>` entry could
79+
name. Unchanged copies emit what the packaged generator emits, `-Dlist` marks them
80+
`identical` or `DIFFERS`, and `mvn metaobjects:verify` runs them when your pom names them.
81+
Every Java generator is now ejectable except `template`. Ruling and reasoning: ADR-0034
82+
Amendment 6.
83+
- **The owned `entity` includes the class body.** Eject copies seven files into one
84+
package: `JavaObjectCodeGenerator`, the two bases it extends (`JavaCodeGenerator`,
85+
`BaseObjectCodeGenerator`), and the four writers that emit each class
86+
(`BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`,
87+
`ValueObjectCodeWriter`). The copies use one another, never the packaged classes: where
88+
one imports another, eject rewrites that import along with the package line. You change
89+
a field, a constructor or an accessor by editing the writer, with nothing to subclass.
90+
`-Dlist` gives `entity` one verdict over all seven files. Details:
91+
[own-your-codegen](docs/features/own-your-codegen.md#java-entity-and-extractor).
92+
- **`extractor` is a generator of its own (Java).** `ExtractorCodeGenerator` can be named
93+
in `<generators>`, with the same `outputDir`, `pkgPrefix`, `pkgSuffix`, `namePrefix`,
94+
`nameSuffix` and `<filters>` as your `entity` generator. When it is, the plugin sets
95+
`emitExtractors=false` on `entity`, so each `<Name>Extractor` has one emitter; an
96+
`emitExtractors=true` in the pom does not override that. Outside Maven, set the arg
97+
yourself. Ejecting it also copies `JavaCodeWriter` and `BaseObjectCodeWriter`, where it
98+
reads class names from. In the same package as an owned `entity`, the two share that
99+
one copy.
100+
- **A pom that wires only `entity` still writes the extractors.** Its class files change
101+
once, in the header comment only: see **Changed** below.
102+
- New public API: in `metaobjects-codegen-base`, the `EmitsObjectExtractors` marker, whose
103+
`ARG_EMIT_EXTRACTORS` names the `emitExtractors` arg; in `metaobjects-codegen-spring`,
104+
`GeneratorRegistry.GeneratorInfo.ejectWith()`, the classes eject copies with a
105+
generator.
106+
74107
- **Agent skills: a cost test for owned generators.** The `metaobjects-codegen` skill now says when not to write a generator: only when generator code plus
75108
its generated output and added metadata is fewer lines than the hand-written code it
76109
replaces, more instances of the shape are expected, or drift protection is the
@@ -456,6 +489,24 @@ until you regenerate.
456489

457490
### Changed
458491

492+
- **Java classes written by `entity` lose two header lines, and `mvn metaobjects:verify` no
493+
longer depends on the clock.** The header comment of every class `JavaObjectCodeGenerator`
494+
writes carried `ObjectCodeWriter:` (the writer's class name) and `Generated On:` (the
495+
wall-clock second of the run). Both lines are removed. **Expect a one-time diff in every
496+
regenerated Java class file, in the header comment only.** The timestamp meant
497+
`mvn metaobjects:verify`, which compares bytes, reported `[content-differs]` for every
498+
class file unless it ran in the same second as the generate it checked. Generated output
499+
now holds nothing that varies between runs, so regenerating an unchanged model writes the
500+
same bytes and verify passes whenever it runs. The writer's class name is dropped, not
501+
replaced by a stable name: it would differ between a packaged writer and an owned copy of
502+
it, and dropping the line is the smaller change.
503+
- **`mvn metaobjects:eject -Dnames=entity` and `-Dnames=extractor` need a port when the
504+
project does not imply one.** Both names are now ejectable on Java and on Kotlin. A
505+
project that declares exactly one of `metaobjects-codegen-spring` and
506+
`metaobjects-codegen-kotlin` is unaffected, because the goal infers the port from it.
507+
With neither or both declared, the goal used to resolve these two names to Kotlin, the
508+
only port that had them; it now refuses with `ejectable on more than one port` and asks
509+
for `-Dport=java|kotlin`, as it already did for `names` and `requirement-tests`.
459510
- **TypeScript: `RequirementTestArgs` and `RequirementView` gained required fields — a
460511
compile break for code that builds them by hand.** `RequirementTestArgs` has six new
461512
required fields (`package`, `unit`, `id`, `witnessKey`, `skip`, `digest`) and

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ first-week wedge plan — and `meta init` picks up from there.
159159
| Metadata dependencies (`dependencies`) | Yes (`meta deps sync`, `path` transport) | Phase 2 | Phase 2 | Phase 2 | Yes (loads the synced snapshot) |
160160
| Runtime metadata (ObjectManager-style) | Yes (`runtime-ts`) | Yes (OMDB) | Yes (via Java OMDB + Exposed) | Roadmap | Yes (ObjectManager) |
161161
| 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 |
162-
| 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`) |
162+
| Own a generator (`eject`) | Yes (`meta eject`) | Yes (`mvn metaobjects:eject`) | Yes (`mvn metaobjects:eject`) | Yes (`dotnet meta eject`) | Yes (`metaobjects eject`) |
163163
| 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) |
164164

165165
A "Yes" means the feature is covered by the shared conformance corpora at

‎agent-context/skills/metaobjects-codegen/references/java.md‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,21 @@ separate `metaobjects-codegen-base` module instead.)
159159
| `LlmTraceHelperGenerator` | `<Entity>TraceHelper.java` per concrete entity — the LLM-trace helper |
160160
| `SpringFilterAllowlistGenerator` | per-entity filter allowlist |
161161
| `SpringNamesGenerator` | `<Entity>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_<ROLE>_TABLE` / `_VIEW` / `_MATERIALIZED_VIEW` / `_PROC` / `_FUNCTION`, from the metamodel's own `@kind`-to-alias map, with `<ROLE>` being `PRIMARY` or `REPLICA` (a write-through entity has a member for each). Plus a `<FIELD>_FIELD`/`<FIELD>_COLUMN` pair per field and a complete `COLUMNS_BY_FIELD`; an `identity.secondary` or `index.lookup` also carries `IDENTITY_<NAME>_INDEX` / `INDEX_<NAME>_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_<ROLE>_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. |
162-
| `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 <Name> 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 <Name> extends ValueObject` (map-backed; less hostile to a default mapper, but still not the sanctioned serialization path). Either concrete flavor also emits a `<Name>Extractor` plus a self-registering `ObjectClassBindingProvider`. For a plain default-Jackson-friendly type, use the `codegen-spring` record surface instead — never `pojoAware`. |
162+
| `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 <Name> 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 <Name> 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 `<Name>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`. |
163+
| `ExtractorCodeGenerator` | also in `metaobjects-codegen-base`. One `<Name>Extractor` per concrete object, the same files `JavaObjectCodeGenerator` writes when this is not wired. Wire it as its own `<generator>` only to own it or to give it different args: same `outputDir`, `pkgPrefix` / `pkgSuffix` / `namePrefix` / `nameSuffix` and `<filters>` as the entity generator, which must run a concrete flavor in the same `<generators>` (an extractor for an object the entity generator wrote no class for does not compile). The plugin then stops the entity generator writing them. A pom with more than one entity generator entry needs one `ExtractorCodeGenerator` entry per entity entry, each with that entry's `outputDir`, naming args and `<filters>`: a single extractor generator in the run stops every entity entry writing its extractors. |
164+
165+
**Owning one of these two.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java`
166+
copies each generator into the `codegen/` module with the rest of its emit (`-Dport` is
167+
required: both names are ejectable on both JVM ports). For `entity` that is seven files in
168+
one package: `JavaObjectCodeGenerator` (flavor selection, `createWriter`, the binding
169+
provider source and its `META-INF/services` registration), the bases it extends
170+
(`JavaCodeGenerator`, `BaseObjectCodeGenerator`), and the writers that emit the class body
171+
(`BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter`).
172+
The copies use one another, not the packaged classes. To change a field, a constructor or
173+
an accessor, edit the owned writer; do not subclass a packaged one. `extractor` copies
174+
`ExtractorCodeGenerator` with `JavaCodeWriter` and `BaseObjectCodeWriter`, and shares them
175+
with `entity` when both are in one package. Every Java generator can be ejected except
176+
`template`.
163177

164178
**Projections (read-only views).** An `object.projection` (read-only `source.rdb`
165179
`@kind: view` child) is served read-only through OMDB at the ObjectManager layer

‎docs/features/own-your-codegen.md‎

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,9 @@ The JVM plugin loads generator classes from your project's classpath, but only f
520520
classes compiled **before** `generate-sources` runs, and the plugin's own copy of a class
521521
always wins over one with the same name. So eject puts each copy into a separate
522522
`codegen/` Maven module, under your own package (`-Dpackage`, default
523-
`<groupId>.codegen`). The package declaration is the only line it changes. On first use
523+
`<groupId>.codegen`). The package declaration is the only line it changes, except for a
524+
generator that is copied with other classes (Java `entity` and `extractor`, below): there
525+
the imports those copies have of one another change too. On first use
524526
it writes `codegen/pom.xml`, which depends on `metaobjects-codegen-spring` or
525527
`metaobjects-codegen-kotlin` at the plugin's version.
526528

@@ -538,6 +540,69 @@ from which the goal infers it. `requirement-tests` is one such name:
538540
`KotlinRequirementTestsGenerator.kt`, each with its default rendering. The identity
539541
function, the digest and the renderer and filter hook types stay in the package.
540542

543+
### Java `entity` and `extractor`
544+
545+
```bash
546+
mvn metaobjects:eject -Dnames=entity,extractor -Dport=java
547+
```
548+
549+
Both names exist in both ports, so pass `-Dport`. Their reference source ships in
550+
`metaobjects-codegen-base`, not `metaobjects-codegen-spring`; your `codegen/` module
551+
reaches it through the same dependency.
552+
553+
**`entity` copies seven files, and together they are the generator.**
554+
`JavaObjectCodeGenerator` does not emit a class body itself. A writer does, one per
555+
flavor, so the writers come with it:
556+
557+
| Copied file | What it holds |
558+
|---|---|
559+
| `JavaObjectCodeGenerator` | Which flavors exist and which writer each one gets (`createWriter`); the `ObjectClassBindingProvider` source and its `META-INF/services` registration; whether the `<Name>Extractor` classes are written here. |
560+
| `JavaCodeGenerator`, `BaseObjectCodeGenerator` | The generator's arguments, and the directory and file name each class lands in. |
561+
| `BaseObjectCodeWriter` | The class header comment, and the per-field loop every flavor shares. |
562+
| `JavaCodeWriter` | The Java type of each field; accessor, parameter, class and package names; the body of the interface flavor. |
563+
| `PojoAwareCodeWriter`, `ValueObjectCodeWriter` | The fields, the constructor and the accessor bodies of the two concrete flavors. |
564+
565+
All seven land in one package and use one another, never the packaged classes: eject
566+
rewrites the package line of each and, where one imports another, that import. To change a
567+
field, a constructor or an accessor, edit the writer. You subclass nothing. `-Dlist` gives
568+
`entity` one verdict over all seven files.
569+
570+
What stays in the package holds no emit: `MultiFileDirectGeneratorBase` (the
571+
one-file-per-object loop), `FileDirectWriter` (indentation and `println`) and
572+
`GeneratedFileWriter` (the write guard: a file with no `GENERATED` marker is left alone).
573+
The owned `entity` still calls the packaged `ExtractorCodeGenerator` to write the
574+
extractors, until you wire an `extractor` generator of your own.
575+
576+
**`extractor` copies three files.** `ExtractorCodeGenerator.java` holds its emit.
577+
`JavaCodeWriter` and `BaseObjectCodeWriter` come with it because that is where it reads the
578+
package and class name of the type each extractor casts to. Eject both generators into the
579+
same package, which is the default, and they share the one `JavaCodeWriter`: a naming
580+
change made there moves the classes and their extractors together.
581+
582+
`extractor` is a generator of its own: add it to `<generators>` with the same `outputDir`,
583+
`pkgPrefix`, `pkgSuffix`, `namePrefix` and `nameSuffix` as your `entity` generator, and the
584+
same `<filters>` if that one has any. An extractor casts to the generated flavored class,
585+
so `entity` with a concrete `flavor` has to be in the same run and has to select the same
586+
objects: an extractor for an object `entity` wrote no class for does not compile.
587+
588+
```xml
589+
<generator>
590+
<classname>com.acme.codegen.ExtractorCodeGenerator</classname>
591+
<args><outputDir>${project.build.directory}/generated-sources/metaobjects</outputDir></args>
592+
</generator>
593+
```
594+
595+
`entity` still writes the extractors itself when no separate `extractor` generator is in
596+
the run, so a pom that wires only `entity` emits what it always did. When one is in
597+
`<generators>`, the plugin tells `entity` to leave those files to it: it sets
598+
`emitExtractors` to `false`, whatever the pom says, so each file has one emitter. Outside
599+
Maven, set `emitExtractors=false` on `entity` yourself.
600+
601+
A pom with more than one `entity` entry needs one `extractor` entry per `entity` entry,
602+
each with that entry's `outputDir`, naming args and `<filters>`. A single `extractor`
603+
generator in the run stops every `entity` entry writing its extractors, so an `entity`
604+
entry left without one gets none.
605+
541606
### What eject hands over, and what stays core
542607

543608
An owned generator is only yours if the code its output depends on is yours too. The Java
@@ -610,13 +675,16 @@ diff -u -I '^package ' -I '^// metaobjects:owned-runtime' \
610675

611676
Apply the hunks you want by hand. For a generator, diff `reference/java/<Generator>.java`
612677
against `codegen/src/main/java/<package>/<Generator>.java` the same way, and also ignore
613-
`RUNTIME_PACKAGE`. Or, if you have no edits of your own (`-Dlist` says `identical`),
614-
re-eject with `-Dforce`.
678+
`RUNTIME_PACKAGE`. `entity` and `extractor`, and the classes copied with them, are in
679+
`metaobjects-codegen-base-$V.jar` instead; for those also pass `-I '^import '`, because
680+
eject pointed the copies' imports of one another at your package. Or, if you have no edits
681+
of your own (`-Dlist` says `identical`), re-eject with `-Dforce`.
615682

616683
`-Dport` is needed only when a name exists in both ports and your project's dependencies
617-
do not say which. Every Kotlin generator is ejectable. In Java, `entity`, `extractor` and
618-
`template` are not: `entity` shares internal writer classes with `extractor`, and
619-
`template` is the Mustache primitive, which has no emit logic of its own to own.
684+
do not say which. Every Kotlin generator is ejectable, and every Java generator except
685+
`template`. `template` is the Mustache primitive: the template you supply is its emit
686+
logic, so the generator has none of its own to hand over. That holds in every port that
687+
ships it.
620688

621689
## C#: `dotnet meta eject`
622690

0 commit comments

Comments
 (0)