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 `