Skip to content

Commit 71ccfcc

Browse files
dmealingclaude
andcommitted
fix(integration-tests-kotlin): the AllTypes oracle was missing intEnumVal
`persistence-conformance` has no skip valve — every port runs every fixture — so adding `intEnumVal` to the shared corpus obliged all five ports to carry it. Four did: Java and Python drive their columns from metadata, and C# regenerates its AppDbContext. Kotlin's oracle is a HAND-WRITTEN Exposed table, and nobody added the column, so QueryScenarioConformanceTest[20] and [27] died with IllegalStateException: No column 'intEnumVal' on table 'all_types' The lane that catches this runs on release tags and manual dispatch only, so the branch would have merged with a standing red nothing on a PR would have shown. Fixed by supplying the missing piece, not by narrowing the corpus. The new IntBackedEnumColumnType is the harness analogue of the customEnumeration the Kotlin generator emits: a Column<String> carrying the member SYMBOL over an INTEGER column, translating through @intValueMap in both directions, and THROWING on a stored int that maps to no member — the same ruling every port now implements. It is String-typed rather than enum-typed for the reason already documented for the jsonb columns beside it: this generic runner moves plain YAML scalars and has no generated enum class to bind. Both coercion paths need an explicit guard ahead of the sqlType dispatch, whose `int` branch would otherwise try "PUBLISHED".toInt() — the column is physically INTEGER while its authoring value is a symbol, which is the one shape that dispatch cannot infer. QueryScenarioConformanceTest: 27/27. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0508e45 commit 71ccfcc

3 files changed

Lines changed: 76 additions & 0 deletions

File tree

