Skip to content

Commit 05bd26c

Browse files
dmealingclaude
andcommitted
feat(codegen-kotlin): FR-017 Tier 4 — TPH polymorphic-CRUD api-contract (generated Kotlin lane)
Kotlin port of the table-per-hierarchy REST contract, mirroring the Java/C#/Python/TS generated lanes. A discriminator base (`@discriminator`) now emits ONE self-contained Kotlin Spring controller mounting the polymorphic collection plus a per-subtype CRUD set; its subtypes (`@discriminatorValue`) are folded into the base's single Exposed table and emit no standalone table/controller. Routes (segment = `@discriminatorValue` lowercased): GET /api/<base>, GET /<base>/{id}, GET /<base>/<seg>, GET /<base>/<seg>/{id} (404 cross-subtype), POST /<base>/<seg> (discriminator injected from URL, not body), PATCH|PUT /<base>/<seg>/{id} (partial patch, discriminator immutable, 404 cross-subtype), DELETE /<base>/<seg>/{id} (404 cross-subtype). - KotlinTphPlan — single source of truth: isTphSubtype / isTphBase / planFor / collectSubtypeFields / routeSegment (mirrors TphPlan.java). - KotlinExposedTableGenerator — the base table folds in every subtype column NULLABLE. - KotlinEntityGenerator — the base data class is the UNION (all properties nullable + defaulted, no validation) so it's a partial-PATCH-friendly wire body for every endpoint. - KotlinSpringControllerGenerator.emitTph — the self-contained TPH controller, embedding Exposed against the union table; the discriminator is the generated enum (scoped/injected as `<Enum>.<Value>`); create forces the required base column non-null, update is a partial COALESCE-style patch. Generated lane (NO Postgres — kotlin-compile-testing → MockMvc over in-memory H2, seeded via the controller's own per-subtype POST): the 4 tph api-contract scenarios pass against the EMITTED AuthController. No regression: codegen-kotlin green; Author generated 20/0, reference + codegen-matches green (TPH paths are gated to discriminator entities). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 57d84ef commit 05bd26c

6 files changed

Lines changed: 590 additions & 4 deletions

File tree

server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,23 +94,41 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
9494
.addAnnotation(serializable)
9595
.addKdoc("GENERATED — do not hand-edit. Regenerated from metadata.\n")
9696

97+
// FR-017 TPH: a discriminator base's data class is the UNION of subtype columns and serves
98+
// as the partial-PATCH-friendly wire body — every property is nullable + defaulted + carries
99+
// NO validation (a per-subtype POST/PATCH supplies only its own columns; the DB column
100+
// nullability is the real constraint). Non-TPH entities keep their per-field required rules.
101+
val tphBase = KotlinTphPlan.isTphBase(obj, loader)
102+
97103
val ctorBuilder = FunSpec.constructorBuilder()
98104
for (field in obj.metaFields) {
99105
val baseType = resolvePropertyType(field, obj, loader)
100-
val nullable = !KotlinGenUtil.isRequiredField(field)
106+
val nullable = tphBase || !KotlinGenUtil.isRequiredField(field)
101107
val propType = if (nullable) baseType.copy(nullable = true) else baseType
102108
val propName = field.name
103109
val param = ParameterSpec.builder(propName, propType)
104110
.apply { if (nullable) defaultValue("null") }
105111
.build()
106112
ctorBuilder.addParameter(param)
107113
val propBuilder = PropertySpec.builder(propName, propType).initializer(propName)
108-
for (annotation in validationAnnotations(field)) {
109-
propBuilder.addAnnotation(annotation)
114+
if (!tphBase) {
115+
for (annotation in validationAnnotations(field)) {
116+
propBuilder.addAnnotation(annotation)
117+
}
110118
}
111119
typeBuilder.addProperty(propBuilder.build())
112120
}
113121

122+
// FR-017 TPH: a discriminator base's data class carries the UNION of subtype columns (each
123+
// NULLABLE, no validation) so one wire shape backs the polymorphic + per-subtype endpoints.
124+
// collectSubtypeFields is empty for a non-TPH entity (the loop is then a no-op).
125+
for (field in KotlinTphPlan.collectSubtypeFields(obj, loader)) {
126+
val propType = resolvePropertyType(field, obj, loader).copy(nullable = true)
127+
val propName = field.name
128+
ctorBuilder.addParameter(ParameterSpec.builder(propName, propType).defaultValue("null").build())
129+
typeBuilder.addProperty(PropertySpec.builder(propName, propType).initializer(propName).build())
130+
}
131+
114132
val fileSpec = FileSpec.builder(pkg, shortName)
115133
.addType(typeBuilder.primaryConstructor(ctorBuilder.build()).build())
116134
.build()

server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,21 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject
351351
val full = if (nullable) "$decorated.nullable()" else decorated
352352
append(" val ${field.name} = $full\n")
353353
}
354+
// FR-017 TPH: a discriminator base's single table also carries every subtype-only
355+
// column, emitted NULLABLE (a row of another subtype stores null there, even when the
356+
// field is @required on its subtype). collectSubtypeFields is empty for a non-TPH entity.
357+
for (field in KotlinTphPlan.collectSubtypeFields(entity, loader)) {
358+
if (field is ObjectField) continue
359+
val baseSpec = if (field is EnumField) {
360+
val enumName = KotlinTypeMapper.enumTypeName(field, entity)?.simpleName
361+
?: error("enumTypeName returned null for EnumField '${field.name}' on ${entity.name}")
362+
val colName = KotlinGenUtil.camelToSnake(field.name)
363+
"enumerationByName(\"$colName\", ${KotlinTypeMapper.ENUM_VARCHAR_LEN}, $enumName::class)"
364+
} else {
365+
KotlinTypeMapper.exposedColumnSpec(field)
366+
}
367+
append(" val ${field.name} = $baseSpec.nullable()\n")
368+
}
354369
for (oc in objectColumns) {
355370
append(" val ${oc.propertyName} = ${oc.columnExpr}\n")
356371
}

