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 @@ -207,6 +207,13 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
if (scalarFields.any { it.elementType == "Instant" }) {
append("import java.time.Instant\n")
}
// FR-009 (#179): a filterable enum column is compared as its stored string via
// `col.castTo<String>(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")
Expand Down Expand Up @@ -475,6 +482,13 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
if (filterSpecs.any { it.elementType == "Instant" }) {
append("import java.time.Instant\n")
}
// FR-009 (#179): a non-discriminator filterable enum in the union is compared as its
// stored string via `col.castTo<String>(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")
Expand Down Expand Up @@ -843,6 +857,7 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
elementType = elementType,
isStringLike = isStringLike,
isBoolean = isBoolean,
isEnum = (subType == EnumField.SUBTYPE_ENUM),
)
}
out.append(" else -> continue\n")
Expand All @@ -867,28 +882,37 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
elementType: String,
isStringLike: Boolean,
isBoolean: Boolean,
isEnum: Boolean,
) {
// Exposed's typed `Column<T>.eq(t: T)` / `.neq` / `.inList(Iterable<T>)` 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<Enum>`, so a
// String-valued predicate would not resolve `eq`/`like`. Filter it by its STORED STRING
// via `CAST(col AS text)` (`.castTo<String>(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<String>(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")
Expand Down Expand Up @@ -981,11 +1005,14 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
* The element Kotlin type the generated `<Entity>Table`'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(..., <Enum>::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<String> for enums.
if (field is EnumField) return "String"
return KotlinTypeMapper.kotlinTypeName(field).let { tn ->
(tn as? com.squareup.kotlinpoet.ClassName)?.simpleName ?: tn.toString()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Enum>`, 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 <BareEnum>)` — 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<String>(...)` 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<Int, String> {
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<String> {
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<Map<String, Any?>>
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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>(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 {
Expand All @@ -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 } }
Expand Down
Loading