} 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. */
public String simpleName() {
- int dot = classname.lastIndexOf('.');
- return dot < 0 ? classname : classname.substring(dot + 1);
+ return simpleNameOf(classname);
}
}
+ /** The simple name of a fully-qualified class name. */
+ public static String simpleNameOf(String classname) {
+ int dot = classname.lastIndexOf('.');
+ return dot < 0 ? classname : classname.substring(dot + 1);
+ }
+
/**
* 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 +140,8 @@ 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(),
+ info.ejectWith()));
}
for (Map.Entry e : GeneratorRegistryKt.getGENERATOR_REGISTRY().entrySet()) {
GeneratorInfo info = e.getValue();
@@ -198,8 +239,9 @@ public static Port inferPort(List declaredArtifactIds) {
}
// ------------------------------------------------------------------------------------
- // package rewrite — the ONE edit a copy gets (ADR-0034; the JVM eject design's "the
- // package rename is the only edit").
+ // package rewrite — the edit every copy gets (ADR-0034; the JVM eject design's "the
+ // package rename is the only edit"). A generator copied WITH other classes gets one
+ // more, which is the same rename seen from the importing side: see rewriteCopiedImports.
// ------------------------------------------------------------------------------------
private static final Pattern PACKAGE_LINE = Pattern.compile("(?m)^package\\s+[\\w.]+\\s*;?\\s*$");
@@ -220,6 +262,32 @@ public static String rewritePackage(String source, String newPackage) {
return new StringBuilder(source).replace(m.start(), m.end(), replacement).toString();
}
+ /**
+ * Point every import of a class in {@code copied} (fully-qualified names) at its copy in
+ * {@code ownedPackage}. The classes eject copies for one generator land in one package,
+ * so a bare same-package reference between them already means the copy; an
+ * {@code import} of one would still name the packaged class, and the owned generator
+ * would go on extending and calling code its owner cannot edit. An import of anything
+ * else is left alone.
+ */
+ public static String rewriteCopiedImports(String source, List copied, String ownedPackage) {
+ String out = source;
+ for (String classname : copied) {
+ out = out.replaceAll("(?m)^import\\s+" + Pattern.quote(classname) + "\\s*;",
+ Matcher.quoteReplacement("import " + ownedPackage + "." + simpleNameOf(classname) + ";"));
+ }
+ return out;
+ }
+
+ /**
+ * The owned copy of {@code reference}, the source of one of {@code entry}'s
+ * {@link Entry#sources()}: its package renamed to {@code ownedPackage}, and its imports
+ * of the classes copied with it pointed at those copies.
+ */
+ public static String ownedSource(Entry entry, String reference, String ownedPackage) {
+ return rewriteCopiedImports(rewritePackage(reference, ownedPackage), entry.sources(), ownedPackage);
+ }
+
// ------------------------------------------------------------------------------------
// helper runtime — the source a generator's OUTPUT imports, handed over with it
// ------------------------------------------------------------------------------------
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..d648c1ed8 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
@@ -40,6 +40,12 @@
* codegen/src/main/(java|kotlin)//}. On first use it also writes {@code
* codegen/pom.xml}.
*
+ * The rest of a generator's emit comes with it. The Java {@code entity} generator
+ * emits each class body through writer classes, and {@code extractor} reads its names from
+ * one. Those classes ({@link EjectSupport.Entry#with}) are copied into the same package as
+ * the generator, and the imports the copies have of one another are pointed at the copies
+ * ({@link EjectSupport#ownedSource}), so the owned generator extends and calls owned code.
+ *
*
The helper runtime comes too. The Java controller, DTO and repository output
* imports helper classes ({@code FilterParser}, {@code PatchValidationException},
* {@code ConstraintErrors}, …) that are not core (ADR-0034 Amendment 3). Ejecting such a
@@ -160,28 +166,40 @@ public void execute() throws MojoExecutionException, MojoFailureException {
// with no single obvious app module fails the whole call, not half of it.
Path runtimeRoot = runtime.isEmpty() ? null : resolveRuntimeRoot();
+ // Two generators named in one call may share a class copied with each of them; it
+ // is written once.
+ Set copied = new LinkedHashSet<>();
for (EjectSupport.Resolution r : resolved) {
EjectSupport.Entry e = r.entry;
portsTouched.add(e.port);
- String reference;
- try {
- reference = readResource(e.resourcePath);
- } catch (IOException ex) {
- throw new MojoExecutionException(
- "Could not read reference source for \"" + e.stableName + "\" at classpath "
- + "resource " + e.resourcePath, ex);
- }
- String rewritten = EjectSupport.rewritePackage(reference, pkg);
- if (!e.runtime.isEmpty()) {
- rewritten = EjectSupport.rewriteRuntimePackage(rewritten, runtimePkg);
- }
-
String langDir = e.port == EjectSupport.Port.KOTLIN ? "kotlin" : "java";
Path pkgDir = codegenRoot.resolve("src/main/" + langDir).resolve(pkg.replace('.', '/'));
- Path target = pkgDir.resolve(e.simpleName() + "." + e.port.extension);
- reportEject(e, target, rewritten, write(pkgDir, target, rewritten, force));
+ for (String source : e.sources()) {
+ String simpleName = EjectSupport.simpleNameOf(source);
+ Path target = pkgDir.resolve(simpleName + "." + e.port.extension);
+ if (!copied.add(target)) continue;
+
+ boolean generator = source.equals(e.classname);
+ String resource = e.resourceOf(source);
+ String reference;
+ try {
+ reference = readResource(resource);
+ } catch (IOException ex) {
+ throw new MojoExecutionException(
+ "Could not read reference source for \"" + e.stableName + "\" at classpath "
+ + "resource " + resource, ex);
+ }
+ String rewritten = EjectSupport.ownedSource(e, reference, pkg);
+ if (generator && !e.runtime.isEmpty()) {
+ rewritten = EjectSupport.rewriteRuntimePackage(rewritten, runtimePkg);
+ }
+
+ String what = generator ? "\"" + e.stableName + "\""
+ : simpleName + " (with \"" + e.stableName + "\")";
+ reportEject(what, target, rewritten, write(pkgDir, target, rewritten, force));
+ }
classnameChanges.add(" " + e.stableName + ": " + e.classname + " -> "
+ pkg + "." + e.simpleName());
}
@@ -193,6 +211,11 @@ public void execute() throws MojoExecutionException, MojoFailureException {
}
printWiring(pomWritten, classnameChanges);
+ // 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);
}
@@ -328,15 +351,15 @@ private WriteStatus write(Path dir, Path target, String content, boolean force)
}
}
- private void reportEject(EjectSupport.Entry e, Path target, String rewritten, WriteStatus status)
+ private void reportEject(String what, Path target, String rewritten, WriteStatus status)
throws MojoExecutionException {
switch (status) {
case CREATED:
- getLog().info("Ejected \"" + e.stableName + "\" -> " + target
+ getLog().info("Ejected " + what + " -> " + target
+ ". You own it now (ADR-0034 scaffold-and-own).");
break;
case REPLACED:
- getLog().info("Ejected \"" + e.stableName + "\" -> " + target + ", REPLACING the file "
+ getLog().info("Ejected " + what + " -> " + target + ", REPLACING the file "
+ "that was there (-Dforce=true).");
break;
case PRESERVED:
@@ -567,12 +590,26 @@ private String ownedStaleness(Path codegenRoot, EjectSupport.Entry e) {
Path found = walk.filter(p -> p.getFileName().toString().equals(filename))
.findFirst().orElse(null);
if (found == null) return null;
- String owned = Files.readString(found, StandardCharsets.UTF_8);
- String reference = readResource(e.resourcePath);
- EjectSupport.Comparison cmp = EjectSupport.compare(owned, reference);
- return cmp.verdict == EjectSupport.Verdict.IDENTICAL
+ // The generator is owned together with every class copied with it, so the verdict
+ // is over all of them, each against what eject would write beside this copy. One
+ // that has gone missing counts as wholly behind.
+ String ownedPackage = srcRoot.relativize(found.getParent()).toString()
+ .replace(found.getFileSystem().getSeparator(), ".");
+ int behind = 0;
+ int ownedOnly = 0;
+ for (String source : e.sources()) {
+ Path file = found.resolveSibling(
+ EjectSupport.simpleNameOf(source) + "." + e.port.extension);
+ String owned = Files.exists(file) ? Files.readString(file, StandardCharsets.UTF_8) : "";
+ String reference = EjectSupport.rewriteCopiedImports(
+ readResource(e.resourceOf(source)), e.sources(), ownedPackage);
+ EjectSupport.Comparison cmp = EjectSupport.compare(owned, reference);
+ behind += cmp.behind;
+ ownedOnly += cmp.ownedOnly;
+ }
+ return behind == 0 && ownedOnly == 0
? "[owned — identical]"
- : "[owned — DIFFERS: " + cmp.behind + " behind, " + cmp.ownedOnly + " of your own]";
+ : "[owned — DIFFERS: " + behind + " behind, " + ownedOnly + " of your own]";
} catch (IOException ex) {
return "[owned — could not compare: " + ex.getMessage() + "]";
}
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;
}
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..ddcc46b11 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
@@ -167,6 +172,34 @@ public void rewritePackageRejectsSourceWithNoPackageLine() {
EjectSupport.rewritePackage("class Foo {}", "com.acme.codegen");
}
+ /**
+ * ADR-0034 Amendment 6: an owned generator extends and calls the classes copied with it,
+ * never the packaged ones. So an import of a copied class follows the copy, and an
+ * import of anything else (here the {@code extractor} generator, which is not part of
+ * {@code entity}'s copy, and a class whose name merely starts like a copied one) stays.
+ */
+ @Test
+ public void anImportOfAClassCopiedAlongFollowsTheCopyAndNoOtherImportMoves() {
+ EjectSupport.Entry entity = EjectSupport.resolve(
+ "entity", EjectSupport.ejectableByStableName(), EjectSupport.Port.JAVA, null).entry;
+ String src = "package com.metaobjects.generator.direct.object.javacode;\n\n"
+ + "import com.metaobjects.generator.direct.object.BaseObjectCodeWriter;\n"
+ + "import com.metaobjects.generator.direct.object.BaseObjectCodeWriterFactory;\n"
+ + "import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator;\n"
+ + "import com.metaobjects.generator.direct.GenerationContext;\n\n"
+ + "public class Foo extends BaseObjectCodeWriter {}\n";
+
+ assertEquals("package com.metaobjects.generator.direct.object.javacode;\n\n"
+ + "import com.acme.codegen.BaseObjectCodeWriter;\n"
+ + "import com.metaobjects.generator.direct.object.BaseObjectCodeWriterFactory;\n"
+ + "import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator;\n"
+ + "import com.metaobjects.generator.direct.GenerationContext;\n\n"
+ + "public class Foo extends BaseObjectCodeWriter {}\n",
+ EjectSupport.rewriteCopiedImports(src, entity.sources(), "com.acme.codegen"));
+ assertTrue(EjectSupport.ownedSource(entity, src, "com.acme.codegen")
+ .startsWith("package com.acme.codegen;\n"));
+ }
+
// ------------------------------------------------------------------------------------
// owned-copy comparison
// ------------------------------------------------------------------------------------
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..da81488c8
--- /dev/null
+++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EmitExtractorsDerivationTest.java
@@ -0,0 +1,103 @@
+package com.metaobjects.mojo;
+
+import com.metaobjects.generator.EmitsObjectExtractors;
+import org.junit.Before;
+import org.junit.Test;
+
+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 {
+
+ /**
+ * 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() {
+ UseNamesDerivationTest.RecordingGenerator.SEEN.clear();
+ mojo = new MetaDataGeneratorMojo();
+ mojo.setLoader(new LoaderParam());
+ }
+
+ private static GeneratorParam param(Class> impl, Map args) {
+ GeneratorParam p = new GeneratorParam();
+ p.setClassname(impl.getName());
+ if (args != null) p.setArgs(args);
+ return p;
+ }
+
+ private List