From 5a4c81565a99b5db9cfd3f31655083807eeed9dd Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 6 Jul 2026 13:22:41 -0400 Subject: [PATCH] =?UTF-8?q?fix(codegen-kotlin):=20fold=20TPH=20discriminat?= =?UTF-8?q?or=20subtypes=20into=20the=20base=20=E2=80=94=20no=20dead=20per?= =?UTF-8?q?-subtype=20artifacts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A TPH (table-per-hierarchy) discriminator base already emitted the union table + data class + discriminator enum + polymorphic controller, and the controller generator skipped subtypes — but five other generation paths still emitted dead, partly non-compiling per-subtype artifacts for each subtype: - KotlinExposedTableGenerator emitted Table mapping the SAME physical table with a partial column set (a footgun), AND its back-FK inference (buildGlobalFkMap) fanned a base's inherited to-many composition into phantom per-subtype inverse FKs (Table.id references) — a compile break once the tables are folded away. - KotlinEntityGenerator emitted a dead data class that re-emitted the inherited discriminator enum under a spurious name. - KotlinFilterAllowlistGenerator / KotlinValidatorGenerator / KotlinRelationsGenerator emitted dead allowlists / registry entries / relation helpers, the latter two referencing the now-missing Table (compile breaks). Fix: every entity-iterating generator now skips KotlinTphPlan.isTphSubtype (mirroring the controller's existing skip). Because a subtype folds into the base, the base union data class must also emit the enum class for any subtype-only field.enum it folds in (KotlinEntityGenerator now emits enum files over collectSubtypeFields, owner = base) — else the union references an enum no file defines. This brings Kotlin into line with the Java (codegen-spring) port, which already folds TPH subtypes into the base. Gated: - Expanded entity-with-tph snapshot fixture (a to-many composition Auth.lines→AuthLine + validator/relations generators) byte-gates the five structural skips + the buildGlobalFkMap fold — reverting any one re-emits a per-subtype artifact. - New KotlinOutputCompilesTest case compile-proves the folded-subtype-enum path (a subtype field.enum → the base union compiles). - New integration TphFullSuiteCompilesTest compiles the FULL generator suite (Exposed + Spring on the classpath) over a discriminator base owning a composition — the compile gate the snapshot byte-compare can't provide. Scoped out: a pre-existing, non-TPH bug where the generated Kotlin controller emits non-compiling enum-filter code for @filterable field.enum columns (wrong enum name + String→enum cast) — filed as #179; this change keeps its snapshot fixture's subtype enum off the controller surface. Two rounds of code review + simplify informed this change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ew1XfYSbEAezxjs9opynAe --- .../generator/kotlin/KotlinEntityGenerator.kt | 12 + .../kotlin/KotlinExposedTableGenerator.kt | 9 + .../kotlin/KotlinFilterAllowlistGenerator.kt | 4 + .../kotlin/KotlinRelationsGenerator.kt | 4 + .../kotlin/KotlinValidatorGenerator.kt | 3 + .../kotlin/KotlinOutputCompilesTest.kt | 49 +++ .../fixtures/entity-with-tph/config.json | 2 +- .../fixtures/entity-with-tph/meta.json | 10 + .../entity-with-tph/acme/auth/AuthLine.kt | 16 + .../acme/auth/AuthLineController.kt | 300 ++++++++++++++++++ .../acme/auth/AuthLineFilterAllowlist.kt | 12 + .../acme/auth/AuthLineTable.kt | 13 + .../acme/auth/AuthRelations.kt | 9 + .../entity-with-tph/acme/auth/BridgeAuth.kt | 24 -- .../acme/auth/BridgeAuthFilterAllowlist.kt | 22 -- .../acme/auth/BridgeAuthTable.kt | 13 - .../acme/auth/BridgeAuthType.kt | 13 - .../entity-with-tph/acme/auth/CopayAuth.kt | 23 -- .../acme/auth/CopayAuthFilterAllowlist.kt | 22 -- .../acme/auth/CopayAuthTable.kt | 13 - .../acme/auth/CopayAuthType.kt | 13 - .../acme/auth/PriorAuthAuth.kt | 23 -- .../acme/auth/PriorAuthAuthFilterAllowlist.kt | 22 -- .../acme/auth/PriorAuthAuthTable.kt | 13 - .../acme/auth/PriorAuthAuthType.kt | 13 - .../auth/validation/ExposedTableValidator.kt | 25 ++ .../validation/MetadataStartupValidator.kt | 35 ++ .../api/tph/TphFullSuiteCompilesTest.kt | 109 +++++++ 28 files changed, 611 insertions(+), 215 deletions(-) create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthLine.kt create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthLineController.kt create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthLineFilterAllowlist.kt create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthLineTable.kt create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthRelations.kt delete mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/BridgeAuth.kt delete mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/BridgeAuthFilterAllowlist.kt delete mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/BridgeAuthTable.kt delete mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/BridgeAuthType.kt delete mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/CopayAuth.kt delete mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/CopayAuthFilterAllowlist.kt delete mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/CopayAuthTable.kt delete mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/CopayAuthType.kt delete mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/PriorAuthAuth.kt delete mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/PriorAuthAuthFilterAllowlist.kt delete mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/PriorAuthAuthTable.kt delete mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/PriorAuthAuthType.kt create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/validation/ExposedTableValidator.kt create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/validation/MetadataStartupValidator.kt create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/tph/TphFullSuiteCompilesTest.kt 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 da7433d58..b06ece30d 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 @@ -88,6 +88,10 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { if (emitAbstractShapes) emitAbstractShape(obj, outRoot, loader, emittedEnumFqns) continue } + // FR-017 TPH: a discriminator subtype folds into its base's union data class + + // discriminator enum. A per-subtype data class is dead and re-emits the inherited + // enum under a spurious name. Mirror the controller/table skip. + if (KotlinTphPlan.isTphSubtype(obj)) continue emit(obj, outRoot, loader, emittedEnumFqns) } } @@ -100,6 +104,14 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { if (field is EnumField && !Fr019SharedEnum.isProvidedEnumField(field)) KotlinEnumEmitter.emitEnumFile(obj, field, outRoot, emittedEnumFqns) } + // FR-017 TPH: the base union folds in subtype-only fields (below). A folded field.enum + // resolves to a typed enum ClassName, so its enum file must be emitted here (owner = base, + // matching the fold's type resolution) — the declaring subtype is skipped in execute(), so + // no other pass emits it. No-op for a non-TPH entity (collectSubtypeFields is empty). + for (field in KotlinTphPlan.collectSubtypeFields(obj, loader)) { + if (field is EnumField && !Fr019SharedEnum.isProvidedEnumField(field)) + KotlinEnumEmitter.emitEnumFile(obj, field, outRoot, emittedEnumFqns) + } val (pkg, shortName) = PackageMapping.splitFqn(obj.name) val serializable = ClassName("kotlinx.serialization", "Serializable") diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt index 9d6a611ae..095a7eec1 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt @@ -82,6 +82,11 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBaseTable. Mirror the table-emission skip. + if (KotlinTphPlan.isTphSubtype(entity)) continue // ADR-0039: relationships are inheritable — RESOLVE via entity.relationships; // entity.children (own-only) would miss an inherited relationship. for (child in entity.relationships) { diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinFilterAllowlistGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinFilterAllowlistGenerator.kt index 83c2e472a..5a69d6806 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinFilterAllowlistGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinFilterAllowlistGenerator.kt @@ -55,6 +55,10 @@ open class KotlinFilterAllowlistGenerator : MultiFileDirectGeneratorBase() if (entity.subType != MetaObject.SUBTYPE_ENTITY) continue // Abstract entities are inheritance scaffolding — never emit relation helpers. if (KotlinGenUtil.isAbstractEntity(entity)) continue + // FR-017 TPH: a subtype folds into its base — its inherited relationships are emitted on + // the base's helper; a per-subtype helper would reference the missing Table. Mirror + // the table/controller skip. + if (KotlinTphPlan.isTphSubtype(entity)) continue // No source.rdb → no persistence layer → no FK column to query against. // ADR-0039: resolving (an inherited source.rdb still means the entity is persisted). if (!KotlinGenUtil.hasRdbSource(entity)) continue diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinValidatorGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinValidatorGenerator.kt index 29afd7633..2cadfb405 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinValidatorGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinValidatorGenerator.kt @@ -40,6 +40,9 @@ open class KotlinValidatorGenerator : MultiFileDirectGeneratorBase() .filter { it.subType == MetaObject.SUBTYPE_ENTITY } // Abstract entities are inheritance scaffolding — never register a validator for them. .filter { !KotlinGenUtil.isAbstractEntity(it) } + // FR-017 TPH: a subtype folds into its base's single table — never register a per- + // subtype table (it no longer exists). Mirror the table/controller skip. + .filter { !KotlinTphPlan.isTphSubtype(it) } // ADR-0039: resolving source lookup (inherited source.rdb via extends). .filter { KotlinGenUtil.hasRdbSource(it) } .map { entity -> diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinOutputCompilesTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinOutputCompilesTest.kt index a4efc80f4..81a027101 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinOutputCompilesTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinOutputCompilesTest.kt @@ -55,6 +55,55 @@ class KotlinOutputCompilesTest { ] } }""".trimIndent() + // FR-017 TPH regression guard: a subtype's OWN field.enum (CopayAuth.tier) folds into the base + // Auth union data class, so the base must emit that enum class (owner = base) — else the union + // references a type no generated file defines. The subtypes themselves emit nothing. + private val tphSubtypeEnumFixture = """{ + "metadata.root": { "package": "acme::auth", "children": [ + { "object.entity": { "name": "Auth", "@discriminator": "type", "children": [ + { "source.rdb": { "@table": "auths" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "type", "@values": ["Bridge", "Copay"] } }, + { "identity.primary": { "@fields": "id", "@generation": "increment" } } + ] } }, + { "object.entity": { "name": "BridgeAuth", "extends": "Auth", "@discriminatorValue": "Bridge", "children": [ + { "field.int": { "name": "quantity", "@required": true } } + ] } }, + { "object.entity": { "name": "CopayAuth", "extends": "Auth", "@discriminatorValue": "Copay", "children": [ + { "field.enum": { "name": "tier", "@values": ["Standard", "Premium"] } } + ] } } + ] } + }""".trimIndent() + + @Test fun `FR-017 TPH base union with a folded subtype enum compiles`() { + val outDir = Files.createTempDirectory("compile-tph-") + try { + val gen = KotlinEntityGenerator() + gen.setArgs(mapOf("outputDir" to outDir.toString())) + gen.execute(loadString("tph-test", tphSubtypeEnumFixture)) + + val emitted = Files.walk(outDir).filter { it.isRegularFile() }.sorted().toList() + val names = emitted.map { it.fileName.toString() }.toSet() + // The base emits the union + the discriminator enum + the folded subtype enum; the + // subtypes emit nothing (they fold into the base — the whole point of the skip). + assertTrue("AuthTier.kt" in names, + "the folded subtype enum (CopayAuth.tier) must be emitted by the base; got $names") + assertFalse(names.any { it.startsWith("BridgeAuth") || it.startsWith("CopayAuth") }, + "TPH subtypes must emit no data class / enum of their own; got $names") + + val result = KotlinCompilation().apply { + this.sources = emitted.map { SourceFile.kotlin(it.fileName.toString(), it.readText()) } + inheritClassPath = true + messageOutputStream = System.out + }.compile() + + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, + "TPH union with a folded subtype enum failed to compile:\n${result.messages}") + } finally { + outDir.toFile().deleteRecursively() + } + } + @Test fun `generated Author kt compiles`() { val outDir = Files.createTempDirectory("compile-") try { diff --git a/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-tph/config.json b/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-tph/config.json index 1bb63fb1b..f90792132 100644 --- a/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-tph/config.json +++ b/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-tph/config.json @@ -1 +1 @@ -{ "generators": ["entity", "table", "controller", "filter-allowlist"] } +{ "generators": ["entity", "table", "controller", "filter-allowlist", "validator", "relations"], "validatorPackage": "acme.auth.validation" } diff --git a/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-tph/meta.json b/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-tph/meta.json index 94454b507..b92d98ec2 100644 --- a/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-tph/meta.json +++ b/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-tph/meta.json @@ -10,6 +10,7 @@ { "field.long": { "name": "id", "@filterable": true, "@sortable": true } }, { "field.enum": { "name": "type", "@values": ["Bridge", "Copay", "PriorAuth"], "@filterable": true } }, { "field.string": { "name": "reference", "@required": true, "@maxLength": 80, "@filterable": true, "@sortable": true } }, + { "relationship.composition": { "name": "lines", "@cardinality": "many", "@objectRef": "AuthLine", "@onDelete": "cascade" } }, { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } ] }}, @@ -36,6 +37,15 @@ "children": [ { "field.string": { "name": "priorAuthNumber", "@maxLength": 80, "@filterable": true, "@sortable": true } } ] + }}, + { "object.entity": { + "name": "AuthLine", + "children": [ + { "source.rdb": { "@table": "auth_lines" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "label", "@maxLength": 40 } }, + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } + ] }} ] } diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthLine.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthLine.kt new file mode 100644 index 000000000..e4f718622 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthLine.kt @@ -0,0 +1,16 @@ +package acme.auth + +import jakarta.validation.constraints.Size +import kotlin.Long +import kotlin.String +import kotlinx.serialization.Serializable + +/** + * GENERATED — do not hand-edit. Regenerated from metadata. + */ +@Serializable +public data class AuthLine( + public val id: Long? = null, + @field:Size(max = 40) + public val label: String? = null, +) diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthLineController.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthLineController.kt new file mode 100644 index 000000000..d46462878 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthLineController.kt @@ -0,0 +1,300 @@ +package acme.auth + +import org.jetbrains.exposed.sql.Op +import org.jetbrains.exposed.sql.SortOrder +import org.jetbrains.exposed.sql.ResultRow +import org.jetbrains.exposed.sql.SqlExpressionBuilder +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.deleteWhere +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.update +import org.jetbrains.exposed.sql.transactions.transaction +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestMethod +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.sql.Timestamp +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +/** GENERATED — sort allowlist for AuthLine (cross-port API contract). */ +private val AuthLineSortAllowlist = setOf( + "id", + "label", +) + +private fun parseAuthLineSort(raw: String): Pair? { + val parts = raw.split(":", limit = 2) + val field = parts.getOrNull(0) ?: return null + if (field !in AuthLineSortAllowlist) return null + val dirRaw = parts.getOrNull(1)?.lowercase() ?: "asc" + val dir = when (dirRaw) { + "asc" -> SortOrder.ASC + "desc" -> SortOrder.DESC + else -> return null + } + return field to dir +} + +/** GENERATED — cross-port cap on `in`-list size (matches TS DEFAULT_MAX_IN_LIST). */ +private const val MAX_IN_LIST = 100 + +/** GENERATED — single parsed + validated FR-009 filter predicate. */ +private data class AuthLineFilterPredicate(val field: String, val op: String, val value: Any?) + +/** GENERATED — parse outcome: either a list of predicates or a cross-port error envelope key. */ +private data class AuthLineFilterResult(val predicates: List, val error: String?) + +/** + * GENERATED — parse the bracketed-qs FR-009 filter grammar from a URL-decoded + * {@code allParams} map. Returns either a list of validated predicates or one of + * the cross-port error envelope keys ({@code invalid_filter_field / + * invalid_filter_op / invalid_filter_value / filter.in_too_large}). + */ +private fun parseAuthLineFilter(allParams: Map): AuthLineFilterResult { + val out = mutableListOf() + for ((rawKey, value) in allParams) { + if (!rawKey.startsWith("filter[")) continue + val firstClose = rawKey.indexOf(']', 7) + if (firstClose < 0) continue + val field = rawKey.substring(7, firstClose) + val rest = firstClose + 1 + val op: String = when { + rest >= rawKey.length -> "eq" + rawKey[rest] == '[' -> { + val secondClose = rawKey.indexOf(']', rest + 1) + if (secondClose < 0) continue + rawKey.substring(rest + 1, secondClose) + } + else -> continue + } + if (field !in AuthLineFilterAllowlist.FIELDS) return AuthLineFilterResult(emptyList(), "invalid_filter_field") + val ops = AuthLineFilterAllowlist.OPS_BY_FIELD[field] + if (ops == null || op !in ops) return AuthLineFilterResult(emptyList(), "invalid_filter_op") + val coerced = coerceAuthLineValue(field, op, value) + ?: return AuthLineFilterResult(emptyList(), "invalid_filter_value") + val coercedValue = coerced.value + if (op == "in" && coercedValue is List<*> && coercedValue.size > MAX_IN_LIST) { + return AuthLineFilterResult(emptyList(), "filter.in_too_large") + } + out.add(AuthLineFilterPredicate(field, op, coercedValue)) + } + return AuthLineFilterResult(out, null) +} + +/** Box result: null = invalid; Box(value) = coerced. Distinguishes failure from a legitimate null. */ +private data class AuthLineCoercedValue(val value: Any?) + +private fun coerceAuthLineValue(field: String, op: String, raw: String): AuthLineCoercedValue? { + if (op == "isNull") return when (raw) { + "true" -> AuthLineCoercedValue(true) + "false" -> AuthLineCoercedValue(false) + else -> null + } + return when (field) { + "id" -> coerceAuthLineLong(op, raw) + "label" -> if (op == "in") AuthLineCoercedValue(raw.split(",").map { it.trim() }) else AuthLineCoercedValue(raw) + else -> null + } +} + +private fun coerceAuthLineLong(op: String, raw: String): AuthLineCoercedValue? { + val parse: (String) -> Any? = { s -> runCatching { java.lang.Long.parseLong(s) }.getOrNull() } + if (op == "in") { + val parts = raw.split(",").map { it.trim() } + val list = parts.map { parse(it) ?: return null } + return AuthLineCoercedValue(list) + } + return AuthLineCoercedValue(parse(raw) ?: return null) +} + +private fun coerceAuthLineInt(op: String, raw: String): AuthLineCoercedValue? { + val parse: (String) -> Any? = { s -> runCatching { java.lang.Integer.parseInt(s) }.getOrNull() } + if (op == "in") { + val parts = raw.split(",").map { it.trim() } + val list = parts.map { parse(it) ?: return null } + return AuthLineCoercedValue(list) + } + return AuthLineCoercedValue(parse(raw) ?: return null) +} + +private fun coerceAuthLineDouble(op: String, raw: String): AuthLineCoercedValue? { + val parse: (String) -> Any? = { s -> runCatching { java.lang.Double.parseDouble(s) }.getOrNull() } + if (op == "in") { + val parts = raw.split(",").map { it.trim() } + val list = parts.map { parse(it) ?: return null } + return AuthLineCoercedValue(list) + } + return AuthLineCoercedValue(parse(raw) ?: return null) +} + +private fun coerceAuthLineDate(op: String, raw: String): AuthLineCoercedValue? { + val parse: (String) -> Any? = { s -> runCatching { LocalDate.parse(s) }.getOrNull() } + if (op == "in") { + val parts = raw.split(",").map { it.trim() } + val list = parts.map { parse(it) ?: return null } + return AuthLineCoercedValue(list) + } + return AuthLineCoercedValue(parse(raw) ?: return null) +} + +private fun coerceAuthLineTime(op: String, raw: String): AuthLineCoercedValue? { + val parse: (String) -> Any? = { s -> runCatching { LocalTime.parse(s) }.getOrNull() } + if (op == "in") { + val parts = raw.split(",").map { it.trim() } + val list = parts.map { parse(it) ?: return null } + return AuthLineCoercedValue(list) + } + return AuthLineCoercedValue(parse(raw) ?: return null) +} + +private val AuthLineTimestampFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss") + +private fun coerceAuthLineTimestamp(op: String, raw: String): AuthLineCoercedValue? { + val parse: (String) -> LocalDateTime? = { s -> runCatching { LocalDateTime.parse(s, AuthLineTimestampFmt) }.getOrNull() } + if (op == "in") { + val parts = raw.split(",").map { it.trim() } + val list = parts.map { parse(it) ?: return null } + return AuthLineCoercedValue(list) + } + return AuthLineCoercedValue(parse(raw) ?: return null) +} + +private fun coerceAuthLineBoolean(op: String, raw: String): AuthLineCoercedValue? { + val parse: (String) -> Boolean? = { s -> when (s) { "true" -> true; "false" -> false; else -> null } } + if (op == "in") { + val parts = raw.split(",").map { it.trim() } + val list = parts.map { parse(it) ?: return null } + return AuthLineCoercedValue(list) + } + return AuthLineCoercedValue(parse(raw) ?: return null) +} + +/** + * GENERATED — fold a list of validated predicates into an Exposed + * {@code Op}, AND-combining each predicate's column-op-value triple + * against AuthLineTable. Returns null when [predicates] is empty so the + * caller can elide the WHERE clause entirely. + */ +@Suppress("UNCHECKED_CAST") +private fun AuthLineWhereOp(predicates: List): Op? { + if (predicates.isEmpty()) return null + return with(SqlExpressionBuilder) { + var combined: Op? = null + for (p in predicates) { + val op: Op = when (p.field) { + "id" -> when (p.op) { + "eq" -> AuthLineTable.id eq (p.value as Long) + "ne" -> AuthLineTable.id neq (p.value as Long) + "gt" -> AuthLineTable.id greater (p.value as Long) + "gte" -> AuthLineTable.id greaterEq (p.value as Long) + "lt" -> AuthLineTable.id less (p.value as Long) + "lte" -> AuthLineTable.id lessEq (p.value as Long) + "in" -> AuthLineTable.id inList (p.value as List) + "isNull" -> if (p.value as Boolean) AuthLineTable.id.isNull() else AuthLineTable.id.isNotNull() + else -> throw IllegalStateException("unsupported op for id: " + p.op) + } + "label" -> when (p.op) { + "eq" -> AuthLineTable.label eq (p.value as String) + "ne" -> AuthLineTable.label neq (p.value as String) + "in" -> AuthLineTable.label inList (p.value as List) + "like" -> AuthLineTable.label like (p.value as String) + "isNull" -> if (p.value as Boolean) AuthLineTable.label.isNull() else AuthLineTable.label.isNotNull() + else -> throw IllegalStateException("unsupported op for label: " + p.op) + } + else -> continue + } + combined = combined?.and(op) ?: op + } + combined + } +} + +/** GENERATED — map an Exposed ResultRow to the AuthLine data class. */ +private fun rowToAuthLine(row: ResultRow): AuthLine = AuthLine( + id = row[AuthLineTable.id], + label = row[AuthLineTable.label], +) + +/** GENERATED — REST controller for AuthLine entity. Implements the cross-port API contract. */ +@RestController +@RequestMapping("/api/authlines") +class AuthLineController { + + @GetMapping + fun list( + @RequestParam(required = false) limit: Int?, + @RequestParam(required = false) offset: Int?, + @RequestParam(required = false) sort: String?, + @RequestParam(required = false, name = "withCount") withCount: Int?, + @RequestParam allParams: Map, + ): ResponseEntity = transaction { + // FR-009 filter operators — short-circuit 400 on invalid field/op/value. + val filterResult = parseAuthLineFilter(allParams) + if (filterResult.error != null) { + return@transaction ResponseEntity.badRequest().body(mapOf("error" to filterResult.error) as Any) + } + val whereOp = AuthLineWhereOp(filterResult.predicates) + var q = if (whereOp != null) AuthLineTable.selectAll().where { whereOp } else AuthLineTable.selectAll() + if (sort != null) { + val parsed = parseAuthLineSort(sort) + ?: return@transaction ResponseEntity.badRequest().body(mapOf("error" to "invalid_sort") as Any) + val (field, dir) = parsed + q = q.orderBy(AuthLineTable.columns.first { it.name == field } to dir) + } + val total: Long = if (withCount == 1) q.count() else -1L + val effectiveLimit = limit ?: 50 + val effectiveOffset = (offset ?: 0).toLong() + val rows = q.limit(effectiveLimit, effectiveOffset).map { rowToAuthLine(it) } + if (withCount == 1) ResponseEntity.ok(mapOf("rows" to rows, "total" to total) as Any) + else ResponseEntity.ok(rows as Any) + } + + @GetMapping("/{id}") + fun get(@PathVariable id: Long): ResponseEntity = transaction { + val row = AuthLineTable.selectAll().where { AuthLineTable.id eq id }.singleOrNull() + ?: return@transaction ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf("error" to "not_found") as Any) + ResponseEntity.ok(rowToAuthLine(row) as Any) + } + + @PostMapping + fun create(@Valid @RequestBody dto: AuthLine): ResponseEntity = transaction { + val newId = AuthLineTable.insert { + it[label] = dto.label + }[AuthLineTable.id] + val saved = AuthLineTable.selectAll().where { AuthLineTable.id eq newId }.single() + ResponseEntity.status(HttpStatus.CREATED).body(rowToAuthLine(saved)) + } + + @RequestMapping(value = ["/{id}"], method = [RequestMethod.PATCH, RequestMethod.PUT]) + fun update(@PathVariable id: Long, @Valid @RequestBody dto: AuthLine): ResponseEntity = transaction { + val updated = AuthLineTable.update({ AuthLineTable.id eq id }) { + it[label] = dto.label + } + if (updated == 0) ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf("error" to "not_found") as Any) + else { + val row = AuthLineTable.selectAll().where { AuthLineTable.id eq id }.single() + ResponseEntity.ok(rowToAuthLine(row) as Any) + } + } + + @DeleteMapping("/{id}") + fun delete(@PathVariable id: Long): ResponseEntity = transaction { + val deleted = AuthLineTable.deleteWhere { with(SqlExpressionBuilder) { AuthLineTable.id eq id } } + if (deleted == 0) ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf("error" to "not_found") as Any) + else ResponseEntity.noContent().build() + } +} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthLineFilterAllowlist.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthLineFilterAllowlist.kt new file mode 100644 index 000000000..cb19466f9 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthLineFilterAllowlist.kt @@ -0,0 +1,12 @@ +package acme.auth + +/** + * GENERATED — per-entity FR-009 filter allowlist for AuthLine. + * FIELDS lists the filterable field names; OPS_BY_FIELD constrains the + * operator vocabulary for each field by its subtype. + */ +object AuthLineFilterAllowlist { + val FIELDS: Set = emptySet() + + val OPS_BY_FIELD: Map> = emptyMap() +} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthLineTable.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthLineTable.kt new file mode 100644 index 000000000..74aeca990 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthLineTable.kt @@ -0,0 +1,13 @@ +package acme.auth + +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.ReferenceOption + +/** GENERATED — do not hand-edit. Regenerated from metadata. */ +object AuthLineTable : Table("auth_lines") { + val id = long("id").autoIncrement() + val label = varchar("label", 40).nullable() + val authId = long("auth_id").references(AuthTable.id, onDelete = ReferenceOption.CASCADE) + + override val primaryKey = PrimaryKey(id) +} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthRelations.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthRelations.kt new file mode 100644 index 000000000..e2bda9ace --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/AuthRelations.kt @@ -0,0 +1,9 @@ +package acme.auth + +import org.jetbrains.exposed.sql.Query +import org.jetbrains.exposed.sql.selectAll + +/** GENERATED — extension fns for `Auth` to-many relationships. Do not hand-edit. */ +/** Query `Auth.lines` (cardinality=many) on the AuthLine side. */ +fun AuthTable.linesQuery(authId: Long): Query = + AuthLineTable.selectAll().where { AuthLineTable.authId eq authId } diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/BridgeAuth.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/BridgeAuth.kt deleted file mode 100644 index 5fe6a4ec5..000000000 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/BridgeAuth.kt +++ /dev/null @@ -1,24 +0,0 @@ -package acme.auth - -import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.NotNull -import jakarta.validation.constraints.Size -import kotlin.Int -import kotlin.Long -import kotlin.String -import kotlinx.serialization.Serializable - -/** - * GENERATED — do not hand-edit. Regenerated from metadata. - */ -@Serializable -public data class BridgeAuth( - @field:NotNull - public val quantity: Int, - public val id: Long? = null, - public val type: BridgeAuthType? = null, - @field:NotNull - @field:NotBlank - @field:Size(max = 80) - public val reference: String, -) diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/BridgeAuthFilterAllowlist.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/BridgeAuthFilterAllowlist.kt deleted file mode 100644 index bea89ea59..000000000 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/BridgeAuthFilterAllowlist.kt +++ /dev/null @@ -1,22 +0,0 @@ -package acme.auth - -/** - * GENERATED — per-entity FR-009 filter allowlist for BridgeAuth. - * FIELDS lists the filterable field names; OPS_BY_FIELD constrains the - * operator vocabulary for each field by its subtype. - */ -object BridgeAuthFilterAllowlist { - val FIELDS: Set = setOf( - "quantity", - "id", - "type", - "reference", - ) - - val OPS_BY_FIELD: Map> = mapOf( - "quantity" to setOf("eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull"), - "id" to setOf("eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull"), - "type" to setOf("eq", "ne", "in", "like", "isNull"), - "reference" to setOf("eq", "ne", "in", "like", "isNull") - ) -} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/BridgeAuthTable.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/BridgeAuthTable.kt deleted file mode 100644 index 2264e25fa..000000000 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/BridgeAuthTable.kt +++ /dev/null @@ -1,13 +0,0 @@ -package acme.auth - -import org.jetbrains.exposed.sql.Table - -/** GENERATED — do not hand-edit. Regenerated from metadata. */ -object BridgeAuthTable : Table("auths") { - val id = long("id").autoIncrement() - val quantity = integer("quantity") - val type = enumerationByName("type", 64, BridgeAuthType::class).nullable() - val reference = varchar("reference", 80) - - override val primaryKey = PrimaryKey(id) -} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/BridgeAuthType.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/BridgeAuthType.kt deleted file mode 100644 index a1c843cca..000000000 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/BridgeAuthType.kt +++ /dev/null @@ -1,13 +0,0 @@ -package acme.auth - -import kotlinx.serialization.Serializable - -/** - * GENERATED — do not hand-edit. Regenerated from metadata. - */ -@Serializable -public enum class BridgeAuthType { - Bridge, - Copay, - PriorAuth, -} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/CopayAuth.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/CopayAuth.kt deleted file mode 100644 index e8f8fcd59..000000000 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/CopayAuth.kt +++ /dev/null @@ -1,23 +0,0 @@ -package acme.auth - -import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.NotNull -import jakarta.validation.constraints.Size -import java.math.BigDecimal -import kotlin.Long -import kotlin.String -import kotlinx.serialization.Serializable - -/** - * GENERATED — do not hand-edit. Regenerated from metadata. - */ -@Serializable -public data class CopayAuth( - public val copayAmount: BigDecimal? = null, - public val id: Long? = null, - public val type: CopayAuthType? = null, - @field:NotNull - @field:NotBlank - @field:Size(max = 80) - public val reference: String, -) diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/CopayAuthFilterAllowlist.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/CopayAuthFilterAllowlist.kt deleted file mode 100644 index 223c61b4c..000000000 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/CopayAuthFilterAllowlist.kt +++ /dev/null @@ -1,22 +0,0 @@ -package acme.auth - -/** - * GENERATED — per-entity FR-009 filter allowlist for CopayAuth. - * FIELDS lists the filterable field names; OPS_BY_FIELD constrains the - * operator vocabulary for each field by its subtype. - */ -object CopayAuthFilterAllowlist { - val FIELDS: Set = setOf( - "copayAmount", - "id", - "type", - "reference", - ) - - val OPS_BY_FIELD: Map> = mapOf( - "copayAmount" to setOf("eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull"), - "id" to setOf("eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull"), - "type" to setOf("eq", "ne", "in", "like", "isNull"), - "reference" to setOf("eq", "ne", "in", "like", "isNull") - ) -} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/CopayAuthTable.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/CopayAuthTable.kt deleted file mode 100644 index 1fdb57852..000000000 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/CopayAuthTable.kt +++ /dev/null @@ -1,13 +0,0 @@ -package acme.auth - -import org.jetbrains.exposed.sql.Table - -/** GENERATED — do not hand-edit. Regenerated from metadata. */ -object CopayAuthTable : Table("auths") { - val id = long("id").autoIncrement() - val copayAmount = decimal("copay_amount", 10, 2).nullable() - val type = enumerationByName("type", 64, CopayAuthType::class).nullable() - val reference = varchar("reference", 80) - - override val primaryKey = PrimaryKey(id) -} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/CopayAuthType.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/CopayAuthType.kt deleted file mode 100644 index d594483cc..000000000 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/CopayAuthType.kt +++ /dev/null @@ -1,13 +0,0 @@ -package acme.auth - -import kotlinx.serialization.Serializable - -/** - * GENERATED — do not hand-edit. Regenerated from metadata. - */ -@Serializable -public enum class CopayAuthType { - Bridge, - Copay, - PriorAuth, -} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/PriorAuthAuth.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/PriorAuthAuth.kt deleted file mode 100644 index 148b18993..000000000 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/PriorAuthAuth.kt +++ /dev/null @@ -1,23 +0,0 @@ -package acme.auth - -import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.NotNull -import jakarta.validation.constraints.Size -import kotlin.Long -import kotlin.String -import kotlinx.serialization.Serializable - -/** - * GENERATED — do not hand-edit. Regenerated from metadata. - */ -@Serializable -public data class PriorAuthAuth( - @field:Size(max = 80) - public val priorAuthNumber: String? = null, - public val id: Long? = null, - public val type: PriorAuthAuthType? = null, - @field:NotNull - @field:NotBlank - @field:Size(max = 80) - public val reference: String, -) diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/PriorAuthAuthFilterAllowlist.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/PriorAuthAuthFilterAllowlist.kt deleted file mode 100644 index 291724da6..000000000 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/PriorAuthAuthFilterAllowlist.kt +++ /dev/null @@ -1,22 +0,0 @@ -package acme.auth - -/** - * GENERATED — per-entity FR-009 filter allowlist for PriorAuthAuth. - * FIELDS lists the filterable field names; OPS_BY_FIELD constrains the - * operator vocabulary for each field by its subtype. - */ -object PriorAuthAuthFilterAllowlist { - val FIELDS: Set = setOf( - "priorAuthNumber", - "id", - "type", - "reference", - ) - - val OPS_BY_FIELD: Map> = mapOf( - "priorAuthNumber" to setOf("eq", "ne", "in", "like", "isNull"), - "id" to setOf("eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull"), - "type" to setOf("eq", "ne", "in", "like", "isNull"), - "reference" to setOf("eq", "ne", "in", "like", "isNull") - ) -} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/PriorAuthAuthTable.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/PriorAuthAuthTable.kt deleted file mode 100644 index fa8e8282a..000000000 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/PriorAuthAuthTable.kt +++ /dev/null @@ -1,13 +0,0 @@ -package acme.auth - -import org.jetbrains.exposed.sql.Table - -/** GENERATED — do not hand-edit. Regenerated from metadata. */ -object PriorAuthAuthTable : Table("auths") { - val id = long("id").autoIncrement() - val priorAuthNumber = varchar("prior_auth_number", 80).nullable() - val type = enumerationByName("type", 64, PriorAuthAuthType::class).nullable() - val reference = varchar("reference", 80) - - override val primaryKey = PrimaryKey(id) -} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/PriorAuthAuthType.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/PriorAuthAuthType.kt deleted file mode 100644 index cdd013d0c..000000000 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/PriorAuthAuthType.kt +++ /dev/null @@ -1,13 +0,0 @@ -package acme.auth - -import kotlinx.serialization.Serializable - -/** - * GENERATED — do not hand-edit. Regenerated from metadata. - */ -@Serializable -public enum class PriorAuthAuthType { - Bridge, - Copay, - PriorAuth, -} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/validation/ExposedTableValidator.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/validation/ExposedTableValidator.kt new file mode 100644 index 000000000..318c39a71 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/validation/ExposedTableValidator.kt @@ -0,0 +1,25 @@ +package acme.auth.validation + +import com.metaobjects.`object`.MetaObject +import com.metaobjects.field.MetaField +import org.jetbrains.exposed.sql.Table + +/** + * GENERATED — compares a [MetaObject]'s field set vs an Exposed [Table]'s column set + * and records any discrepancies into the supplied `errors` list. + */ +object ExposedTableValidator { + + fun check(obj: MetaObject, table: Table, errors: MutableList) { + val expectedCols = obj.metaFields.map { it.name }.toSet() + val actualCols = table.columns.map { it.name }.toSet() + val missing = expectedCols - actualCols + val extra = actualCols - expectedCols + if (missing.isNotEmpty()) { + errors.add("${obj.name}: metadata declares fields not in generated table: $missing") + } + if (extra.isNotEmpty()) { + errors.add("${obj.name}: generated table has columns not in metadata: $extra") + } + } +} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/validation/MetadataStartupValidator.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/validation/MetadataStartupValidator.kt new file mode 100644 index 000000000..f63b91312 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/validation/MetadataStartupValidator.kt @@ -0,0 +1,35 @@ +package acme.auth.validation + +import com.metaobjects.loader.MetaDataLoader +import com.metaobjects.metadata.ktx.metaObjectOrNull +import org.jetbrains.exposed.sql.Table +import acme.auth.AuthLineTable +import acme.auth.AuthTable + +/** + * GENERATED — runtime drift gate. Call [validate] from a Spring `@PostConstruct` or + * `ApplicationReadyEvent` listener to fail-fast when generated Tables drift from metadata. + */ +object MetadataStartupValidator { + + private val tablesToValidate: List> = listOf( + "acme::auth::Auth" to AuthTable, + "acme::auth::AuthLine" to AuthLineTable + ) + + fun validate(loader: MetaDataLoader) { + val errors = mutableListOf() + for ((fqn, table) in tablesToValidate) { + val obj = loader.metaObjectOrNull(fqn) + if (obj == null) { + errors.add("metadata missing $fqn (generated table: ${table.tableName})") + continue + } + ExposedTableValidator.check(obj, table, errors) + } + check(errors.isEmpty()) { + "MetadataStartupValidator: ${errors.size} drift(s):\n - " + + errors.joinToString("\n - ") + } + } +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/tph/TphFullSuiteCompilesTest.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/tph/TphFullSuiteCompilesTest.kt new file mode 100644 index 000000000..46139dc1f --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/tph/TphFullSuiteCompilesTest.kt @@ -0,0 +1,109 @@ +package com.metaobjects.integration.kotlin.api.tph + +import com.metaobjects.generator.kotlin.KotlinEntityGenerator +import com.metaobjects.generator.kotlin.KotlinExposedTableGenerator +import com.metaobjects.generator.kotlin.KotlinFilterAllowlistGenerator +import com.metaobjects.generator.kotlin.KotlinRelationsGenerator +import com.metaobjects.generator.kotlin.KotlinSpringControllerGenerator +import com.metaobjects.generator.kotlin.KotlinValidatorGenerator +import com.metaobjects.metadata.ktx.loadString +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import java.nio.file.Files +import kotlin.io.path.isRegularFile +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** + * FR-017 TPH — compile-gate for the FULL codegen-kotlin generator suite over a discriminator + * hierarchy that also owns a to-many composition. The codegen-kotlin unit snapshot test only + * byte-compares, and its own [com.metaobjects.generator.kotlin.KotlinOutputCompilesTest] TPH case + * runs the entity generator alone (no Exposed/Spring on that module's test classpath). So a + * generated `Table` / `Relations` referencing a folded-away subtype table would slip past + * both. This module HAS Exposed + Spring, so it compiles the whole emitted set: + * + * The `Auth` base owns `lines: many AuthLine`, inherited by every subtype. If any generator failed + * to fold a subtype into the base — the table's back-FK inference (`buildGlobalFkMap`), the relations + * helper, or the validator registry — it would emit a reference to a `BridgeAuthTable` / + * `CopayAuthTable` / `PriorAuthAuthTable` that the table generator no longer emits, and this compile + * would fail. (No subtype `field.enum`: the controller's enum-filter emission is a separate + * pre-existing bug tracked elsewhere; this gate targets the TPH subtype-fold, not that.) + */ +@OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class) +class TphFullSuiteCompilesTest { + + private val tphWithCompositionFixture = """{ + "metadata.root": { "package": "acme::auth", "children": [ + { "object.entity": { "name": "Auth", "@discriminator": "type", "children": [ + { "source.rdb": { "@table": "auths" } }, + { "field.long": { "name": "id", "@filterable": true } }, + { "field.enum": { "name": "type", "@values": ["Bridge", "Copay", "PriorAuth"] } }, + { "field.string": { "name": "reference", "@required": true, "@maxLength": 80, "@filterable": true } }, + { "relationship.composition": { "name": "lines", "@cardinality": "many", "@objectRef": "AuthLine", "@onDelete": "cascade" } }, + { "identity.primary": { "@fields": "id", "@generation": "increment" } } + ] } }, + { "object.entity": { "name": "BridgeAuth", "extends": "Auth", "@discriminatorValue": "Bridge", "children": [ + { "field.int": { "name": "quantity", "@required": true, "@filterable": true } } + ] } }, + { "object.entity": { "name": "CopayAuth", "extends": "Auth", "@discriminatorValue": "Copay", "children": [ + { "field.decimal": { "name": "copayAmount", "@precision": 10, "@scale": 2, "@filterable": true } } + ] } }, + { "object.entity": { "name": "PriorAuthAuth", "extends": "Auth", "@discriminatorValue": "PriorAuth", "children": [ + { "field.string": { "name": "priorAuthNumber", "@maxLength": 80, "@filterable": true } } + ] } }, + { "object.entity": { "name": "AuthLine", "children": [ + { "source.rdb": { "@table": "auth_lines" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "label", "@maxLength": 40 } }, + { "identity.primary": { "@fields": "id", "@generation": "increment" } } + ] } } + ] } + }""".trimIndent() + + @Test + fun `FR-017 full TPH generator suite with a composition compiles`() { + val outDir = Files.createTempDirectory("tph-suite-") + try { + val loader = loadString("tph-suite", tphWithCompositionFixture) + for (gen in listOf( + KotlinEntityGenerator(), + KotlinExposedTableGenerator(), + KotlinSpringControllerGenerator(), + KotlinFilterAllowlistGenerator(), + KotlinRelationsGenerator(), + KotlinValidatorGenerator(), + )) { + val args = mutableMapOf("outputDir" to outDir.toString()) + if (gen is KotlinValidatorGenerator) args["packageName"] = "acme.auth.validation" + gen.setArgs(args) + gen.execute(loader) + } + + val emitted = Files.walk(outDir).filter { it.isRegularFile() }.sorted().toList() + val names = emitted.map { it.fileName.toString() }.toSet() + // The subtypes fold into the base — no per-subtype persistence artifact of any kind. + for (dead in listOf( + "BridgeAuthTable.kt", "CopayAuthTable.kt", "PriorAuthAuthTable.kt", + "BridgeAuthRelations.kt", "CopayAuthRelations.kt", "PriorAuthAuthRelations.kt", + )) { + assertFalse(dead in names, "TPH subtype must not emit $dead; got $names") + } + + val sources = emitted.map { path -> + SourceFile.kotlin(outDir.relativize(path).toString().replace('/', '_'), path.readText()) + } + val result = KotlinCompilation().apply { + this.sources = sources + inheritClassPath = true + messageOutputStream = System.out + }.compile() + + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, + "TPH full generator suite (with composition) failed to compile:\n${result.messages}") + } finally { + outDir.toFile().deleteRecursively() + } + } +}