server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import org.jetbrains.exposed.sql.update
3333
import org.jetbrains.exposed.sql.statements.InsertStatement
3434
import org.jetbrains.exposed.sql.transactions.transaction
3535
import com.metaobjects.integration.kotlin.tables.AllTypesTable
36+
import com.metaobjects.integration.kotlin.tables.IntBackedEnumColumnType
3637
import java.math.BigDecimal
3738
import java.net.URI
3839
import java.sql.DriverManager
@@ -418,6 +419,9 @@ object QueryScenarioRunner {
418419
*/
419420
private fun coerceForWrite(raw: Any?, col: Column<*>): Any? {
420421
if (raw == null) return null
422+
// Int-backed field.enum: bind the member SYMBOL and let the column's codec encode it to
423+
// the stored int (see the same guard in `coerce`).
424+
if (col.columnType is IntBackedEnumColumnType) return raw.toString()
421425
val type = col.columnType.sqlType().lowercase()
422426
return when {
423427
type == "uuid" -> if (raw is UUID) raw else UUID.fromString(raw.toString().lowercase())
@@ -607,6 +611,10 @@ object QueryScenarioRunner {
607611
*/
608612
private fun coerce(raw: Any?, col: Column<*>): Any? {
609613
if (raw == null) return null
614+
// An int-backed field.enum is a Column<String> over INTEGER: the authoring value is the
615+
// member SYMBOL and the column's own codec maps it to the stored int. Checked BEFORE the
616+
// sqlType dispatch, whose `int` branch would try "PUBLISHED".toInt().
617+
if (col.columnType is IntBackedEnumColumnType) return raw.toString()
610618
val type = col.columnType.sqlType().lowercase()
611619
return when {
612620
// uuid columns compare against java.util.UUID — the YAML supplies a string

server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AllTypesTable.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import org.jetbrains.exposed.sql.json.jsonb
2929
* - default `field.timestamp` (instant/TZ-aware, ADR-0036 Wave 2) → `instantWithTimeZone("tsTzVal")` (TIMESTAMPTZ)
3030
* - `field.currency` (@currency USD) → `long("moneyVal")` (BIGINT minor units)
3131
* - `field.enum` (@values LOW/MEDIUM/HIGH) → `varchar("enumVal", 64)` (text + CHECK in DDL)
32+
* - `field.enum` + `@intValueMap` (int-backed) → `intBackedEnum("intEnumVal", …)` (INTEGER + int CHECK)
3233
* - `field.uuid` (non-key, @required) → `uuid("uuidVal")` (Postgres native uuid)
3334
* - `field.object` (@objectRef Settings, @storage jsonb) → `jsonb("settings", …)` (real Postgres JSONB)
3435
* - `field.object` (@objectRef Label, @storage jsonb, isArray) → `jsonb("labels", …)` (JSONB array)
@@ -64,6 +65,13 @@ object AllTypesTable : Table("all_types") {
6465
val tsTzVal = instantWithTimeZone("tsTzVal")
6566
val moneyVal = long("moneyVal")
6667
val enumVal = varchar("enumVal", 64)
68+
// INT-BACKED `field.enum` (@intValueMap): physically INTEGER (+ the canonical DDL's
69+
// CHECK (… IN (0, 5, 9))), carrying the member SYMBOL in and out — storage changes, the
70+
// wire format does not. Nullable to match the canonical DDL (`"intEnumVal" INTEGER`, no
71+
// NOT NULL). See [IntBackedEnumColumnType] for why this is a Column<String> here while the
72+
// GENERATED form binds a real enum through customEnumeration.
73+
val intEnumVal =
74+
intBackedEnum("intEnumVal", mapOf("DRAFT" to 0, "PUBLISHED" to 5, "ARCHIVED" to 9)).nullable()
6775
val uuidVal = uuid("uuidVal")
6876
// `field.uri` → plain `text` column carrying the verbatim URI string (Postgres has no uri
6977
// type). See [MetaUriColumnType] — round-trips the URI unchanged.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.metaobjects.integration.kotlin.tables
2+
3+
import org.jetbrains.exposed.sql.Column
4+
import org.jetbrains.exposed.sql.ColumnType
5+
import org.jetbrains.exposed.sql.Table
6+
7+
/**
8+
* Hand-written reference Exposed column type for an INT-BACKED `field.enum` (`@intValueMap`) —
9+
* the test-harness analogue of the `customEnumeration(...)` call
10+
* [com.metaobjects.generator.kotlin.KotlinExposedTableGenerator] emits for such a field.
11+
*
12+
* The column is physically `INTEGER` (plus the canonical DDL's `CHECK (… IN (0, 5, 9))`) while
13+
* the value carried in and out is the member SYMBOL — which is the whole contract of int-backing:
14+
* storage changes, the wire format does not.
15+
*
16+
* It is a `Column<String>` rather than a `Column<SomeEnum>` because this cross-port oracle has no
17+
* generated enum class to bind — the generic [com.metaobjects.integration.kotlin.QueryScenarioRunner]
18+
* moves plain YAML scalars. The GENERATED form binds a real Kotlin enum through
19+
* `customEnumeration`, and is covered separately by the codegen tests; the divergence is the same
20+
* one already documented for the jsonb columns in [AllTypesTable].
21+
*
22+
* **A stored int that maps to no member THROWS** rather than surfacing the raw value or nulling
23+
* it, matching the generated `customEnumeration`'s `else ->` branch and every sibling port: the
24+
* row holds data the model says is impossible, and both alternatives hand the caller something
25+
* untrue. The write side is exhaustive by construction upstream (`@intValueMap`'s keys are
26+
* loader-validated to equal `@values`), so an unmapped SYMBOL here can only be a harness bug —
27+
* it fails loudly for the same reason.
28+
*/
29+
internal class IntBackedEnumColumnType(
30+
private val intByMember: Map<String, Int>,
31+
) : ColumnType<String>() {
32+
33+
private val memberByInt: Map<Int, String> =
34+
intByMember.entries.associate { (member, stored) -> stored to member }
35+
36+
override fun sqlType(): String = "INTEGER"
37+
38+
override fun valueFromDB(value: Any): String {
39+
val stored = (value as? Number)?.toInt()
40+
?: error("int-backed field.enum column read a non-numeric value: $value")
41+
return memberByInt[stored]
42+
?: error(
43+
"field.enum read stored value $stored with no member in @intValueMap " +
44+
"(declared: $intByMember) — the database holds a value the model does not describe."
45+
)
46+
}
47+
48+
override fun notNullValueToDB(value: String): Any =
49+
intByMember[value]
50+
?: error("field.enum has no @intValueMap entry for member '$value' (declared: $intByMember).")
51+
52+
override fun nonNullValueToString(value: String): String = notNullValueToDB(value).toString()
53+
}
54+
55+
/**
56+
* Column builder for an int-backed `field.enum`: a `Column<String>` carrying the member symbol
57+
* over an `INTEGER` column, translating through [intByMember] (`@intValueMap`) in both directions.
58+
*/
59+
internal fun Table.intBackedEnum(name: String, intByMember: Map<String, Int>): Column<String> =
60+
registerColumn(name, IntBackedEnumColumnType(intByMember))

0 commit comments

Comments
 (0)