Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
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)
}
}
Expand All @@ -100,6 +104,14 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject
entity.subType != MetaObject.SUBTYPE_PROJECTION) continue
// Abstract entities are inheritance scaffolding — never emit a persistence table.
if (KotlinGenUtil.isAbstractEntity(entity)) continue
// FR-017 TPH: a discriminator subtype folds into its base's single table — the base
// emits the union table. A per-subtype table would map the SAME physical table with
// a partial column set (a footgun). The subtype inherits source.rdb via extends, so
// the resolving lookup below would otherwise emit one — mirror the controller's skip.
if (KotlinTphPlan.isTphSubtype(entity)) continue
// ADR-0039: resolving source lookup — an entity inheriting its source.rdb via
// extends must still emit a table (own-only .children returned null → no table).
val sourceRdb = KotlinGenUtil.firstRdbSource(entity) ?: continue
Expand Down Expand Up @@ -854,6 +859,10 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject

for (entity in loader.metaObjects) {
if (entity.subType != MetaObject.SUBTYPE_ENTITY) continue
// FR-017 TPH: a subtype shares its base's single table, so its INHERITED to-many
// composition is already covered by the base's inverse FK — emitting one per subtype
// would target the folded-away <Sub>Table. 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ open class KotlinFilterAllowlistGenerator : MultiFileDirectGeneratorBase<MetaObj
val outRoot = Paths.get(outDir.absolutePath)
for (entity in loader.metaObjects) {
if (entity.subType != MetaObject.SUBTYPE_ENTITY) continue
// FR-017 TPH: a discriminator subtype folds into its base — the base's allowlist
// (unioned across subtype columns via isTphBase) is the only one the polymorphic
// controller uses; a per-subtype allowlist is dead. Mirror the controller/table skip.
if (KotlinTphPlan.isTphSubtype(entity)) continue
// ADR-0039: resolving source lookup (inherited source.rdb via extends).
val sourceRdb = KotlinGenUtil.firstRdbSource(entity) ?: continue
// Only writable tables get a filter allowlist (the controller is also
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ open class KotlinRelationsGenerator : MultiFileDirectGeneratorBase<MetaObject>()
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 <Sub>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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ open class KotlinValidatorGenerator : MultiFileDirectGeneratorBase<MetaObject>()
.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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{ "generators": ["entity", "table", "controller", "filter-allowlist"] }
{ "generators": ["entity", "table", "controller", "filter-allowlist", "validator", "relations"], "validatorPackage": "acme.auth.validation" }
Original file line number Diff line number Diff line change
Expand Up @@ -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" } }
]
}},
Expand All @@ -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" } }
]
}}
]
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading