|
| 1 | +package com.metaobjects.generator.kotlin.exposed1x |
| 2 | + |
| 3 | +import com.metaobjects.generator.Generator |
| 4 | +import com.metaobjects.generator.kotlin.KotlinEntityGenerator |
| 5 | +import com.metaobjects.generator.kotlin.KotlinExposedTableGenerator |
| 6 | +import com.metaobjects.generator.kotlin.KotlinFilterAllowlistGenerator |
| 7 | +import com.metaobjects.generator.kotlin.KotlinNamesGenerator |
| 8 | +import com.metaobjects.generator.kotlin.KotlinRelationsGenerator |
| 9 | +import com.metaobjects.generator.kotlin.KotlinSpringControllerGenerator |
| 10 | +import com.metaobjects.generator.kotlin.KotlinValidatorGenerator |
| 11 | +import com.metaobjects.generator.util.GeneratedFileWriter |
| 12 | +import com.metaobjects.metadata.ktx.loadDirectory |
| 13 | +import com.tschuchort.compiletesting.KotlinCompilation |
| 14 | +import com.tschuchort.compiletesting.SourceFile |
| 15 | +import java.nio.file.Files |
| 16 | +import java.nio.file.Path |
| 17 | +import java.nio.file.Paths |
| 18 | +import kotlin.io.path.isRegularFile |
| 19 | +import kotlin.io.path.readText |
| 20 | +import kotlin.test.Test |
| 21 | +import kotlin.test.assertEquals |
| 22 | +import kotlin.test.assertTrue |
| 23 | + |
| 24 | +/** |
| 25 | + * Issue #390 (review fix #2) — the controller tier's `exposedApi=1` compile pass that was |
| 26 | + * MISSING: [Exposed1xCodegenCompileTest] mirrors `CodegenCompileConformanceTest`'s exclusion of |
| 27 | + * `KotlinSpringControllerGenerator` (the framework-bound tier, proven by a real HTTP lane for |
| 28 | + * 0.x) and the round-trip test only runs `KotlinExposedTableGenerator` — so the blocker review |
| 29 | + * caught (every `emitFilterPipeline` operator but `eq`/`and`/`castTo` missing its v1 top-level |
| 30 | + * import, across all three of `emit`/`emitTph`/`emitReadOnly`) shipped with BOTH existing passes |
| 31 | + * green. This test closes that gap for exactly the part 0.x proves by a *different* mechanism |
| 32 | + * (a real HTTP lane) that this module does not also stand up: it compiles the generated |
| 33 | + * controller — not host it — which is exactly where a missing operator import surfaces. |
| 34 | + * |
| 35 | + * Generates the FULL shared fitness corpus (not a trimmed selection) with |
| 36 | + * `exposedApi=1`, using every generator a real build would wire alongside the controller |
| 37 | + * (Entity, ExposedTable, Names, Relations, FilterAllowlist, Validator, SpringController) so the |
| 38 | + * corpus's TPH hierarchy (Auth/Bridge/Copay/PriorAuth/Referral — exercises `emitTph`), its M:N |
| 39 | + * associations (tags/referrals/following/friends — exercises `emitM2mEndpoint` + |
| 40 | + * `KotlinRelationsGenerator`'s M:N join helpers), its view projections (exercises |
| 41 | + * `emitReadOnly`), and its mixed scalar subtypes (string/enum/int-backed-enum/boolean/numeric/ |
| 42 | + * uuid — exercises every `emitPerFieldDispatchArm` operator arm) all compile in ONE pass against |
| 43 | + * real Spring 6.2 (`spring-webmvc`) + jakarta.servlet + Jackson types on this module's test |
| 44 | + * classpath (this module's pom — real dependencies, not stubs, so a missing import fails here |
| 45 | + * exactly as it would for a real consumer). |
| 46 | + * |
| 47 | + * **Excluded: `AllTypesController.kt` and `AssetController.kt`** — found by this same thoroughness, |
| 48 | + * but PRE-EXISTING and `exposedApi`-INDEPENDENT (confirmed: neither trace mentions a package the |
| 49 | + * `exposedApi` branch chooses, and the root causes hold identically for `uuid`/`javaUUID`, which |
| 50 | + * bind the same `java.util.UUID` on both versions). Verbatim from the compiler, before this |
| 51 | + * exclusion was added: |
| 52 | + * 1. `AllTypesController.kt` — `emitPerFieldDispatchArm`'s `!isStringLike && !isBoolean` gate |
| 53 | + * emits `greater`/`greaterEq`/`less`/`lessEq` for ANY such field, but `field.inet`'s Kotlin |
| 54 | + * type (`java.net.InetAddress`) does not implement `Comparable` (unlike `field.uuid`'s `UUID` |
| 55 | + * and `field.uri`'s `URI`, which do and compile fine) — "None of the following candidates is |
| 56 | + * applicable" on `inetVal`/`inet6Val`'s ordering arms. |
| 57 | + * 2. `AssetController.kt` — the `field.string @dbColumnType=uuid` escape hatch's own contract |
| 58 | + * (`KotlinTypeMapper`: "the property stays String") binds `externalId` to a `Column<UUID>` |
| 59 | + * table-side ADR-0037) while `emitPerFieldDispatchArm` casts `(p.value as String)` |
| 60 | + * controller-side — a String/UUID mismatch on every comparison AND the PATCH bind. |
| 61 | + * Neither is reachable through `KotlinFilterAllowlistGenerator`'s allowlist in a REAL request (it |
| 62 | + * does not admit `gt`/`gte`/`lt`/`lte` for `inet`, matching the port's own filter-op semantics), |
| 63 | + * but the generated Kotlin still has to COMPILE regardless of what a well-formed HTTP request can |
| 64 | + * reach — and until now nothing had ever compiled `KotlinSpringControllerGenerator`'s output for |
| 65 | + * either entity, on any `exposedApi`. Both are genuine defects worth fixing, but a design |
| 66 | + * decision (not a mechanical import) each, and out of issue #390's scope — call out, not papered |
| 67 | + * over, so a reader of a green run here knows exactly what is and is not proven. |
| 68 | + */ |
| 69 | +@OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class) |
| 70 | +class Exposed1xControllerCompileTest { |
| 71 | + |
| 72 | + private fun findCorpusRoot(): Path { |
| 73 | + var cur: Path? = Paths.get("").toAbsolutePath() |
| 74 | + while (cur != null) { |
| 75 | + val candidate = cur.resolve("fixtures/persistence-conformance") |
| 76 | + if (Files.isDirectory(candidate)) return candidate |
| 77 | + cur = cur.parent |
| 78 | + } |
| 79 | + throw IllegalStateException( |
| 80 | + "Could not locate fixtures/persistence-conformance from ${Paths.get("").toAbsolutePath()}") |
| 81 | + } |
| 82 | + |
| 83 | + @Test |
| 84 | + fun `exposedApi=1 controller tier compiles against Exposed 1_3_x and Spring 6_2`() { |
| 85 | + val canonical = findCorpusRoot().resolve("canonical") |
| 86 | + val outDir = Files.createTempDirectory("exposed1x-controller-compile-") |
| 87 | + try { |
| 88 | + val loader = loadDirectory("fitness-exposed1x-controller", canonical) |
| 89 | + |
| 90 | + val generators: List<Pair<String, Generator>> = listOf( |
| 91 | + "KotlinEntityGenerator" to KotlinEntityGenerator(), |
| 92 | + "KotlinExposedTableGenerator" to KotlinExposedTableGenerator(), |
| 93 | + "KotlinNamesGenerator" to KotlinNamesGenerator(), |
| 94 | + "KotlinRelationsGenerator" to KotlinRelationsGenerator(), |
| 95 | + "KotlinFilterAllowlistGenerator" to KotlinFilterAllowlistGenerator(), |
| 96 | + "KotlinValidatorGenerator" to KotlinValidatorGenerator(), |
| 97 | + "KotlinSpringControllerGenerator" to KotlinSpringControllerGenerator(), |
| 98 | + ) |
| 99 | + |
| 100 | + GeneratedFileWriter.beginRun().use { run -> |
| 101 | + for ((name, gen) in generators) { |
| 102 | + run.attributeTo(name) |
| 103 | + gen.setArgs(mapOf( |
| 104 | + "outputDir" to outDir.toString(), |
| 105 | + "packageName" to "fitness", |
| 106 | + "templateRoot" to canonical.resolve("prompts").toString(), |
| 107 | + "exposedApi" to "1", |
| 108 | + )) |
| 109 | + gen.execute(loader) |
| 110 | + } |
| 111 | + } |
| 112 | + |
| 113 | + // See the class doc's "Excluded" section: two PRE-EXISTING, exposedApi-INDEPENDENT |
| 114 | + // controller defects (non-Comparable field.inet ordering ops; the |
| 115 | + // @dbColumnType=uuid escape hatch's String/UUID mismatch) — neither a missing |
| 116 | + // import, neither introduced by this issue, both out of #390's scope. |
| 117 | + val preexistingBrokenControllers = setOf("AllTypesController.kt", "AssetController.kt") |
| 118 | + val emittedPaths: List<Path> = Files.walk(outDir) |
| 119 | + .filter { it.isRegularFile() && it.toString().endsWith(".kt") } |
| 120 | + .filter { it.fileName.toString() !in preexistingBrokenControllers } |
| 121 | + .toList() |
| 122 | + assertTrue(emittedPaths.any { it.fileName.toString().endsWith("Controller.kt") }, |
| 123 | + "expected at least one <Entity>Controller.kt; saw ${emittedPaths.map { it.fileName }}") |
| 124 | + |
| 125 | + val sources = emittedPaths.map { SourceFile.kotlin(it.fileName.toString(), it.readText()) } |
| 126 | + val result = KotlinCompilation().apply { |
| 127 | + this.sources = sources |
| 128 | + inheritClassPath = true |
| 129 | + messageOutputStream = System.out |
| 130 | + }.compile() |
| 131 | + |
| 132 | + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, |
| 133 | + "exposedApi=1 controller tier over the shared fitness corpus emitted " + |
| 134 | + "${sources.size} file(s) that do not compile against Exposed 1.3.x + " + |
| 135 | + "Spring 6.2:\n${result.messages}") |
| 136 | + } finally { |
| 137 | + outDir.toFile().deleteRecursively() |
| 138 | + } |
| 139 | + } |
| 140 | +} |
0 commit comments