diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fa2edb55..9a3d1884b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,13 +53,26 @@ number. The first landed on 1.0.x: 439 lines of handler code retired. The second and it is a SQL reduction only: in an adopter, hand-written SQL went from 75 lines to 15 (-80%). Total hand-written lines did not fall, so do not read it as a total line reduction. -**Three diffs to expect when you upgrade.** +**Four diffs to expect when you upgrade.** - **An ejected generator needs a resync (G11).** An owned copy keeps the gate and the options it was copied with, so it does not receive the new report behaviour until you resync it. The entries under **Added** and **Fixed** name the command for each affected generator (`meta eject hooks --force`, `meta eject routes --force`, `meta eject routes-hono --force`). A generator you never ejected needs nothing. +- **An ejected Java Spring generator keeps its old rules until you re-eject it.** A copy of + `routes`, `dto`, `repository` or `value-object` made before 1.1.0 compiles after the upgrade + wherever it compiled before, and applies the rules it was copied with: it calls its own sibling + copies, and the four `AutoSetSupport` selectors it calls on the packaged class are still there, + marked `@Deprecated`. A copy of `routes`, `dto` or `value-object` ejected alone did not compile + before 1.1.0, and the upgrade does not change that. Run + `mvn metaobjects:eject -Dnames= -Dforce=true` for each one you own to get a copy + that compiles alone and reads the shared rules from `SpringGeneratorSupport` (the copy of it + written beside them is yours too), and merge your own edits back from `git diff`. Code of your + own that calls a static helper on the packaged `SpringDtoGenerator` or + `SpringRepositoryGenerator`, or subclasses one of them, still compiles too: the 1.0.x helpers + are still there, marked `@Deprecated`, and forward to `SpringGeneratorSupport`. A generator you + never ejected needs nothing. - **A keyless projection regenerates differently (G12).** `meta gen` rewrites the read-only surface of a keyless projection (no `/:id` route, no by-id function, and no write verbs in the API docs), and `meta verify --docs` reports the affected pages as stale until they are @@ -847,6 +860,40 @@ until you regenerate. another field, or, in Java only, a value-object field that reaches a shared or `@provided` enum or a redeclaration under a shared enum. +- **`mvn metaobjects:eject` of Java `routes`, `dto` or `value-object` on its own now writes a copy that + compiles, and eject hands over the emit rules the generators share.** Found on a 1.1.0 release + candidate, before any 1.1.0 artifact was published. The copies called static helpers on the packaged + `SpringDtoGenerator` and `SpringRepositoryGenerator` with no import, and eject rewrote only the package + line, so the documented `mvn metaobjects:eject -Dnames=routes,names -Dport=java` failed with 12 errors + for `routes` and 3 each for `dto` and `value-object`. Only ejecting the whole web tier together + compiled, because the siblings then shared a package. The shared rules (field selection, value-object + column predicates, component types, validation annotations, M:N finder naming) now sit in one class, + `SpringGeneratorSupport`, and eject copies it: ejecting `routes`, `dto`, `repository` or `value-object` + writes one `SpringGeneratorSupport.java` beside the owned generators, in their package, and points + each owned generator's import at it. An owned generator therefore never calls a packaged `dto` or the + packaged rules, and you can change a rule in code you own. That includes the paths through the engine + classes a copy still imports: the selection of DTO fields stamped for `@autoSet` now lives with the + rules (`SpringGeneratorSupport.stampFields`, `onUpdateFields`, `hasAutoSetFields`, + `hasOnUpdateFields`), and `mapValueObjectRefOf`, a type-mapping fact, moved to `SpringTypeMapper`, so + no packaged class an owned generator reaches refers to the packaged rules. The copy is left alone without `-Dforce`, + `-Dlist` marks it `identical` or `DIFFERS`, and `mvn metaobjects:verify` does not report it as stale + output. Generated output is unchanged. A test now ejects each registered Java generator alone through + the real goal and compiles what it wrote with the packaged `SpringGeneratorSupport` hidden from the + compiler, and another reads the compiled classes to prove no packaged class an owned generator reaches + refers to it; Kotlin already compiled alone and no Kotlin generator can reach that class. The Python port + had the same shape in two places: `filter-allowlist` imported `emits_router` from the router generator + and `extractor` imported `has_literal_default` from the entity generator, so an owned copy of either + was not seen by the other. Both predicates moved to `metaobjects/codegen/runtime/shared_predicates.py`, + and `metaobjects eject` of `routes`, `filter-allowlist`, `entity` or `extractor` copies it to + `codegen/runtime/shared_predicates.py`, which the owned generators read instead of the installed + module. Not in this fix: `template` stays package-only by decision. The public and protected + static helpers that 1.0.x had on `SpringDtoGenerator` and `SpringRepositoryGenerator` are still + there with the same signatures, marked `@Deprecated`, and forward to `SpringGeneratorSupport` + (`SpringRepositoryGenerator.appliesTo` to `repositoryAppliesTo`), so code of your own that calls + them, or a subclass that uses them, still compiles; call `SpringGeneratorSupport.` in + new code. The four `AutoSetSupport` selectors of 1.0.x (`stampFields`, `onUpdateFields`, + `hasAutoSetFields`, `hasOnUpdateFields`) are still there too, marked `@Deprecated`, and apply + the 1.0.x rule; the same four are on `SpringGeneratorSupport`. - **`meta verify --db` no longer reports a matching view as drift, and says what differs when a view does not.** An adopter on 1.1.0-rc.2 rebuilt a SQLite database from the committed chain: every report and projection view's stored SQL was byte-identical to the metadata's, yet diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index c6beb4ca9..09d835167 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -614,7 +614,7 @@ like the rest of the package. and the `ObjectManager` runtime (`metaobjects.meta…`, `metaobjects.render…`, `metaobjects.runtime`) remain package imports in every generator's output, because MetaObjects guarantees them. The `entity`, `names` and `filter-allowlist` outputs import - no helper runtime at all, so ejecting them copies only the generator. + no helper runtime at all, so ejecting them emits no `_runtime/`. - **What `verify` sees.** `codegen/runtime/` is owned source. It sits outside every outDir, so `verify --codegen` never reports it as drift. The emitted `_runtime/` copy is generated output: edit `codegen/runtime/` without regenerating and `verify @@ -643,6 +643,34 @@ codegen/runtime/`). The same recipe covers the generator itself: `metaobjects ej routes` into a scratch directory gives you the new reference to diff your `codegen/generators/routes.py` against. +### The rules two generators share come with them + +Some emit rules are asked by more than one generator, and the answers have to match. +`routes` and `filter-allowlist` both ask `emits_router` (which objects get a router; the +generated router imports the allowlist module, so a mismatch is an import error at +startup). `entity` and `extractor` both ask `has_literal_default`. These live in one +module, `shared_predicates`, and ejecting any of those four generators copies it too: + +```text +codegen/ + generators/routes.py + generators/filter_allowlist.py + runtime/shared_predicates.py # the shared rules, now your code +``` + +Each generator imports it relatively (`from ..runtime.shared_predicates import ...`), so +the verbatim copy in `codegen/generators/` reads `codegen/runtime/shared_predicates.py` +and never the installed module. Two owned generators read the one copy: change +`emits_router` there and your `routes` and your `filter-allowlist` change together. No +generated code imports this module. It runs at `gen` time only, so nothing is emitted into +`_runtime/` for it. The rest works as for the runtime above: an existing copy is kept, and +`--list` marks it `identical` or `DIFFERS`. + +A packaged generator keeps reading the installed rules. If you own `routes` and change +`emits_router` but still run the packaged `filter-allowlist`, the two disagree. Eject +`filter-allowlist` too before you change that rule; the same goes for `entity` and +`extractor` with `has_literal_default`. + ## Java and Kotlin: `mvn metaobjects:eject` ```bash @@ -654,12 +682,17 @@ 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, 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 +`.codegen`). Every copy compiles on its own, and none calls another generator. The +package declaration is the only line it changes, except for a generator that is copied with +other classes (Java `entity` and `extractor`, below) and for the support imports described +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. +Eject changes a copy's package declaration, and for a Java generator up to two more +lines, both described below: its import of `SpringGeneratorSupport`, and its +`RUNTIME_PACKAGE` constant. + Eject never edits an existing pom. It prints the three things to wire: the `` entry for your parent pom, a plugin `` on the codegen module for the module that runs `metaobjects:generate`, and the new `` for each ``. It @@ -739,15 +772,48 @@ entry left without one gets none. ### 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 -`routes`, `dto` and `repository` output imports helper classes that are not core: +An owned generator is only yours if the rules it applies and the code its output depends +on are yours too. Eject copies two kinds of source with a Java generator: -| Ejecting | Also copies | -|---|---| -| `routes` | `ConstraintErrors`, `FilterParseResult`, `FilterParser`, `FilterPredicate`, `PatchValidationException`, `RecordComponentNames` | -| `dto` | `PatchValidationException` | -| `repository` | `FilterPredicate` | -| any other Java generator, every Kotlin generator | nothing: its output imports only core | +| Ejecting | Shared emit rules, beside the generator | Helper runtime its output imports | +|---|---|---| +| `routes` | `SpringGeneratorSupport` | `ConstraintErrors`, `FilterParseResult`, `FilterParser`, `FilterPredicate`, `PatchValidationException`, `RecordComponentNames` | +| `dto` | `SpringGeneratorSupport` | `PatchValidationException` | +| `repository` | `SpringGeneratorSupport` | `FilterPredicate` | +| `value-object` | `SpringGeneratorSupport` | nothing | +| any other Java generator, every Kotlin generator | nothing | nothing: its output imports only core | + +**The shared emit rules.** `SpringGeneratorSupport` holds the rules those four generators +apply together: which fields a DTO and a patch carry, which of them are stamped for +`@autoSet`, the value-object column predicates, component types, validation annotations, +and the M:N finder names the repository declares and the controller calls. Eject writes +ONE copy to +`codegen/src/main/java//SpringGeneratorSupport.java`, in the owned generators' +package, and rewrites each owned generator's import to that copy. An owned generator never +calls the packaged class, so you can change a rule (say, `@NotBlank` where the reference +emits `@NotNull @Size(min = 1)`) in code you own. Ejecting another of the four later +shares the same copy. The copy's first line is `// metaobjects:owned-support …`; like an +owned generator, it is left alone unless you pass `-Dforce`, `-Dlist` marks it `identical` +or `DIFFERS`, and `mvn metaobjects:verify` never reports it as stale output. + +A packaged generator keeps applying the packaged rules, and so do the generated API docs +pages, which describe the reference shape. If you own `routes` and change a finder name +but still run the packaged `repository`, the controller calls a method the repository does +not declare. Eject the generators that share a rule before you change it. + +**What stays engine.** The naming and type-mapping tables the rules read (`SpringNaming`, +`SpringTypeMapper`, `SpringM2mSupport`, `AutoSetSupport`, `TphPlan`) stay in +`metaobjects-codegen-spring`, and your copies import them. None of them calls +`SpringGeneratorSupport`, so nothing your owned generator reaches runs the packaged rules +behind your copy. Two facts you might expect in the rules are engine instead: +`SpringTypeMapper.mapValueObjectRefOf` (which value object a `field.map @objectRef` +carries) decides the map's Java type, so it is a type-mapping fact; and `AutoSetSupport` +keeps what `@autoSet` means for one field (its policy and its `now()` expression), plus +four `@Deprecated` 1.0.x selectors that apply the 1.0.x rule for copies made before 1.1.0. +Which DTO fields get stamped is a rule in your copy. + +**The helper runtime.** The Java `routes`, `dto` and `repository` output imports helper +classes that are not core, listed in the last column. `M2mJoinResolver`, in both ports, is not copied. No generated file imports it; it is a helper for traversal code you write by hand. If you call it and want to own it, copy it yourself. @@ -809,10 +875,12 @@ 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`, 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`. +`RUNTIME_PACKAGE` and the `SpringGeneratorSupport` import. `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. For the shared rules, diff `reference/java/support/SpringGeneratorSupport.java` +against your copy, ignoring the package line and `^// metaobjects:owned-support`. 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 6a6391dec..f43e36894 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -586,6 +586,8 @@ copy imports them. The model and the other ports' spelling are in **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 shared emit rules.** The `routes`, `dto`, `repository` and `value-object` generators apply rules they share (which fields a DTO and a patch carry, validation annotations, M:N finder names) through `SpringGeneratorSupport`. Ejecting any of them writes one copy of that class beside the owned generators, in their package, and points each owned generator's import at it, so an owned generator never calls the packaged rules and you can change them. The engine classes the copies still import (`SpringNaming`, `SpringTypeMapper`, `SpringM2mSupport`, `AutoSetSupport`, `TphPlan`) never call `SpringGeneratorSupport`; `SpringTypeMapper.mapValueObjectRefOf` is a type-mapping fact and stays engine. The copy is left alone without `-Dforce`, `-Dlist` marks it `identical` or `DIFFERS`, and `mvn metaobjects:verify` does not report it as stale output. + **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/server/csharp/MetaObjects.Codegen/Generators/KNOWN_GAPS.md b/server/csharp/MetaObjects.Codegen/Generators/KNOWN_GAPS.md index a7c7e2cad..589112585 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/KNOWN_GAPS.md +++ b/server/csharp/MetaObjects.Codegen/Generators/KNOWN_GAPS.md @@ -47,7 +47,7 @@ Update when a gap closes or a new one surfaces. **Residual.** TPH only: the per-subtype partial-update path passes an empty VO list (`RoutesGenerator.cs:961`), so VO columns on a TPH-rooted entity are still skipped on PATCH — the same staging-out Java's entry records for TPH. -**RULED (1.0.1, [#359](https://github.com/metaobjectsdev/metaobjects/issues/359)): narrowed to the TPH residual, and the residual is a deliberate cross-port stage-out — not a C# divergence.** The question the issue could not answer from C# alone was whether C# now patches VO columns while Java and Kotlin still do not, which would make this an api-contract divergence rather than a gap. It does not: Java stages out the same columns on the same path. `SpringDtoGenerator.scalarFields` is documented as "every MetaField that is not an ObjectField", and the TPH union is built from it, so a `field.object` column never reaches a Java TPH per-subtype write path either — Java's own `KNOWN_GAPS.md` records the carve-out in the same terms. Both ports patch VO columns on the vanilla path and stage them out on TPH, which is exactly the uniform Day-1 simplification this entry was written to describe. +**RULED (1.0.1, [#359](https://github.com/metaobjectsdev/metaobjects/issues/359)): narrowed to the TPH residual, and the residual is a deliberate cross-port stage-out — not a C# divergence.** The question the issue could not answer from C# alone was whether C# now patches VO columns while Java and Kotlin still do not, which would make this an api-contract divergence rather than a gap. It does not: Java stages out the same columns on the same path. `SpringGeneratorSupport.scalarFields` is documented as "every MetaField that is not an ObjectField", and the TPH union is built from it, so a `field.object` column never reaches a Java TPH per-subtype write path either — Java's own `KNOWN_GAPS.md` records the carve-out in the same terms. Both ports patch VO columns on the vanilla path and stage them out on TPH, which is exactly the uniform Day-1 simplification this entry was written to describe. So the entry stays OPEN, scoped to TPH only, and its cross-port framing is intact rather than obsolete. What closed is the part that was stale: the vanilla path, corrected in **Today** above. Reopening the TPH half is a feature decision (extend the TPH union to carry owned navigations), not a bug fix, and it would want to move on Java and C# together. diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt index 04a1f2470..dfebad248 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt @@ -766,7 +766,7 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { * Canonical DNS-hostname matcher for `@stringFormat: hostname` (ADR-0036/0037 Wave 3). * Codegen-owned (NOT author `validator.regex`) — one or more dot-separated labels, each * 1–63 chars of alphanumerics/hyphens not starting/ending with a hyphen. Byte-identical - * to the Spring port's `SpringDtoGenerator.HOSTNAME_REGEX`. + * to the Spring port's `SpringGeneratorSupport.HOSTNAME_REGEX`. */ const val HOSTNAME_REGEX = "^(?=.{1,253}\$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\$" diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt index a8e08986f..9667f27b6 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt @@ -215,7 +215,7 @@ object KotlinM2mSupport { * helper (doing so silently reused a wrong-target/wrong-junction query — see * `KotlinTphM2mShadowedCodegenTest`). It gets its OWN helper, suffixed by the * `@discriminatorValue` of the subtype that first introduces it (mirrors the Java port's - * per-subtype finder shape, `SpringRepositoryGenerator.m2mFinderNameForSubtype` / + * per-subtype finder shape, `SpringGeneratorSupport.m2mFinderNameForSubtype` / * `findTagsForBridge`): `Query`. A LATER mount whose nav resolves to that SAME * shape (structural equality) reuses that same helper rather than minting another — so two * subtypes shadowing a name identically still share one helper, matching the plain-name case. diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectedGeneratorAloneCompilesTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectedGeneratorAloneCompilesTest.kt new file mode 100644 index 000000000..0ca0aaa0f --- /dev/null +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectedGeneratorAloneCompilesTest.kt @@ -0,0 +1,51 @@ +package com.metaobjects.generator.kotlin + +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Eject proof (a), one generator at a time: every ejectable Kotlin generator, copied out + * ALONE into a foreign package, compiles against the published API. + * + * An adopter ejects the generator they want to change, not the whole tier. A copy that calls + * a helper on a sibling generator resolves only while that sibling sits in the same package, + * so [EjectedGeneratorsCompileTest], which compiles every copy together, cannot see the + * defect. Each copy here is compiled with nothing else beside it. + * + * The list comes from [GENERATOR_REGISTRY], never a hand-typed one. The package line is + * rewritten exactly as `mvn metaobjects:eject` rewrites it. + */ +@OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class) +class EjectedGeneratorAloneCompilesTest { + + private val packageLine = Regex("(?m)^package\\s+[\\w.]+\\s*$") + + @Test + fun everyEjectableGeneratorCompilesAlone() { + val srcDir = Path.of("src/main/kotlin/com/metaobjects/generator/kotlin") + val ejectable = GENERATOR_REGISTRY.values.filter { it.ejectResourcePath != null } + assertTrue(ejectable.isNotEmpty(), "expected the registry to name ejectable generators") + + val failures = ejectable.mapNotNull { info -> + val simpleName = info.ejectResourcePath!!.substringAfterLast('/').substringBeforeLast('.') + val file = srcDir.resolve("$simpleName.kt") + assertTrue(Files.exists(file), "expected $file to exist") + val original = Files.readString(file) + val match = packageLine.find(original) ?: throw AssertionError("$simpleName has no package line") + val rewritten = original.replaceRange(match.range, "package com.acme.owned") + + val result = KotlinCompilation().apply { + sources = listOf(SourceFile.kotlin("$simpleName.kt", rewritten)) + inheritClassPath = true + messageOutputStream = System.out + }.compile() + if (result.exitCode == KotlinCompilation.ExitCode.OK) null + else "${info.name} ($simpleName) does not compile alone:\n${result.messages}" + } + assertTrue(failures.isEmpty(), failures.joinToString("\n")) + } +} diff --git a/server/java/codegen-spring/pom.xml b/server/java/codegen-spring/pom.xml index a350c68fa..e761dddc7 100644 --- a/server/java/codegen-spring/pom.xml +++ b/server/java/codegen-spring/pom.xml @@ -224,6 +224,28 @@ + + + ship-eject-support-source + generate-resources + + copy-resources + + + ${project.build.outputDirectory}/META-INF/metaobjects/reference/java/support + + + src/main/java/com/metaobjects/generator/spring + + SpringGeneratorSupport.java + + + + + 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 9957a1277..260546025 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 @@ -249,6 +249,20 @@ public String toString() { * .java} per class, for {@code mvn metaobjects:eject} to copy. */ public static final String EJECT_RUNTIME_RESOURCE_ROOT = EJECT_RESOURCE_ROOT + "runtime/"; + /** Simple names of the support classes the Java generators share: emit rules more than + * one generator applies (which fields a DTO carries, validation annotations, M:N finder + * naming). {@code mvn metaobjects:eject} copies one beside every ejected generator whose + * source imports it, so the adopter owns those rules with the generator. */ + public static final List EJECT_SUPPORT = List.of("SpringGeneratorSupport"); + + /** The package the packaged support classes live in, and the one an ejectable generator + * imports them from. */ + public static final String SUPPORT_PACKAGE = "com.metaobjects.generator.spring"; + + /** Classpath root the support sources are shipped under, one {@code .java} + * per class, for {@code mvn metaobjects:eject} to copy. */ + public static final String EJECT_SUPPORT_RESOURCE_ROOT = EJECT_RESOURCE_ROOT + "support/"; + private GeneratorRegistry() { } diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java index 23a735822..ea5b7c156 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java @@ -10,7 +10,7 @@ import com.metaobjects.generator.spring.SpringOutputParserGenerator; import com.metaobjects.generator.spring.SpringOutputPromptGenerator; import com.metaobjects.generator.spring.SpringRenderHelperGenerator; -import com.metaobjects.generator.spring.SpringRepositoryGenerator; +import com.metaobjects.generator.spring.SpringGeneratorSupport; import com.metaobjects.io.util.IOUtil; import com.metaobjects.generator.util.GeneratorUtil; import com.metaobjects.loader.MetaDataLoader; @@ -172,7 +172,7 @@ dto, ApiSymbolKind.VALIDATION, fqn(javaPkg, dto), // DATA_ACCESS — one repository interface symbol; signature lists the // CRUD methods plus one find per resolved M:N. - if (SpringRepositoryGenerator.appliesTo(obj)) { + if (SpringGeneratorSupport.repositoryAppliesTo(obj)) { String repo = SpringNaming.repositoryName(shortName); String dto = SpringNaming.dtoName(shortName); symbols.add(symbol( @@ -278,8 +278,8 @@ private String repositorySignature(String repo, String dto, MetaObject obj, Meta sb.append(" boolean delete(Long id);\n"); for (SpringM2mSupport.M2mNav nav : SpringM2mSupport.resolve(obj, loader)) { sb.append(" List<").append(nav.targetDtoType()).append("> ") - .append(SpringRepositoryGenerator.m2mFinderName(nav.relationName())).append("(Long sourceId") - .append(SpringRepositoryGenerator.targetSubtypeParam(nav)).append(");\n"); + .append(SpringGeneratorSupport.m2mFinderName(nav.relationName())).append("(Long sourceId") + .append(SpringGeneratorSupport.targetSubtypeParam(nav)).append(");\n"); } sb.append("}"); return sb.toString(); diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaFieldShapes.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaFieldShapes.java index 48c1cd5f2..8b2ab2205 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaFieldShapes.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaFieldShapes.java @@ -4,6 +4,7 @@ import com.metaobjects.field.EnumField; import com.metaobjects.field.MetaField; import com.metaobjects.generator.spring.SpringDtoGenerator; +import com.metaobjects.generator.spring.SpringGeneratorSupport; import com.metaobjects.generator.spring.SpringNaming; import com.metaobjects.generator.spring.SpringTypeMapper; import com.metaobjects.loader.MetaDataLoader; @@ -23,13 +24,13 @@ * *

DTO field shapes