server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt

Lines changed: 206 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
8787
if (entity.subType != MetaObject.SUBTYPE_ENTITY) continue
8888
// Abstract entities are inheritance scaffolding — never emit a CRUD controller.
8989
if (KotlinGenUtil.isAbstractEntity(entity)) continue
90+
// FR-017 TPH: a subtype is folded into its base's single table + base controller (it
91+
// also carries no own source.rdb, so the guard below would skip it too).
92+
if (KotlinTphPlan.isTphSubtype(entity)) continue
9093
val sourceRdb = entity.children.filterIsInstance<RdbSource>().firstOrNull() ?: continue
9194
val kind = sourceRdb.effectiveKind
9295
// Only writable tables get a CRUD controller. View / materializedView are
@@ -116,7 +119,10 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
116119
}
117120
continue
118121
}
119-
emit(entity, outRoot, loader)
122+
// FR-017 TPH: a discriminator base emits ONE controller mounting the polymorphic
123+
// collection routes plus a full per-subtype CRUD set scoped by the discriminator.
124+
val tph = KotlinTphPlan.planFor(entity, loader)
125+
if (tph != null) emitTph(entity, tph, outRoot) else emit(entity, outRoot, loader)
120126
}
121127
}
122128

@@ -364,6 +370,205 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
364370
Files.writeString(outFile, source)
365371
}
366372

