diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt index 592933652..4f6e9debb 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt @@ -207,6 +207,13 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase(TextColumnType())`; import both only when such a column exists + // so entities without a filterable enum stay byte-identical. + if (scalarFields.any { it.subType == EnumField.SUBTYPE_ENUM }) { + append("import org.jetbrains.exposed.sql.TextColumnType\n") + append("import org.jetbrains.exposed.sql.castTo\n") + } append("import java.time.LocalDate\n") append("import java.time.LocalDateTime\n") append("import java.time.LocalTime\n") @@ -475,6 +482,13 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase(TextColumnType())` — same conditional imports + // as the vanilla controller. (The discriminator enum is excluded from filterSpecs.) + if (filterSpecs.any { it.subType == EnumField.SUBTYPE_ENUM }) { + append("import org.jetbrains.exposed.sql.TextColumnType\n") + append("import org.jetbrains.exposed.sql.castTo\n") + } append("import java.time.LocalDate\n") append("import java.time.LocalDateTime\n") append("import java.time.LocalTime\n") @@ -843,6 +857,7 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase continue\n") @@ -867,28 +882,37 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase.eq(t: T)` / `.neq` / `.inList(Iterable)` reject a // bare `Any?` — cast each predicate value to the column's element Kotlin type so // the comparison resolves. (Surfaced by the SP-F generated-controller HTTP lane: // `(p.value as Any?)` was an `Unresolved reference: eq` compile error.) The // coercer already produced a value of exactly this type; the cast is total. + // + // FR-009 enum columns (#179): an Exposed enum column is typed `Column`, so a + // String-valued predicate would not resolve `eq`/`like`. Filter it by its STORED STRING + // via `CAST(col AS text)` (`.castTo(TextColumnType())`) — matching every other + // port's string-band enum-filter semantics — so eq/ne/in/like all compare Strings. + // (`isNull` still checks the raw column's nullability.) + val col = if (isEnum) "${tableObjectName}.${fieldName}.castTo(TextColumnType())" + else "${tableObjectName}.${fieldName}" out.append(" \"$fieldName\" -> when (p.op) {\n") - out.append(" \"eq\" -> ${tableObjectName}.${fieldName} eq (p.value as $elementType)\n") + out.append(" \"eq\" -> $col eq (p.value as $elementType)\n") if (!isBoolean) { - out.append(" \"ne\" -> ${tableObjectName}.${fieldName} neq (p.value as $elementType)\n") + out.append(" \"ne\" -> $col neq (p.value as $elementType)\n") } if (!isStringLike && !isBoolean) { - out.append(" \"gt\" -> ${tableObjectName}.${fieldName} greater (p.value as $elementType)\n") - out.append(" \"gte\" -> ${tableObjectName}.${fieldName} greaterEq (p.value as $elementType)\n") - out.append(" \"lt\" -> ${tableObjectName}.${fieldName} less (p.value as $elementType)\n") - out.append(" \"lte\" -> ${tableObjectName}.${fieldName} lessEq (p.value as $elementType)\n") + out.append(" \"gt\" -> $col greater (p.value as $elementType)\n") + out.append(" \"gte\" -> $col greaterEq (p.value as $elementType)\n") + out.append(" \"lt\" -> $col less (p.value as $elementType)\n") + out.append(" \"lte\" -> $col lessEq (p.value as $elementType)\n") } if (!isBoolean) { - out.append(" \"in\" -> ${tableObjectName}.${fieldName} inList (p.value as List<$elementType>)\n") + out.append(" \"in\" -> $col inList (p.value as List<$elementType>)\n") } if (isStringLike) { - out.append(" \"like\" -> ${tableObjectName}.${fieldName} like (p.value as String)\n") + out.append(" \"like\" -> $col like (p.value as String)\n") } out.append(" \"isNull\" -> if (p.value as Boolean) ${tableObjectName}.${fieldName}.isNull() else ${tableObjectName}.${fieldName}.isNotNull()\n") out.append(" else -> throw IllegalStateException(\"unsupported op for $fieldName: \" + p.op)\n") @@ -981,11 +1005,14 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBaseTable`'s column for [field] holds — * the cast target for the eq/ne/in predicate value. For scalar fields this is the same * simple type name the DTO property uses ([KotlinTypeMapper.kotlinTypeName]); for an - * [EnumField] it is the generated typed enum class (the column is - * `enumerationByName(..., ::class)`), keeping the cast aligned with the column type. + * [EnumField] it is `String`, since the column is filtered by its stored string + * (a CAST-to-text — see [emitPerFieldDispatchArm]). */ private fun columnElementType(field: com.metaobjects.field.MetaField<*>): String { - KotlinTypeMapper.enumTypeName(field, null)?.let { return it.simpleName } + // FR-009 (#179): an enum column is filtered by its stored string (compared against a + // `CAST(col AS text)` in the WHERE arm — see emitPerFieldDispatchArm), so the predicate + // VALUE type is String. The coercer already produces String / List for enums. + if (field is EnumField) return "String" return KotlinTypeMapper.kotlinTypeName(field).let { tn -> (tn as? com.squareup.kotlinpoet.ClassName)?.simpleName ?: tn.toString() } diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/generated/EnumFilterControllerRunTest.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/generated/EnumFilterControllerRunTest.kt new file mode 100644 index 000000000..14f2fcb25 --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/generated/EnumFilterControllerRunTest.kt @@ -0,0 +1,124 @@ +package com.metaobjects.integration.kotlin.api.generated + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import com.metaobjects.generator.kotlin.KotlinEntityGenerator +import com.metaobjects.generator.kotlin.KotlinExposedTableGenerator +import com.metaobjects.generator.kotlin.KotlinFilterAllowlistGenerator +import com.metaobjects.generator.kotlin.KotlinSpringControllerGenerator +import com.metaobjects.metadata.ktx.loadString +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.transactions.transaction +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request +import java.net.URI +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import kotlin.io.path.isRegularFile +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * #179 regression: a generated Kotlin Spring controller must FILTER on a `@filterable field.enum` + * column — compile AND execute. The Exposed column is typed `Column`, so the fix compares it + * by its stored string via `CAST(col AS text)` (the FR-009 string band: eq/ne/in/like/isNull). Before + * the fix the controller emitted `col eq (p.value as )` — an unresolved type + a + * String→enum ClassCastException. + * + * This drives the GENERATED controller over MockMvc against H2 (PostgreSQL mode) — the same + * generate→compile→load→seed→query pattern as [GeneratedAuthorControllerHarness] — so it proves the + * emitted `castTo(...)` SQL both compiles and returns the right rows. + */ +@OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class) +class EnumFilterControllerRunTest { + + private val mapper: ObjectMapper = ObjectMapper().registerKotlinModule() + + private val fixture = """{ + "metadata.root": { "package": "acme::widget", "children": [ + { "object.entity": { "name": "Widget", "children": [ + { "source.rdb": { "@table": "widgets" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name", "@maxLength": 40, "@filterable": true } }, + { "field.enum": { "name": "color", "@values": ["RED", "GREEN", "BLUE"], "@filterable": true } }, + { "identity.primary": { "@fields": "id", "@generation": "increment" } } + ] } } + ] } + }""".trimIndent() + + @Test + fun `generated controller filters a field-enum column (eq, like, in) over HTTP`() { + val outDir = Files.createTempDirectory("enum-filter-") + try { + val loader = loadString("enum-filter", fixture) + for (g in listOf( + KotlinEntityGenerator(), + KotlinExposedTableGenerator(), + KotlinFilterAllowlistGenerator(), + KotlinSpringControllerGenerator(), + )) { + g.setArgs(mapOf("outputDir" to outDir.toString())) + g.execute(loader) + } + + val sources = Files.walk(outDir).filter { it.isRegularFile() } + .map { SourceFile.kotlin(outDir.relativize(it).toString().replace('/', '_'), it.readText()) } + .toList() + val result = KotlinCompilation().apply { + this.sources = sources + inheritClassPath = true + messageOutputStream = System.out + }.compile() + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, + "generated controller with a @filterable enum failed to compile:\n${result.messages}") + + val cl = result.classLoader + val controllerClass = cl.loadClass("acme.widget.WidgetController") + val widgetTable = cl.loadClass("acme.widget.WidgetTable") + .getDeclaredField("INSTANCE").get(null) as Table + + val db = Database.connect( + "jdbc:h2:mem:enum_filter;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", driver = "org.h2.Driver") + transaction(db) { SchemaUtils.create(widgetTable) } + + val controller = controllerClass.getDeclaredConstructor().newInstance() + val converter = MappingJackson2HttpMessageConverter().apply { objectMapper = mapper } + val mvc = MockMvcBuilders.standaloneSetup(controller).setMessageConverters(converter).build() + + fun exchange(method: String, path: String, body: Any?): Pair { + val builder = request(HttpMethod.valueOf(method), URI.create(path)) + if (body != null) builder.contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(body)) + val res = mvc.perform(builder).andReturn().response + return res.status to res.getContentAsString(StandardCharsets.UTF_8) + } + fun colorsOf(path: String): List { + val (status, body) = exchange("GET", path, null) + assertEquals(200, status, "GET $path -> $body") + @Suppress("UNCHECKED_CAST") + val rows = mapper.readValue(body, List::class.java) as List> + return rows.map { it["color"] as String }.sorted() + } + + for (color in listOf("RED", "GREEN", "BLUE")) { + val (status, body) = exchange("POST", "/api/widgets", mapOf("name" to "w-$color", "color" to color)) + assertEquals(201, status, "seed POST $color -> $body") + } + + assertEquals(listOf("GREEN"), colorsOf("/api/widgets?filter[color][eq]=GREEN")) + assertEquals(listOf("BLUE", "RED"), colorsOf("/api/widgets?filter[color][ne]=GREEN")) + assertEquals(listOf("BLUE", "RED"), colorsOf("/api/widgets?filter[color][in]=RED,BLUE")) + assertEquals(listOf("GREEN"), colorsOf("/api/widgets?filter[color][like]=%25EE%25")) // %EE% url-encoded + assertEquals(listOf("BLUE", "GREEN", "RED"), colorsOf("/api/widgets")) + } finally { + outDir.toFile().deleteRecursively() + } + } +} 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 index 46139dc1f..da92716dc 100644 --- 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 @@ -28,8 +28,9 @@ import kotlin.test.assertFalse * 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.) + * would fail. `CopayAuth` also carries a non-discriminator `@filterable field.enum` (`tier`) that + * folds into the union, so the TPH controller emits `castTo(TextColumnType())` for it — this + * gate also covers the #179 enum-filter import block on the TPH controller path. */ @OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class) class TphFullSuiteCompilesTest { @@ -48,7 +49,8 @@ class TphFullSuiteCompilesTest { { "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 } } + { "field.decimal": { "name": "copayAmount", "@precision": 10, "@scale": 2, "@filterable": true } }, + { "field.enum": { "name": "tier", "@values": ["Standard", "Premium"], "@filterable": true } } ] } }, { "object.entity": { "name": "PriorAuthAuth", "extends": "Auth", "@discriminatorValue": "PriorAuth", "children": [ { "field.string": { "name": "priorAuthNumber", "@maxLength": 80, "@filterable": true } }