* {@link #dtoFields(MetaObject)} iterates the exact same scalar fields the DTO - * generator emits ({@link SpringDtoGenerator#scalarFields(MetaObject)} — skips + * generator emits ({@link SpringGeneratorSupport#scalarFields(MetaObject)} — skips * {@code ObjectField}), and for each field: *
    - *
  • type = {@link SpringDtoGenerator#componentType(MetaField, MetaObject)} (the + *
  • type = {@link SpringGeneratorSupport#componentType(MetaField, MetaObject)} (the * DTO record component type, incl. the value-constrained enum + {@code List<…>} for arrays).
  • *
  • optional = derived from - * {@link SpringDtoGenerator#validationAnnotations(MetaField)}: a field is + * {@link SpringGeneratorSupport#validationAnnotations(MetaField)}: a field is * required (optional=false) iff its annotation string contains * {@code @NotNull} or {@code @NotBlank}; otherwise optional=true.
  • *
  • note = for {@link EnumField}, the allowed values (effective @@ -41,9 +42,9 @@ * ({@link com.metaobjects.generator.spring.SpringValueObjectGenerator}, ADR-0056), so * {@link #payloadFieldsOf(MetaObject, MetaDataLoader)} documents that record with the SAME * rules the record generator uses: type = - * {@link SpringDtoGenerator#componentType(MetaField, MetaObject)}; optional unless the + * {@link SpringGeneratorSupport#componentType(MetaField, MetaObject)}; optional unless the * component carries {@code @NotNull} / {@code @NotBlank} - * ({@link SpringDtoGenerator#validationAnnotations(MetaField)}); enum notes carry the allowed + * ({@link SpringGeneratorSupport#validationAnnotations(MetaField)}); enum notes carry the allowed * values. */ public final class JavaFieldShapes { @@ -54,15 +55,15 @@ private JavaFieldShapes() { /* no instances */ } * The DTO record's documented field shapes for {@code entity}: one * {@link FieldShape} per component field the {@link SpringDtoGenerator} emits — * scalars PLUS value-object jsonb columns (Program D), matching the record's own - * {@link SpringDtoGenerator#dtoComponentFields} iteration. Reuses the generator's + * {@link SpringGeneratorSupport#dtoComponentFields} iteration. Reuses the generator's * component-type mapping and validation-annotation derivation so the docs can't * drift from the record (a @required VO column shows @NotNull → required). */ public static List dtoFields(MetaObject entity) { List out = new ArrayList<>(); - for (MetaField field : SpringDtoGenerator.dtoComponentFields(entity)) { - String type = SpringDtoGenerator.componentType(field, entity); - String annotations = SpringDtoGenerator.validationAnnotations(field); + for (MetaField field : SpringGeneratorSupport.dtoComponentFields(entity)) { + String type = SpringGeneratorSupport.componentType(field, entity); + String annotations = SpringGeneratorSupport.validationAnnotations(field); // Required iff the DTO component carries @NotNull or @NotBlank — read // straight from the generator's own annotation string (drift-proof). boolean required = annotations.contains("@NotNull") || annotations.contains("@NotBlank"); @@ -82,8 +83,8 @@ public static List payloadFieldsOf(MetaObject vo, MetaDataLoader loa if (vo == null) return List.of(); List out = new ArrayList<>(); for (MetaField field : vo.getMetaFields()) { - String type = SpringDtoGenerator.componentType(field, vo); - String annotations = SpringDtoGenerator.validationAnnotations(field); + String type = SpringGeneratorSupport.componentType(field, vo); + String annotations = SpringGeneratorSupport.validationAnnotations(field); boolean required = annotations.contains("@NotNull") || annotations.contains("@NotBlank"); out.add(new FieldShape(field.getName(), type, !required, enumNote(field))); } diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/AutoSetSupport.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/AutoSetSupport.java index fd5254171..c4cc2de9a 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/AutoSetSupport.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/AutoSetSupport.java @@ -2,6 +2,7 @@ import com.metaobjects.field.DateField; import com.metaobjects.field.MetaField; +import com.metaobjects.field.ObjectField; import com.metaobjects.field.TimeField; import com.metaobjects.field.TimestampField; import com.metaobjects.object.MetaObject; @@ -87,15 +88,25 @@ public static String nowExpr(MetaField field) { return null; } + // === 1.0.x selectors (deprecated) ============================================ + // + // These four shipped on this class through 1.0.13, and an adopter's own code, or an + // ejected copy made before 1.1.0, may still call them. The generators no longer do: the + // same selectors live on SpringGeneratorSupport, the rules eject hands over. They stay + // here, self-contained, so no packaged engine class calls back into those rules (an + // owned generator's path through this class must not reach the packaged rules). + /** - * The DTO component fields of {@code entity} carrying a STAMPABLE {@code @autoSet} - * (onCreate OR onUpdate) marker, in declared order — the fields {@code stampForInsert} - * overwrites with {@code now()}. Reuses {@link SpringDtoGenerator#dtoComponentFields} - * so the set matches the DTO record's component order exactly. + * The DTO component fields of {@code entity} carrying a stampable {@code @autoSet} + * (onCreate OR onUpdate) marker, in declared order. + * + * @deprecated moved with the other emit rules to {@code SpringGeneratorSupport}; kept so + * 1.0.x callers still compile. */ + @Deprecated public static List stampFields(MetaObject entity) { List out = new ArrayList<>(); - for (MetaField field : SpringDtoGenerator.dtoComponentFields(entity)) { + for (MetaField field : legacyDtoComponentFields(entity)) { if (isAutoSet(field) && nowExpr(field) != null) out.add(field); } return out; @@ -103,23 +114,68 @@ public static List stampFields(MetaObject entity) { /** * The DTO component fields of {@code entity} carrying {@code @autoSet: onUpdate}, in - * declared order — the fields stamped on update / patch ({@code onCreate} is skipped). + * declared order. + * + * @deprecated moved with the other emit rules to {@code SpringGeneratorSupport}; kept so + * 1.0.x callers still compile. */ + @Deprecated public static List onUpdateFields(MetaObject entity) { List out = new ArrayList<>(); - for (MetaField field : SpringDtoGenerator.dtoComponentFields(entity)) { + for (MetaField field : legacyDtoComponentFields(entity)) { if (isOnUpdate(field) && nowExpr(field) != null) out.add(field); } return out; } - /** True iff {@code entity} declares at least one stampable {@code @autoSet} field. */ + /** + * True iff {@code entity} declares at least one stampable {@code @autoSet} field. + * + * @deprecated moved with the other emit rules to {@code SpringGeneratorSupport}; kept so + * 1.0.x callers still compile. + */ + @Deprecated public static boolean hasAutoSetFields(MetaObject entity) { return !stampFields(entity).isEmpty(); } - /** True iff {@code entity} declares at least one {@code @autoSet: onUpdate} field. */ + /** + * True iff {@code entity} declares at least one {@code @autoSet: onUpdate} field. + * + * @deprecated moved with the other emit rules to {@code SpringGeneratorSupport}; kept so + * 1.0.x callers still compile. + */ + @Deprecated public static boolean hasOnUpdateFields(MetaObject entity) { return !onUpdateFields(entity).isEmpty(); } + + /** The vanilla DTO component fields: every scalar field plus every value-object jsonb + * column, in declared order. Same set as {@code SpringGeneratorSupport.dtoComponentFields}, + * restated here so the deprecated selectors stand alone. */ + private static List legacyDtoComponentFields(MetaObject entity) { + List out = new ArrayList<>(); + for (MetaField field : entity.getMetaFields()) { + if (field instanceof ObjectField) { + if (legacyIsValueObjectJsonbField((ObjectField) field)) out.add(field); + continue; + } + out.add(field); + } + return out; + } + + private static boolean legacyIsValueObjectJsonbField(ObjectField of) { + if (!of.hasMetaAttr(ObjectField.ATTR_OBJECTREF)) return false; + MetaObject ref; + try { + ref = of.getObjectRef(); + } catch (RuntimeException unresolved) { + return false; + } + if (ref == null || !MetaObject.SUBTYPE_VALUE.equals(ref.getSubType())) return false; + if (!of.hasMetaAttr(ObjectField.ATTR_STORAGE)) return true; + Object storage = of.getMetaAttr(ObjectField.ATTR_STORAGE).getValue(); + return "jsonb".equalsIgnoreCase(String.valueOf(storage).trim()); + } } diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/KNOWN_GAPS.md b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/KNOWN_GAPS.md index 26c36d154..150db2a8e 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/KNOWN_GAPS.md +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/KNOWN_GAPS.md @@ -41,7 +41,7 @@ these once the partial-update story is settled cross-port. **Write-through interaction (FR-024 §7, #214).** A write-through entity read-view carries DERIVED (`origin.*`) fields on its read shape. The `Patch` (update input) EXCLUDES all derived fields -(`SpringDtoGenerator.minusPk` drops `field.isDerived()`), the read `Dto` +(`SpringGeneratorSupport.minusPk` drops `field.isDerived()`), the read `Dto` carries them, and — because Java shares one `Dto` for read AND create — a derived field on a write-through entity carries NO client-validation constraints (`validationAnnotations` early-returns for it, like an `@autoSet` field), so a `POST`-create @@ -127,7 +127,7 @@ A `field.object` value-object column mapped to a jsonb column (single or `@isArray`) is now bound → validated → written on `PATCH` and `POST`, cross-port (TS / Python / Java / Kotlin / C#), gated by `fixtures/api-contract-conformance/jsonb/scenarios/jsonb-value-object-patch.yaml` -in both lanes. `SpringDtoGenerator.settableFields` includes `ObjectField`; a new +in both lanes. `SpringGeneratorSupport.settableFields` includes `ObjectField`; a new `SpringValueObjectGenerator` emits the VO records the DTO/Patch bind. Nested VO constraints validate in full (spec §0: `validateValue` does NOT cascade `@Valid`, so the controller adds an explicit `validator.validate(voElement)` per present diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java index 3dd030642..8cc8a6f50 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java @@ -5,6 +5,7 @@ // `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.spring.AutoSetSupport; +import com.metaobjects.generator.spring.SpringGeneratorSupport; import com.metaobjects.generator.spring.SpringM2mSupport; import com.metaobjects.generator.spring.SpringNaming; import com.metaobjects.generator.spring.SpringTypeMapper; @@ -168,7 +169,7 @@ public void execute(MetaDataLoader loader) { * {@link RestSurfaceGate#isReadOnly(MetaObject)} which of the two shapes is emitted. * *

    The predicate itself lives in {@link RestSurfaceGate} and is SHARED with - * {@link SpringRepositoryGenerator#appliesTo(MetaObject)} and + * {@link SpringGeneratorSupport#repositoryAppliesTo(MetaObject)} and * {@link SpringFilterAllowlistGenerator#appliesTo(MetaObject)} — not merely equal to * them. The controller NAMES the repository and the allowlist, so a gate widened in * one copy and not the others emits a controller importing types nothing generated, @@ -304,7 +305,7 @@ protected void emit(MetaObject entity, Path outRoot) { // @required, and the only "workaround" was a client-invented primary key. // // The TPH create path has always applied this rule, through - // SpringDtoGenerator.settableFields ("the PK is auto-generated and the discriminator is + // SpringGeneratorSupport.settableFields ("the PK is auto-generated and the discriminator is // injected from the URL, so neither is validated from the body") — which is why a TPH // entity POSTed fine while a vanilla one did not. This is the vanilla path adopting the // same rule, so the two write surfaces finally agree. @@ -327,7 +328,7 @@ protected void emit(MetaObject entity, Path outRoot) { // persisting (the caller's value is ignored; a fresh row's updated_at equals its created_at). // The stamp is a static helper on the DTO record; entities with no @autoSet field call // repository.create(dto) verbatim (byte-identical to the pre-#203 handler). - String createArg = AutoSetSupport.hasAutoSetFields(entity) + String createArg = SpringGeneratorSupport.hasAutoSetFields(entity) ? dtoName + ".stampForInsert(dto)" : "dto"; src.append(" try {\n"); src.append(" ").append(dtoName).append(" saved = repository.create(").append(createArg).append(");\n"); @@ -376,7 +377,7 @@ protected void emit(MetaObject entity, Path outRoot) { // update (the model value is ignored; the server owns the timestamp), injected AFTER the // caller's present values are validated and BEFORE delegating so a PATCH that omits the // timestamp still bumps it. onCreate columns are never touched here (created_at is immutable). - if (AutoSetSupport.hasOnUpdateFields(entity)) { + if (SpringGeneratorSupport.hasOnUpdateFields(entity)) { src.append(" patch.stampAutoSetOnUpdate();\n"); } src.append(" try {\n"); @@ -404,12 +405,12 @@ protected void emit(MetaObject entity, Path outRoot) { // relationship name. Related-row order is not contractual. The repository finder // traverses the junction (hetero / directed self-join / symmetric union-on-read). for (SpringM2mSupport.M2mNav nav : SpringM2mSupport.resolve(entity, loader)) { - String finder = SpringRepositoryGenerator.m2mFinderName(nav.relationName()); + String finder = SpringGeneratorSupport.m2mFinderName(nav.relationName()); src.append(" @GetMapping(\"/{id}/").append(nav.relationName()).append("\")\n"); src.append(" public ResponseEntity> ") .append(finder).append("(@PathVariable ").append(pkType).append(" id) {\n"); src.append(" return ResponseEntity.ok(repository.").append(finder) - .append("(id").append(SpringRepositoryGenerator.targetSubtypeCallArg(nav)).append("));\n"); + .append("(id").append(SpringGeneratorSupport.targetSubtypeCallArg(nav)).append("));\n"); src.append(" }\n\n"); } @@ -437,7 +438,7 @@ protected void emit(MetaObject entity, Path outRoot) { * entity with no value-object columns, keeping non-VO controllers byte-identical. */ private static void appendValueObjectValidation(StringBuilder src, MetaObject entity) { - for (ObjectField vf : SpringDtoGenerator.valueObjectJsonbFields(entity)) { + for (ObjectField vf : SpringGeneratorSupport.valueObjectJsonbFields(entity)) { String name = vf.getName(); // has() needs no escape — capitalisation already clears the collision — but // the value accessor does. @@ -458,7 +459,7 @@ private static void appendValueObjectValidation(StringBuilder src, MetaObject en // with validateValue, which does NOT cascade — so without this the two write paths // disagree for maps where they agree for field.object, and a PATCH could write a // nested value object POST rejects. The map's VALUES are the beans (keys are strings). - for (MetaField vf : SpringDtoGenerator.valueObjectMapFields(entity)) { + for (MetaField vf : SpringGeneratorSupport.valueObjectMapFields(entity)) { String name = vf.getName(); String accessor = SpringNaming.recordComponentName(name); appendElementValidationLoop( @@ -937,12 +938,12 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { // the per-subtype loop — the overlap is deliberate (FR-017 design): a subtype // resource carries the same sub-resources as any other resource. for (SpringM2mSupport.M2mNav nav : SpringM2mSupport.resolve(base, loader)) { - String finder = SpringRepositoryGenerator.m2mFinderName(nav.relationName()); + String finder = SpringGeneratorSupport.m2mFinderName(nav.relationName()); src.append(" @GetMapping(\"/{id}/").append(nav.relationName()).append("\")\n"); src.append(" public ResponseEntity> ") .append(finder).append("(@PathVariable ").append(pkType).append(" id) {\n"); src.append(" return ResponseEntity.ok(repository.").append(finder) - .append("(id").append(SpringRepositoryGenerator.targetSubtypeCallArg(nav)).append("));\n"); + .append("(id").append(SpringGeneratorSupport.targetSubtypeCallArg(nav)).append("));\n"); src.append(" }\n\n"); } @@ -1004,7 +1005,7 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { // validateValue applies @NotNull to a null value, so an absent @required column 400s too — // matching the vanilla create's whole-bean validate, scoped to this subtype. List createValidated = - SpringDtoGenerator.settableFields(st.entity(), TphPlan.discriminatorFieldOf(st.entity())); + SpringGeneratorSupport.settableFields(st.entity(), TphPlan.discriminatorFieldOf(st.entity())); src.append(" @PostMapping(\"/").append(seg).append("\")\n"); src.append(" public ResponseEntity create").append(suffix) .append("(@RequestBody ").append(dtoName).append(" dto) {\n"); @@ -1024,7 +1025,7 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { // so before this loop its VALUES — which are the beans — were written unvalidated. // The vanilla create cascades via @Valid on the DTO component; this is the TPH // path's equivalent, so the two write surfaces agree. - for (MetaField vf : SpringDtoGenerator.valueObjectMapFields(st.entity())) { + for (MetaField vf : SpringGeneratorSupport.valueObjectMapFields(st.entity())) { appendMapValueValidationLoop( src, "dto." + SpringNaming.recordComponentName(vf.getName()) + "()"); } @@ -1037,14 +1038,14 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { // // Review fix (Task 3): gate on `base`, NOT `st.entity()`. The helper this emits // (Dto.stampForInsert) is only ever generated by - // SpringDtoGenerator.emitTphUnion when AutoSetSupport.hasAutoSetFields(base) is true + // SpringDtoGenerator.emitTphUnion when SpringGeneratorSupport.hasAutoSetFields(base) is true // (@autoSet columns live on the base, shared by every subtype row in the single // table) — gating this call on the subtype's own resolving field set instead let a // subtype-declared @autoSet column fire `true` here while the helper it calls was // never emitted, producing a non-compiling controller. A TPH subtype declaring its // OWN @autoSet column is a documented non-goal (uniform with the Kotlin and Python // ports, both base-scoped): the column is simply never stamped, not a compile break. - String createArg = AutoSetSupport.hasAutoSetFields(base) + String createArg = SpringGeneratorSupport.hasAutoSetFields(base) ? dtoName + ".stampForInsert(dto)" : "dto"; src.append(" try {\n"); src.append(" ").append(dtoName).append(" saved = repository.createWithType(\"").append(disc) @@ -1080,7 +1081,7 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { // bean, so a PRESENT field.map @objectRef had its values written unvalidated. The // Patch carries the hasX()/x() tristate, so this is the same loop the vanilla // PATCH runs — skipping absent keys, and skipping an explicit null (which clears). - for (MetaField vf : SpringDtoGenerator.valueObjectMapFields(st.entity())) { + for (MetaField vf : SpringGeneratorSupport.valueObjectMapFields(st.entity())) { String mvc = SpringNaming.recordComponentName(vf.getName()); appendElementValidationLoop( src, mvc, SpringNaming.capitalize(vf.getName()), @@ -1094,7 +1095,7 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { // Review fix (Task 3): gate on `base`, matching the create-arg gate above, for the // same reason — a subtype-own @autoSet column is a documented non-goal, never // stamped, uniform across ports. - if (AutoSetSupport.hasOnUpdateFields(base)) { + if (SpringGeneratorSupport.hasOnUpdateFields(base)) { src.append(" patch.stampAutoSetOnUpdate();\n"); } src.append(" try {\n"); @@ -1129,7 +1130,7 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { // implemented identically to findTags) compiles, type-checks, and silently 200s a // sibling's rows — nothing else in the pipeline catches that. for (SpringM2mSupport.M2mNav nav : SpringM2mSupport.resolve(st.entity(), loader)) { - String subFinder = SpringRepositoryGenerator.m2mFinderNameForSubtype(nav.relationName(), disc); + String subFinder = SpringGeneratorSupport.m2mFinderNameForSubtype(nav.relationName(), disc); src.append(" @GetMapping(\"/").append(seg).append("/{id}/").append(nav.relationName()).append("\")\n"); src.append(" public ResponseEntity> ") .append(subFinder).append("(@PathVariable ").append(pkType).append(" id) {\n"); @@ -1137,7 +1138,7 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { src.append(" return ResponseEntity.ok(List.of());\n"); src.append(" }\n"); src.append(" return ResponseEntity.ok(repository.").append(subFinder) - .append("(id").append(SpringRepositoryGenerator.targetSubtypeCallArg(nav)).append("));\n"); + .append("(id").append(SpringGeneratorSupport.targetSubtypeCallArg(nav)).append("));\n"); src.append(" }\n\n"); } } @@ -1248,7 +1249,7 @@ private static void appendSortDefaultOrders(StringBuilder src, MetaObject owner, * The DTO record components a CALLER never supplies on create, because the server owns them: * the primary-key field(s) and every {@code @autoSet} column. * - *

    This is the same concept {@link SpringDtoGenerator#settableFields(MetaObject, String)} + *

    This is the same concept {@link SpringGeneratorSupport#settableFields(MetaObject, String)} * already excludes for the TPH create path and the {@code Patch} — stated here as the * COMPLEMENT (what to ignore) rather than the settable set, because the vanilla create * validates the whole DTO for its {@code @Valid} cascade and filters the result.

    @@ -1264,7 +1265,7 @@ private static List serverOwnedOnCreate(MetaObject entity) { .orElse(List.of()); List out = new ArrayList<>(); - for (MetaField f : SpringDtoGenerator.scalarFields(entity)) { + for (MetaField f : SpringGeneratorSupport.scalarFields(entity)) { if (pkFields.contains(f.getName()) || AutoSetSupport.isAutoSet(f)) { String component = SpringNaming.recordComponentName(f.getName()); if (!out.contains(component)) out.add(component); diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java index 9a5b6d53d..d97a7ae56 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java @@ -6,6 +6,7 @@ // same-package bare-name resolution, is what keeps the ejected copy compiling. import com.metaobjects.generator.spring.AutoSetSupport; import com.metaobjects.generator.spring.Fr019SharedEnum; +import com.metaobjects.generator.spring.SpringGeneratorSupport; import com.metaobjects.generator.spring.SpringNaming; import com.metaobjects.generator.spring.SpringRecordBuilder; import com.metaobjects.generator.spring.SpringTypeMapper; @@ -150,7 +151,7 @@ public void execute(MetaDataLoader loader) { // produce a controller/repository that references a missing Patch. // A sourceless entity (identity, no source — a record in a store MetaObjects does // not manage) gets the same Patch for the PATCH endpoint its adopter writes. - if ((SpringRepositoryGenerator.appliesTo(entity) || RestSurfaceGate.isSourcelessEntity(entity)) + if ((SpringGeneratorSupport.repositoryAppliesTo(entity) || RestSurfaceGate.isSourcelessEntity(entity)) && !TphPlan.isTphBase(entity, loader) && !TphPlan.isTphSubtype(entity)) { emitPatch(entity, outRoot); @@ -194,19 +195,19 @@ public static boolean appliesTo(MetaObject entity) { } protected void emit(MetaObject entity, Path outRoot) { - List fields = dtoComponentFields(entity); + List fields = SpringGeneratorSupport.dtoComponentFields(entity); // Each field carries its full validation (the standard, non-TPH DTO). A value-object // jsonb column additionally gets @Valid so the POST path's validator.validate(dto) // cascades into the nested VO's constraints (spec section 0 — @Valid cascades on // validate(bean), unlike the PATCH path's per-field validateValue). List annotationsPerField = new ArrayList<>(fields.size()); for (MetaField field : fields) { - String a = validationAnnotations(field); + String a = SpringGeneratorSupport.validationAnnotations(field); // @Valid also cascades on a Map component -- Bean Validation descends into a map's // VALUES -- so a field.map @objectRef gets it too. Parity with the TS zod emit, // which types such a map as z.record(z.string(), InsertSchema): the map's // values ARE validated there, so the JVM port must validate them as well. - if (valueObjectRefOf(field) != null) a = a.isEmpty() ? "@Valid" : "@Valid " + a; + if (SpringGeneratorSupport.valueObjectRefOf(field) != null) a = a.isEmpty() ? "@Valid" : "@Valid " + a; // #234: a STRICT field.uri / field.inet component binds through the codegen-owned // literal deserializer (absolute-scheme URI / IPv4-or-IPv6 literal, no DNS) so a // malformed value is rejected at the wire tier (HTTP 400), aligning the JVM DTO with @@ -223,7 +224,7 @@ protected void emit(MetaObject entity, Path outRoot) { // a read-only projection DTO never sprouts write-path helpers. // // ADR-0045 (#203/#229): a TPH subtype (e.g. BridgeAuth) has no OWN source.rdb — single- - // table inheritance shares the base's — so SpringRepositoryGenerator.appliesTo's own-only + // table inheritance shares the base's — so SpringGeneratorSupport.repositoryAppliesTo's own-only // firstRdbSource lookup always returns false for it, even though it IS writable via the // base's table. Include TphPlan.isTphSubtype(entity) explicitly so its standalone Dto // gets the same stamping helpers as any other writable entity's DTO (parity with the @@ -232,9 +233,9 @@ protected void emit(MetaObject entity, Path outRoot) { // A report row is read-only and derives no @autoSet field; excluded by name so the // "writable" reading of this flag stays true. boolean writableForAutoSet = !GeneratorUtil.isReport(entity) - && (SpringRepositoryGenerator.appliesTo(entity) || TphPlan.isTphSubtype(entity)); + && (SpringGeneratorSupport.repositoryAppliesTo(entity) || TphPlan.isTphSubtype(entity)); List extraBodyMembers = - (writableForAutoSet && AutoSetSupport.hasAutoSetFields(entity)) + (writableForAutoSet && SpringGeneratorSupport.hasAutoSetFields(entity)) ? autoSetStampHelpers(entity, fields, SpringNaming.dtoName( SpringNaming.splitFqn(entity.getName())[1])) : List.of(); @@ -258,7 +259,7 @@ private void emitPatch(MetaObject entity, Path outRoot) { // Issue #203: the vanilla patch gains stampAutoSetOnUpdate() for each @autoSet onUpdate // column, so the generated controller's PATCH handler bumps updated_at on every write. writePatch(entity, outRoot, SpringNaming.patchName(shortName), - SpringNaming.dtoName(shortName), settableFields(entity), AutoSetSupport.onUpdateFields(entity)); + SpringNaming.dtoName(shortName), SpringGeneratorSupport.settableFields(entity), SpringGeneratorSupport.onUpdateFields(entity)); } /** @@ -281,8 +282,8 @@ private void emitTphSubtypePatch(MetaObject subtype, Path outRoot) { // the hook. Empty for a hierarchy with no @autoSet column, so a non-@autoSet Patch // stays byte-identical. writePatch(subtype, outRoot, SpringNaming.patchName(shortName), - SpringNaming.dtoName(shortName), settableFields(subtype, discriminatorField), - AutoSetSupport.onUpdateFields(subtype)); + SpringNaming.dtoName(shortName), SpringGeneratorSupport.settableFields(subtype, discriminatorField), + SpringGeneratorSupport.onUpdateFields(subtype)); } /** @@ -369,7 +370,7 @@ private void writePatch(MetaObject entity, Path outRoot, String patchName, Strin for (MetaField field : settable) { String name = field.getName(); String type = patchComponentType(field, entity, dtoName); - boolean required = isRequired(field); + boolean required = SpringGeneratorSupport.isRequired(field); // A TypeReference (not a raw Class) so a generic component — e.g. a // field. @isArray List — binds with its element type intact. src.append(" bind(body, mapper, assigned, \"").append(name) @@ -400,100 +401,9 @@ private void writePatch(MetaObject entity, Path outRoot, String patchName, Strin writeJavaFile(entity, outRoot, pkg, patchName, src.toString()); } - /** - * The vanilla {@code Patch} settable set: DTO component fields (scalars PLUS - * value-object jsonb columns, Program D) MINUS the primary-key field(s) MINUS any - * {@code @autoSet} column. - * - *

    Review-gate fix (write-once contract): {@code @autoSet} is server-owned — onCreate - * columns (e.g. {@code createdAt}) are write-once and nothing else in the generated CRUD - * ever overwrites them past insert, so leaving one caller-bindable here let a PATCH mutate - * it directly. onUpdate columns (e.g. {@code updatedAt}) are always overwritten by the - * separately-emitted {@code stampAutoSetOnUpdate()} hook regardless of this list, but are - * excluded too for consistency with the TPH {@link #settableFields(MetaObject, String)} - * 2-arg overload below, which already excludes {@code @autoSet} entirely — this is the - * SSOT alignment the 2-arg overload's own comment calls out as remaining scope.

    - */ - private static List settableFields(MetaObject entity) { - List out = new ArrayList<>(); - for (MetaField field : minusPk(entity, dtoComponentFields(entity), null)) { - if (AutoSetSupport.isAutoSet(field)) continue; - // FR-037 R1: both non-readWrite @mutability modes leave the PATCH settable - // set — `readOnly` is never written at all, `writeOnce` is frozen after - // create. A value presented for one is STRIPPED (simply not in the settable - // set), never 400'd — the uniform behaviour of every excluded key here. - if (isNonReadWriteMutability(field)) continue; - out.add(field); - } - return out; - } - /** - * FR-037 R1 — true when the field's EFFECTIVE {@code @mutability} is anything other - * than {@code readWrite}, i.e. it is excluded from the PATCH settable set. Resolving - * (ADR-0039), so a mode inherited through {@code extends} is honoured. - * - *

    The two modes differ on CREATE, not here: {@code readOnly} is additionally - * dropped from the create shape, while {@code writeOnce} stays settable there - * exactly once. That split lives in the create-shape emitters.

    - */ - static boolean isNonReadWriteMutability(MetaField field) { - return !MetaField.MUTABILITY_READ_WRITE.equals(field.getMutability()); - } - /** - * The TPH per-subtype settable set: {@link #scalarFields(MetaObject)} MINUS the primary key - * MINUS the field named {@code alsoExclude} (the immutable discriminator, or nothing when - * {@code null}). Scalar-only — value-object columns on a TPH hierarchy are out of scope - * (Program D), so a TPH base's union DTO and per-subtype patch stay VO-free. - * - *

    Public so {@link SpringControllerGenerator#emitTph} can validate a TPH per-subtype CREATE - * body against exactly the field set the sibling {@code Patch} covers (effective scalars - * MINUS the primary key MINUS the discriminator), keeping create + patch validation on one - * SSOT: the PK is auto-generated and the discriminator is injected from the URL, so neither is - * validated from the body.

    - */ - public static List settableFields(MetaObject entity, String alsoExclude) { - List out = new ArrayList<>(); - for (MetaField field : minusPk(entity, scalarFields(entity), alsoExclude)) { - // ADR-0045 (#203/#229): @autoSet is server-owned — never a caller-settable TPH - // per-subtype field. Excluding it here is the SSOT for both the create-validated set - // (SpringControllerGenerator#emitTph's createValidated) and the sibling Patch's - // settable/bindable set, so a caller can neither be required to supply it on POST nor - // mutate it via PATCH. (The single-arg vanilla settableFields(entity) overload above, - // used by the non-TPH Patch, applies the identical exclusion — review-gate fix, - // see its javadoc.) - if (AutoSetSupport.isAutoSet(field)) continue; - // FR-037 R1: the same exclusion as the vanilla overload above — ADR-0045 - // says the OUTERMOST generated write artifact enforces the mode, and TPH is - // a separate code path (the 0.19.4 lesson), so it is stated here too rather - // than assumed to fall out of the vanilla walk. - if (isNonReadWriteMutability(field)) continue; - out.add(field); - } - return out; - } - /** {@code pool} MINUS the entity's primary-key field(s) MINUS {@code alsoExclude} (when non-null). */ - private static List minusPk(MetaObject entity, List pool, String alsoExclude) { - List pkFields = entity.getIdentities(true).stream() - .filter(MetaIdentity::isPrimary) - .findFirst() - .map(MetaIdentity::getFields) - .orElse(List.of()); - List out = new ArrayList<>(); - for (MetaField field : pool) { - if (pkFields.contains(field.getName())) continue; - if (alsoExclude != null && alsoExclude.equals(field.getName())) continue; - // FR-024 §7 (#214): a DERIVED field (origin.*) is view-computed, not stored on the - // writable table, so it is excluded from every write-input shape (the Patch - // and the TPH per-subtype settable set). The read Dto still carries it (via - // dtoComponentFields) — only the settable/write pools drop it. - if (field.isDerived()) continue; - out.add(field); - } - return out; - } /** The {@code Patch} component type for {@code field}. Identical to the DTO's * FR-019-aware {@link #componentTypeFr019} EXCEPT for an INLINE {@code field.enum}: its @@ -514,7 +424,7 @@ private String patchComponentType(MetaField field, MetaObject entity, String * single wire shape backs both the polymorphic and per-subtype endpoints of the base controller. */ protected void emitTphUnion(MetaObject base, MetaDataLoader loader, Path outRoot) { - List fields = new ArrayList<>(scalarFields(base)); + List fields = new ArrayList<>(SpringGeneratorSupport.scalarFields(base)); for (MetaField field : TphPlan.collectSubtypeFields(base, loader)) { if (field instanceof ObjectField) continue; fields.add(field); @@ -531,11 +441,11 @@ protected void emitTphUnion(MetaObject base, MetaDataLoader loader, Path outRoot // record, so the stamping helper the controller calls (stampForInsert) must be typed to // the union HERE — a standalone Dto's own stampForInsert(SubDto) would not type-check // against the union-typed `dto` the create handler binds. Gated exactly like the vanilla - // path (AutoSetSupport.hasAutoSetFields(base) — @autoSet columns live on the base, shared + // path (SpringGeneratorSupport.hasAutoSetFields(base) — @autoSet columns live on the base, shared // by every subtype row in the single table): empty for a non-@autoSet hierarchy, so the // union DTO stays byte-identical. List extraBodyMembers = - (SpringRepositoryGenerator.appliesTo(base) && AutoSetSupport.hasAutoSetFields(base)) + (SpringGeneratorSupport.repositoryAppliesTo(base) && SpringGeneratorSupport.hasAutoSetFields(base)) ? autoSetStampHelpers(base, fields, SpringNaming.dtoName(SpringNaming.splitFqn(base.getName())[1])) : List.of(); emitRecord(base, outRoot, fields, annotationsPerField, extraBodyMembers); @@ -644,8 +554,8 @@ private void emitRecord(MetaObject entity, Path outRoot, List fields, * component through and substituting {@code now()} for the stamped ones. */ private List autoSetStampHelpers(MetaObject entity, List fields, String recordName) { - List stampAll = AutoSetSupport.stampFields(entity); // onCreate + onUpdate - List onUpdate = AutoSetSupport.onUpdateFields(entity); // onUpdate only + List stampAll = SpringGeneratorSupport.stampFields(entity); // onCreate + onUpdate + List onUpdate = SpringGeneratorSupport.onUpdateFields(entity); // onUpdate only List members = new ArrayList<>(); members.add(stampHelper(recordName, "stampForInsert", fields, stampAll, "a copy with every @autoSet column (onCreate AND onUpdate) stamped to now() on insert " @@ -723,7 +633,7 @@ protected void emitAbstractShape(MetaObject entity, Path outRoot) { src.append("/** GENERATED — abstract wire-DTO shape for ").append(shortName) .append(". Do not hand-edit; regenerated from metadata. */\n"); src.append("public interface ").append(typeName).append(" {\n"); - for (MetaField field : scalarFields(entity)) { + for (MetaField field : SpringGeneratorSupport.scalarFields(entity)) { String type = SpringTypeMapper.javaTypeName(field); // JLS 9.2: an interface method override-equivalent to a public Object method but // with a different return type is a compile error, so the shape escapes too — and @@ -751,149 +661,23 @@ protected void writeJavaFile(MetaObject entity, Path outRoot, String pkg, String } } - /** - * Return the entity's scalar fields — i.e. every {@link MetaField} that is - * not an {@link ObjectField}. Value-object {@code field.object} columns are - * carried separately by {@link #dtoComponentFields(MetaObject)}; this - * scalar-only view still backs the TPH union / abstract-shape / sort surfaces - * (which are value-object-agnostic). - */ - public static List scalarFields(MetaObject entity) { - List out = new ArrayList<>(); - for (MetaField field : entity.getMetaFields()) { - if (field instanceof ObjectField) continue; - out.add(field); - } - return out; - } - /** The single legal {@code @storage} value for a value-object jsonb column (Program D); - * {@code flattened} / {@code subdocument} owned-object storage is out of scope. */ - static final String STORAGE_JSONB = "jsonb"; - /** - * The vanilla {@code Dto} / {@code Patch} component fields: every scalar - * field PLUS every value-object jsonb column ({@code field.object @objectRef= @storage:jsonb}, - * single or {@code @isArray}), in declared order. Program D: the VO columns become - * strongly-typed record components (bound to the generated {@code } record) so they - * POST + PATCH with nested validation. A non-VO {@link ObjectField} (flattened / subdocument - * storage, or an unresolved / non-value {@code @objectRef}) is still skipped. - */ - public static List dtoComponentFields(MetaObject entity) { - List out = new ArrayList<>(); - for (MetaField field : entity.getMetaFields()) { - if (field instanceof ObjectField) { - if (isValueObjectJsonbField(field)) out.add(field); - continue; - } - out.add(field); - } - return out; - } - /** The value-object jsonb columns of {@code entity}, in declared order (Program D) — the - * fields the controller runs explicit nested-VO validation over on PATCH (spec section 0). */ - public static List valueObjectJsonbFields(MetaObject entity) { - List out = new ArrayList<>(); - for (MetaField field : entity.getMetaFields()) { - if (isValueObjectJsonbField(field)) out.add((ObjectField) field); - } - return out; - } - /** The {@code field.map @objectRef} columns of {@code entity}, in declared order — the map - * analog of {@link #valueObjectJsonbFields}. The controller runs the same nested-VO - * validation over their VALUES on PATCH. */ - public static List valueObjectMapFields(MetaObject entity) { - List out = new ArrayList<>(); - for (MetaField field : entity.getMetaFields()) { - if (mapValueObjectRefOf(field) != null) out.add(field); - } - return out; - } - /** - * True iff {@code field} is a value-object jsonb column: a {@link ObjectField} whose - * {@code @objectRef} resolves to an {@code object.value} and whose {@code @storage} is - * jsonb (explicit, or the single-jsonb-column default when {@code @storage} is absent). - * Flattened / subdocument owned-object storage is out of scope (Program D). - */ - public static boolean isValueObjectJsonbField(MetaField field) { - if (!(field instanceof ObjectField of)) return false; - if (!of.hasMetaAttr(ObjectField.ATTR_OBJECTREF)) return false; - MetaObject ref; - try { - ref = of.getObjectRef(); - } catch (RuntimeException unresolved) { - return false; - } - if (ref == null || !MetaObject.SUBTYPE_VALUE.equals(ref.getSubType())) return false; - if (!of.hasMetaAttr(ObjectField.ATTR_STORAGE)) return true; // default single-jsonb-column - Object storage = of.getMetaAttr(ObjectField.ATTR_STORAGE).getValue(); - return STORAGE_JSONB.equalsIgnoreCase(String.valueOf(storage).trim()); - } - /** - * The {@code object.value} that {@code field} carries by reference, else {@code null}. - * TWO field shapes carry one, and both must be reached: - *
      - *
    • a {@code field.object @objectRef} jsonb column ({@link #isValueObjectJsonbField});
    • - *
    • a {@code field.map @objectRef} -- a {@code Map}. A map's storage is - * ALWAYS the single jsonb column, so unlike {@code field.object} it has no - * {@code @storage} axis to gate on.
    • - *
    - * Mirrors the C# {@code EntityGenerator.ReferencesValueObject} predicate, which already - * spans both. Drives {@link SpringValueObjectGenerator}'s reachability walk (a VO reached - * only through a map would otherwise never be EMITTED, leaving the DTO naming a record - * that does not exist) and the {@code @Valid} cascade on the DTO component. - */ - static MetaObject valueObjectRefOf(MetaField field) { - if (isValueObjectJsonbField(field)) return ((ObjectField) field).getObjectRef(); - return mapValueObjectRefOf(field); - } - /** - * The {@code object.value} a {@code field.map @objectRef} maps its values to, else - * {@code null} (a scalar-valued {@code @valueType} map, a non-map field, or a - * {@code @objectRef} that does not resolve to an {@code object.value}). - */ - static MetaObject mapValueObjectRefOf(MetaField field) { - if (!(field instanceof MapField mf)) return null; - if (!mf.hasMetaAttr(MapField.ATTR_OBJECTREF)) return null; - MetaObject ref; - try { - ref = mf.getObjectRef(); - } catch (RuntimeException unresolved) { - return null; - } - return (ref != null && MetaObject.SUBTYPE_VALUE.equals(ref.getSubType())) ? ref : null; - } // === validation (SP-C validator parity) ================================= - /** - * Java DTO-record component type for {@code field} owned by {@code owner}. A - * {@code field.enum} is typed as the value-constrained nested Java {@code enum} - * ({@link SpringTypeMapper#enumTypeName}, single or {@code List}) whose declaration - * {@link #collectEnumDecls} emits inside the record body — parity with the other ports and - * with {@link SpringValueObjectGenerator}. Other scalars delegate to {@link SpringTypeMapper}; - * array fields ({@code isArray=true}) are wrapped as {@code List} (the wrapped - * element type so an omitted JSON element deserialises to {@code null}). - */ - public static String componentType(MetaField field, MetaObject owner) { - if (field instanceof EnumField) { - return SpringTypeMapper.payloadJavaTypeName(field, owner, ""); - } - String element = SpringTypeMapper.javaTypeName(field); - return SpringTypeMapper.wrapsAsList(field) ? "java.util.List<" + element + ">" : element; - } /** * FR-019-aware DTO component type. A {@code field.enum} resolving to a package-level shared enum * is typed by the materialized standalone type (a bare {@code E} in the same package, else its * FQN) or — when {@code @provided} — the configured external {@code .E}; an enum array wraps * it in {@code List<…>}. Every other field (including inline enums) delegates verbatim to the - * static {@link #componentType(MetaField, MetaObject)}, so the inline default stays byte-identical. + * static {@link SpringGeneratorSupport#componentType(MetaField, MetaObject)}, so the inline default stays byte-identical. */ private String componentTypeFr019(MetaField field, MetaObject owner) { if (field instanceof EnumField) { @@ -903,7 +687,7 @@ private String componentTypeFr019(MetaField field, MetaObject owner) { return field.isArrayType() ? "java.util.List<" + ref + ">" : ref; } } - return componentType(field, owner); + return SpringGeneratorSupport.componentType(field, owner); } // === #234 strict field.uri / field.inet bind-layer enforcement ============= @@ -948,7 +732,7 @@ private void emitNetBindings(MetaDataLoader loader, Path outRoot) { // over a strict field.uri / field.inet binds through MetaNetBindings too). MetaObject entity = RestSurfaceGate.restShapeOf(declared); if (entity == null || !appliesTo(entity)) continue; - for (MetaField field : dtoComponentFields(entity)) { + for (MetaField field : SpringGeneratorSupport.dtoComponentFields(entity)) { if (isStrictNetField(field)) { packages.add(SpringNaming.splitFqn(entity.getName())[0]); break; @@ -1078,262 +862,18 @@ private static List collectEnumDecls(MetaObject owner, List f return decls; } - /** True iff {@code field} is {@code @required} — its own {@code @required} attr OR a - * {@code validator.required} child. The SINGLE required-predicate reused across the - * DTO's {@code @NotNull} and the FR-035 patch's present-null-on-@required → 400 rule - * (the FR-035 patch emit below), so the two cannot drift. */ - public static boolean isRequired(MetaField field) { - return attrBool(field, MetaField.ATTR_REQUIRED) || hasValidator(field, RequiredValidator.class); - } - /** - * #195 — a field whose value is derived by an {@code origin.aggregate} - * {@code @agg:any|all|collect} is COALESCE-guaranteed non-null in the synthesized - * view (any→false, all→true, collect→empty-array), so its read type - * is non-null even when the field is not {@code @required}. Drives {@code @NotNull} - * in {@link #validationAnnotations} so a projection DTO's read shape agrees with its - * view column — adopters no longer handle a null that never occurs. - * - *

    {@code origin.first} is deliberately NOT here (an empty related set selects no - * row → null); {@code origin.computed} nullability is expression-dependent, so - * it stays the conservative nullable default. Mirrors the TS - * {@code originGuaranteedNonNull} (codegen-ts {@code column-mapper}).

    - */ - public static boolean originGuaranteedNonNull(MetaField field) { - // ADR-0039: own — origin.* never inherits (ADR-0029), so an own-only read is correct. - for (MetaData c : field.getChildren(MetaData.class, false)) { - if (!(c instanceof MetaOrigin)) continue; - if (!AggregateOrigin.SUBTYPE_AGGREGATE.equals(c.getSubType())) continue; - String agg = ((MetaOrigin) c).getAgg(); - return MetaOrigin.AGG_ANY.equals(agg) || MetaOrigin.AGG_ALL.equals(agg) - || MetaOrigin.AGG_COLLECT.equals(agg); - } - return false; - } - /** - * Build the space-joined jakarta.validation annotation string for a record - * component from the field's constraint metadata — both field attrs - * ({@code @required}, {@code @maxLength}) and {@code validator.*} children - * (length / regex / numeric / array). Returns {@code ""} when the field - * carries no constraints. - * - *

    Cross-port semantics (see the SP-C validator-parity contract):

    - *
      - *
    • required (field {@code @required} or {@code validator.required}) → - * {@code @NotNull}; a required non-array string additionally gets an - * implicit {@code @Size(min = 1)} (FR-036 Ruling 1: reject {@code null} + - * {@code ""}, ACCEPT whitespace-only) — never {@code @NotBlank}, which - * trims and would wrongly reject a whitespace value. An explicitly - * authored {@code validator.length @min} is always authoritative over - * that implicit floor (#224); {@code @min: 0} opts back to - * presence-only and emits no {@code @Size} minimum at all.
    • - *
    • {@code validator.length} + field {@code @maxLength} → - * {@code @Size(min=…, max=…)} on a NON-array string; the effective max is - * {@code min(validator.max, @maxLength)} (FR-036 A3 strictest-wins).
    • - *
    • {@code validator.regex @pattern} → {@code @Pattern(regexp=…)} (jakarta - * {@code matches()} is full-match — FR-036 Ruling 2, no anchoring needed).
    • - *
    • {@code validator.numeric @min/@max} → {@code @Min(…)} / {@code @Max(…)}.
    • - *
    • {@code validator.array @min/@max} → {@code @Size(min=…, max=…)} on the {@code List}.
    • - *
    - */ - /** - * True when {@code field} participates in its owner's ASSIGNED primary key — an - * {@code identity.primary} carrying no {@code @generation} (or an explicit - * {@code assigned}) — and has no {@code @default} to fill it. - * - *

    Such a key is create-REQUIRED regardless of {@code @required}: a natural key or - * an externally-issued id has no other source, so a create body omitting it can only - * fail at the database. An {@code increment}/{@code uuid} key is the opposite — the - * server supplies it, so it must stay optional (and TS omits it from the insert shape - * entirely). A key carrying a {@code @default} also stays optional: the column has - * that default.

    - * - *

    Mirrors the TS {@code assignedPkFieldNames}; gated cross-port by the - * {@code assigned-pk-missing} case in {@code fixtures/validation-conformance}.

    - */ - static boolean isAssignedPrimaryKeyField(MetaField field) { - if (!(field.getParent() instanceof MetaObject owner)) return false; - PrimaryIdentity pk = owner.getPrimaryIdentity(); - if (pk == null || pk.isAutoGenerated()) return false; - if (!pk.getFields().contains(field.getName())) return false; - // ADR-0039: resolving — @default may be inherited via extends. - return !field.hasMetaAttr(MetaField.ATTR_DEFAULT); - } - public static String validationAnnotations(MetaField field) { - // Issue #203: an @autoSet field is CRUD-owned (server stamps now() on insert/update), - // so it is never caller-required and never caller-validated. Emit NO constraint - // annotations — parity with TS, whose InsertSchema makes @autoSet fields - // `.optional().transform(...)` (a @required @autoSet field would otherwise emit - // @NotNull and wrongly 400 a POST that omits the server-filled timestamp). - if (AutoSetSupport.isAutoSet(field)) return ""; - - // FR-024 §7 (#214): a DERIVED field (origin.*) on a WRITE-THROUGH entity is view-computed - // and read-only — never client-supplied. Java shares one Dto for read AND create, - // and the generated POST-create validates the whole DTO (SpringControllerGenerator), so a - // guaranteed-non-null aggregate's @NotNull (from originGuaranteedNonNull, #195) would wrongly - // 400 a create that omits the view-computed field. Emit NO client-validation constraints for - // such a field (parity with the @autoSet early-return above). A PROJECTION is read-only - // (isWriteThrough()=false), so its derived fields keep @NotNull for read-nullability agreement. - if (field.isDerived() && field.getParent() instanceof MetaObject - && ((MetaObject) field.getParent()).isWriteThrough()) { - return ""; - } - boolean isArray = field.isArrayType(); - boolean isString = field instanceof StringField; - List out = new ArrayList<>(); - - boolean required = isRequired(field) || isAssignedPrimaryKeyField(field); - // FR-036 Ruling 1: a @required value → @NotNull (rejects null). Non-empty for a - // @required NON-ARRAY string is enforced by @Size(min>=1) in the string-length arm - // below — NOT @NotBlank, which trims and would wrongly reject a whitespace-only value. - // A @required array keeps @NotNull only (no @NotEmpty). - // #195: an origin.aggregate any/all/collect field is COALESCE-guaranteed non-null on - // read (any→false / all→true / collect→[]), so it is @NotNull even when not @required - // (the non-empty @Size(min=1) floor below stays keyed on @required only — such fields - // are boolean or array, never a non-array string). - if (required || originGuaranteedNonNull(field)) { - out.add("@NotNull"); - } - // String character-length @Size — a NON-array string field only (FR-036 A3): a - // field.string @isArray @maxLength must NOT carry a char-count @Size on its List - // (element-count bounds come solely from the validator.array arm below). Combines - // the explicit validator.length bounds with the field-level @maxLength. - if (isString && !isArray) { - Integer lengthMin = null; - Integer lengthMax = null; - LengthValidator length = validator(field, LengthValidator.class); - if (length != null) { - lengthMin = attrInt(length, LengthValidator.ATTR_MIN); - lengthMax = attrInt(length, LengthValidator.ATTR_MAX); - } - Integer fieldMaxLength = attrInt(field, StringField.ATTR_MAX_LENGTH); - if (fieldMaxLength != null) { - // FR-036 A3 precedence: strictest-wins — min(validator.length @max, @maxLength). - lengthMax = (lengthMax == null) ? fieldMaxLength : Math.min(lengthMax, fieldMaxLength); - } - if (required) { - // FR-036 Ruling 1 non-empty floor — applies ONLY when no - // validator.length @min was authored. An explicitly authored @min - // is always AUTHORITATIVE over the implicit floor (#224 / ADR-0044): - // @min: 0 opts back to presence-only and, per the cross-port - // emission pin, emits NO minimum constraint at all (no - // @Size(min=0)) — cleanest, deterministic output across ports. - if (lengthMin == null) { - lengthMin = 1; - } else if (lengthMin == 0) { - lengthMin = null; - } - } - if (lengthMin != null || lengthMax != null) { - out.add(sizeAnnotation(lengthMin, lengthMax)); - } - } - - // Regex pattern. - RegexValidator regex = validator(field, RegexValidator.class); - if (regex != null) { - String pattern = regex.resolvePattern(); - if (pattern != null) { - out.add("@Pattern(regexp = \"" + escapeJavaString(pattern) + "\")"); - } - } - - // Numeric value bounds. - NumericValidator numeric = validator(field, NumericValidator.class); - if (numeric != null) { - Integer min = attrInt(numeric, NumericValidator.ATTR_MIN); - Integer max = attrInt(numeric, NumericValidator.ATTR_MAX); - if (min != null) out.add("@Min(" + min + ")"); - if (max != null) out.add("@Max(" + max + ")"); - } - - // Array element-count bounds → @Size on the List component. - ArrayValidator array = validator(field, ArrayValidator.class); - if (array != null) { - Integer min = attrInt(array, ArrayValidator.ATTR_MIN); - Integer max = attrInt(array, ArrayValidator.ATTR_MAX); - if (min != null || max != null) { - out.add(sizeAnnotation(min, max)); - } - } - - // ADR-0036/0037 Wave 3 — @stringFormat on a plain string field. The canonical - // matcher per format lives HERE in codegen (NOT author validator.regex), since - // cross-language regex engines diverge. email → Jakarta @Email; hostname → a - // canonical DNS-hostname @Pattern check. - if (isString && !isArray && field.hasMetaAttr(StringField.ATTR_STRING_FORMAT)) { - String format = String.valueOf( - field.getMetaAttr(StringField.ATTR_STRING_FORMAT).getValue()).trim(); - if (StringField.STRING_FORMAT_EMAIL.equals(format)) { - out.add("@Email"); - } else if (StringField.STRING_FORMAT_HOSTNAME.equals(format)) { - out.add("@Pattern(regexp = \"" + escapeJavaString(HOSTNAME_REGEX) + "\")"); - } - } - - return String.join(" ", out); - } - - /** - * Canonical DNS-hostname matcher for {@code @stringFormat: hostname} (ADR-0036/0037 - * Wave 3). Codegen-owned — one or more dot-separated labels, each 1–63 chars of - * alphanumerics/hyphens not starting or ending with a hyphen. Mirrors the cross-port - * hostname matcher (TS Zod hostname check). - */ - static final String HOSTNAME_REGEX = - "^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$"; - - protected static String sizeAnnotation(Integer min, Integer max) { - StringBuilder b = new StringBuilder("@Size("); - boolean wrote = false; - if (min != null) { b.append("min = ").append(min); wrote = true; } - if (max != null) { if (wrote) b.append(", "); b.append("max = ").append(max); } - return b.append(')').toString(); - } // --- metadata read helpers ---------------------------------------------- - protected static boolean attrBool(MetaField field, String attr) { - if (!field.hasMetaAttr(attr)) return false; - Object raw = field.getMetaAttr(attr).getValue(); - if (raw instanceof Boolean b) return b; - return Boolean.parseBoolean(String.valueOf(raw)); - } - /** Read an int-valued attr from a node, trying each name in order; {@code null} when absent. */ - protected static Integer attrInt(MetaData node, String... attrNames) { - for (String attr : attrNames) { - if (node.hasMetaAttr(attr)) { - try { - return Integer.valueOf(node.getMetaAttr(attr).getValueAsString().trim()); - } catch (NumberFormatException ignore) { - return null; - } - } - } - return null; - } - @SuppressWarnings("unchecked") - private static V validator(MetaField field, Class type) { - for (MetaData child : field.getChildren()) { - if (type.isInstance(child)) return (V) child; - } - return null; - } - protected static boolean hasValidator(MetaField field, Class type) { - return validator(field, type) != null; - } - /** Escape a regex/string literal for safe embedding in generated Java source. */ - protected static String escapeJavaString(String s) { - return s.replace("\\", "\\\\").replace("\"", "\\\""); - } // === MultiFileDirectGeneratorBase abstract-method stubs ==================== // Whole files are written in execute(); the parent's print-writer pipeline is unused. @@ -1366,4 +906,94 @@ protected String getSingleOutputFilePath(MetaObject md) { protected String getSingleOutputFilename(MetaObject md) { return SpringNaming.splitFqn(md.getName())[1] + "Dto.java"; } + + /** @deprecated moved to {@link SpringGeneratorSupport#settableFields(MetaObject, String)}; kept so 1.0.x callers still compile. */ + @Deprecated + public static List settableFields(MetaObject entity, String alsoExclude) { + return SpringGeneratorSupport.settableFields(entity, alsoExclude); + } + + /** @deprecated moved to {@link SpringGeneratorSupport#scalarFields}; kept so 1.0.x callers still compile. */ + @Deprecated + public static List scalarFields(MetaObject entity) { + return SpringGeneratorSupport.scalarFields(entity); + } + + /** @deprecated moved to {@link SpringGeneratorSupport#dtoComponentFields}; kept so 1.0.x callers still compile. */ + @Deprecated + public static List dtoComponentFields(MetaObject entity) { + return SpringGeneratorSupport.dtoComponentFields(entity); + } + + /** @deprecated moved to {@link SpringGeneratorSupport#valueObjectJsonbFields}; kept so 1.0.x callers still compile. */ + @Deprecated + public static List valueObjectJsonbFields(MetaObject entity) { + return SpringGeneratorSupport.valueObjectJsonbFields(entity); + } + + /** @deprecated moved to {@link SpringGeneratorSupport#valueObjectMapFields}; kept so 1.0.x callers still compile. */ + @Deprecated + public static List valueObjectMapFields(MetaObject entity) { + return SpringGeneratorSupport.valueObjectMapFields(entity); + } + + /** @deprecated moved to {@link SpringGeneratorSupport#isValueObjectJsonbField}; kept so 1.0.x callers still compile. */ + @Deprecated + public static boolean isValueObjectJsonbField(MetaField field) { + return SpringGeneratorSupport.isValueObjectJsonbField(field); + } + + /** @deprecated moved to {@link SpringGeneratorSupport#componentType}; kept so 1.0.x callers still compile. */ + @Deprecated + public static String componentType(MetaField field, MetaObject owner) { + return SpringGeneratorSupport.componentType(field, owner); + } + + /** @deprecated moved to {@link SpringGeneratorSupport#isRequired}; kept so 1.0.x callers still compile. */ + @Deprecated + public static boolean isRequired(MetaField field) { + return SpringGeneratorSupport.isRequired(field); + } + + /** @deprecated moved to {@link SpringGeneratorSupport#originGuaranteedNonNull}; kept so 1.0.x callers still compile. */ + @Deprecated + public static boolean originGuaranteedNonNull(MetaField field) { + return SpringGeneratorSupport.originGuaranteedNonNull(field); + } + + /** @deprecated moved to {@link SpringGeneratorSupport#validationAnnotations}; kept so 1.0.x callers still compile. */ + @Deprecated + public static String validationAnnotations(MetaField field) { + return SpringGeneratorSupport.validationAnnotations(field); + } + + /** @deprecated moved to {@link SpringGeneratorSupport#sizeAnnotation}; kept so 1.0.x callers still compile. */ + @Deprecated + protected static String sizeAnnotation(Integer min, Integer max) { + return SpringGeneratorSupport.sizeAnnotation(min, max); + } + + /** @deprecated moved to {@link SpringGeneratorSupport#attrBool}; kept so 1.0.x callers still compile. */ + @Deprecated + protected static boolean attrBool(MetaField field, String attr) { + return SpringGeneratorSupport.attrBool(field, attr); + } + + /** @deprecated moved to {@link SpringGeneratorSupport#attrInt}; kept so 1.0.x callers still compile. */ + @Deprecated + protected static Integer attrInt(MetaData node, String... attrNames) { + return SpringGeneratorSupport.attrInt(node, attrNames); + } + + /** @deprecated moved to {@link SpringGeneratorSupport#hasValidator}; kept so 1.0.x callers still compile. */ + @Deprecated + protected static boolean hasValidator(MetaField field, Class type) { + return SpringGeneratorSupport.hasValidator(field, type); + } + + /** @deprecated moved to {@link SpringGeneratorSupport#escapeJavaString}; kept so 1.0.x callers still compile. */ + @Deprecated + protected static String escapeJavaString(String s) { + return SpringGeneratorSupport.escapeJavaString(s); + } } diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java new file mode 100644 index 000000000..382543f1a --- /dev/null +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java @@ -0,0 +1,619 @@ +package com.metaobjects.generator.spring; + +// Eject note (ADR-0034, JVM eject design): `mvn metaobjects:eject` copies this class beside +// every generator that imports it and renames its package. These siblings stay in +// com.metaobjects.generator.spring, so an explicit import, not same-package bare-name +// resolution, is what keeps the copy compiling. +import com.metaobjects.generator.spring.AutoSetSupport; +import com.metaobjects.generator.spring.SpringM2mSupport; +import com.metaobjects.generator.spring.SpringNaming; +import com.metaobjects.generator.spring.SpringTypeMapper; + +import com.metaobjects.MetaData; +import com.metaobjects.field.EnumField; +import com.metaobjects.field.MetaField; +import com.metaobjects.field.ObjectField; +import com.metaobjects.field.StringField; +import com.metaobjects.generator.util.RestSurfaceGate; +import com.metaobjects.identity.MetaIdentity; +import com.metaobjects.identity.PrimaryIdentity; +import com.metaobjects.object.MetaObject; +import com.metaobjects.origin.AggregateOrigin; +import com.metaobjects.origin.MetaOrigin; +import com.metaobjects.validator.ArrayValidator; +import com.metaobjects.validator.LengthValidator; +import com.metaobjects.validator.MetaValidator; +import com.metaobjects.validator.NumericValidator; +import com.metaobjects.validator.RegexValidator; +import com.metaobjects.validator.RequiredValidator; + +import java.util.ArrayList; +import java.util.List; + +/** + * The static helpers the Spring generators share: which fields a DTO / patch carries, the + * value-object column predicates, component types, validation annotations, and the M:N + * finder naming the repository declares and the controller calls. + * + *

    They live here, in one public class, rather than on {@link SpringDtoGenerator} and + * {@link SpringRepositoryGenerator}, so that a generator copied out by + * {@code mvn metaobjects:eject} reaches them through one explicit import and never through + * a sibling generator. An owned {@code routes} must not call a packaged {@code dto}: a call + * on a same-package sibling does not compile in the copy, and one that did would run the + * packaged generator's logic instead of the adopter's own.

    + * + *

    These are emit rules, so eject hands them over too. This file ships as an eject support + * source ({@code GeneratorRegistry.EJECT_SUPPORT}): ejecting a generator that imports it + * writes one copy beside the owned generators, in their package, and points their import at + * that copy. No packaged class an owned generator reaches refers to this packaged class: + * {@link AutoSetSupport}, {@link SpringTypeMapper}, {@link SpringNaming}, {@link SpringM2mSupport} + * and {@link TphPlan} do not call into it ({@code OwnedPathNeverReachesPackagedSupportTest}), + * so an owned generator runs owned rules only.

    + */ +public final class SpringGeneratorSupport { + + private SpringGeneratorSupport() {} + + /** + * The vanilla {@code Patch} settable set: DTO component fields (scalars PLUS + * value-object jsonb columns, Program D) MINUS the primary-key field(s) MINUS any + * {@code @autoSet} column. + * + *

    Review-gate fix (write-once contract): {@code @autoSet} is server-owned — onCreate + * columns (e.g. {@code createdAt}) are write-once and nothing else in the generated CRUD + * ever overwrites them past insert, so leaving one caller-bindable here let a PATCH mutate + * it directly. onUpdate columns (e.g. {@code updatedAt}) are always overwritten by the + * separately-emitted {@code stampAutoSetOnUpdate()} hook regardless of this list, but are + * excluded too for consistency with the TPH {@link #settableFields(MetaObject, String)} + * 2-arg overload below, which already excludes {@code @autoSet} entirely — this is the + * SSOT alignment the 2-arg overload's own comment calls out as remaining scope.

    + */ + public static List settableFields(MetaObject entity) { + List out = new ArrayList<>(); + for (MetaField field : minusPk(entity, dtoComponentFields(entity), null)) { + if (AutoSetSupport.isAutoSet(field)) continue; + // FR-037 R1: both non-readWrite @mutability modes leave the PATCH settable + // set — `readOnly` is never written at all, `writeOnce` is frozen after + // create. A value presented for one is STRIPPED (simply not in the settable + // set), never 400'd — the uniform behaviour of every excluded key here. + if (isNonReadWriteMutability(field)) continue; + out.add(field); + } + return out; + } + + /** + * FR-037 R1 — true when the field's EFFECTIVE {@code @mutability} is anything other + * than {@code readWrite}, i.e. it is excluded from the PATCH settable set. Resolving + * (ADR-0039), so a mode inherited through {@code extends} is honoured. + * + *

    The two modes differ on CREATE, not here: {@code readOnly} is additionally + * dropped from the create shape, while {@code writeOnce} stays settable there + * exactly once. That split lives in the create-shape emitters.

    + */ + public static boolean isNonReadWriteMutability(MetaField field) { + return !MetaField.MUTABILITY_READ_WRITE.equals(field.getMutability()); + } + + /** + * The TPH per-subtype settable set: {@link #scalarFields(MetaObject)} MINUS the primary key + * MINUS the field named {@code alsoExclude} (the immutable discriminator, or nothing when + * {@code null}). Scalar-only — value-object columns on a TPH hierarchy are out of scope + * (Program D), so a TPH base's union DTO and per-subtype patch stay VO-free. + * + *

    Public so {@link SpringControllerGenerator#emitTph} can validate a TPH per-subtype CREATE + * body against exactly the field set the sibling {@code Patch} covers (effective scalars + * MINUS the primary key MINUS the discriminator), keeping create + patch validation on one + * SSOT: the PK is auto-generated and the discriminator is injected from the URL, so neither is + * validated from the body.

    + */ + public static List settableFields(MetaObject entity, String alsoExclude) { + List out = new ArrayList<>(); + for (MetaField field : minusPk(entity, scalarFields(entity), alsoExclude)) { + // ADR-0045 (#203/#229): @autoSet is server-owned — never a caller-settable TPH + // per-subtype field. Excluding it here is the SSOT for both the create-validated set + // (SpringControllerGenerator#emitTph's createValidated) and the sibling Patch's + // settable/bindable set, so a caller can neither be required to supply it on POST nor + // mutate it via PATCH. (The single-arg vanilla settableFields(entity) overload above, + // used by the non-TPH Patch, applies the identical exclusion — review-gate fix, + // see its javadoc.) + if (AutoSetSupport.isAutoSet(field)) continue; + // FR-037 R1: the same exclusion as the vanilla overload above — ADR-0045 + // says the OUTERMOST generated write artifact enforces the mode, and TPH is + // a separate code path (the 0.19.4 lesson), so it is stated here too rather + // than assumed to fall out of the vanilla walk. + if (isNonReadWriteMutability(field)) continue; + out.add(field); + } + return out; + } + + /** {@code pool} MINUS the entity's primary-key field(s) MINUS {@code alsoExclude} (when non-null). */ + private static List minusPk(MetaObject entity, List pool, String alsoExclude) { + List pkFields = entity.getIdentities(true).stream() + .filter(MetaIdentity::isPrimary) + .findFirst() + .map(MetaIdentity::getFields) + .orElse(List.of()); + List out = new ArrayList<>(); + for (MetaField field : pool) { + if (pkFields.contains(field.getName())) continue; + if (alsoExclude != null && alsoExclude.equals(field.getName())) continue; + // FR-024 §7 (#214): a DERIVED field (origin.*) is view-computed, not stored on the + // writable table, so it is excluded from every write-input shape (the Patch + // and the TPH per-subtype settable set). The read Dto still carries it (via + // dtoComponentFields) — only the settable/write pools drop it. + if (field.isDerived()) continue; + out.add(field); + } + return out; + } + + /** + * Return the entity's scalar fields — i.e. every {@link MetaField} that is + * not an {@link ObjectField}. Value-object {@code field.object} columns are + * carried separately by {@link #dtoComponentFields(MetaObject)}; this + * scalar-only view still backs the TPH union / abstract-shape / sort surfaces + * (which are value-object-agnostic). + */ + public static List scalarFields(MetaObject entity) { + List out = new ArrayList<>(); + for (MetaField field : entity.getMetaFields()) { + if (field instanceof ObjectField) continue; + out.add(field); + } + return out; + } + + /** The single legal {@code @storage} value for a value-object jsonb column (Program D); + * {@code flattened} / {@code subdocument} owned-object storage is out of scope. */ + public static final String STORAGE_JSONB = "jsonb"; + + /** + * The vanilla {@code Dto} / {@code Patch} component fields: every scalar + * field PLUS every value-object jsonb column ({@code field.object @objectRef= @storage:jsonb}, + * single or {@code @isArray}), in declared order. Program D: the VO columns become + * strongly-typed record components (bound to the generated {@code } record) so they + * POST + PATCH with nested validation. A non-VO {@link ObjectField} (flattened / subdocument + * storage, or an unresolved / non-value {@code @objectRef}) is still skipped. + */ + public static List dtoComponentFields(MetaObject entity) { + List out = new ArrayList<>(); + for (MetaField field : entity.getMetaFields()) { + if (field instanceof ObjectField) { + if (isValueObjectJsonbField(field)) out.add(field); + continue; + } + out.add(field); + } + return out; + } + + /** + * The DTO component fields of {@code entity} carrying a STAMPABLE {@code @autoSet} + * (onCreate OR onUpdate) marker, in declared order — the fields {@code stampForInsert} + * overwrites with {@code now()}. Reuses {@link #dtoComponentFields} so the set matches + * the DTO record's component order exactly. + */ + public static List stampFields(MetaObject entity) { + List out = new ArrayList<>(); + for (MetaField field : dtoComponentFields(entity)) { + if (AutoSetSupport.isAutoSet(field) && AutoSetSupport.nowExpr(field) != null) out.add(field); + } + return out; + } + + /** + * The DTO component fields of {@code entity} carrying {@code @autoSet: onUpdate}, in + * declared order — the fields stamped on update / patch ({@code onCreate} is skipped). + */ + public static List onUpdateFields(MetaObject entity) { + List out = new ArrayList<>(); + for (MetaField field : dtoComponentFields(entity)) { + if (AutoSetSupport.isOnUpdate(field) && AutoSetSupport.nowExpr(field) != null) out.add(field); + } + return out; + } + + /** True iff {@code entity} declares at least one stampable {@code @autoSet} field. */ + public static boolean hasAutoSetFields(MetaObject entity) { + return !stampFields(entity).isEmpty(); + } + + /** True iff {@code entity} declares at least one {@code @autoSet: onUpdate} field. */ + public static boolean hasOnUpdateFields(MetaObject entity) { + return !onUpdateFields(entity).isEmpty(); + } + + /** The value-object jsonb columns of {@code entity}, in declared order (Program D) — the + * fields the controller runs explicit nested-VO validation over on PATCH (spec section 0). */ + public static List valueObjectJsonbFields(MetaObject entity) { + List out = new ArrayList<>(); + for (MetaField field : entity.getMetaFields()) { + if (isValueObjectJsonbField(field)) out.add((ObjectField) field); + } + return out; + } + + /** The {@code field.map @objectRef} columns of {@code entity}, in declared order — the map + * analog of {@link #valueObjectJsonbFields}. The controller runs the same nested-VO + * validation over their VALUES on PATCH. */ + public static List valueObjectMapFields(MetaObject entity) { + List out = new ArrayList<>(); + for (MetaField field : entity.getMetaFields()) { + if (SpringTypeMapper.mapValueObjectRefOf(field) != null) out.add(field); + } + return out; + } + + /** + * True iff {@code field} is a value-object jsonb column: a {@link ObjectField} whose + * {@code @objectRef} resolves to an {@code object.value} and whose {@code @storage} is + * jsonb (explicit, or the single-jsonb-column default when {@code @storage} is absent). + * Flattened / subdocument owned-object storage is out of scope (Program D). + */ + public static boolean isValueObjectJsonbField(MetaField field) { + if (!(field instanceof ObjectField of)) return false; + if (!of.hasMetaAttr(ObjectField.ATTR_OBJECTREF)) return false; + MetaObject ref; + try { + ref = of.getObjectRef(); + } catch (RuntimeException unresolved) { + return false; + } + if (ref == null || !MetaObject.SUBTYPE_VALUE.equals(ref.getSubType())) return false; + if (!of.hasMetaAttr(ObjectField.ATTR_STORAGE)) return true; // default single-jsonb-column + Object storage = of.getMetaAttr(ObjectField.ATTR_STORAGE).getValue(); + return STORAGE_JSONB.equalsIgnoreCase(String.valueOf(storage).trim()); + } + + /** + * The {@code object.value} that {@code field} carries by reference, else {@code null}. + * TWO field shapes carry one, and both must be reached: + *
      + *
    • a {@code field.object @objectRef} jsonb column ({@link #isValueObjectJsonbField});
    • + *
    • a {@code field.map @objectRef} -- a {@code Map}. A map's storage is + * ALWAYS the single jsonb column, so unlike {@code field.object} it has no + * {@code @storage} axis to gate on.
    • + *
    + * Mirrors the C# {@code EntityGenerator.ReferencesValueObject} predicate, which already + * spans both. Drives {@link SpringValueObjectGenerator}'s reachability walk (a VO reached + * only through a map would otherwise never be EMITTED, leaving the DTO naming a record + * that does not exist) and the {@code @Valid} cascade on the DTO component. + */ + public static MetaObject valueObjectRefOf(MetaField field) { + if (isValueObjectJsonbField(field)) return ((ObjectField) field).getObjectRef(); + return SpringTypeMapper.mapValueObjectRefOf(field); + } + + /** + * Java DTO-record component type for {@code field} owned by {@code owner}. A + * {@code field.enum} is typed as the value-constrained nested Java {@code enum} + * ({@link SpringTypeMapper#enumTypeName}, single or {@code List}) whose declaration + * {@code SpringDtoGenerator.collectEnumDecls} emits inside the record body — parity with the other ports and + * with {@link SpringValueObjectGenerator}. Other scalars delegate to {@link SpringTypeMapper}; + * array fields ({@code isArray=true}) are wrapped as {@code List} (the wrapped + * element type so an omitted JSON element deserialises to {@code null}). + */ + public static String componentType(MetaField field, MetaObject owner) { + if (field instanceof EnumField) { + return SpringTypeMapper.payloadJavaTypeName(field, owner, ""); + } + String element = SpringTypeMapper.javaTypeName(field); + return SpringTypeMapper.wrapsAsList(field) ? "java.util.List<" + element + ">" : element; + } + + /** True iff {@code field} is {@code @required} — its own {@code @required} attr OR a + * {@code validator.required} child. The SINGLE required-predicate reused across the + * DTO's {@code @NotNull} and the FR-035 patch's present-null-on-@required → 400 rule + * (the FR-035 patch emit below), so the two cannot drift. */ + public static boolean isRequired(MetaField field) { + return attrBool(field, MetaField.ATTR_REQUIRED) || hasValidator(field, RequiredValidator.class); + } + + /** + * #195 — a field whose value is derived by an {@code origin.aggregate} + * {@code @agg:any|all|collect} is COALESCE-guaranteed non-null in the synthesized + * view (any→false, all→true, collect→empty-array), so its read type + * is non-null even when the field is not {@code @required}. Drives {@code @NotNull} + * in {@link #validationAnnotations} so a projection DTO's read shape agrees with its + * view column — adopters no longer handle a null that never occurs. + * + *

    {@code origin.first} is deliberately NOT here (an empty related set selects no + * row → null); {@code origin.computed} nullability is expression-dependent, so + * it stays the conservative nullable default. Mirrors the TS + * {@code originGuaranteedNonNull} (codegen-ts {@code column-mapper}).

    + */ + public static boolean originGuaranteedNonNull(MetaField field) { + // ADR-0039: own — origin.* never inherits (ADR-0029), so an own-only read is correct. + for (MetaData c : field.getChildren(MetaData.class, false)) { + if (!(c instanceof MetaOrigin)) continue; + if (!AggregateOrigin.SUBTYPE_AGGREGATE.equals(c.getSubType())) continue; + String agg = ((MetaOrigin) c).getAgg(); + return MetaOrigin.AGG_ANY.equals(agg) || MetaOrigin.AGG_ALL.equals(agg) + || MetaOrigin.AGG_COLLECT.equals(agg); + } + return false; + } + + /** + * Build the space-joined jakarta.validation annotation string for a record + * component from the field's constraint metadata — both field attrs + * ({@code @required}, {@code @maxLength}) and {@code validator.*} children + * (length / regex / numeric / array). Returns {@code ""} when the field + * carries no constraints. + * + *

    Cross-port semantics (see the SP-C validator-parity contract):

    + *
      + *
    • required (field {@code @required} or {@code validator.required}) → + * {@code @NotNull}; a required non-array string additionally gets an + * implicit {@code @Size(min = 1)} (FR-036 Ruling 1: reject {@code null} + + * {@code ""}, ACCEPT whitespace-only) — never {@code @NotBlank}, which + * trims and would wrongly reject a whitespace value. An explicitly + * authored {@code validator.length @min} is always authoritative over + * that implicit floor (#224); {@code @min: 0} opts back to + * presence-only and emits no {@code @Size} minimum at all.
    • + *
    • {@code validator.length} + field {@code @maxLength} → + * {@code @Size(min=…, max=…)} on a NON-array string; the effective max is + * {@code min(validator.max, @maxLength)} (FR-036 A3 strictest-wins).
    • + *
    • {@code validator.regex @pattern} → {@code @Pattern(regexp=…)} (jakarta + * {@code matches()} is full-match — FR-036 Ruling 2, no anchoring needed).
    • + *
    • {@code validator.numeric @min/@max} → {@code @Min(…)} / {@code @Max(…)}.
    • + *
    • {@code validator.array @min/@max} → {@code @Size(min=…, max=…)} on the {@code List}.
    • + *
    + */ + /** + * True when {@code field} participates in its owner's ASSIGNED primary key — an + * {@code identity.primary} carrying no {@code @generation} (or an explicit + * {@code assigned}) — and has no {@code @default} to fill it. + * + *

    Such a key is create-REQUIRED regardless of {@code @required}: a natural key or + * an externally-issued id has no other source, so a create body omitting it can only + * fail at the database. An {@code increment}/{@code uuid} key is the opposite — the + * server supplies it, so it must stay optional (and TS omits it from the insert shape + * entirely). A key carrying a {@code @default} also stays optional: the column has + * that default.

    + * + *

    Mirrors the TS {@code assignedPkFieldNames}; gated cross-port by the + * {@code assigned-pk-missing} case in {@code fixtures/validation-conformance}.

    + */ + public static boolean isAssignedPrimaryKeyField(MetaField field) { + if (!(field.getParent() instanceof MetaObject owner)) return false; + PrimaryIdentity pk = owner.getPrimaryIdentity(); + if (pk == null || pk.isAutoGenerated()) return false; + if (!pk.getFields().contains(field.getName())) return false; + // ADR-0039: resolving — @default may be inherited via extends. + return !field.hasMetaAttr(MetaField.ATTR_DEFAULT); + } + + public static String validationAnnotations(MetaField field) { + // Issue #203: an @autoSet field is CRUD-owned (server stamps now() on insert/update), + // so it is never caller-required and never caller-validated. Emit NO constraint + // annotations — parity with TS, whose InsertSchema makes @autoSet fields + // `.optional().transform(...)` (a @required @autoSet field would otherwise emit + // @NotNull and wrongly 400 a POST that omits the server-filled timestamp). + if (AutoSetSupport.isAutoSet(field)) return ""; + + // FR-024 §7 (#214): a DERIVED field (origin.*) on a WRITE-THROUGH entity is view-computed + // and read-only — never client-supplied. Java shares one Dto for read AND create, + // and the generated POST-create validates the whole DTO (SpringControllerGenerator), so a + // guaranteed-non-null aggregate's @NotNull (from originGuaranteedNonNull, #195) would wrongly + // 400 a create that omits the view-computed field. Emit NO client-validation constraints for + // such a field (parity with the @autoSet early-return above). A PROJECTION is read-only + // (isWriteThrough()=false), so its derived fields keep @NotNull for read-nullability agreement. + if (field.isDerived() && field.getParent() instanceof MetaObject + && ((MetaObject) field.getParent()).isWriteThrough()) { + return ""; + } + + boolean isArray = field.isArrayType(); + boolean isString = field instanceof StringField; + List out = new ArrayList<>(); + + boolean required = isRequired(field) || isAssignedPrimaryKeyField(field); + // FR-036 Ruling 1: a @required value → @NotNull (rejects null). Non-empty for a + // @required NON-ARRAY string is enforced by @Size(min>=1) in the string-length arm + // below — NOT @NotBlank, which trims and would wrongly reject a whitespace-only value. + // A @required array keeps @NotNull only (no @NotEmpty). + // #195: an origin.aggregate any/all/collect field is COALESCE-guaranteed non-null on + // read (any→false / all→true / collect→[]), so it is @NotNull even when not @required + // (the non-empty @Size(min=1) floor below stays keyed on @required only — such fields + // are boolean or array, never a non-array string). + if (required || originGuaranteedNonNull(field)) { + out.add("@NotNull"); + } + + // String character-length @Size — a NON-array string field only (FR-036 A3): a + // field.string @isArray @maxLength must NOT carry a char-count @Size on its List + // (element-count bounds come solely from the validator.array arm below). Combines + // the explicit validator.length bounds with the field-level @maxLength. + if (isString && !isArray) { + Integer lengthMin = null; + Integer lengthMax = null; + LengthValidator length = validator(field, LengthValidator.class); + if (length != null) { + lengthMin = attrInt(length, LengthValidator.ATTR_MIN); + lengthMax = attrInt(length, LengthValidator.ATTR_MAX); + } + Integer fieldMaxLength = attrInt(field, StringField.ATTR_MAX_LENGTH); + if (fieldMaxLength != null) { + // FR-036 A3 precedence: strictest-wins — min(validator.length @max, @maxLength). + lengthMax = (lengthMax == null) ? fieldMaxLength : Math.min(lengthMax, fieldMaxLength); + } + if (required) { + // FR-036 Ruling 1 non-empty floor — applies ONLY when no + // validator.length @min was authored. An explicitly authored @min + // is always AUTHORITATIVE over the implicit floor (#224 / ADR-0044): + // @min: 0 opts back to presence-only and, per the cross-port + // emission pin, emits NO minimum constraint at all (no + // @Size(min=0)) — cleanest, deterministic output across ports. + if (lengthMin == null) { + lengthMin = 1; + } else if (lengthMin == 0) { + lengthMin = null; + } + } + if (lengthMin != null || lengthMax != null) { + out.add(sizeAnnotation(lengthMin, lengthMax)); + } + } + + // Regex pattern. + RegexValidator regex = validator(field, RegexValidator.class); + if (regex != null) { + String pattern = regex.resolvePattern(); + if (pattern != null) { + out.add("@Pattern(regexp = \"" + escapeJavaString(pattern) + "\")"); + } + } + + // Numeric value bounds. + NumericValidator numeric = validator(field, NumericValidator.class); + if (numeric != null) { + Integer min = attrInt(numeric, NumericValidator.ATTR_MIN); + Integer max = attrInt(numeric, NumericValidator.ATTR_MAX); + if (min != null) out.add("@Min(" + min + ")"); + if (max != null) out.add("@Max(" + max + ")"); + } + + // Array element-count bounds → @Size on the List component. + ArrayValidator array = validator(field, ArrayValidator.class); + if (array != null) { + Integer min = attrInt(array, ArrayValidator.ATTR_MIN); + Integer max = attrInt(array, ArrayValidator.ATTR_MAX); + if (min != null || max != null) { + out.add(sizeAnnotation(min, max)); + } + } + + // ADR-0036/0037 Wave 3 — @stringFormat on a plain string field. The canonical + // matcher per format lives HERE in codegen (NOT author validator.regex), since + // cross-language regex engines diverge. email → Jakarta @Email; hostname → a + // canonical DNS-hostname @Pattern check. + if (isString && !isArray && field.hasMetaAttr(StringField.ATTR_STRING_FORMAT)) { + String format = String.valueOf( + field.getMetaAttr(StringField.ATTR_STRING_FORMAT).getValue()).trim(); + if (StringField.STRING_FORMAT_EMAIL.equals(format)) { + out.add("@Email"); + } else if (StringField.STRING_FORMAT_HOSTNAME.equals(format)) { + out.add("@Pattern(regexp = \"" + escapeJavaString(HOSTNAME_REGEX) + "\")"); + } + } + + return String.join(" ", out); + } + + /** + * Canonical DNS-hostname matcher for {@code @stringFormat: hostname} (ADR-0036/0037 + * Wave 3). Codegen-owned — one or more dot-separated labels, each 1–63 chars of + * alphanumerics/hyphens not starting or ending with a hyphen. Mirrors the cross-port + * hostname matcher (TS Zod hostname check). + */ + public static final String HOSTNAME_REGEX = + "^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$"; + + public static String sizeAnnotation(Integer min, Integer max) { + StringBuilder b = new StringBuilder("@Size("); + boolean wrote = false; + if (min != null) { b.append("min = ").append(min); wrote = true; } + if (max != null) { if (wrote) b.append(", "); b.append("max = ").append(max); } + return b.append(')').toString(); + } + + public static boolean attrBool(MetaField field, String attr) { + if (!field.hasMetaAttr(attr)) return false; + Object raw = field.getMetaAttr(attr).getValue(); + if (raw instanceof Boolean b) return b; + return Boolean.parseBoolean(String.valueOf(raw)); + } + + /** Read an int-valued attr from a node, trying each name in order; {@code null} when absent. */ + public static Integer attrInt(MetaData node, String... attrNames) { + for (String attr : attrNames) { + if (node.hasMetaAttr(attr)) { + try { + return Integer.valueOf(node.getMetaAttr(attr).getValueAsString().trim()); + } catch (NumberFormatException ignore) { + return null; + } + } + } + return null; + } + + @SuppressWarnings("unchecked") + private static V validator(MetaField field, Class type) { + for (MetaData child : field.getChildren()) { + if (type.isInstance(child)) return (V) child; + } + return null; + } + + public static boolean hasValidator(MetaField field, Class type) { + return validator(field, type) != null; + } + + /** Escape a regex/string literal for safe embedding in generated Java source. */ + public static String escapeJavaString(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } + + /** + * True iff this generator emits a repository interface for {@code entity} — the same + * shared {@link RestSurfaceGate#emitsRestSurface} decision the controller and the + * filter allowlist make, because the controller delegates to this interface by name. + * + *

    A READ-ONLY view-kind {@code object.projection} (F22) emits a repository too, but + * a read-only one: list / count / findById and nothing that writes. See + * {@code SpringRepositoryGenerator.emitReadOnly}.

    + */ + public static boolean repositoryAppliesTo(MetaObject entity) { + return RestSurfaceGate.emitsRestSurface(entity); + } + + /** Repository finder name for an M:N relationship: {@code tags} → {@code findTags}. */ + public static String m2mFinderName(String relationName) { + if (relationName.isEmpty()) return "find"; + return "find" + Character.toUpperCase(relationName.charAt(0)) + relationName.substring(1); + } + + /** + * FR-017 x FR-018: the subtype-scoped M:N finder name, e.g. {@code ("tags", "Bridge")} → + * {@code findTagsForBridge}. Distinct from {@link #m2mFinderName} because a TPH interface + * may need BOTH the whole-table finder (base-declared relationship) AND a per-subtype one + * for the exact same relation name (an inherited relationship resolved by more than one + * subtype) — same naming shape as the existing {@code list}/{@code get} + * per-subtype CRUD methods. + */ + public static String m2mFinderNameForSubtype(String relationName, String discriminatorValue) { + return m2mFinderName(relationName) + "For" + SpringNaming.capitalize(discriminatorValue); + } + + /** + * FW-8 follow-up (target-side TPH gate): the extra finder parameter clause for {@code nav} — + * {@code ", String targetSubtype"} when {@code nav}'s target is a concrete TPH subtype, + * empty otherwise. Threading this same {@code targetDiscriminatorValue() != null} test at + * every finder declaration AND its matching controller call site (see + * {@link SpringControllerGenerator}) is what keeps a widened finder's interface signature + * and call site in agreement — a real disagreement here is a javac compile error, not a + * silently-wrong runtime result, because each (relation name, source scope) pair is its own + * distinct Java method (never a single shared seam multiple mounts must agree on the arity + * of, unlike the Python port's one Protocol method per relation name). + */ + public static String targetSubtypeParam(SpringM2mSupport.M2mNav nav) { + return nav.targetDiscriminatorValue() != null ? ", String targetSubtype" : ""; + } + + /** + * The matching CALL-SITE argument clause for {@link #targetSubtypeParam} — the quoted + * discriminator literal when {@code nav}'s target is TPH, empty otherwise. Used by + * {@link SpringControllerGenerator} at every M:N call site (unscoped base finder AND every + * subtype-scoped {@code findFor} finder) so each call site's argument count + * always matches its own finder's declared parameter count. + */ + public static String targetSubtypeCallArg(SpringM2mSupport.M2mNav nav) { + return nav.targetDiscriminatorValue() != null + ? ", \"" + nav.targetDiscriminatorValue() + "\"" + : ""; + } +} diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java index 9703d7efc..f0811d2ac 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java @@ -485,7 +485,7 @@ protected String mapperArgForField(MetaField field, MetaObject owner, String * needed; the caller reads straight through {@code ExtractMap.asString}). * *

    For an ARRAY field this answers for the ELEMENT: {@code javaTypeName} returns the - * element type and {@link SpringDtoGenerator#componentType} does the {@code List<...>} wrap.

    + * element type and {@link SpringGeneratorSupport#componentType} does the {@code List<...>} wrap.

    */ private static String coercerFor(MetaField field) { if (field instanceof EnumField) return COERCE_STRING; // string-backed wire form diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRepositoryGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRepositoryGenerator.java index 92dfdf47b..60657956d 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRepositoryGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRepositoryGenerator.java @@ -4,6 +4,7 @@ // com.metaobjects.generator.spring 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.spring.SpringGeneratorSupport; import com.metaobjects.generator.spring.SpringM2mSupport; import com.metaobjects.generator.spring.SpringNaming; import com.metaobjects.generator.spring.SpringTypeMapper; @@ -94,7 +95,7 @@ public void execute(MetaDataLoader loader) { MetaObject entity = RestSurfaceGate.restShapeOf(declared); if (entity == null) continue; if (TphPlan.isTphSubtype(entity)) continue; // folded into the base — no own repository - if (!appliesTo(entity)) continue; + if (!SpringGeneratorSupport.repositoryAppliesTo(entity)) continue; // F22 — a view-only projection gets a READ-ONLY seam: nothing that writes. A // served report (FR-044) takes the same path: list and count, no findById. if (RestSurfaceGate.isReadOnly(entity)) emitReadOnly(entity, outRoot); @@ -104,18 +105,6 @@ public void execute(MetaDataLoader loader) { } } - /** - * True iff this generator emits a repository interface for {@code entity} — the same - * shared {@link RestSurfaceGate#emitsRestSurface} decision the controller and the - * filter allowlist make, because the controller delegates to this interface by name. - * - *

    A READ-ONLY view-kind {@code object.projection} (F22) emits a repository too, but - * a read-only one: list / count / findById and nothing that writes. See - * {@link #emitReadOnly(MetaObject, Path)}.

    - */ - public static boolean appliesTo(MetaObject entity) { - return RestSurfaceGate.emitsRestSurface(entity); - } protected void emit(MetaObject entity, Path outRoot) { String[] split = SpringNaming.splitFqn(entity.getName()); @@ -169,8 +158,8 @@ protected void emit(MetaObject entity, Path outRoot) { src.append(targetSubtypeJavadocClause(nav)); src.append(". */\n"); src.append(" List<").append(nav.targetDtoType()).append("> ") - .append(m2mFinderName(nav.relationName())).append("(").append(pkType).append(" sourceId") - .append(targetSubtypeParam(nav)).append(");\n"); + .append(SpringGeneratorSupport.m2mFinderName(nav.relationName())).append("(").append(pkType).append(" sourceId") + .append(SpringGeneratorSupport.targetSubtypeParam(nav)).append(");\n"); } // ADR-0038 reverse navigation — one finder PAIR per FK this entity holds @@ -358,8 +347,8 @@ protected void emitTph(MetaObject base, Path outRoot) { src.append(targetSubtypeJavadocClause(nav)); src.append(". */\n"); src.append(" List<").append(nav.targetDtoType()).append("> ") - .append(m2mFinderName(nav.relationName())).append("(").append(pkType).append(" sourceId") - .append(targetSubtypeParam(nav)).append(");\n"); + .append(SpringGeneratorSupport.m2mFinderName(nav.relationName())).append("(").append(pkType).append(" sourceId") + .append(SpringGeneratorSupport.targetSubtypeParam(nav)).append(");\n"); } for (TphPlan.Subtype st : plan.subtypes()) { for (SpringM2mSupport.M2mNav nav : SpringM2mSupport.resolve(st.entity(), loader)) { @@ -386,9 +375,9 @@ protected void emitTph(MetaObject base, Path outRoot) { } src.append(" */\n"); src.append(" List<").append(nav.targetDtoType()).append("> ") - .append(m2mFinderNameForSubtype(nav.relationName(), st.value())) + .append(SpringGeneratorSupport.m2mFinderNameForSubtype(nav.relationName(), st.value())) .append("(").append(pkType).append(" sourceId") - .append(targetSubtypeParam(nav)).append(");\n"); + .append(SpringGeneratorSupport.targetSubtypeParam(nav)).append(");\n"); } } src.append("}\n"); @@ -402,51 +391,9 @@ protected void emitTph(MetaObject base, Path outRoot) { } } - /** Repository finder name for an M:N relationship: {@code tags} → {@code findTags}. */ - public static String m2mFinderName(String relationName) { - if (relationName.isEmpty()) return "find"; - return "find" + Character.toUpperCase(relationName.charAt(0)) + relationName.substring(1); - } - /** - * FR-017 x FR-018: the subtype-scoped M:N finder name, e.g. {@code ("tags", "Bridge")} → - * {@code findTagsForBridge}. Distinct from {@link #m2mFinderName} because a TPH interface - * may need BOTH the whole-table finder (base-declared relationship) AND a per-subtype one - * for the exact same relation name (an inherited relationship resolved by more than one - * subtype) — same naming shape as the existing {@code list}/{@code get} - * per-subtype CRUD methods. - */ - public static String m2mFinderNameForSubtype(String relationName, String discriminatorValue) { - return m2mFinderName(relationName) + "For" + SpringNaming.capitalize(discriminatorValue); - } - /** - * FW-8 follow-up (target-side TPH gate): the extra finder parameter clause for {@code nav} — - * {@code ", String targetSubtype"} when {@code nav}'s target is a concrete TPH subtype, - * empty otherwise. Threading this same {@code targetDiscriminatorValue() != null} test at - * every finder declaration AND its matching controller call site (see - * {@link SpringControllerGenerator}) is what keeps a widened finder's interface signature - * and call site in agreement — a real disagreement here is a javac compile error, not a - * silently-wrong runtime result, because each (relation name, source scope) pair is its own - * distinct Java method (never a single shared seam multiple mounts must agree on the arity - * of, unlike the Python port's one Protocol method per relation name). - */ - public static String targetSubtypeParam(SpringM2mSupport.M2mNav nav) { - return nav.targetDiscriminatorValue() != null ? ", String targetSubtype" : ""; - } - /** - * The matching CALL-SITE argument clause for {@link #targetSubtypeParam} — the quoted - * discriminator literal when {@code nav}'s target is TPH, empty otherwise. Used by - * {@link SpringControllerGenerator} at every M:N call site (unscoped base finder AND every - * subtype-scoped {@code findFor} finder) so each call site's argument count - * always matches its own finder's declared parameter count. - */ - static String targetSubtypeCallArg(SpringM2mSupport.M2mNav nav) { - return nav.targetDiscriminatorValue() != null - ? ", \"" + nav.targetDiscriminatorValue() + "\"" - : ""; - } /** * The javadoc clause naming a widened finder's target-subtype literal, appended right @@ -526,4 +473,28 @@ protected String getSingleOutputFilePath(MetaObject md) { protected String getSingleOutputFilename(MetaObject md) { return SpringNaming.splitFqn(md.getName())[1] + "Repository.java"; } + + /** @deprecated moved to {@link SpringGeneratorSupport#repositoryAppliesTo}; kept so 1.0.x callers still compile. */ + @Deprecated + public static boolean appliesTo(MetaObject entity) { + return SpringGeneratorSupport.repositoryAppliesTo(entity); + } + + /** @deprecated moved to {@link SpringGeneratorSupport#m2mFinderName}; kept so 1.0.x callers still compile. */ + @Deprecated + public static String m2mFinderName(String relationName) { + return SpringGeneratorSupport.m2mFinderName(relationName); + } + + /** @deprecated moved to {@link SpringGeneratorSupport#m2mFinderNameForSubtype}; kept so 1.0.x callers still compile. */ + @Deprecated + public static String m2mFinderNameForSubtype(String relationName, String discriminatorValue) { + return SpringGeneratorSupport.m2mFinderNameForSubtype(relationName, discriminatorValue); + } + + /** @deprecated moved to {@link SpringGeneratorSupport#targetSubtypeParam}; kept so 1.0.x callers still compile. */ + @Deprecated + public static String targetSubtypeParam(SpringM2mSupport.M2mNav nav) { + return SpringGeneratorSupport.targetSubtypeParam(nav); + } } diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java index 2d888b4b2..30732f001 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java @@ -58,7 +58,7 @@ private SpringTypeMapper() { /* no instances */ } *

    The returned string is the ELEMENT type, inserted verbatim into the * generated record component declaration. Array-ness ({@code isArray=true}) * wrapping in {@code java.util.List} is the CALLER's concern — - * {@code SpringDtoGenerator.componentType} (DTO and value-object records) + * {@code SpringGeneratorSupport.componentType} (DTO and value-object records) * does it; * a Java record accepts a {@code List} component fine (the * earlier varargs rationale here was wrong).

    @@ -132,7 +132,7 @@ public static String javaTypeName(MetaField field) { // — via componentType wrapping — List<> (isArray). Fully-qualified so the // consuming DTO / Patch / VO record needs no import. Only reached for the // VO-ref fields the DTO/patch paths select via - // SpringDtoGenerator.isValueObjectJsonbField; a non-VO ObjectField never lands here. + // SpringGeneratorSupport.isValueObjectJsonbField; a non-VO ObjectField never lands here. if (field instanceof ObjectField of && of.hasMetaAttr(ObjectField.ATTR_OBJECTREF)) { MetaObject ref = of.getObjectRef(); if (ref != null) return fqJavaTypeName(ref); @@ -153,6 +153,27 @@ public static String javaTypeName(MetaField field) { + field.getClass().getSimpleName() + " '" + field.getName() + "'"); } + /** + * The {@code object.value} a {@code field.map @objectRef} maps its values to, else + * {@code null} (a scalar-valued {@code @valueType} map, a non-map field, or a + * {@code @objectRef} that does not resolve to an {@code object.value}). + * + *

    A type-mapping fact, so it lives here and stays packaged: it decides the map's Java + * value type below, and the generators' {@code @Valid} cascade and value-object + * reachability walk ask the same question through it.

    + */ + public static MetaObject mapValueObjectRefOf(MetaField field) { + if (!(field instanceof MapField mf)) return null; + if (!mf.hasMetaAttr(MapField.ATTR_OBJECTREF)) return null; + MetaObject ref; + try { + ref = mf.getObjectRef(); + } catch (RuntimeException unresolved) { + return null; + } + return (ref != null && MetaObject.SUBTYPE_VALUE.equals(ref.getSubType())) ? ref : null; + } + /** * The Java value type of a {@link MapField} -- the {@code V} in {@code Map}: * the value object named by {@code @objectRef}, else the scalar named by @@ -181,7 +202,7 @@ private static String mapValueJavaType(MapField field) { // mapValueObjectRefOf requires object.value, so an @objectRef naming an ENTITY typed the // component as that entity while nothing ever emitted a record for it. if (field.hasMetaAttr(MapField.ATTR_OBJECTREF)) { - MetaObject ref = SpringDtoGenerator.mapValueObjectRefOf(field); + MetaObject ref = mapValueObjectRefOf(field); return ref == null ? null : fqJavaTypeName(ref); } // ADR-0039: @valueType is an EFFECTIVE property — getMetaAttr's one-arg RESOLVING form, diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringValueObjectGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringValueObjectGenerator.java index 1a05544d5..31df17a24 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringValueObjectGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringValueObjectGenerator.java @@ -4,6 +4,7 @@ // com.metaobjects.generator.spring 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.spring.SpringGeneratorSupport; import com.metaobjects.generator.spring.SpringNaming; import com.metaobjects.generator.spring.SpringRecordBuilder; import com.metaobjects.generator.spring.SpringTypeMapper; @@ -45,7 +46,7 @@ *
* *

Its record carries jakarta bean-validation constraints on each member (reusing - * {@link SpringDtoGenerator#validationAnnotations(MetaField)}) plus {@code @Valid} on any nested + * {@link SpringGeneratorSupport#validationAnnotations(MetaField)}) plus {@code @Valid} on any nested * value-object member, so {@code validator.validate(bean)} cascades to depth ≥ 2 (spec section * 0). The record name is the value object's short name (e.g. {@code Marker}), emitted into the * value object's own Java package; {@link SpringTypeMapper} and the template tier reference it @@ -122,12 +123,12 @@ private void emit(MetaObject vo, Path outRoot) { boolean usesValidation = false; boolean usesValid = false; for (MetaField field : fields) { - String a = SpringDtoGenerator.validationAnnotations(field); + String a = SpringGeneratorSupport.validationAnnotations(field); // valueObjectRefOf, not isValueObjectJsonbField: it spans BOTH shapes that carry a // value object (field.object @objectRef and field.map @objectRef). The entity DTO // cascades into a map of value objects; a VO nesting the same shape must too, or // the nested constraints go unenforced one level down. - if (SpringDtoGenerator.valueObjectRefOf(field) != null) { + if (SpringGeneratorSupport.valueObjectRefOf(field) != null) { a = a.isEmpty() ? "@Valid" : "@Valid " + a; usesValid = true; } @@ -194,7 +195,7 @@ private void emit(MetaObject vo, Path outRoot) { * package-level shared enum is typed by the materialized standalone type (a bare {@code E} * in the same package, else its FQN) or — when {@code @provided} — the configured external * {@code .E}; an enum array wraps it in {@code List<…>}. Every other field (including - * inline enums) delegates verbatim to {@link SpringDtoGenerator#componentType}, so the inline + * inline enums) delegates verbatim to {@link SpringGeneratorSupport#componentType}, so the inline * default stays byte-identical. Mirrors {@link SpringDtoGenerator}'s own * {@code componentTypeFr019} — the two generators resolve a shared field to the SAME * reference because both call {@link Fr019SharedEnum#sharedEnumForField}. @@ -207,7 +208,7 @@ private String componentTypeFr019(MetaField field, MetaObject owner) { return field.isArrayType() ? "java.util.List<" + ref + ">" : ref; } } - return SpringDtoGenerator.componentType(field, owner); + return SpringGeneratorSupport.componentType(field, owner); } /** diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/legacyforms/SpringGeneratorLegacyFormsTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/legacyforms/SpringGeneratorLegacyFormsTest.java new file mode 100644 index 000000000..5f39a524a --- /dev/null +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/legacyforms/SpringGeneratorLegacyFormsTest.java @@ -0,0 +1,175 @@ +package com.metaobjects.generator.legacyforms; + +import com.metaobjects.field.MetaField; +import com.metaobjects.field.ObjectField; +import com.metaobjects.generator.spring.SpringDtoGenerator; +import com.metaobjects.generator.spring.SpringM2mSupport; +import com.metaobjects.generator.spring.SpringRepositoryGenerator; +import com.metaobjects.generator.spring.SpringTestFixtures; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.object.MetaObject; +import com.metaobjects.validator.RegexValidator; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * The static helpers {@link SpringDtoGenerator} and {@link SpringRepositoryGenerator} + * published through 1.0.13 still compile and answer as they did, so an adopter's own + * generator that calls them, or subclasses {@code SpringDtoGenerator}, keeps building after + * the upgrade. + * + *

This class sits outside {@code com.metaobjects.generator.spring} on purpose: an adopter's + * code does, and a {@code protected} helper is reachable from another package only through a + * subclass. The call forms are the 1.0.13 ones. Expected values come from the fixture's own + * declarations and from the documented rules (a server-owned {@code @autoSet} field is never + * settable or validated, FR-036 Ruling 1 for a required string, wrapped primitives for a + * DTO component), not from the implementation. + */ +@SuppressWarnings("deprecation") +public class SpringGeneratorLegacyFormsTest { + + @Rule public TemporaryFolder tmp = new TemporaryFolder(); + + private static final String FIXTURE = """ + { "metadata.root": { "package": "acme::crm", "children": [ + { "object.value": { "name": "Address", "children": [ + { "field.string": { "name": "city", "@maxLength": 80 } } + ]}}, + { "object.entity": { "name": "Customer", "children": [ + { "source.rdb": { "@table": "customers" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name", "@required": true, "@maxLength": 100 } }, + { "field.string": { "name": "code", "children": [ + { "validator.regex": { "@pattern": "[A-Z]+" } } + ]}}, + { "field.timestamp": { "name": "updatedAt", "@autoSet": "onUpdate" } }, + { "field.object": { "name": "home", "@objectRef": "Address", "@storage": "jsonb" } }, + { "field.map": { "name": "addresses", "@objectRef": "Address" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } } + ]}}, + { "object.entity": { "name": "Note", "children": [ + { "field.string": { "name": "text" } } + ]}} + ]}} + """; + + /** An adopter's subclass, calling the inherited helpers unqualified as 1.0.x code did. */ + private static final class AdopterDtoGenerator extends SpringDtoGenerator { + String annotationsOf(MetaField field) { return validationAnnotations(field); } + String typeOf(MetaField field, MetaObject owner) { return componentType(field, owner); } + String size(Integer min, Integer max) { return sizeAnnotation(min, max); } + boolean flag(MetaField field, String attr) { return attrBool(field, attr); } + Integer number(MetaField field, String... attrs) { return attrInt(field, attrs); } + boolean hasRegex(MetaField field) { return hasValidator(field, RegexValidator.class); } + String escaped(String s) { return escapeJavaString(s); } + } + + private MetaObject customer; + private MetaObject note; + + @Before + public void load() throws IOException { + MetaDataLoader loader = SpringTestFixtures.loadFixture(tmp.newFolder().toPath(), "crm", FIXTURE); + customer = entity(loader, "Customer"); + note = entity(loader, "Note"); + } + + private static MetaObject entity(MetaDataLoader loader, String shortName) { + for (MetaObject mo : loader.getMetaObjects()) { + if (mo.getName().endsWith("::" + shortName)) return mo; + } + throw new IllegalStateException(shortName + " not in fixture"); + } + + private MetaField field(String name) { + return customer.getMetaField(name); + } + + private static List names(List fields) { + return fields.stream().map(MetaField::getName).collect(Collectors.toList()); + } + + @Test + public void fieldSelectorsAnswerFromTheStaticCallForm() { + List scalars = names(SpringDtoGenerator.scalarFields(customer)); + assertTrue(scalars.containsAll(List.of("id", "name", "code", "updatedAt"))); + assertFalse("a value-object column is not a scalar", scalars.contains("home")); + + List components = names(SpringDtoGenerator.dtoComponentFields(customer)); + assertTrue(components.containsAll(List.of("id", "name", "code", "updatedAt", "home"))); + + List settable = names(SpringDtoGenerator.settableFields(customer, "code")); + assertTrue(settable.contains("name")); + assertFalse("the primary key is not settable", settable.contains("id")); + assertFalse("the excluded field is not settable", settable.contains("code")); + assertFalse("an @autoSet field is server-owned", settable.contains("updatedAt")); + assertFalse("a value-object column is not in the scalar settable set", settable.contains("home")); + } + + @Test + public void valueObjectSelectorsAnswerFromTheStaticCallForm() { + List jsonb = SpringDtoGenerator.valueObjectJsonbFields(customer); + assertEquals(List.of("home"), names(jsonb)); + assertEquals(List.of("addresses"), names(SpringDtoGenerator.valueObjectMapFields(customer))); + assertTrue(SpringDtoGenerator.isValueObjectJsonbField(field("home"))); + assertFalse(SpringDtoGenerator.isValueObjectJsonbField(field("name"))); + } + + @Test + public void fieldRulesAnswerFromTheStaticCallForm() { + assertEquals("Long", SpringDtoGenerator.componentType(field("id"), customer)); + assertEquals("String", SpringDtoGenerator.componentType(field("name"), customer)); + assertTrue(SpringDtoGenerator.isRequired(field("name"))); + assertFalse(SpringDtoGenerator.isRequired(field("code"))); + assertFalse(SpringDtoGenerator.originGuaranteedNonNull(field("name"))); + + String required = SpringDtoGenerator.validationAnnotations(field("name")); + assertTrue(required, required.contains("@NotNull")); + assertTrue(required, required.contains("@Size(min = 1, max = 100)")); + assertEquals("", SpringDtoGenerator.validationAnnotations(field("updatedAt"))); + } + + @Test + public void aSubclassInAnotherPackageReachesTheInheritedHelpers() { + AdopterDtoGenerator gen = new AdopterDtoGenerator(); + + assertTrue(gen.annotationsOf(field("name")).contains("@NotNull")); + assertEquals("Long", gen.typeOf(field("id"), customer)); + assertEquals("@Size(min = 1, max = 100)", gen.size(1, 100)); + assertEquals("@Size(max = 5)", gen.size(null, 5)); + assertTrue(gen.flag(field("name"), "required")); + assertFalse(gen.flag(field("code"), "required")); + assertEquals(Integer.valueOf(100), gen.number(field("name"), "maxLength")); + assertNull(gen.number(field("code"), "maxLength")); + assertTrue(gen.hasRegex(field("code"))); + assertFalse(gen.hasRegex(field("name"))); + assertEquals("a\\\"b\\\\c", gen.escaped("a\"b\\c")); + } + + @Test + public void repositoryHelpersAnswerFromTheStaticCallForm() { + assertTrue(SpringRepositoryGenerator.appliesTo(customer)); + assertFalse("an entity with no source has no repository", SpringRepositoryGenerator.appliesTo(note)); + + assertEquals("findTags", SpringRepositoryGenerator.m2mFinderName("tags")); + assertEquals("findTagsForBridge", SpringRepositoryGenerator.m2mFinderNameForSubtype("tags", "Bridge")); + + SpringM2mSupport.M2mNav plain = + new SpringM2mSupport.M2mNav("tags", "Tag", "TagDto", "PostTag", "postId", "tagId", false, null); + SpringM2mSupport.M2mNav onSubtype = + new SpringM2mSupport.M2mNav("tags", "Tag", "TagDto", "PostTag", "postId", "tagId", false, "Bridge"); + assertEquals("", SpringRepositoryGenerator.targetSubtypeParam(plain)); + assertEquals(", String targetSubtype", SpringRepositoryGenerator.targetSubtypeParam(onSubtype)); + } +} diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/AutoSetSupportLegacyFormsTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/AutoSetSupportLegacyFormsTest.java new file mode 100644 index 000000000..6a79cfe24 --- /dev/null +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/AutoSetSupportLegacyFormsTest.java @@ -0,0 +1,83 @@ +package com.metaobjects.generator.spring; + +import com.metaobjects.field.MetaField; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.object.MetaObject; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * The four {@code AutoSetSupport} selectors that shipped through 1.0.13 still compile and + * answer as they did, so an adopter's own code, or a copy ejected before 1.1.0, keeps + * building after the upgrade. + * + *

The call forms below are the 1.0.13 ones: one {@link MetaObject} argument, the same + * return types. The expected field sets come from the fixture's own {@code @autoSet} + * declarations, not from the implementation. + */ +@SuppressWarnings("deprecation") +public class AutoSetSupportLegacyFormsTest { + + @Rule public TemporaryFolder tmp = new TemporaryFolder(); + + private static final String SUBSCRIBER = """ + { "metadata.root": { "package": "acme::sub", "children": [ + { "object.entity": { "name": "Subscriber", "children": [ + { "source.rdb": { "@table": "subscribers" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "email", "@required": true } }, + { "field.timestamp": { "name": "createdAt", "@autoSet": "onCreate" } }, + { "field.timestamp": { "name": "updatedAt", "@autoSet": "onUpdate" } }, + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } + ]}}, + { "object.entity": { "name": "Note", "children": [ + { "source.rdb": { "@table": "notes" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "body" } }, + { "field.timestamp": { "name": "createdAt" } }, + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } + ]}} + ]}} + """; + + private MetaObject entity(String shortName) throws IOException { + MetaDataLoader loader = SpringTestFixtures.loadFixture(tmp.newFolder().toPath(), "sub", SUBSCRIBER); + for (MetaObject mo : loader.getMetaObjects()) { + if (mo.getName().endsWith(shortName)) return mo; + } + throw new IllegalStateException(shortName + " not in fixture"); + } + + private static List names(List fields) { + return fields.stream().map(MetaField::getName).collect(Collectors.toList()); + } + + @Test + public void stampFieldsNamesEveryStampableAutoSetField() throws IOException { + List fields = AutoSetSupport.stampFields(entity("Subscriber")); + assertEquals(List.of("createdAt", "updatedAt"), names(fields)); + } + + @Test + public void onUpdateFieldsNamesOnlyTheOnUpdateField() throws IOException { + List fields = AutoSetSupport.onUpdateFields(entity("Subscriber")); + assertEquals(List.of("updatedAt"), names(fields)); + } + + @Test + public void hasFlagsAreTrueForAnAutoSetEntityAndFalseForAPlainOne() throws IOException { + assertTrue(AutoSetSupport.hasAutoSetFields(entity("Subscriber"))); + assertTrue(AutoSetSupport.hasOnUpdateFields(entity("Subscriber"))); + assertFalse(AutoSetSupport.hasAutoSetFields(entity("Note"))); + assertFalse(AutoSetSupport.hasOnUpdateFields(entity("Note"))); + } +} diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/OwnedPathNeverReachesPackagedSupportTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/OwnedPathNeverReachesPackagedSupportTest.java new file mode 100644 index 000000000..702aa58bb --- /dev/null +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/OwnedPathNeverReachesPackagedSupportTest.java @@ -0,0 +1,178 @@ +package com.metaobjects.generator.spring; + +import com.metaobjects.generator.GeneratorRegistry; +import com.metaobjects.generator.GeneratorRegistry.GeneratorInfo; +import org.junit.Test; + +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * An owned generator must run owned emit rules only. + * + *

{@code mvn metaobjects:eject} copies a generator and the support class it imports + * ({@link GeneratorRegistry#EJECT_SUPPORT}) into the adopter's package. Everything else the + * copies call stays packaged. If one of those packaged classes called back into the packaged + * support class, an owned generator would still run the packaged rule through it: the + * adopter edits which fields a DTO carries, and a packaged helper keeps selecting the + * reference set. Likewise an ejectable generator that called another ejectable generator + * would, as a copy, keep calling the packaged one. + * + *

Both are read from the compiled classes of this module, the dependency graph the JVM + * links, so a comment or a javadoc link is not a reference and an unimported call is. The + * starting points come from the registry, never a hand-typed list. + */ +public class OwnedPathNeverReachesPackagedSupportTest { + + private final Path classes = classesDir(); + private final Map> refsByClass = new HashMap<>(); + + private static Path classesDir() { + try { + Path dir = Path.of(GeneratorRegistry.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + assertTrue("expected this module's compiled classes as a directory: " + dir, Files.isDirectory(dir)); + return dir; + } catch (java.net.URISyntaxException e) { + throw new IllegalStateException(e); + } + } + + private static Set support() { + Set out = new TreeSet<>(); + for (String simple : GeneratorRegistry.EJECT_SUPPORT) out.add(GeneratorRegistry.SUPPORT_PACKAGE + "." + simple); + return out; + } + + /** Every ejectable generator in this module (an ejectable generator of another module, + * if one is ever registered, has no class file here and is not this test's to read). */ + private Set ejectableGenerators() { + Set out = new TreeSet<>(); + for (GeneratorInfo info : GeneratorRegistry.list().values()) { + if (info.ejectResourcePath() != null && Files.exists(classFile(info.classname()))) { + out.add(info.classname()); + } + } + return out; + } + + @Test + public void noPackagedClassAnOwnedGeneratorReachesRefersBackToTheSupportClass() throws IOException { + Set support = support(); + Set owned = new TreeSet<>(support); + owned.addAll(ejectableGenerators()); + + Deque queue = new ArrayDeque<>(owned); + Set seen = new HashSet<>(owned); + List backEdges = new ArrayList<>(); + int packagedReached = 0; + boolean anOwnedGeneratorUsesSupport = false; + while (!queue.isEmpty()) { + String cls = queue.poll(); + boolean isOwned = owned.contains(outer(cls)); + boolean refersToSupport = false; + for (String ref : refs(cls)) { + if (support.contains(outer(ref))) refersToSupport = true; + if (seen.add(ref) && Files.exists(classFile(ref))) queue.add(ref); + } + if (!isOwned) { + packagedReached++; + if (refersToSupport) backEdges.add(cls); + } else if (refersToSupport && !support.contains(outer(cls))) { + anOwnedGeneratorUsesSupport = true; + } + } + assertTrue("the walk found no generator using the support class, so it proves nothing", + anOwnedGeneratorUsesSupport); + assertTrue("the walk reached no packaged class, so it proves nothing", packagedReached > 0); + assertTrue("packaged classes an owned generator calls still refer to " + support + + ", so an owned generator runs the packaged rules through them: " + backEdges, + backEdges.isEmpty()); + } + + @Test + public void noEjectableGeneratorRefersToAnotherEjectableGenerator() throws IOException { + Set generators = ejectableGenerators(); + assertFalse("expected the registry to name ejectable generators", generators.isEmpty()); + List offenders = new ArrayList<>(); + for (String generator : generators) { + Deque queue = new ArrayDeque<>(List.of(generator)); + Set seen = new HashSet<>(queue); + while (!queue.isEmpty()) { + for (String ref : refs(queue.poll())) { + String refOuter = outer(ref); + if (refOuter.equals(generator)) { + if (seen.add(ref) && Files.exists(classFile(ref))) queue.add(ref); + } else if (generators.contains(refOuter)) { + offenders.add(generator + " -> " + refOuter); + } + } + } + } + assertTrue("an owned copy of the first would keep calling the packaged second: " + offenders, + offenders.isEmpty()); + } + + private static String outer(String className) { + int dollar = className.indexOf('$'); + return dollar < 0 ? className : className.substring(0, dollar); + } + + private Path classFile(String className) { + return classes.resolve(className.replace('.', '/') + ".class"); + } + + /** The classes {@code className} links against: every {@code CONSTANT_Class} entry of its + * constant pool (JVMS 4.4), which covers calls, field reads, casts and nested classes. */ + private Set refs(String className) throws IOException { + Set cached = refsByClass.get(className); + if (cached != null) return cached; + Set out = new TreeSet<>(); + try (InputStream raw = Files.newInputStream(classFile(className)); + DataInputStream in = new DataInputStream(raw)) { + in.readInt(); // magic + in.readUnsignedShort(); // minor + in.readUnsignedShort(); // major + int count = in.readUnsignedShort(); + String[] utf8 = new String[count]; + List classNameIndexes = new ArrayList<>(); + for (int i = 1; i < count; i++) { + int tag = in.readUnsignedByte(); + switch (tag) { + case 1 -> utf8[i] = in.readUTF(); + case 7 -> classNameIndexes.add(in.readUnsignedShort()); + case 8, 16, 19, 20 -> in.skipBytes(2); + case 15 -> in.skipBytes(3); + case 3, 4, 9, 10, 11, 12, 17, 18 -> in.skipBytes(4); + case 5, 6 -> { + in.skipBytes(8); + i++; + } + default -> throw new IOException("unknown constant pool tag " + tag + " in " + className); + } + } + for (int index : classNameIndexes) { + String name = utf8[index]; + while (name.startsWith("[")) name = name.substring(1); + if (name.startsWith("L") && name.endsWith(";")) name = name.substring(1, name.length() - 1); + if (name.length() > 1) out.add(name.replace('/', '.')); + } + } + refsByClass.put(className, out); + return out; + } +} diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringAppliesToTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringAppliesToTest.java index ed2faa2e1..1312c59d3 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringAppliesToTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringAppliesToTest.java @@ -142,16 +142,16 @@ public void repositoryAppliesToPersistedReadSurfaces() throws Exception { MetaObject abstractEntity = loader.getMetaObjectByName("acme::shop::AbstractEntity"); MetaObject address = loader.getMetaObjectByName("acme::shop::Address"); - assertTrue("table entity emits a repository", SpringRepositoryGenerator.appliesTo(author)); + assertTrue("table entity emits a repository", SpringGeneratorSupport.repositoryAppliesTo(author)); // F22: INVERTED, and the SHAPE of the repository inverted with it. A view-kind // projection has a list route, so it has a consumer seam — a READ-ONLY one // (list/count/findById, nothing that writes). It used to emit nothing, which is // why a projection got no REST surface in Java at all. assertTrue("view-kind projection emits a read-only repository", - SpringRepositoryGenerator.appliesTo(report)); - assertFalse("abstract entity emits nothing", SpringRepositoryGenerator.appliesTo(abstractEntity)); + SpringGeneratorSupport.repositoryAppliesTo(report)); + assertFalse("abstract entity emits nothing", SpringGeneratorSupport.repositoryAppliesTo(abstractEntity)); assertFalse("value object is neither an entity nor a projection", - SpringRepositoryGenerator.appliesTo(address)); + SpringGeneratorSupport.repositoryAppliesTo(address)); } @Test diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringAutoSetStampingTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringAutoSetStampingTest.java index a367d78cc..7fdc79331 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringAutoSetStampingTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringAutoSetStampingTest.java @@ -291,7 +291,7 @@ public void tphControllerWithoutAutoSetIsByteIdentical() throws Exception { * {@code activatedAt}/{@code pingedAt}) so they fired {@code true} and the controller emitted * {@code DeviceDto.stampForInsert(dto)} / {@code patch.stampAutoSetOnUpdate()} — but * {@code SpringDtoGenerator.emitTphUnion} gates the union {@code DeviceDto}'s - * {@code stampForInsert} helper on {@code AutoSetSupport.hasAutoSetFields(base)}, which is + * {@code stampForInsert} helper on {@code SpringGeneratorSupport.hasAutoSetFields(base)}, which is * FALSE here (the base has no {@code @autoSet} field), so {@code DeviceDto} never gets that * static method: a non-compiling controller. Fails before the fix (the controller string * assertions below saw the phantom calls); passes after. diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringControllerGeneratorTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringControllerGeneratorTest.java index 44448384e..cdfcea43f 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringControllerGeneratorTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringControllerGeneratorTest.java @@ -299,7 +299,7 @@ public void viewKindProjectionsGetAReadOnlyController() throws Exception { * was a client-supplied primary key, which defeats the declared strategy (FINDINGS F25).

* *

The TPH create path already applied this rule via - * {@code SpringDtoGenerator.settableFields}; this pins the vanilla path to the same answer.

+ * {@code SpringGeneratorSupport.settableFields}; this pins the vanilla path to the same answer.

*/ @Test public void createDoesNotValidateServerOwnedColumnsOutOfTheBody() throws Exception { diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringM2mCodegenTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringM2mCodegenTest.java index 482f4737f..3ef013ab3 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringM2mCodegenTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringM2mCodegenTest.java @@ -159,7 +159,7 @@ public void junctionEntityEmitsNoTraversalEndpoint() throws Exception { @Test public void m2mFinderNameCapitalizesRelation() { - assertEquals("findTags", SpringRepositoryGenerator.m2mFinderName("tags")); - assertEquals("findFollowing", SpringRepositoryGenerator.m2mFinderName("following")); + assertEquals("findTags", SpringGeneratorSupport.m2mFinderName("tags")); + assertEquals("findFollowing", SpringGeneratorSupport.m2mFinderName("following")); } } diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringProjectionDtoCompileRunTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringProjectionDtoCompileRunTest.java index 4be46e5a7..db106247e 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringProjectionDtoCompileRunTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringProjectionDtoCompileRunTest.java @@ -112,7 +112,7 @@ public void projectionDtoCompilesAndNoWriteSurfaceIsEmitted() throws Exception { assertTrue("a concrete entity (even view-kind) gets a read DTO", SpringDtoGenerator.appliesTo(summary)); assertTrue("view-kind projection gets a read-only repository", - SpringRepositoryGenerator.appliesTo(summary)); + SpringGeneratorSupport.repositoryAppliesTo(summary)); assertTrue("view-kind projection gets a read-only controller", SpringControllerGenerator.appliesTo(summary)); assertTrue("view-kind projection gets a filter allowlist (its list route filters)", diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringProjectionRestSurfaceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringProjectionRestSurfaceTest.java index 58c34b357..5682c6a08 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringProjectionRestSurfaceTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringProjectionRestSurfaceTest.java @@ -100,7 +100,7 @@ public void controllerRepositoryAndAllowlistAgreeOnEveryObject() throws Exceptio for (MetaObject obj : loader.getMetaObjects()) { boolean controller = SpringControllerGenerator.appliesTo(obj); assertEquals(obj.getName() + ": repository must agree with controller", - controller, SpringRepositoryGenerator.appliesTo(obj)); + controller, SpringGeneratorSupport.repositoryAppliesTo(obj)); assertEquals(obj.getName() + ": filter allowlist must agree with controller", controller, SpringFilterAllowlistGenerator.appliesTo(obj)); } diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringReportRestSurfaceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringReportRestSurfaceTest.java index dbfe44e78..f7699e6e5 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringReportRestSurfaceTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringReportRestSurfaceTest.java @@ -154,7 +154,7 @@ public void controllerRepositoryAndAllowlistAgreeOnEveryObjectAndEveryShape() th for (MetaObject obj : objects) { boolean controller = SpringControllerGenerator.appliesTo(obj); assertEquals(obj.getName() + ": repository must agree with controller", - controller, SpringRepositoryGenerator.appliesTo(obj)); + controller, SpringGeneratorSupport.repositoryAppliesTo(obj)); assertEquals(obj.getName() + ": filter allowlist must agree with controller", controller, SpringFilterAllowlistGenerator.appliesTo(obj)); } diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSourcelessEntityTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSourcelessEntityTest.java index a9b1936d7..191c7ad94 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSourcelessEntityTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSourcelessEntityTest.java @@ -77,7 +77,7 @@ public void sourcelessEntityGetsDtoPatchAndAllowlistButNoRestSurface() throws Ex assertFalse("no identity: nothing to address", RestSurfaceGate.isSourcelessEntity(note)); assertFalse("no store MetaObjects manages: no controller", SpringControllerGenerator.appliesTo(order)); - assertFalse("no store MetaObjects manages: no repository", SpringRepositoryGenerator.appliesTo(order)); + assertFalse("no store MetaObjects manages: no repository", SpringGeneratorSupport.repositoryAppliesTo(order)); assertTrue("the adopter's list endpoint needs the allowlist", SpringFilterAllowlistGenerator.appliesTo(order)); assertFalse(SpringFilterAllowlistGenerator.appliesTo(item)); diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSourcelessProjectionTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSourcelessProjectionTest.java index 818c4fc60..51ebc5d4d 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSourcelessProjectionTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSourcelessProjectionTest.java @@ -66,7 +66,7 @@ public void sourcelessProjectionGetsNoRepository() throws Exception { // The sourced entity still emits — the no-churn half of the assertion. assertTrue("a sourced entity must still get a repository", - SpringRepositoryGenerator.appliesTo(author)); + SpringGeneratorSupport.repositoryAppliesTo(author)); // A sourceless ENTITY is what actually exercises the source gate: it clears // appliesTo's leading SUBTYPE_ENTITY check and is rejected only by the @@ -74,10 +74,10 @@ public void sourcelessProjectionGetsNoRepository() throws Exception { // would be vacuous — the subtype check short-circuits before the source is // ever consulted, so deleting the source guard would leave that green. assertFalse("a sourceless entity has no table to back a repository", - SpringRepositoryGenerator.appliesTo(draft)); + SpringGeneratorSupport.repositoryAppliesTo(draft)); // The sourceless projection is excluded too (by the subtype gate). assertFalse("a sourceless projection has no table to back a repository", - SpringRepositoryGenerator.appliesTo(payload)); + SpringGeneratorSupport.repositoryAppliesTo(payload)); } } diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTypeMapperTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTypeMapperTest.java index fbf69c357..d0baa4209 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTypeMapperTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTypeMapperTest.java @@ -161,7 +161,7 @@ public void stringFormatEmailEmitsJakartaEmail() { StringField f = new StringField("contactEmail"); f.addMetaAttr(com.metaobjects.attr.StringAttribute.create( StringField.ATTR_STRING_FORMAT, StringField.STRING_FORMAT_EMAIL)); - assertEquals("@Email", SpringDtoGenerator.validationAnnotations(f)); + assertEquals("@Email", SpringGeneratorSupport.validationAnnotations(f)); } @Test @@ -170,7 +170,7 @@ public void stringFormatHostnameEmitsCanonicalPattern() { StringField f = new StringField("host"); f.addMetaAttr(com.metaobjects.attr.StringAttribute.create( StringField.ATTR_STRING_FORMAT, StringField.STRING_FORMAT_HOSTNAME)); - String ann = SpringDtoGenerator.validationAnnotations(f); + String ann = SpringGeneratorSupport.validationAnnotations(f); assertEquals(true, ann.startsWith("@Pattern(regexp = ")); assertEquals(true, ann.contains("[A-Za-z0-9]")); } @@ -182,7 +182,7 @@ public void stringArrayFieldDtoComponentIsListOfString() { // DERIVED from isArray, never declared via @dbColumnType:text_array). StringField f = new StringField("tags"); f.setArray(true); - assertEquals("java.util.List", SpringDtoGenerator.componentType(f, null)); + assertEquals("java.util.List", SpringGeneratorSupport.componentType(f, null)); } @Test @@ -193,7 +193,7 @@ public void uuidArrayFieldDtoComponentIsListOfUUID() { // was unwired; this pins the cross-port List shape. com.metaobjects.field.UuidField f = new com.metaobjects.field.UuidField("refs"); f.setArray(true); - assertEquals("java.util.List", SpringDtoGenerator.componentType(f, null)); + assertEquals("java.util.List", SpringGeneratorSupport.componentType(f, null)); } // === field.map (open-keyed map) ========================================== @@ -289,7 +289,7 @@ public void scalarValuedMapIsNeverWrappedInList() { // would silently produce List>, which no other port emits. com.metaobjects.field.MapField f = scalarMap("labels", StringField.SUBTYPE_STRING); f.setArray(true); - assertEquals("java.util.Map", SpringDtoGenerator.componentType(f, null)); + assertEquals("java.util.Map", SpringGeneratorSupport.componentType(f, null)); } @Test diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringWriteThroughCompileRunTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringWriteThroughCompileRunTest.java index 25fb520dc..732f7f168 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringWriteThroughCompileRunTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringWriteThroughCompileRunTest.java @@ -122,7 +122,7 @@ public void writeThroughReadDtoCarriesDerived_patchExcludesDerived_writeSurfaces // --- write-through IS writable → every write surface emits (unlike a projection) --- assertTrue("read DTO applies", SpringDtoGenerator.appliesTo(order)); - assertTrue("write-through has a repository", SpringRepositoryGenerator.appliesTo(order)); + assertTrue("write-through has a repository", SpringGeneratorSupport.repositoryAppliesTo(order)); assertTrue("write-through has a controller", SpringControllerGenerator.appliesTo(order)); assertTrue("write-through has a filter allowlist", SpringFilterAllowlistGenerator.appliesTo(order)); @@ -240,7 +240,7 @@ public void writeThroughDetectionIsSourceOrderIndependent() throws Exception { assertTrue("view-first Order is still write-through", order.isWriteThrough()); // The pre-#214 gate keyed on the FIRST source (the view) and wrongly skipped these. assertTrue("view-first write-through still gets a repository", - SpringRepositoryGenerator.appliesTo(order)); + SpringGeneratorSupport.repositoryAppliesTo(order)); assertTrue("view-first write-through still gets a controller", SpringControllerGenerator.appliesTo(order)); assertTrue("view-first write-through still gets a filter allowlist", 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 071879a80..a41064636 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 @@ -239,9 +239,10 @@ public static Port inferPort(List declaredArtifactIds) { } // ------------------------------------------------------------------------------------ - // 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. + // package rewrite — the edit every copy gets (ADR-0034, the JVM eject design). A generator + // copied WITH other classes gets one more, which is the same rename seen from the importing + // side: see rewriteCopiedImports. A Java generator may get two further edits, both further + // down: its RUNTIME_PACKAGE declaration and its support imports. // ------------------------------------------------------------------------------------ private static final Pattern PACKAGE_LINE = Pattern.compile("(?m)^package\\s+[\\w.]+\\s*;?\\s*$"); @@ -329,10 +330,14 @@ public static String ownedRuntimeSource(String reference, String simpleName, Str /** Whether {@code content} is a runtime file eject wrote (its marker is on the first * non-blank line). */ public static boolean isOwnedRuntime(String content) { + return firstLineStartsWith(content, OWNED_RUNTIME_MARKER); + } + + private static boolean firstLineStartsWith(String content, String marker) { for (String line : content.split("\n", -1)) { String t = line.strip(); if (t.isEmpty()) continue; - return t.startsWith(OWNED_RUNTIME_MARKER); + return t.startsWith(marker); } return false; } @@ -344,6 +349,64 @@ public static List runtimeFor(List entries) { return new ArrayList<>(out); } + // ------------------------------------------------------------------------------------ + // support source — the emit rules the Java generators share, handed over with them + // ------------------------------------------------------------------------------------ + + /** An {@code import} of one of {@link GeneratorRegistry#EJECT_SUPPORT}, from any package: + * group 1 is the package, group 2 the simple name. */ + private static final Pattern SUPPORT_IMPORT = Pattern.compile( + "(?m)^import\\s+([\\w.]+)\\.(" + String.join("|", GeneratorRegistry.EJECT_SUPPORT) + ")\\s*;[ \\t]*$"); + + /** + * The first line of every support file eject writes: the same job + * {@link #OWNED_RUNTIME_MARKER} does for a runtime file. Keep it if you move the file. + */ + public static final String OWNED_SUPPORT_MARKER = "// metaobjects:owned-support"; + + /** Simple names of the packaged support classes {@code source} imports, in source order. + * Eject copies exactly these beside the generator. */ + public static List supportFor(String source) { + List out = new ArrayList<>(); + Matcher m = SUPPORT_IMPORT.matcher(source); + while (m.find()) { + if (m.group(1).equals(GeneratorRegistry.SUPPORT_PACKAGE) && !out.contains(m.group(2))) { + out.add(m.group(2)); + } + } + return out; + } + + /** Point an ejected generator's imports of the packaged support classes at the copies in + * {@code ownedPackage}. A source that imports none is returned unchanged. */ + public static String rewriteSupportImports(String source, String ownedPackage) { + Matcher m = SUPPORT_IMPORT.matcher(source); + StringBuilder out = new StringBuilder(); + while (m.find()) { + String replacement = m.group(1).equals(GeneratorRegistry.SUPPORT_PACKAGE) + ? "import " + ownedPackage + "." + m.group(2) + ";" : m.group(); + m.appendReplacement(out, Matcher.quoteReplacement(replacement)); + } + return m.appendTail(out).toString(); + } + + /** + * The owned copy of one support class: its package renamed to {@code ownedPackage}, and + * the {@link #OWNED_SUPPORT_MARKER} line (naming the reference it came from) on top. + * Nothing else changes. + */ + public static String ownedSupportSource(String reference, String simpleName, String ownedPackage) { + return OWNED_SUPPORT_MARKER + " — copied by `mvn metaobjects:eject` from " + + GeneratorRegistry.SUPPORT_PACKAGE + "." + simpleName + + ". You own this file.\n" + + rewritePackage(reference, ownedPackage); + } + + /** Whether {@code content} is a support file eject wrote. */ + public static boolean isOwnedSupport(String content) { + return firstLineStartsWith(content, OWNED_SUPPORT_MARKER); + } + // ------------------------------------------------------------------------------------ // owned-copy comparison — line-multiset, ignoring the package line (mirrors Python's // metaobjects.codegen.eject._normalised / owned_status). @@ -366,8 +429,9 @@ public static final class Comparison { } /** Every non-blank line, trimmed, with the lines eject itself writes dropped — the - * package line, a generator's {@code RUNTIME_PACKAGE} declaration and a runtime file's - * ownership marker are the deliberate edits of eject, not drift. */ + * package line, a generator's {@code RUNTIME_PACKAGE} declaration and support imports, + * and a runtime or support file's ownership marker are the deliberate edits of eject, + * not drift. */ private static List normalizedLines(String source) { List out = new ArrayList<>(); for (String line : source.split("\n", -1)) { @@ -375,7 +439,8 @@ private static List normalizedLines(String source) { if (t.isEmpty()) continue; if (PACKAGE_LINE.matcher(t).matches()) continue; if (RUNTIME_PACKAGE_DECL.matcher(t).matches()) continue; - if (t.startsWith(OWNED_RUNTIME_MARKER)) continue; + if (SUPPORT_IMPORT.matcher(t).matches()) continue; + if (t.startsWith(OWNED_RUNTIME_MARKER) || t.startsWith(OWNED_SUPPORT_MARKER)) continue; out.add(t); } return out; 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 d648c1ed8..7fdbd5c75 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 @@ -33,12 +33,18 @@ * *

What it does. For each requested stable name it resolves the generator (see * {@link EjectSupport#resolve}), reads its reference source from the classpath resource the - * owning {@code codegen-spring}/{@code codegen-kotlin} jar ships it as, rewrites ONLY the - * {@code package ...} declaration (ADR-0034: the package rename is the sole edit — a copy - * that kept the reference's original package would silently lose to the plugin's own class, - * since the project classloader is parent-first), and writes it under {@code - * codegen/src/main/(java|kotlin)//}. On first use it also writes {@code - * codegen/pom.xml}. + * owning {@code codegen-spring}/{@code codegen-kotlin} jar ships it as, rewrites the + * {@code package ...} declaration (ADR-0034 — a copy that kept the reference's original + * package would silently lose to the plugin's own class, since the project classloader is + * parent-first), and writes it under {@code codegen/src/main/(java|kotlin)//}. + * On first use it also writes {@code codegen/pom.xml}. + * + *

The shared emit rules come too. The Java routes, DTO, repository and value-object + * generators apply rules they share (which fields a DTO carries, validation annotations, M:N + * finder naming) through {@code SpringGeneratorSupport}. Ejecting any of them writes ONE copy + * of that class beside the owned generators, in their package, and points each owned + * generator's import at it — so an owned generator never calls the packaged rules, and the + * adopter can change them. Its first line is {@link EjectSupport#OWNED_SUPPORT_MARKER}. * *

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 @@ -169,6 +175,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { // Two generators named in one call may share a class copied with each of them; it // is written once. Set copied = new LinkedHashSet<>(); + Set support = new LinkedHashSet<>(); for (EjectSupport.Resolution r : resolved) { EjectSupport.Entry e = r.entry; portsTouched.add(e.port); @@ -195,6 +202,10 @@ public void execute() throws MojoExecutionException, MojoFailureException { if (generator && !e.runtime.isEmpty()) { rewritten = EjectSupport.rewriteRuntimePackage(rewritten, runtimePkg); } + if (e.port == EjectSupport.Port.JAVA) { + support.addAll(EjectSupport.supportFor(rewritten)); + rewritten = EjectSupport.rewriteSupportImports(rewritten, pkg); + } String what = generator ? "\"" + e.stableName + "\"" : simpleName + " (with \"" + e.stableName + "\")"; @@ -204,6 +215,8 @@ public void execute() throws MojoExecutionException, MojoFailureException { + pkg + "." + e.simpleName()); } + ejectSupport(support, codegenRoot.resolve("src/main/java").resolve(pkg.replace('.', '/')), pkg); + boolean pomWritten = writeCodegenPomIfNeeded(codegenRoot, pkg, portsTouched); if (!runtime.isEmpty()) { @@ -221,6 +234,43 @@ public void execute() throws MojoExecutionException, MojoFailureException { } } + // ------------------------------------------------------------------------------------ + // support source + // ------------------------------------------------------------------------------------ + + /** Write one owned copy of each support class beside the owned generators that import it. */ + private void ejectSupport(Set support, Path dir, String pkg) throws MojoExecutionException { + for (String simpleName : support) { + String owned = EjectSupport.ownedSupportSource(readSupport(simpleName), simpleName, pkg); + Path target = dir.resolve(simpleName + ".java"); + switch (write(dir, target, owned, force)) { + case CREATED: + getLog().info("Copied support " + simpleName + " -> " + target + + ". The owned generators import this copy; you own it now."); + break; + case REPLACED: + getLog().info("Copied support " + simpleName + " -> " + target + + ", REPLACING the file that was there (-Dforce=true)."); + break; + case PRESERVED: + getLog().info(target + " already exists — " + describeOwned(target, owned) + + " Left untouched (-Dforce=true replaces it)."); + break; + } + } + } + + private String readSupport(String simpleName) throws MojoExecutionException { + String resource = GeneratorRegistry.EJECT_SUPPORT_RESOURCE_ROOT + simpleName + ".java"; + try { + return readResource(resource); + } catch (IOException ex) { + throw new MojoExecutionException( + "Could not read reference support source \"" + simpleName + "\" at classpath " + + "resource " + resource, ex); + } + } + // ------------------------------------------------------------------------------------ // helper runtime // ------------------------------------------------------------------------------------ @@ -526,6 +576,11 @@ private String buildListOutput() { sb.append('\n'); } } + String supportRows = ownedSupportRows(codegenRoot); + if (!supportRows.isEmpty()) { + sb.append("\nOwned support (the shared emit rules, copied by eject beside the generators " + + "that import them):\n").append(supportRows); + } String runtimeRows = ownedRuntimeRows(); if (!runtimeRows.isEmpty()) { sb.append("\nOwned helper runtime (copied by eject with routes/dto/repository):\n") @@ -578,21 +633,54 @@ private String ownedRuntimeRows() { return sb.toString(); } - private String ownedStaleness(Path codegenRoot, EjectSupport.Entry e) { + /** One row per owned support copy under {@code codegen/src/main/java}. */ + private String ownedSupportRows(Path codegenRoot) { + StringBuilder sb = new StringBuilder(); + for (String simple : GeneratorRegistry.EJECT_SUPPORT) { + String owned = ownedSupportStaleness(codegenRoot, simple); + if (owned != null) sb.append(" ").append(simple).append(" ").append(owned).append('\n'); + } + return sb.toString(); + } + + /** The owned copy named {@code filename} under {@code codegen/src/main/}, or + * {@code null}. It may live under ANY package the adopter chose. */ + private static Path findOwned(Path codegenRoot, String langDir, String filename) throws IOException { if (codegenRoot == null) return null; - String langDir = e.port == EjectSupport.Port.KOTLIN ? "kotlin" : "java"; - // The owned copy may live under ANY package the adopter chose — search for a - // file with this generator's simple name under codegen/src/main/. Path srcRoot = codegenRoot.resolve("src/main/" + langDir); if (!Files.isDirectory(srcRoot)) return null; - String filename = e.simpleName() + "." + e.port.extension; try (var walk = Files.walk(srcRoot)) { - Path found = walk.filter(p -> p.getFileName().toString().equals(filename)) - .findFirst().orElse(null); + return walk.filter(p -> p.getFileName().toString().equals(filename)).findFirst().orElse(null); + } + } + + /** One owned support copy's staleness against the reference shipped with this plugin. */ + private String ownedSupportStaleness(Path codegenRoot, String simple) { + try { + Path found = findOwned(codegenRoot, "java", simple + ".java"); + if (found == null) return null; + String owned = Files.readString(found, StandardCharsets.UTF_8); + String reference = readSupport(simple); + EjectSupport.Comparison cmp = EjectSupport.compare(owned, reference); + return cmp.verdict == EjectSupport.Verdict.IDENTICAL + ? "[owned — identical]" + : "[owned — DIFFERS: " + cmp.behind + " behind, " + cmp.ownedOnly + " of your own]"; + } catch (IOException | MojoExecutionException ex) { + return "[owned — could not compare: " + ex.getMessage() + "]"; + } + } + + /** The staleness of the owned copy of {@code e}, over every class copied with it; + * {@code null} when there is no such copy. */ + private String ownedStaleness(Path codegenRoot, EjectSupport.Entry e) { + String langDir = e.port == EjectSupport.Port.KOTLIN ? "kotlin" : "java"; + try { + Path found = findOwned(codegenRoot, langDir, e.simpleName() + "." + e.port.extension); if (found == null) return null; // 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. + Path srcRoot = codegenRoot.resolve("src/main/" + langDir); String ownedPackage = srcRoot.relativize(found.getParent()).toString() .replace(found.getFileSystem().getSeparator(), "."); int behind = 0; diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java index 264175b91..53891fb98 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java @@ -411,10 +411,11 @@ private List compareTrees(GenTarget t) throws MojoExecutionException { if (inGen && !inCommitted) { drift.add("[missing-from-repo] " + committedFile + " (generator produces it; not committed)"); } else if (!inGen && inCommitted) { - // Helper runtime copied by `mvn metaobjects:eject` is owned code, not output: - // no generator produces it, so an output dir that also holds it (outputDir = - // src/main/java is legal) would otherwise report it stale forever. - if (isOwnedRuntime(committedFile)) continue; + // Helper runtime and support source copied by `mvn metaobjects:eject` are + // owned code, not output: no generator produces them, so an output dir that + // also holds one (outputDir = src/main/java is legal) would otherwise report + // it stale forever. + if (isOwnedByEject(committedFile)) continue; drift.add("[stale-in-repo] " + committedFile + " (committed; generator no longer produces it)"); } else { // Present in both — compare bytes. @@ -446,11 +447,13 @@ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { return out; } - /** Whether {@code file} carries {@link EjectSupport#OWNED_RUNTIME_MARKER}. */ - private static boolean isOwnedRuntime(Path file) throws MojoExecutionException { + /** Whether {@code file} carries {@link EjectSupport#OWNED_RUNTIME_MARKER} or + * {@link EjectSupport#OWNED_SUPPORT_MARKER}. */ + private static boolean isOwnedByEject(Path file) throws MojoExecutionException { if (!file.getFileName().toString().endsWith(".java")) return false; try { - return EjectSupport.isOwnedRuntime(Files.readString(file, StandardCharsets.UTF_8)); + String content = Files.readString(file, StandardCharsets.UTF_8); + return EjectSupport.isOwnedRuntime(content) || EjectSupport.isOwnedSupport(content); } catch (IOException e) { throw new MojoExecutionException("Could not read [" + file + "]", e); } diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectAloneRoundTripTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectAloneRoundTripTest.java new file mode 100644 index 000000000..e8dcadf09 --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectAloneRoundTripTest.java @@ -0,0 +1,389 @@ +package com.metaobjects.mojo; + +import com.metaobjects.generator.Generator; +import com.metaobjects.generator.GeneratorRegistry; +import com.metaobjects.loader.LoaderOptions; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.loader.uri.URIHelper; +import org.apache.maven.project.MavenProject; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.Mockito; + +import javax.tools.Diagnostic; +import javax.tools.DiagnosticCollector; +import javax.tools.ForwardingJavaFileManager; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileManager; +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.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Eject proof, one generator at a time: an adopter ejects the generator they want to change, + * not the whole tier, and what {@code mvn metaobjects:eject} writes for that one name must + * compile on its own and own every emit rule it applies. + * + *

The Java routes, DTO, repository and value-object generators share emit rules through + * {@code SpringGeneratorSupport}. Eject hands that class over with them, so each proof here + * compiles what the real mojo wrote with the PACKAGED support class hidden from javac: a + * copy that still resolved the packaged class would fail to compile. + *

    + *
  1. Alone. Every ejectable Java generator, ejected by itself, compiles.
  2. + *
  3. Unchanged. The owned generators emit byte for byte what the packaged ones do.
  4. + *
  5. Owned. An edit to the owned support copy changes what two owned generators + * emit, and they still agree with each other. That holds for a rule a packaged helper + * used to answer on the owned generator's behalf too: which fields a DTO carries + * decides the record, its stamp methods and the controller's stamp calls together.
  6. + *
+ * The list of generators comes from the registry, never a hand-typed one. No Kotlin generator + * can reach the support class ({@code codegen-kotlin} does not depend on {@code + * codegen-spring}); the Kotlin copies are compiled alone in that module. + */ +public class EjectAloneRoundTripTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + private Path canonical; + private MetaDataLoader loader; + + @Before + public void setUp() { + canonical = findCanonical(); + loader = load(canonical.resolve("meta.fitness.json"), "eject-alone-test"); + } + + private static MetaDataLoader load(Path model, String name) { + URI uri = URIHelper.toURI("model:file:" + model.toAbsolutePath().toString().replace('\\', '/')); + MetaDataLoader loader = new MetaDataLoader(LoaderOptions.create(false, false, true), + MetaDataLoader.SUBTYPE_MANUAL, name); + loader.setSourceURIs(List.of(uri)); + loader.init(); + return loader; + } + + private static Path findCanonical() { + Path dir = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize(); + for (Path p = dir; p != null; p = p.getParent()) { + Path c = p.resolve("fixtures/persistence-conformance/canonical"); + if (Files.isDirectory(c)) return c; + } + throw new IllegalStateException("could not locate fixtures/persistence-conformance/canonical from " + dir); + } + + private Map args(Path outDir) { + return args(outDir, "fitness"); + } + + private Map args(Path outDir, String packageName) { + Map a = new LinkedHashMap<>(); + a.put("outputDir", outDir.toString()); + a.put("packageName", packageName); + a.put("templateRoot", canonical.resolve("prompts").toString()); + return a; + } + + private MetaDataEjectMojo mojo(Path projectDir, String names) { + MavenProject project = Mockito.mock(MavenProject.class); + Mockito.when(project.getBasedir()).thenReturn(projectDir.toFile()); + Mockito.when(project.getGroupId()).thenReturn("com.acme"); + Mockito.when(project.getArtifactId()).thenReturn("widgets"); + Mockito.when(project.getVersion()).thenReturn("1.0.0"); + Mockito.when(project.getDependencies()).thenReturn(List.of()); + MetaDataEjectMojo mojo = new MetaDataEjectMojo(); + mojo.project = project; + mojo.names = names; + mojo.port = "java"; + return mojo; + } + + private static List javaEjectable() { + return EjectSupport.allEntries().stream() + .filter(e -> e.port == EjectSupport.Port.JAVA && e.resourcePath != null) + .collect(Collectors.toList()); + } + + // --------------------------------------------------------------------------------- + // 1. alone + // --------------------------------------------------------------------------------- + + @Test + public void everyJavaGeneratorEjectedAloneCompilesWithoutThePackagedSupport() throws Exception { + List failures = new ArrayList<>(); + List entries = javaEjectable(); + assertFalse("expected the registry to name ejectable Java generators", entries.isEmpty()); + for (EjectSupport.Entry e : entries) { + Path projectDir = tempFolder.newFolder("alone-" + e.stableName).toPath(); + mojo(projectDir, e.stableName).execute(); + String errors = compileHidingPackagedSupport( + projectDir.resolve("codegen/src/main/java"), tempFolder.newFolder().toPath()); + if (!errors.isEmpty()) { + failures.add(e.stableName + " (" + e.simpleName() + ") does not compile alone:\n" + errors); + } + } + assertTrue(String.join("\n", failures), failures.isEmpty()); + } + + /** The control for every compile in this class: javac really cannot see the packaged + * support classes, so a copy that named one would not compile. */ + @Test + public void aSourceNamingThePackagedSupportDoesNotCompileHere() throws Exception { + for (String simple : GeneratorRegistry.EJECT_SUPPORT) { + Path src = tempFolder.newFolder().toPath(); + Path dir = src.resolve("com/acme/probe"); + Files.createDirectories(dir); + Files.writeString(dir.resolve("Probe.java"), "package com.acme.probe;\n" + + "public class Probe { Class c = " + GeneratorRegistry.SUPPORT_PACKAGE + "." + + simple + ".class; }\n", StandardCharsets.UTF_8); + assertEquals("the probe is valid Java when the class is visible", + "", compile(src, tempFolder.newFolder().toPath(), false)); + assertFalse(simple + " must be hidden from the compile", + compileHidingPackagedSupport(src, tempFolder.newFolder().toPath()).isEmpty()); + } + } + + // --------------------------------------------------------------------------------- + // 2. unchanged + // --------------------------------------------------------------------------------- + + private static final List WEB_TIER = + List.of("routes", "dto", "repository", "filter-allowlist", "value-object", "names"); + + @Test + public void theOwnedGeneratorsEmitByteForByteWhatThePackagedOnesEmit() throws Exception { + Path projectDir = tempFolder.newFolder("adopter").toPath(); + mojo(projectDir, String.join(",", WEB_TIER)).execute(); + + Path packagedOut = tempFolder.newFolder("packaged-out").toPath(); + for (String stable : WEB_TIER) { + Generator gen = (Generator) Class.forName(GeneratorRegistry.get(stable).classname()) + .getDeclaredConstructor().newInstance(); + Map a = args(packagedOut); + // The one deliberate difference: an owned generator's output imports the owned runtime. + a.put("runtimePackage", "com.acme.runtime"); + gen.setArgs(a).execute(loader); + } + Path ownedOut = runOwned(projectDir, WEB_TIER); + + Map packaged = tree(packagedOut); + Map owned = tree(ownedOut); + assertFalse("the generators emitted nothing", packaged.isEmpty()); + assertEquals(packaged.keySet(), owned.keySet()); + for (Map.Entry f : packaged.entrySet()) { + assertEquals(f.getKey(), f.getValue(), owned.get(f.getKey())); + } + } + + // --------------------------------------------------------------------------------- + // 3. owned + // --------------------------------------------------------------------------------- + + @Test + public void anEditToTheOwnedSupportChangesWhatTheOwnedGeneratorsEmit() throws Exception { + Path projectDir = tempFolder.newFolder("adopter-edit").toPath(); + mojo(projectDir, "routes,repository").execute(); + + // The fitness corpus declares Post.tags, an M:N relationship: the repository declares + // its finder and the controller calls it, both by the name this one rule returns. + Path support = projectDir.resolve("codegen/src/main/java/com/acme/codegen/SpringGeneratorSupport.java"); + String src = Files.readString(support, StandardCharsets.UTF_8); + String edited = src.replace("return \"find\" + Character.toUpperCase(", + "return \"lookup\" + Character.toUpperCase("); + assertFalse("the reference finder-name rule changed shape; update this test's edit", edited.equals(src)); + Files.writeString(support, edited, StandardCharsets.UTF_8); + + Path out = runOwned(projectDir, List.of("routes", "repository")); + String repository = Files.readString(out.resolve("fitness/PostRepository.java"), StandardCharsets.UTF_8); + String controller = Files.readString(out.resolve("fitness/PostController.java"), StandardCharsets.UTF_8); + assertTrue(repository.contains(" lookupTags(")); + assertTrue(controller.contains(".lookupTags(")); + assertFalse(repository.contains("findTags(")); + assertFalse(controller.contains("findTags(")); + } + + /** + * The docs' own example of an owned rule: which fields a DTO carries. The api-contract + * corpus declares {@code Author.autoCreatedAt} ({@code @autoSet: onCreate}) and + * {@code autoUpdatedAt} ({@code onUpdate}), so the reference DTO stamps both and the + * controller calls the stamp methods. Dropping the {@code @autoSet} columns from the + * owned rule must remove the components, the stamp methods AND the controller's calls: a + * stamp method left behind would assign a component the record no longer has. + */ + @Test + public void anEditToWhichFieldsADtoCarriesMovesTheStampMethodsAndTheControllerToo() throws Exception { + MetaDataLoader blog = load(canonical.getParent().getParent() + .resolve("api-contract-conformance/meta.json"), "eject-alone-autoset"); + List names = List.of("routes", "dto"); + + Path reference = tempFolder.newFolder("adopter-reference").toPath(); + mojo(reference, String.join(",", names)).execute(); + Path referenceOut = runOwned(reference, names, blog, "blog"); + String referenceDto = emitted(referenceOut, "AuthorDto.java"); + String referencePatch = emitted(referenceOut, "AuthorPatch.java"); + String referenceController = emitted(referenceOut, "AuthorController.java"); + assertTrue(referenceDto.contains("autoCreatedAt") && referenceDto.contains("stampForInsert(")); + assertTrue(referencePatch.contains("stampAutoSetOnUpdate(")); + assertTrue(referenceController.contains("AuthorDto.stampForInsert(dto)")); + assertTrue(referenceController.contains("patch.stampAutoSetOnUpdate()")); + + Path projectDir = tempFolder.newFolder("adopter-fields").toPath(); + mojo(projectDir, String.join(",", names)).execute(); + Path support = projectDir.resolve("codegen/src/main/java/com/acme/codegen/SpringGeneratorSupport.java"); + String src = Files.readString(support, StandardCharsets.UTF_8); + String rule = " public static List dtoComponentFields(MetaObject entity) {\n" + + " List out = new ArrayList<>();\n" + + " for (MetaField field : entity.getMetaFields()) {\n"; + String edited = src.replace(rule, rule + " if (AutoSetSupport.isAutoSet(field)) continue;\n"); + assertFalse("the reference DTO-field rule changed shape; update this test's edit", edited.equals(src)); + Files.writeString(support, edited, StandardCharsets.UTF_8); + + Path out = runOwned(projectDir, names, blog, "blog"); + String dto = emitted(out, "AuthorDto.java"); + String patch = emitted(out, "AuthorPatch.java"); + String controller = emitted(out, "AuthorController.java"); + for (String record : List.of(dto, patch)) { + assertFalse(record, record.contains("autoCreatedAt")); + assertFalse(record, record.contains("autoUpdatedAt")); + } + for (String emitted : List.of(dto, patch, controller)) { + assertFalse(emitted, emitted.contains("stampForInsert")); + assertFalse(emitted, emitted.contains("stampAutoSetOnUpdate")); + } + // The rest of the record is untouched: the edit removed two columns, not the DTO. + assertTrue(dto.contains("issuedCurrency")); + } + + /** Ejecting a second generator later shares the one support copy, edits and all. */ + @Test + public void reEjectingKeepsAnEditedSupportCopyUnlessForced() throws Exception { + Path projectDir = tempFolder.newFolder("adopter-reeject").toPath(); + mojo(projectDir, "dto").execute(); + Path support = projectDir.resolve("codegen/src/main/java/com/acme/codegen/SpringGeneratorSupport.java"); + String reference = Files.readString(support, StandardCharsets.UTF_8); + String mine = reference + "\n// mine\n"; + Files.writeString(support, mine, StandardCharsets.UTF_8); + + mojo(projectDir, "routes").execute(); + assertEquals(mine, Files.readString(support, StandardCharsets.UTF_8)); + + MetaDataEjectMojo forced = mojo(projectDir, "routes"); + forced.force = true; + forced.execute(); + assertEquals(reference, Files.readString(support, StandardCharsets.UTF_8)); + } + + // --------------------------------------------------------------------------------- + // helpers + // --------------------------------------------------------------------------------- + + /** Compile the owned generators under codegen/ and run the named ones into a fresh dir. */ + private Path runOwned(Path projectDir, List stableNames) throws Exception { + return runOwned(projectDir, stableNames, loader, "fitness"); + } + + private Path runOwned(Path projectDir, List stableNames, MetaDataLoader model, + String packageName) throws Exception { + Path classes = tempFolder.newFolder().toPath(); + assertEquals("", compileHidingPackagedSupport(projectDir.resolve("codegen/src/main/java"), classes)); + Path out = tempFolder.newFolder().toPath(); + try (URLClassLoader cl = new URLClassLoader(new URL[]{classes.toUri().toURL()}, + getClass().getClassLoader())) { + for (String stable : stableNames) { + String simple = GeneratorRegistry.get(stable).classname(); + simple = simple.substring(simple.lastIndexOf('.') + 1); + Generator gen = (Generator) Class.forName("com.acme.codegen." + simple, true, cl) + .getDeclaredConstructor().newInstance(); + gen.setArgs(args(out, packageName)).execute(model); + } + } + return out; + } + + /** The one generated file named {@code fileName} under {@code root}. */ + private static String emitted(Path root, String fileName) throws IOException { + try (Stream s = Files.walk(root)) { + List found = s.filter(p -> p.getFileName().toString().equals(fileName)) + .collect(Collectors.toList()); + assertEquals("expected exactly one " + fileName + " under " + root, 1, found.size()); + return Files.readString(found.get(0), StandardCharsets.UTF_8); + } + } + + /** Every file under {@code root}, by relative path. */ + private static Map tree(Path root) throws IOException { + Map out = new TreeMap<>(); + try (Stream s = Files.walk(root)) { + for (Path p : s.filter(Files::isRegularFile).collect(Collectors.toList())) { + out.put(root.relativize(p).toString(), Files.readString(p, StandardCharsets.UTF_8)); + } + } + return out; + } + + private static String compileHidingPackagedSupport(Path sourceRoot, Path out) throws IOException { + return compile(sourceRoot, out, true); + } + + /** Compile every source under {@code sourceRoot} against the test classpath; return the + * errors, or "". With {@code hideSupport}, javac is not shown the packaged support classes. */ + private static String compile(Path sourceRoot, Path out, boolean hideSupport) throws IOException { + List files = new ArrayList<>(); + try (Stream s = Files.walk(sourceRoot)) { + s.filter(p -> p.toString().endsWith(".java")).forEach(p -> files.add(p.toFile())); + } + assertFalse("nothing to compile under " + sourceRoot, files.isEmpty()); + Set hidden = GeneratorRegistry.EJECT_SUPPORT.stream() + .map(n -> GeneratorRegistry.SUPPORT_PACKAGE + "." + n).collect(Collectors.toSet()); + JavaCompiler javac = ToolProvider.getSystemJavaCompiler(); + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + try (StandardJavaFileManager std = javac.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)) { + std.setLocation(StandardLocation.CLASS_OUTPUT, List.of(out.toFile())); + JavaFileManager fm = !hideSupport ? std : new ForwardingJavaFileManager<>(std) { + @Override + public Iterable list(Location location, String packageName, + Set kinds, boolean recurse) + throws IOException { + List kept = new ArrayList<>(); + for (JavaFileObject f : super.list(location, packageName, kinds, recurse)) { + String binary = f.getKind() == JavaFileObject.Kind.CLASS + ? std.inferBinaryName(location, f) : null; + String outer = binary == null ? null : binary.split("\\$", 2)[0]; + if (!hidden.contains(outer)) kept.add(f); + } + return kept; + } + }; + List options = List.of("-proc:none", "-classpath", System.getProperty("java.class.path")); + javac.getTask(null, fm, diagnostics, options, null, std.getJavaFileObjectsFromFiles(files)).call(); + } + StringBuilder errors = new StringBuilder(); + for (Diagnostic d : diagnostics.getDiagnostics()) { + if (d.getKind() == Diagnostic.Kind.ERROR) errors.append(" ").append(d).append('\n'); + } + return errors.toString(); + } +} 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 ddcc46b11..6e8211d19 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 @@ -254,6 +254,38 @@ public void eachEjectEditIsNotDrift() { assertEquals(EjectSupport.Verdict.IDENTICAL, EjectSupport.compare(ownedRuntime, ref).verdict); } + @Test + public void supportImportsMoveToTheOwnedPackageAndAreNotDrift() { + String gen = "package com.metaobjects.generator.spring;\n\n" + + "import com.metaobjects.generator.spring.SpringGeneratorSupport;\n" + + "import com.metaobjects.generator.spring.SpringNaming;\n\n" + + "public class G {}\n"; + assertEquals(List.of("SpringGeneratorSupport"), EjectSupport.supportFor(gen)); + String owned = EjectSupport.rewriteSupportImports(gen, "com.acme.codegen"); + assertEquals("package com.metaobjects.generator.spring;\n\n" + + "import com.acme.codegen.SpringGeneratorSupport;\n" + + "import com.metaobjects.generator.spring.SpringNaming;\n\n" + + "public class G {}\n", owned); + assertEquals(EjectSupport.Verdict.IDENTICAL, EjectSupport.compare( + EjectSupport.rewritePackage(owned, "com.acme.codegen"), gen).verdict); + // The owned copy no longer imports a packaged support class: nothing more to hand over. + assertEquals(List.of(), EjectSupport.supportFor(owned)); + + String none = "package p;\nimport java.util.List;\nclass X {}\n"; + assertEquals(List.of(), EjectSupport.supportFor(none)); + assertEquals(none, EjectSupport.rewriteSupportImports(none, "com.acme.codegen")); + + String ref = "package com.metaobjects.generator.spring;\n\npublic final class SpringGeneratorSupport {}\n"; + String ownedSupport = EjectSupport.ownedSupportSource(ref, "SpringGeneratorSupport", "com.acme.codegen"); + assertTrue(EjectSupport.isOwnedSupport(ownedSupport)); + assertFalse(EjectSupport.isOwnedSupport(ref)); + assertFalse(EjectSupport.isOwnedRuntime(ownedSupport)); + assertTrue(ownedSupport.contains("\npackage com.acme.codegen;\n")); + assertEquals(EjectSupport.Verdict.IDENTICAL, EjectSupport.compare(ownedSupport, ref).verdict); + assertEquals(EjectSupport.Verdict.DIFFERS, + EjectSupport.compare(ownedSupport + "// mine\n", ref).verdict); + } + @Test public void onlyTheJavaWebTierCarriesRuntime() { for (EjectSupport.Entry e : EjectSupport.allEntries()) { diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java index eaf0b8a33..79bd685ff 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java @@ -282,6 +282,42 @@ public void listPrintsTheCatalogHeaderAndWritesNothing() throws Exception { assertFalse(Files.exists(codegenRoot())); } + @Test + public void listReportsTheOwnedSupportCopyAndWhetherItWasEdited() throws Exception { + mojo("dto", false, "java", null).execute(); + Path support = codegenRoot().resolve("src/main/java/com/acme/codegen/SpringGeneratorSupport.java"); + assertTrue(EjectSupport.isOwnedSupport(Files.readString(support, StandardCharsets.UTF_8))); + + assertTrue(listOutput().contains(" SpringGeneratorSupport [owned — identical]")); + // The owned dto's import now names the owned support class; that is eject's edit, not drift. + assertTrue(listOutput().contains("Per-entity Spring DTO record. [owned — identical]")); + + Files.writeString(support, Files.readString(support, StandardCharsets.UTF_8) + "// mine\n", + StandardCharsets.UTF_8); + assertTrue(listOutput().contains(" SpringGeneratorSupport [owned — DIFFERS: 0 behind, 1 of your own]")); + } + + @Test + public void aGeneratorThatImportsNoSupportGetsNoSupportCopy() throws Exception { + mojo("names", false, "java", null).execute(); + try (var files = Files.list(codegenRoot().resolve("src/main/java/com/acme/codegen"))) { + assertEquals(List.of("SpringNamesGenerator.java"), + files.map(p -> p.getFileName().toString()).collect(java.util.stream.Collectors.toList())); + } + assertFalse(listOutput().contains("Owned support")); + } + + private String listOutput() throws Exception { + MetaDataEjectMojo m = mojo(null, false, null, null); + m.list = true; + Log log = Mockito.mock(Log.class); + m.setLog(log); + m.execute(); + ArgumentCaptor captor = ArgumentCaptor.forClass(CharSequence.class); + Mockito.verify(log).info(captor.capture()); + return captor.getValue().toString(); + } + @Test public void namesIsRequiredWhenNotListing() { MetaDataEjectMojo m = mojo(null, false, null, null); diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyMojoTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyMojoTest.java index a293d7dc5..013b71c24 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyMojoTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyMojoTest.java @@ -117,6 +117,21 @@ public void verifyIgnoresOwnedRuntimeInTheOutputDir() throws Exception { verify.execute(); } + /** The same holds for the support source eject copies beside an owned generator. */ + @Test + public void verifyIgnoresOwnedSupportInTheOutputDir() throws Exception { + Path codegenDir = committedDir.resolve("com/acme/codegen"); + Files.createDirectories(codegenDir); + Files.writeString(codegenDir.resolve("SpringGeneratorSupport.java"), + EjectSupport.ownedSupportSource("package com.metaobjects.generator.spring;\n" + + "public final class SpringGeneratorSupport {}\n", "SpringGeneratorSupport", + "com.acme.codegen"), StandardCharsets.UTF_8); + + MetaDataVerifyMojo verify = new MetaDataVerifyMojo(); + configure(verify, committedDir); + verify.execute(); + } + @Test public void verifyFailsOnMissingCommittedFile() throws Exception { Path someKt = firstKtFile(committedDir); diff --git a/server/python/src/metaobjects/codegen/eject.py b/server/python/src/metaobjects/codegen/eject.py index 541e9d799..c9afc5e5e 100644 --- a/server/python/src/metaobjects/codegen/eject.py +++ b/server/python/src/metaobjects/codegen/eject.py @@ -17,6 +17,11 @@ generator emits that source into the generated package and its output imports the local copy. Core modules (loader, registry, render, extract, ``ObjectManager``) stay package imports. An existing runtime copy is never overwritten, even with ``--force``. + +The emit rules several generators share (``shared_predicates``) are listed and copied the +same way. A generator imports them relatively from its ``runtime`` sibling, so the verbatim +copy in ``codegen/generators/`` reads ``codegen/runtime/shared_predicates.py`` with no +edit, and two owned generators read the one copy. """ from __future__ import annotations @@ -70,14 +75,15 @@ def packaged_source(entry: GeneratorEntry) -> str: def ejected_source(entry: GeneratorEntry) -> str: """What ``eject`` writes for *entry*: the packaged source, with the runtime switch on - when its output imports helper runtime. This is the reference an owned copy is - compared against.""" + when it carries one (a generator whose output imports helper runtime does). This is + the reference an owned copy is compared against.""" source = packaged_source(entry) - if not entry.runtime: + switches = source.count(_RUNTIME_SWITCH_OFF) + if switches == 0: return source - if source.count(_RUNTIME_SWITCH_OFF) != 1: + if switches != 1: raise RuntimeError( - f"{entry.source_module} must carry exactly one {_RUNTIME_SWITCH_OFF!r} line " + f"{entry.source_module} must carry at most one {_RUNTIME_SWITCH_OFF!r} line " "for eject to hand its runtime over" ) return source.replace(_RUNTIME_SWITCH_OFF, _RUNTIME_SWITCH_ON) @@ -142,7 +148,7 @@ def eject(names: list[str], root: Path, *, force: bool = False) -> EjectResult: rt.parent.mkdir(parents=True, exist_ok=True) rt.write_text(packaged_runtime_path(module).read_text(encoding="utf-8"), encoding="utf-8") - out.append(f" runtime {rel}: copied — its generated output imports this copy") + out.append(f" runtime {rel}: copied — your owned generator reads this copy") out.append("") out.append( "The copies are yours: edit them freely. Keep only one of the packaged name and your " diff --git a/server/python/src/metaobjects/codegen/generator_registry.py b/server/python/src/metaobjects/codegen/generator_registry.py index 46376c2a8..b8794ab0b 100644 --- a/server/python/src/metaobjects/codegen/generator_registry.py +++ b/server/python/src/metaobjects/codegen/generator_registry.py @@ -137,12 +137,14 @@ class GeneratorEntry: #: symbol the copy is wired by. ``None`` = not ejectable (the ``template`` primitive #: has no emit logic of its own to own). source: Callable[..., Generator] | None = None - #: Helper runtime modules (under ``metaobjects.codegen.runtime``) the generator's - #: OUTPUT imports. ``metaobjects eject`` copies their source into the adopter's - #: ``codegen/runtime/`` beside the owned generator, and the owned copy's output imports - #: that copy instead of the installed package (ADR-0034 Amendment 3: the adopter owns - #: every helper line its generated code depends on). Core modules — loader, registry, - #: render, extract, ``ObjectManager`` — are never listed here: they stay package imports. + #: Helper modules (under ``metaobjects.codegen.runtime``) handed over with the + #: generator: the runtime its OUTPUT imports, and the emit rules the generator ITSELF + #: imports from its ``runtime`` sibling (``shared_predicates``). ``metaobjects eject`` + #: copies their source into the adopter's ``codegen/runtime/`` beside the owned + #: generator, which then reads that copy instead of the installed package (ADR-0034 + #: Amendment 3: the adopter owns every helper line the generator and its generated code + #: depend on). Core modules — loader, registry, render, extract, ``ObjectManager`` — are + #: never listed here: they stay package imports. runtime: tuple[str, ...] = () @property @@ -189,6 +191,10 @@ def _render_helper_default(ctx: GeneratorBuildContext) -> Generator: template_root=ctx.template_root or tempfile.gettempdir()) +#: The emit rules several generators share, handed over with each of them so owned copies +#: agree with each other (see ``runtime/shared_predicates.py``). +_SHARED_PREDICATES: tuple[str, ...] = ("shared_predicates",) + #: Stable name -> GeneratorEntry. The 11 native generators whose manifest `ports` #: include `python` (ADR-0021 D3). Set equality, tier AND layer are conformance-tested #: against the manifest. @@ -205,6 +211,7 @@ def _render_helper_default(ctx: GeneratorBuildContext) -> Generator: layer="model", factory=lambda _ctx: entity_model(), source=entity_model, + runtime=_SHARED_PREDICATES, ), "routes": GeneratorEntry( name="routes", @@ -217,7 +224,7 @@ def _render_helper_default(ctx: GeneratorBuildContext) -> Generator: # generated package fails at import (ModuleNotFoundError). requires=("entity", "filter-allowlist"), source=router_generator, - runtime=_ROUTER_RUNTIME, + runtime=_ROUTER_RUNTIME + _SHARED_PREDICATES, ), "output-parser": GeneratorEntry( name="output-parser", @@ -253,6 +260,7 @@ def _render_helper_default(ctx: GeneratorBuildContext) -> Generator: factory=lambda _ctx: extractor_generator(), requires=("entity",), source=extractor_generator, + runtime=_SHARED_PREDICATES, ), "requirement-tests": GeneratorEntry( name="requirement-tests", @@ -276,6 +284,7 @@ def _render_helper_default(ctx: GeneratorBuildContext) -> Generator: layer="api", factory=lambda _ctx: filter_allowlist_generator(), source=filter_allowlist_generator, + runtime=_SHARED_PREDICATES, ), "names": GeneratorEntry( name="names", diff --git a/server/python/src/metaobjects/codegen/generators/entity_model.py b/server/python/src/metaobjects/codegen/generators/entity_model.py index a45a0f2d4..476c0fc7c 100644 --- a/server/python/src/metaobjects/codegen/generators/entity_model.py +++ b/server/python/src/metaobjects/codegen/generators/entity_model.py @@ -8,7 +8,6 @@ another object shares its short name).""" from __future__ import annotations -import re from metaobjects.meta.core.field.meta_field import MetaField from metaobjects.meta.core.object.meta_object import MetaObject @@ -56,42 +55,14 @@ object_ref_class_name, ) +from ..runtime.shared_predicates import has_literal_default, is_sql_expr_default + def _is_int(value: object) -> bool: """A real int (bool is an int subclass, so `@maxLength: true` etc. must not count).""" return isinstance(value, int) and not isinstance(value, bool) -# SQL expression defaults: anything matching is a SERVER-side default (the DB fills -# it), NOT a Python literal default — so `@default: gen_random_uuid()` must not emit -# `id: uuid.UUID = "gen_random_uuid()"`. Mirrors the TS column-mapper's -# SQL_EXPR_PATTERNS (and migrate-ts EXPR_DEFAULT_PATTERNS) so every port agrees on -# what counts as an expression. -_SQL_EXPR_DEFAULT_PATTERNS = ( - re.compile(r"^now$", re.I), - re.compile(r"^now\(\)$", re.I), - re.compile(r"^current_timestamp$", re.I), - re.compile(r"^current_date$", re.I), - re.compile(r"^current_time$", re.I), - re.compile(r"\(\)$"), # anything function-like, e.g. gen_random_uuid() -) - - -def _is_sql_expr_default(value: object) -> bool: - """True iff *value* is a server-side SQL expression default (DB-filled), not a - Python literal. Only string defaults can be expressions.""" - return isinstance(value, str) and any(p.search(value) for p in _SQL_EXPR_DEFAULT_PATTERNS) - - -def has_literal_default(field: MetaField) -> bool: - """True iff the read model gives *field* a Python default: a ``@default`` that is not a - server-side SQL expression. Such a field is annotated ``T`` (not ``T | None``) with that - default, so code that builds the model without a value for it must OMIT the keyword - rather than pass ``None`` — the extractor's mapper relies on exactly this.""" - default_raw = field.attrs().get(fc.FIELD_ATTR_DEFAULT) - return default_raw is not None and not _is_sql_expr_default(default_raw) - - # ADR-0036/0037 Wave 3 — the CANONICAL hostname matcher for @stringFormat: hostname. # The matcher lives in codegen (NOT author validator.regex) so every port replicates # the SAME canonical form — cross-language regex engines diverge, so this one @@ -456,7 +427,7 @@ def _create_field_line( type_expr, enum_type_name = _type_expr_for_field(field, imports, config, wire=True) required = field.attrs().get(fc.FIELD_ATTR_REQUIRED) is True default_raw = field.attrs().get(fc.FIELD_ATTR_DEFAULT) - literal_default = default_raw is not None and not _is_sql_expr_default(default_raw) + literal_default = default_raw is not None and not is_sql_expr_default(default_raw) auto_set = field.attrs().get(fc.FIELD_ATTR_AUTO_SET) is_auto_set = auto_set in (fc.AUTO_SET_ON_CREATE, fc.AUTO_SET_ON_UPDATE) # A field is required in the CREATE shape only when @required AND not server-filled: diff --git a/server/python/src/metaobjects/codegen/generators/extractor_generator.py b/server/python/src/metaobjects/codegen/generators/extractor_generator.py index 26aff641f..2659ed6bc 100644 --- a/server/python/src/metaobjects/codegen/generators/extractor_generator.py +++ b/server/python/src/metaobjects/codegen/generators/extractor_generator.py @@ -47,12 +47,13 @@ inbound_templates, response_shape, ) -from metaobjects.codegen.generators.entity_model import has_literal_default from metaobjects.codegen.value_objects import is_field_required, model_class_name, pkg_of from metaobjects.meta.core.field import field_constants as fc from metaobjects.meta.core.object.meta_object import MetaObject from metaobjects.meta.meta_data import MetaData +from ..runtime.shared_predicates import has_literal_default + _GENERATOR_NAME = "extractor-generator" diff --git a/server/python/src/metaobjects/codegen/generators/filter_allowlist_generator.py b/server/python/src/metaobjects/codegen/generators/filter_allowlist_generator.py index e5a81361c..6b67406ab 100644 --- a/server/python/src/metaobjects/codegen/generators/filter_allowlist_generator.py +++ b/server/python/src/metaobjects/codegen/generators/filter_allowlist_generator.py @@ -37,13 +37,14 @@ from metaobjects.codegen.format import ruff_format from metaobjects.codegen.generator import EmittedFile, GenContext, Generator, per_entity from metaobjects.codegen.generators.m2m_codegen import build_object_index -from metaobjects.codegen.generators.router_generator import emits_router from metaobjects.codegen.generators.tph_plan import tph_plan_for from metaobjects.codegen.instance_artifacts import is_sourceless_entity from metaobjects.meta.core.field import field_constants as fc from metaobjects.meta.core.field.meta_field import MetaField from metaobjects.meta.core.object.meta_object import MetaObject +from ..runtime.shared_predicates import emits_router + # Operator sets — preserve insertion order (Python dict order == spec order). _OPS_STRING: tuple[str, ...] = ("eq", "ne", "in", "like", "isNull") @@ -260,7 +261,7 @@ def render_filter_allowlist( """Render the filter allowlist module for ``entity`` (or ``None`` to skip). Returns ``None`` for exactly the objects that get no router — asked of - :func:`~metaobjects.codegen.generators.router_generator.emits_router` + :func:`~metaobjects.codegen.runtime.shared_predicates.emits_router` rather than re-derived here, because the generated router IMPORTS this module and a disagreement is an ImportError at app startup, not a cosmetic mismatch. @@ -268,7 +269,7 @@ def render_filter_allowlist( ``object_index`` (optional) lets a TPH discriminator base fold in its subtypes' filterable fields (see :meth:`_compute_filterable_ops`). """ - # ONE predicate, shared with the router generator (see its `emits_router`). + # ONE predicate, shared with the router generator (see `shared_predicates.emits_router`). # This gate previously carried its own copy of the rule, and the copy is # what broke twice: once for a view-source-first write-through entity, and # again at F22 when the router learned to serve view-only projections and diff --git a/server/python/src/metaobjects/codegen/generators/router_generator.py b/server/python/src/metaobjects/codegen/generators/router_generator.py index ad75b3226..e25c7a348 100644 --- a/server/python/src/metaobjects/codegen/generators/router_generator.py +++ b/server/python/src/metaobjects/codegen/generators/router_generator.py @@ -55,7 +55,7 @@ resolve_m2m_descriptors, ) from metaobjects.codegen.generators.tph_plan import TphPlan, is_tph_subtype, tph_plan_for -from metaobjects.codegen.instance_artifacts import emits_instance_artifacts, has_item_route +from metaobjects.codegen.instance_artifacts import has_item_route from metaobjects.source_resolution import primary_rdb_source from metaobjects.codegen.type_map import PyType, py_type_for from metaobjects.meta.core.field import field_constants as fc @@ -75,14 +75,14 @@ reference_target_entity, ) from metaobjects.meta.persistence.source.source_constants import ( - SOURCE_KIND_MATERIALIZED_VIEW, SOURCE_KIND_TABLE, - SOURCE_KIND_VIEW, ) from metaobjects.naming import DEFAULT_COLUMN_NAMING from metaobjects.shared.base_types import TYPE_IDENTITY from metaobjects.shared.separators import PACKAGE_SEP +from ..runtime.shared_predicates import READ_ONLY_ROUTED_KINDS, emits_router + #: Where the emitted router imports its helper runtime (``filter_parser``, #: ``constraint_errors``) from. ``False`` — the packaged generator — imports the copy #: installed with ``metaobjects``: ``from metaobjects.codegen.runtime. import ...``. @@ -412,39 +412,6 @@ def reverse_fks_for(entity: MetaObject) -> list[ReverseFk]: return out -# F22 — the read-only source kinds that still get a REST surface. A view (and a -# materialized view) is a collection of rows addressable by the identity the -# projection inherits, which is exactly the shape a collection-and-item REST -# resource needs. `storedProc` / `tableFunction` are invocations, not collections: -# they have no such shape and stay skipped, as they are for the writable router. -_READ_ONLY_ROUTED_KINDS = frozenset({SOURCE_KIND_VIEW, SOURCE_KIND_MATERIALIZED_VIEW}) - - -def emits_router(entity: MetaObject) -> bool: - """Whether this object gets a generated router — writable CRUD or read-only. - - THE single source of truth for the router emit set, exported so - ``filter_allowlist_generator`` can gate on the same answer instead of a - second copy of the rule. The generated router does ``from - ._filter_allowlist import ...``, so an allowlist emitted for a - different set than the routers is not a cosmetic mismatch — the module is - missing at import and the app fails to start. That failure mode is already - recorded in this codebase for the write-through case; keeping two copies of - the predicate is what let it happen, so there is now one. - """ - if not emits_instance_artifacts(entity): - return False - src = primary_rdb_source(entity) - if src is None: - return False - kind = src.effective_kind() - return ( - entity.is_write_through() - or kind == SOURCE_KIND_TABLE - or kind in _READ_ONLY_ROUTED_KINDS - ) - - def is_read_only_routed(entity: MetaObject) -> bool: """Whether :meth:`RouterGenerator.render_router` chooses the READ-ONLY router for this object (a view / materialized-view source that is not write-through): GET list, @@ -455,7 +422,7 @@ def is_read_only_routed(entity: MetaObject) -> bool: if not emits_router(entity) or entity.is_write_through(): return False src = primary_rdb_source(entity) - return src is not None and src.effective_kind() in _READ_ONLY_ROUTED_KINDS + return src is not None and src.effective_kind() in READ_ONLY_ROUTED_KINDS def _sort_type_and_tables_lines(sort_field_nodes: list[MetaField]) -> list[str]: @@ -1251,7 +1218,7 @@ def render_router( the descriptor itself never gives two different answers for one column within one run. """ - if not emits_instance_artifacts(entity): + if not emits_router(entity): return None # FR-017 TPH: a concrete subtype is folded into its base's single table — it # emits no standalone router (its CRUD lives under the base's per-subtype segment). diff --git a/server/python/src/metaobjects/codegen/runtime/__init__.py b/server/python/src/metaobjects/codegen/runtime/__init__.py index 0c29bf41a..beabbcd3d 100644 --- a/server/python/src/metaobjects/codegen/runtime/__init__.py +++ b/server/python/src/metaobjects/codegen/runtime/__init__.py @@ -1,9 +1,14 @@ -"""Runtime helpers shipped alongside generated routers. +"""Helper source ``metaobjects eject`` hands over with a generator. -Generated code from the PACKAGED ``routes`` generator imports from this package directly. -These modules are helper tier, not core (ADR-0034 Amendment 3): ``metaobjects eject -routes`` copies their source into the adopter's ``codegen/runtime/``, and the owned -generator's output imports that copy instead. So each module must stay self-contained — -standard library only, no ``metaobjects`` import and no FastAPI / SQLAlchemy / pg8000 -dependency — or the copy would not run on its own. +Two kinds of module live here, and eject copies both into the adopter's +``codegen/runtime/`` (helper tier, not core: ADR-0034 Amendment 3). + +``filter_parser`` and ``constraint_errors`` are what generated routers import. Generated +code from the PACKAGED ``routes`` generator imports them from this package directly; an +owned ``routes`` emits the adopter's copy into the generated package instead. So each must +stay self-contained — standard library only, no ``metaobjects`` import and no FastAPI / +SQLAlchemy / pg8000 dependency — or the copy would not run on its own. + +``shared_predicates`` is what several GENERATORS import, at ``gen`` time: the emit rules +they have to agree on. No generated code imports it, so it may import ``metaobjects``. """ diff --git a/server/python/src/metaobjects/codegen/runtime/shared_predicates.py b/server/python/src/metaobjects/codegen/runtime/shared_predicates.py new file mode 100644 index 000000000..1ed052e6c --- /dev/null +++ b/server/python/src/metaobjects/codegen/runtime/shared_predicates.py @@ -0,0 +1,95 @@ +"""Predicates more than one generator asks, in one module no generator owns. + +`metaobjects eject` copies a generator into the adopter's repo and wires it in beside the +packaged ones. A generator that imported one of these from ANOTHER generator's module +would, in an owned copy, keep calling the packaged copy of that other generator, so an +edit to the owned one would never be seen. Both predicates therefore live here. + +They are emit rules, so eject hands them over with the generator. Every generator that +needs one imports it relatively, from its ``runtime`` sibling +(``from ..runtime.shared_predicates import ...``), and lists this module in its registry +entry's ``runtime``. Eject copies it to ``codegen/runtime/shared_predicates.py``, and the +same import in an owned generator (``codegen/generators/``) then resolves to that copy: +owned generators agree with each other and never read the installed one. +``tests/codegen/test_eject_independence.py`` runs both halves. + +Unlike ``filter_parser`` and ``constraint_errors``, no GENERATED code imports this module: +it runs at ``gen`` time only, which is why it may import ``metaobjects``. +""" +from __future__ import annotations + +import re + +from metaobjects.codegen.instance_artifacts import emits_instance_artifacts +from metaobjects.meta.core.field import field_constants as fc +from metaobjects.meta.core.field.meta_field import MetaField +from metaobjects.meta.core.object.meta_object import MetaObject +from metaobjects.meta.persistence.source.source_constants import ( + SOURCE_KIND_MATERIALIZED_VIEW, + SOURCE_KIND_TABLE, + SOURCE_KIND_VIEW, +) +from metaobjects.source_resolution import primary_rdb_source + + +# SQL expression defaults: anything matching is a SERVER-side default (the DB fills +# it), NOT a Python literal default — so `@default: gen_random_uuid()` must not emit +# `id: uuid.UUID = "gen_random_uuid()"`. Mirrors the TS column-mapper's +# SQL_EXPR_PATTERNS (and migrate-ts EXPR_DEFAULT_PATTERNS) so every port agrees on +# what counts as an expression. +SQL_EXPR_DEFAULT_PATTERNS = ( + re.compile(r"^now$", re.I), + re.compile(r"^now\(\)$", re.I), + re.compile(r"^current_timestamp$", re.I), + re.compile(r"^current_date$", re.I), + re.compile(r"^current_time$", re.I), + re.compile(r"\(\)$"), # anything function-like, e.g. gen_random_uuid() +) + + +def is_sql_expr_default(value: object) -> bool: + """True iff *value* is a server-side SQL expression default (DB-filled), not a + Python literal. Only string defaults can be expressions.""" + return isinstance(value, str) and any(p.search(value) for p in SQL_EXPR_DEFAULT_PATTERNS) + + +def has_literal_default(field: MetaField) -> bool: + """True iff the read model gives *field* a Python default: a ``@default`` that is not a + server-side SQL expression. Such a field is annotated ``T`` (not ``T | None``) with that + default, so code that builds the model without a value for it must OMIT the keyword + rather than pass ``None`` — the extractor's mapper relies on exactly this.""" + default_raw = field.attrs().get(fc.FIELD_ATTR_DEFAULT) + return default_raw is not None and not is_sql_expr_default(default_raw) + + +# F22 — the read-only source kinds that still get a REST surface. A view (and a +# materialized view) is a collection of rows addressable by the identity the +# projection inherits, which is exactly the shape a collection-and-item REST +# resource needs. `storedProc` / `tableFunction` are invocations, not collections: +# they have no such shape and stay skipped, as they are for the writable router. +READ_ONLY_ROUTED_KINDS = frozenset({SOURCE_KIND_VIEW, SOURCE_KIND_MATERIALIZED_VIEW}) + + +def emits_router(entity: MetaObject) -> bool: + """Whether this object gets a generated router — writable CRUD or read-only. + + THE single source of truth for the router emit set, exported so + ``filter_allowlist_generator`` can gate on the same answer instead of a + second copy of the rule. The generated router does ``from + ._filter_allowlist import ...``, so an allowlist emitted for a + different set than the routers is not a cosmetic mismatch — the module is + missing at import and the app fails to start. That failure mode is already + recorded in this codebase for the write-through case; keeping two copies of + the predicate is what let it happen, so there is now one. + """ + if not emits_instance_artifacts(entity): + return False + src = primary_rdb_source(entity) + if src is None: + return False + kind = src.effective_kind() + return ( + entity.is_write_through() + or kind == SOURCE_KIND_TABLE + or kind in READ_ONLY_ROUTED_KINDS + ) diff --git a/server/python/tests/codegen/test_eject_independence.py b/server/python/tests/codegen/test_eject_independence.py new file mode 100644 index 000000000..420d4f79b --- /dev/null +++ b/server/python/tests/codegen/test_eject_independence.py @@ -0,0 +1,151 @@ +"""An owned copy must see another owned copy, not the packaged one. + +`metaobjects eject` copies one generator and wires it in beside the packaged ones. Two +things would leave an owned copy calling packaged emit logic, and each is executed here: + +* A generator that imports a helper from ANOTHER ejectable generator's module. The owned + copy keeps calling the packaged generator, so loading one generator must load no other. +* An emit rule two generators share that eject does not hand over. `shared_predicates` is + copied to `codegen/runtime/` with every generator that imports it, and the owned + generators read that copy: an edit to it moves both, together. + +Each import check runs in a fresh interpreter, because in this process every generator is +already loaded. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import uuid +from pathlib import Path +from typing import Any + +import metaobjects +from metaobjects.cli import main +from metaobjects.codegen import eject as owned +from metaobjects.codegen.generator_registry import GENERATOR_REGISTRY + +REPO = Path(__file__).parents[4] +FITNESS = REPO / "fixtures" / "persistence-conformance" / "canonical" / "meta.fitness.json" +SHARED = "shared_predicates" +PACKAGED_SHARED = f"metaobjects.codegen.runtime.{SHARED}" +OWNED_SHARED = f"codegen.runtime.{SHARED}" +#: The two cross-generator calls the eject audit found: filter-allowlist asked the routes +#: generator which objects get a router, and extractor asked the entity generator which +#: fields carry a literal default. +SHARED_USERS = { + "routes": ["emits_router"], + "filter-allowlist": ["emits_router"], + "entity": ["has_literal_default"], + "extractor": ["has_literal_default"], +} + + +def _fresh_interpreter(code: str, cwd: Path) -> Any: + """Run *code* in a new interpreter that can import this ``metaobjects``; return the JSON + it prints.""" + src = str(Path(metaobjects.__file__).resolve().parents[1]) + env = {**os.environ, "PYTHONPATH": os.pathsep.join( + p for p in (src, os.environ.get("PYTHONPATH", "")) if p)} + proc = subprocess.run([sys.executable, "-c", code], cwd=cwd, env=env, + capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_loading_one_ejectable_generator_loads_no_other_generators_module(tmp_path) -> None: + sources = {e.name: e.source_module for e in GENERATOR_REGISTRY.values() if e.source_module} + assert sources, "expected the registry to name ejectable generators" + offenders: list[str] = [] + for name, own in sources.items(): + siblings = sorted(set(sources.values()) - {own}) + loaded = _fresh_interpreter( + "import importlib, json, sys\n" + f"importlib.import_module({own!r})\n" + f"print(json.dumps([m for m in {siblings!r} if m in sys.modules]))\n", + tmp_path, + ) + offenders.extend(f"loading {name} ({own}) also loads {m}" for m in loaded) + assert not offenders, "\n".join(offenders) + + +def test_the_generators_that_share_a_predicate_hand_it_over() -> None: + users = {n for n, e in GENERATOR_REGISTRY.items() if SHARED in e.runtime} + assert users == set(SHARED_USERS) + + +def test_each_owned_generator_ejected_alone_reads_the_owned_predicates(tmp_path, monkeypatch) -> None: + for name, predicates in SHARED_USERS.items(): + root = tmp_path / owned.owned_stem(name) + root.mkdir() + monkeypatch.chdir(root) + assert main(["eject", name]) == 0 + # The installed module is unimportable here, so an owned copy that still reached for + # it (directly, or through a packaged module it imports) fails instead of passing. + homes = _fresh_interpreter( + "import importlib, json, sys\n" + f"sys.modules[{PACKAGED_SHARED!r}] = None\n" + f"sys.path.insert(0, {str(root)!r})\n" + f"mod = importlib.import_module('codegen.generators.{owned.owned_stem(name)}')\n" + f"print(json.dumps({{p: getattr(mod, p).__module__ for p in {predicates!r}}}))\n", + root, + ) + assert homes == {p: OWNED_SHARED for p in predicates}, name + + +def _project(root: Path, generators: str) -> Path: + """A project whose single target writes into a uniquely-named package; returns it.""" + (root / "metaobjects").mkdir(parents=True) + (root / "metaobjects" / FITNESS.name).write_text(FITNESS.read_text(encoding="utf-8")) + pkg = f"gen_{uuid.uuid4().hex[:8]}" + (root / "metaobjects.config.yaml").write_text( + f"targets:\n api:\n outDir: {pkg}\n generators: [{generators}]\n" + ) + return root / pkg + + +def _emitted(out: Path, suffix: str) -> list[str]: + return sorted(p.name for p in out.glob(f"*{suffix}")) + + +def test_an_edit_to_the_owned_predicate_moves_owned_routes_and_owned_allowlist_together( + tmp_path, monkeypatch, capsys, +) -> None: + packaged = tmp_path / "packaged" + packaged_out = _project(packaged, "entity, filter-allowlist, routes") + monkeypatch.chdir(packaged) + assert main(["gen"]) == 0 + routers = _emitted(packaged_out, "_router.py") + allowlists = _emitted(packaged_out, "_filter_allowlist.py") + assert routers and allowlists + + root = tmp_path / "owned" + out = _project(root, "entity, codegen.generators.filter_allowlist:filter_allowlist_generator, " + "codegen.generators.routes:router_generator") + monkeypatch.chdir(root) + assert main(["eject", "routes"]) == 0 + capsys.readouterr() + assert main(["eject", "filter-allowlist"]) == 0 + assert f"codegen/runtime/{SHARED}.py: kept your copy" in capsys.readouterr().out + + # One owned rule, edited once: the Post object no longer gets a router. + rule = root / "codegen" / "runtime" / f"{SHARED}.py" + rule.write_text( + rule.read_text(encoding="utf-8") + + "\n\n_reference_emits_router = emits_router\n\n\n" + "def emits_router(entity):\n" + " return entity.name != 'Post' and _reference_emits_router(entity)\n", + encoding="utf-8", + ) + assert main(["gen"]) == 0 + assert "post_router.py" in routers and "post_filter_allowlist.py" in allowlists + assert _emitted(out, "_router.py") == [r for r in routers if r != "post_router.py"] + assert _emitted(out, "_filter_allowlist.py") == [ + a for a in allowlists if a != "post_filter_allowlist.py" + ] + + assert main(["gen", "--list"]) == 0 + assert (f"runtime codegen/runtime/{SHARED}.py [owned — DIFFERS: 0 behind, 3 of your own]" + in capsys.readouterr().out) diff --git a/server/python/tests/codegen/test_eject_runtime.py b/server/python/tests/codegen/test_eject_runtime.py index a0f3e2bb7..5e98e47bd 100644 --- a/server/python/tests/codegen/test_eject_runtime.py +++ b/server/python/tests/codegen/test_eject_runtime.py @@ -84,10 +84,15 @@ def _block_installed_runtime(monkeypatch) -> None: # --- what eject writes ------------------------------------------------------------------ -def test_routes_entry_lists_its_runtime_and_nothing_else_does() -> None: - assert GENERATOR_REGISTRY["routes"].runtime == RUNTIME_MODULES - others = {n: e.runtime for n, e in GENERATOR_REGISTRY.items() if n != "routes" and e.runtime} - assert others == {} +def test_routes_entry_lists_the_runtime_its_output_imports_and_nothing_else_does() -> None: + """``shared_predicates`` is handed over through the same field but no generated code + imports it (``test_eject_independence.py`` covers it), so it is set aside here.""" + output_runtime = { + n: tuple(m for m in e.runtime if m != "shared_predicates") + for n, e in GENERATOR_REGISTRY.items() + } + assert output_runtime.pop("routes") == RUNTIME_MODULES + assert {n: r for n, r in output_runtime.items() if r} == {} def test_every_runtime_module_is_self_contained() -> None: @@ -146,7 +151,7 @@ def test_eject_never_overwrites_an_owned_runtime_copy(tmp_path, monkeypatch, cap def test_eject_of_a_generator_without_runtime_writes_no_runtime(tmp_path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) - assert main(["eject", "entity", "filter-allowlist", "names"]) == 0 + assert main(["eject", "names", "output-prompt", "trace-helper"]) == 0 assert not (tmp_path / "codegen" / "runtime").exists()