373+
/**
374+
* FR-017 TPH: emit the discriminator-base controller — ONE self-contained `@RestController`
375+
* at `/api/<base-plural>` mounting the polymorphic collection (`GET /`, `GET /{id}`) plus, per
376+
* subtype `<seg>` (the `@discriminatorValue` lowercased), a full CRUD set scoped by the
377+
* discriminator: `GET /<seg>` (subtype list), `GET /<seg>/{id}` (404 cross-subtype),
378+
* `POST /<seg>` (discriminator injected from the URL — never the body),
379+
* `PATCH|PUT /<seg>/{id}` (partial patch; discriminator immutable; 404 cross-subtype),
380+
* `DELETE /<seg>/{id}` (404 cross-subtype).
381+
*
382+
* Every endpoint trades the base `<Base>` data class — for a TPH base that class is the UNION
383+
* of subtype columns (folded nullable by [KotlinEntityGenerator]), so a polymorphic row surfaces
384+
* its subtype values and a per-subtype POST/PATCH body binds its own columns. The controller
385+
* embeds Exposed against the union `<Base>Table` ([KotlinExposedTableGenerator]); the
386+
* discriminator is the generated enum, so `type` is scoped/injected as `<Enum>.<Value>`.
387+
*/
388+
protected open fun emitTph(base: MetaObject, plan: KotlinTphPlan.Plan, outRoot: Path) {
389+
val (pkg, shortName) = PackageMapping.splitFqn(base.name)
390+
val table = shortName + "Table"
391+
val routeBase = "/api/" + pluralLowercase(shortName)
392+
val discField = base.metaFields.first { it.name == plan.discriminatorField }
393+
val discEnum = KotlinTypeMapper.enumTypeName(discField, base)?.simpleName
394+
?: error("TPH base ${base.name}: discriminator field '${plan.discriminatorField}' is not an enum")
395+
396+
// Union scalar fields (base own + subtype-only), in the data class / table order.
397+
val scalarFields = (base.metaFields.filterNot { it is ObjectField } +
398+
KotlinTphPlan.collectSubtypeFields(base, plan).filterNot { it is ObjectField })
399+
val sortFields = base.metaFields.filterNot { it is ObjectField }.map { it.name }
400+
val baseFieldNames = base.metaFields.map { it.name }.toSet()
401+
// Non-discriminator, non-PK columns the create/update handlers write from the body.
402+
val writableFields = scalarFields.map { it.name }
403+
.filter { it != plan.discriminatorField && it != "id" }
404+
405+
val src = buildString {
406+
if (pkg.isNotEmpty()) append("package $pkg\n\n")
407+
append("import org.jetbrains.exposed.sql.ResultRow\n")
408+
append("import org.jetbrains.exposed.sql.SortOrder\n")
409+
append("import org.jetbrains.exposed.sql.SqlExpressionBuilder\n")
410+
append("import org.jetbrains.exposed.sql.and\n")
411+
append("import org.jetbrains.exposed.sql.deleteWhere\n")
412+
append("import org.jetbrains.exposed.sql.insert\n")
413+
append("import org.jetbrains.exposed.sql.selectAll\n")
414+
append("import org.jetbrains.exposed.sql.update\n")
415+
append("import org.jetbrains.exposed.sql.transactions.transaction\n")
416+
append("import org.springframework.http.HttpStatus\n")
417+
append("import org.springframework.http.ResponseEntity\n")
418+
append("import org.springframework.web.bind.annotation.DeleteMapping\n")
419+
append("import org.springframework.web.bind.annotation.GetMapping\n")
420+
append("import org.springframework.web.bind.annotation.PathVariable\n")
421+
append("import org.springframework.web.bind.annotation.PostMapping\n")
422+
append("import org.springframework.web.bind.annotation.RequestBody\n")
423+
append("import org.springframework.web.bind.annotation.RequestMapping\n")
424+
append("import org.springframework.web.bind.annotation.RequestMethod\n")
425+
append("import org.springframework.web.bind.annotation.RequestParam\n")
426+
append("import org.springframework.web.bind.annotation.RestController\n\n")
427+
428+
// sort allowlist (base scalar columns — the polymorphic sort surface)
429+
append("/** GENERATED — sort allowlist for $shortName (cross-port API contract). */\n")
430+
append("private val ${shortName}SortAllowlist = setOf(\n")
431+
for (f in sortFields) append(" \"$f\",\n")
432+
append(")\n\n")
433+
append("private fun parse${shortName}Sort(raw: String): Pair<String, SortOrder>? {\n")
434+
append(" val parts = raw.split(\":\", limit = 2)\n")
435+
append(" val field = parts.getOrNull(0) ?: return null\n")
436+
append(" if (field !in ${shortName}SortAllowlist) return null\n")
437+
append(" val dir = when (parts.getOrNull(1)?.lowercase() ?: \"asc\") {\n")
438+
append(" \"asc\" -> SortOrder.ASC\n")
439+
append(" \"desc\" -> SortOrder.DESC\n")
440+
append(" else -> return null\n")
441+
append(" }\n")
442+
append(" return field to dir\n")
443+
append("}\n\n")
444+
445+
// rowTo<Base>: union ResultRow → data class
446+
append("/** GENERATED — map an Exposed ResultRow to the union $shortName data class. */\n")
447+
append("private fun rowTo${shortName}(row: ResultRow): $shortName = $shortName(\n")
448+
for (field in scalarFields) append(" ${field.name} = row[$table.${field.name}],\n")
449+
append(")\n\n")
450+
451+
append("/** GENERATED — TPH discriminator-base controller for $shortName (polymorphic + per-subtype CRUD). */\n")
452+
append("@RestController\n")
453+
append("@RequestMapping(\"$routeBase\")\n")
454+
append("class ${shortName}Controller {\n\n")
455+
456+
// polymorphic list
457+
append(" @GetMapping\n")
458+
append(" fun list(\n")
459+
append(" @RequestParam(required = false) limit: Int?,\n")
460+
append(" @RequestParam(required = false) offset: Int?,\n")
461+
append(" @RequestParam(required = false) sort: String?,\n")
462+
append(" @RequestParam(required = false, name = \"withCount\") withCount: Int?,\n")
463+
append(" ): ResponseEntity<Any> = transaction {\n")
464+
append(" var q = $table.selectAll()\n")
465+
append(" if (sort != null) {\n")
466+
append(" val parsed = parse${shortName}Sort(sort)\n")
467+
append(" ?: return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"invalid_sort\") as Any)\n")
468+
append(" val (field, dir) = parsed\n")
469+
append(" q = q.orderBy($table.columns.first { it.name == field } to dir)\n")
470+
append(" }\n")
471+
append(" val total: Long = if (withCount == 1) q.count() else -1L\n")
472+
append(" val rows = q.limit(limit ?: 50, (offset ?: 0).toLong()).map { rowTo${shortName}(it) }\n")
473+
append(" if (withCount == 1) ResponseEntity.ok(mapOf(\"rows\" to rows, \"total\" to total) as Any)\n")
474+
append(" else ResponseEntity.ok(rows as Any)\n")
475+
append(" }\n\n")
476+
477+
// polymorphic get
478+
append(" @GetMapping(\"/{id}\")\n")
479+
append(" fun get(@PathVariable id: Long): ResponseEntity<Any> = transaction {\n")
480+
append(" val row = $table.selectAll().where { $table.id eq id }.singleOrNull()\n")
481+
append(" ?: return@transaction ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf(\"error\" to \"not_found\") as Any)\n")
482+
append(" ResponseEntity.ok(rowTo${shortName}(row) as Any)\n")
483+
append(" }\n\n")
484+
485+
for (st in plan.subtypes) {
486+
val seg = st.routeSegment
487+
val disc = "$discEnum.${st.value}" // e.g. AuthType.Bridge
488+
val sfx = capitalizeFirst(st.value) // method-name suffix, e.g. Bridge
489+
490+
append(" // --- subtype ${st.value} (segment /$seg) ---\n")
491+
492+
// per-subtype list
493+
append(" @GetMapping(\"/$seg\")\n")
494+
append(" fun list$sfx(\n")
495+
append(" @RequestParam(required = false) limit: Int?,\n")
496+
append(" @RequestParam(required = false) offset: Int?,\n")
497+
append(" @RequestParam(required = false) sort: String?,\n")
498+
append(" ): ResponseEntity<Any> = transaction {\n")
499+
append(" var q = $table.selectAll().where { $table.${plan.discriminatorField} eq $disc }\n")
500+
append(" if (sort != null) {\n")
501+
append(" val parsed = parse${shortName}Sort(sort)\n")
502+
append(" ?: return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"invalid_sort\") as Any)\n")
503+
append(" val (field, dir) = parsed\n")
504+
append(" q = q.orderBy($table.columns.first { it.name == field } to dir)\n")
505+
append(" }\n")
506+
append(" val rows = q.limit(limit ?: 50, (offset ?: 0).toLong()).map { rowTo${shortName}(it) }\n")
507+
append(" ResponseEntity.ok(rows as Any)\n")
508+
append(" }\n\n")
509+
510+
// per-subtype get
511+
append(" @GetMapping(\"/$seg/{id}\")\n")
512+
append(" fun get$sfx(@PathVariable id: Long): ResponseEntity<Any> = transaction {\n")
513+
append(" val row = $table.selectAll().where { ($table.id eq id) and ($table.${plan.discriminatorField} eq $disc) }.singleOrNull()\n")
514+
append(" ?: return@transaction ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf(\"error\" to \"not_found\") as Any)\n")
515+
append(" ResponseEntity.ok(rowTo${shortName}(row) as Any)\n")
516+
append(" }\n\n")
517+
518+
// per-subtype create (discriminator injected from URL)
519+
append(" @PostMapping(\"/$seg\")\n")
520+
append(" fun create$sfx(@RequestBody dto: $shortName): ResponseEntity<$shortName> = transaction {\n")
521+
append(" val newId = $table.insert {\n")
522+
append(" it[${plan.discriminatorField}] = $disc\n")
523+
for (f in writableFields) {
524+
// A column is non-null in the union table iff it is a BASE @required field
525+
// (subtype-only columns are folded NULLABLE). For a non-null column the union
526+
// DTO field is still nullable, so force non-null on create (create bodies for
527+
// that subtype always supply it); nullable columns bind the nullable value as-is.
528+
val colNonNull = baseFieldNames.contains(f) && KotlinGenUtil.isRequiredField(scalarFields.first { it.name == f })
529+
if (colNonNull) append(" it[$f] = dto.$f!!\n")
530+
else append(" it[$f] = dto.$f\n")
531+
}
532+
append(" }[$table.id]\n")
533+
append(" val saved = $table.selectAll().where { $table.id eq newId }.single()\n")
534+
append(" ResponseEntity.status(HttpStatus.CREATED).body(rowTo${shortName}(saved))\n")
535+
append(" }\n\n")
536+
537+
// per-subtype update (partial patch; discriminator immutable; 404 cross-subtype)
538+
append(" @RequestMapping(value = [\"/$seg/{id}\"], method = [RequestMethod.PATCH, RequestMethod.PUT])\n")
539+
append(" fun update$sfx(@PathVariable id: Long, @RequestBody dto: $shortName): ResponseEntity<Any> = transaction {\n")
540+
append(" val updated = $table.update({ ($table.id eq id) and ($table.${plan.discriminatorField} eq $disc) }) {\n")
541+
for (f in writableFields) {
542+
append(" if (dto.$f != null) it[$f] = dto.$f\n")
543+
}
544+
append(" }\n")
545+
append(" if (updated == 0) ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf(\"error\" to \"not_found\") as Any)\n")
546+
append(" else {\n")
547+
append(" val row = $table.selectAll().where { ($table.id eq id) and ($table.${plan.discriminatorField} eq $disc) }.single()\n")
548+
append(" ResponseEntity.ok(rowTo${shortName}(row) as Any)\n")
549+
append(" }\n")
550+
append(" }\n\n")
551+
552+
// per-subtype delete (404 cross-subtype)
553+
append(" @DeleteMapping(\"/$seg/{id}\")\n")
554+
append(" fun delete$sfx(@PathVariable id: Long): ResponseEntity<Any> = transaction {\n")
555+
append(" val deleted = $table.deleteWhere { with(SqlExpressionBuilder) { ($table.id eq id) and ($table.${plan.discriminatorField} eq $disc) } }\n")
556+
append(" if (deleted == 0) ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf(\"error\" to \"not_found\") as Any)\n")
557+
append(" else ResponseEntity.noContent().build<Any>()\n")
558+
append(" }\n\n")
559+
}
560+
append("}\n")
561+
}
562+
563+
val outFile = outRoot.resolve(pkg.replace('.', '/')).resolve("${shortName}Controller.kt")
564+
outFile.parent?.let { Files.createDirectories(it) }
565+
Files.writeString(outFile, src)
566+
}
567+
568+
/** Capitalize the first char (method-name suffix from a discriminator value). */
569+
private fun capitalizeFirst(s: String): String =
570+
if (s.isEmpty()) s else s[0].uppercaseChar() + s.substring(1)
571+
367572
/**
368573
* Emit the three-piece FR-009 filter pipeline (data class + parse function +
369574
* Exposed dispatch function) into [out]. The dispatcher uses

0 commit comments

Comments
 (0)