From 808931c493a034fc7fe7e603f9a5032d88e15ed2 Mon Sep 17 00:00:00 2001 From: cies Date: Thu, 29 Jan 2026 12:18:47 +0100 Subject: [PATCH] Preliminary support for enum deserialization on PG Signed-off-by: cies --- .../kotlin/io/exoquery/controller/Encoding.kt | 4 + .../PreparedStatementElementEncoder.kt | 9 +- .../io/exoquery/controller/RowDecoder.kt | 24 +- .../sqlite/SqliteWrappedEncoding.kt | 3 + .../exoquery/controller/jdbc/JdbcEncoders.kt | 2 + .../controller/r2dbc/R2dbcEncoders.kt | 6 + .../sql/postgres/EnumSerializationSpec.kt | 287 ++++++++++++++++++ .../r2dbc/postgres/EnumSerializationSpec.kt | 281 +++++++++++++++++ 8 files changed, 613 insertions(+), 3 deletions(-) create mode 100644 terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/postgres/EnumSerializationSpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/postgres/EnumSerializationSpec.kt diff --git a/controller-core/src/commonMain/kotlin/io/exoquery/controller/Encoding.kt b/controller-core/src/commonMain/kotlin/io/exoquery/controller/Encoding.kt index 54cd7e4..ff27172 100644 --- a/controller-core/src/commonMain/kotlin/io/exoquery/controller/Encoding.kt +++ b/controller-core/src/commonMain/kotlin/io/exoquery/controller/Encoding.kt @@ -54,6 +54,7 @@ interface ApiEncoders { val ShortEncoder: SqlEncoder val StringEncoder: SqlEncoder val ByteArrayEncoder: SqlEncoder + val EnumEncoder: SqlEncoder } // Used by the RowDecoder interface ApiDecoders { @@ -67,6 +68,7 @@ interface ApiDecoders { val ShortDecoder: SqlDecoder val StringDecoder: SqlDecoder val ByteArrayDecoder: SqlDecoder + val EnumDecoder: SqlDecoder abstract fun isNull(index: Int, row: Row): Boolean abstract fun preview(index: Int, row: Row): String? @@ -106,6 +108,7 @@ interface SqlEncoding: ShortEncoder, StringEncoder, ByteArrayEncoder, + EnumEncoder, LocalDateEncoder, LocalDateTimeEncoder, LocalTimeEncoder, @@ -124,6 +127,7 @@ interface SqlEncoding: ShortDecoder, StringDecoder, ByteArrayDecoder, + EnumDecoder, LocalDateDecoder, LocalDateTimeDecoder, LocalTimeDecoder, diff --git a/controller-core/src/commonMain/kotlin/io/exoquery/controller/PreparedStatementElementEncoder.kt b/controller-core/src/commonMain/kotlin/io/exoquery/controller/PreparedStatementElementEncoder.kt index 35a7ab6..ff4539a 100644 --- a/controller-core/src/commonMain/kotlin/io/exoquery/controller/PreparedStatementElementEncoder.kt +++ b/controller-core/src/commonMain/kotlin/io/exoquery/controller/PreparedStatementElementEncoder.kt @@ -53,8 +53,11 @@ class PreparedStatementElementEncoder( override fun encodeShort(value: Short) = api.ShortEncoder.encode(ctx, value, index) override fun encodeString(value: String) = api.StringEncoder.encode(ctx, value, index) - override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) = - TODO("Enum encoding not yet supported") + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") // Or they'd both be called `index` + @OptIn(ExperimentalSerializationApi::class) + override fun encodeEnum(enumDescriptor: SerialDescriptor, i: Int) = + api.EnumEncoder.encode(ctx, enumDescriptor.getElementName(i), index) + /** * Since the assumption of this encoder is that it is created per every single value that needs to be inserted, we pass a serializer for that particular value @@ -142,6 +145,8 @@ class PreparedStatementElementEncoder( // if it is a primitive type then use the encoder defined in the serialization. Note that // if it is a wrappedType (e.g. NewTypeInt(value: Int) then this serializer will be the wrapped one serializer.serialize(this, value) + } else if (desc.kind == SerialKind.ENUM) { + serializer.serialize(this, value) } else { throw IllegalArgumentException("Unsupported serial-kind: ${desc.kind} for the value ${value} with the descriptor ${desc} could not be decoded as a Array, Contextual, or Primitive value") diff --git a/controller-core/src/commonMain/kotlin/io/exoquery/controller/RowDecoder.kt b/controller-core/src/commonMain/kotlin/io/exoquery/controller/RowDecoder.kt index ed3fee2..3f6ce6e 100644 --- a/controller-core/src/commonMain/kotlin/io/exoquery/controller/RowDecoder.kt +++ b/controller-core/src/commonMain/kotlin/io/exoquery/controller/RowDecoder.kt @@ -390,6 +390,13 @@ class RowDecoder private constructor( out } + desc.kind == SerialKind.ENUM -> { + validNullOrElse(desc, index) { + val out = alternateDeserializer.deserialize(this) + out + } + } + else -> throw IllegalArgumentException("Unsupported kind: `${desc.kind}` at (${ctx.startingIndex.description}) index: ${index} (info:${ctx.columnInfos?.get(index)})") } @@ -424,7 +431,22 @@ class RowDecoder private constructor( } override fun decodeEnum(enumDescriptor: SerialDescriptor): Int { - TODO("Not yet implemented") + val stringValue = api.StringDecoder.decode(ctx, rowIndex) + nextRowIndex(enumDescriptor, rowIndex) + + val enumIndex = (0 until enumDescriptor.elementsCount).firstOrNull { i -> + // This checks for any @SerialName annotation specified name on the enum value, otherwise matches the value's constant name. + enumDescriptor.getElementName(i) == stringValue + } + + if (enumIndex == null) { + val enumValuesAsString = enumDescriptor.elementNames.joinToString(", ") + throw IllegalArgumentException( + "Unknown enum value '$stringValue' for ${enumDescriptor.serialName}. Valid values are: $enumValuesAsString" + ) + } + + return enumIndex } diff --git a/controller-core/src/commonMain/kotlin/io/exoquery/controller/sqlite/SqliteWrappedEncoding.kt b/controller-core/src/commonMain/kotlin/io/exoquery/controller/sqlite/SqliteWrappedEncoding.kt index 33a96ef..81ce20c 100644 --- a/controller-core/src/commonMain/kotlin/io/exoquery/controller/sqlite/SqliteWrappedEncoding.kt +++ b/controller-core/src/commonMain/kotlin/io/exoquery/controller/sqlite/SqliteWrappedEncoding.kt @@ -157,6 +157,9 @@ object SqliteBasicEncoding: BasicEncoding = SqliteEncoderAny(SqliteFieldType.TYPE_BLOB, ByteArray::class) { ctx, value, index -> ctx.stmt.bindBytes(index, value) } override val ByteArrayDecoder: SqliteDecoderAny = SqliteDecoderAny(ByteArray::class) { ctx, index -> ctx.row.getBytes(index) } + override val EnumEncoder: SqliteEncoderAny = SqliteEncoderAny(SqliteFieldType.TYPE_TEXT, String::class) { ctx, value, index -> ctx.stmt.bindString(index, value) } + override val EnumDecoder: SqliteDecoderAny = SqliteDecoderAny(String::class) { ctx, index -> ctx.row.getString(index) } + override fun preview(index: Int, row: SqliteCursorWrapper): String? = row.getString(index) override fun isNull(index: Int, row: SqliteCursorWrapper): Boolean = row.isNull(index) } diff --git a/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcEncoders.kt b/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcEncoders.kt index 486bac6..4f301c8 100644 --- a/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcEncoders.kt +++ b/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcEncoders.kt @@ -44,6 +44,7 @@ open class JdbcBasicEncoding: override val ShortEncoder: JdbcEncoderAny = JdbcEncoderAny(Types.SMALLINT, Short::class) { ctx, v, i -> ctx.stmt.setShort(i, v) } override val StringEncoder: JdbcEncoderAny = JdbcEncoderAny(Types.VARCHAR, String::class) { ctx, v, i -> ctx.stmt.setString(i, v) } override val ByteArrayEncoder: JdbcEncoderAny = JdbcEncoderAny(Types.VARBINARY, ByteArray::class) { ctx, v, i -> ctx.stmt.setBytes(i, v) } + override val EnumEncoder: JdbcEncoderAny = JdbcEncoderAny(Types.OTHER, String::class) { ctx, v, i -> ctx.stmt.setObject(i, v) } override fun preview(index: Int, row: ResultSet): String? = row.getObject(index)?.let { it.toString() } @@ -62,6 +63,7 @@ open class JdbcBasicEncoding: override val ShortDecoder: JdbcDecoderAny = JdbcDecoderAny(Short::class) { ctx, i -> ctx.row.getShort(i) } override val StringDecoder: JdbcDecoderAny = JdbcDecoderAny(String::class) { ctx, i -> ctx.row.getString(i) } override val ByteArrayDecoder: JdbcDecoderAny = JdbcDecoderAny(ByteArray::class) { ctx, i -> ctx.row.getBytes(i) } + override val EnumDecoder: JdbcDecoderAny = JdbcDecoderAny(String::class) { ctx, i -> ctx.row.getString(i) } } object JsonObjectEncoding { diff --git a/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcEncoders.kt b/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcEncoders.kt index bcf2a2f..3b131a6 100644 --- a/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcEncoders.kt +++ b/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcEncoders.kt @@ -119,6 +119,8 @@ object R2dbcBasicEncodingOracle: R2dbcBasicEncodingBase() { R2dbcDecoderAny(String::class) { ctx, i -> ctx.row.get(i, String::class.java) ?: "" } override val ByteArrayDecoder: R2dbcDecoder = R2dbcDecoderAny(ByteArray::class) { ctx, i -> ctx.row.get(i, ByteArray::class.java) ?: byteArrayOf() } + override val EnumDecoder: R2dbcDecoder = + R2dbcDecoderAny(String::class) { ctx, i -> ctx.row.get(i, String::class.java) ?: "" } // More oracle crazy behavior that requires encoding booleans as ints //override val BooleanEncoder: R2dbcEncoderAny = @@ -148,6 +150,8 @@ abstract class R2dbcBasicEncodingBase: BasicEncoding R2dbcEncoderAny(NA, String::class) { ctx, v, i -> ctx.stmt.bind(i, v) } override val ByteArrayEncoder: R2dbcEncoderAny = R2dbcEncoderAny(NA, ByteArray::class) { ctx, v, i -> ctx.stmt.bind(i, v) } + override val EnumEncoder: R2dbcEncoderAny = + R2dbcEncoderAny(NA, String::class) { ctx, v, i -> ctx.stmt.bind(i, v) } override fun preview(index: Int, row: Row): String? = row.get(index)?.let { it.toString() } @@ -174,6 +178,8 @@ abstract class R2dbcBasicEncodingBase: BasicEncoding R2dbcDecoderAny(String::class) { ctx, i -> ctx.row.get(i, String::class.java) } override val ByteArrayDecoder: R2dbcDecoder = R2dbcDecoderAny(ByteArray::class) { ctx, i -> ctx.row.get(i, ByteArray::class.java) } + override val EnumDecoder: R2dbcDecoder = + R2dbcDecoderAny(String::class) { ctx, i -> ctx.row.get(i, String::class.java) } } private fun kotlinx.datetime.TimeZone.toJava(): TimeZone = TimeZone.getTimeZone(this.toJavaZoneId()) diff --git a/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/postgres/EnumSerializationSpec.kt b/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/postgres/EnumSerializationSpec.kt new file mode 100644 index 0000000..ef8120e --- /dev/null +++ b/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/postgres/EnumSerializationSpec.kt @@ -0,0 +1,287 @@ +package io.exoquery.sql.postgres + +import io.exoquery.sql.TestDatabases +import io.exoquery.controller.jdbc.JdbcControllers +import io.exoquery.controller.runOn +import io.exoquery.sql.Sql +import io.exoquery.sql.run +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Contextual +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerialName +import kotlinx.serialization.serializer + + +// Define test enums +@Serializable +enum class ProfileType { + Admin, Retailer, Supplier +} + +@Serializable +enum class Status { + @SerialName("active") + Active, + + @SerialName("inactive") + Inactive, + + @SerialName("pending") + Pending +} + +@Serializable +data class OrderStatus(val status: Status) + +@Serializable +data class UserName(val userName: String) + +@Serializable +data class NewUser(val userName: String, val profileType: ProfileType) + + +class EnumSerializationSpec : FreeSpec({ + + val ds = TestDatabases.postgres + val ctx by lazy { JdbcControllers.Postgres(ds) } + + beforeSpec { + ds.run( + """ + -- Drop existing types and tables if they exist + DROP TABLE IF EXISTS user_notification_preferences; + DROP TABLE IF EXISTS order_status_test; + DROP TYPE IF EXISTS profile_type_enum; + DROP TYPE IF EXISTS status_enum; + + -- Create enum types + CREATE TYPE profile_type_enum AS ENUM ('Admin', 'Retailer', 'Supplier'); + CREATE TYPE status_enum AS ENUM ('active', 'inactive', 'pending'); + + -- Create tables + CREATE TABLE user_notification_preferences ( + id SERIAL PRIMARY KEY, + user_name TEXT NOT NULL, + profile_type profile_type_enum NOT NULL + ); + + CREATE TABLE order_status_test ( + id SERIAL PRIMARY KEY, + order_name TEXT NOT NULL, + status status_enum NOT NULL, + optional_status status_enum + ); + + -- Insert test data + INSERT INTO user_notification_preferences (user_name, profile_type) + VALUES ('Alice', 'Admin'), ('Bob', 'Retailer'), ('Charlie', 'Supplier'); + + INSERT INTO order_status_test (order_name, status, optional_status) + VALUES + ('Order1', 'active', 'pending'), + ('Order2', 'inactive', NULL), + ('Order3', 'pending', 'active'); + """ + ) + } + + afterSpec { + ds.run( + """ + DROP TABLE IF EXISTS user_notification_preferences; + DROP TABLE IF EXISTS order_status_test; + DROP TYPE IF EXISTS profile_type_enum; + DROP TYPE IF EXISTS status_enum; + """ + ) + } + + "Basic enum deserialization" - { + "should deserialize single enum field" { + @Serializable + data class UserPref(val profileType: ProfileType) + + val result = Sql("select profile_type from user_notification_preferences where user_name = 'Alice' limit 1") + .queryOf() + .runOn(ctx) + .first() + + result.profileType shouldBe ProfileType.Admin + } + + "should deserialize all enum values correctly" { + @Serializable + data class UserNotification(val id: Int, val userName: String, val profileType: ProfileType) + + val results = Sql("select id, user_name, profile_type from user_notification_preferences order by id") + .queryOf() + .runOn(ctx) + + results.size shouldBe 3 + results[0].profileType shouldBe ProfileType.Admin + results[1].profileType shouldBe ProfileType.Retailer + results[2].profileType shouldBe ProfileType.Supplier + } + } + + "Enum with @SerialName annotation" - { + "should deserialize enum with custom names" { + @Serializable + data class OrderStatus(val id: Int, val orderName: String, val status: Status) + + val results = Sql("select id, order_name, status from order_status_test order by id") + .queryOf() + .runOn(ctx) + + results.size shouldBe 3 + results[0].status shouldBe Status.Active + results[1].status shouldBe Status.Inactive + results[2].status shouldBe Status.Pending + } + } + + "Nullable enum support" - { + "should handle nullable enum fields" { + @Serializable + data class OrderWithOptionalStatus( + val id: Int, + val orderName: String, + val status: Status, + val optionalStatus: Status? + ) + + val results = Sql("select id, order_name, status, optional_status from order_status_test order by id") + .queryOf() + .runOn(ctx) + + results.size shouldBe 3 + results[0].optionalStatus shouldBe Status.Pending + results[1].optionalStatus shouldBe null + results[2].optionalStatus shouldBe Status.Active + } + } + + "Enum serialization (INSERT/UPDATE)" - { + "should insert enum values correctly" { + @Serializable + data class UserPref(val userName: String, val profileType: ProfileType) + + val newUserPref = ProfileType.Admin + // For this to work without the conversion to String we need: https://github.com/ExoQuery/Terpal/issues/17 + val newUserPrefString = newUserPref.name + Sql("INSERT INTO user_notification_preferences (user_name, profile_type) VALUES ('Diana', $newUserPrefString::profile_type_enum)").action() + .runOn(ctx) + + val result = Sql("select user_name, profile_type from user_notification_preferences where user_name = 'Diana'") + .queryOf() + .runOn(ctx) + .first() + + result.profileType shouldBe ProfileType.Admin + } + + "should update enum values correctly" { + val newStatus = Status.Inactive + // For this to work without the conversion to String we need: https://github.com/ExoQuery/Terpal/issues/17 + @OptIn(ExperimentalSerializationApi::class) + val newStatusString = serializer().descriptor.getElementName(newStatus.ordinal) + Sql("UPDATE order_status_test SET status = $newStatusString::status_enum WHERE id = 1").action().runOn(ctx) + + val result = Sql("select status from order_status_test where id = 1") + .queryOf() + .runOn(ctx) + .first() + + result.status shouldBe Status.Inactive + } + + "should handle enum in WHERE clause" { + val targetType = ProfileType.Retailer + // For this to work without the conversion to String we need: https://github.com/ExoQuery/Terpal/issues/17 + val targetTypeString = targetType.name + val result = + Sql("SELECT user_name FROM user_notification_preferences WHERE profile_type = $targetTypeString::profile_type_enum") + .queryOf() + .runOn(ctx) + .first() + + result.userName shouldBe "Bob" + } + } + + "Batch operations with enums" - { + "should handle batch inserts with enums" { + + val newUsers = listOf( + NewUser("Eve", ProfileType.Admin), + NewUser("Frank", ProfileType.Supplier) + ) + + // Insert using batch - note: this test depends on batch support + for (user in newUsers) { + val usersName = user.userName + // For this to work without the conversion to String we need: https://github.com/ExoQuery/Terpal/issues/17 + val usersProfileTypeName = user.profileType.name + Sql("INSERT INTO user_notification_preferences (user_name, profile_type) VALUES ($usersName, $usersProfileTypeName::profile_type_enum)") + .action() + .runOn(ctx) + } + + @Serializable + data class UserCount(val count: Long) + + val count = Sql("select count(*) as count from user_notification_preferences where user_name in ('Eve', 'Frank')") + .queryOf() + .runOn(ctx) + .first() + + count.count shouldBe 2 + } + } + + "Complex enum queries" - { + "should work with CASE statements" { + @Serializable + data class UserCategory(val userName: String, val category: String) + + val results = Sql( + """ + select + user_name, + case profile_type + when 'Admin' then 'Administrator' + when 'Retailer' then 'Retail User' + when 'Supplier' then 'Supply User' + end as category + from user_notification_preferences + order by id + """ + ).queryOf().runOn(ctx) + + results[0].category shouldBe "Administrator" + results[1].category shouldBe "Retail User" + results[2].category shouldBe "Supply User" + } + + "should work with GROUP BY on enum column" { + @Serializable + data class ProfileCount(val profileType: ProfileType, val count: Long) + + val results = Sql( + """ + select profile_type, count(*) as count + from user_notification_preferences + group by profile_type + order by profile_type::text + """ + ).queryOf().runOn(ctx) + + // After our inserts we should have at least 1 of each type + results.find { it.profileType == ProfileType.Admin }?.count?.let { it >= 1 } shouldBe true + results.find { it.profileType == ProfileType.Retailer }?.count shouldBe 1 + results.find { it.profileType == ProfileType.Supplier }?.count?.let { it >= 1 } shouldBe true + } + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/postgres/EnumSerializationSpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/postgres/EnumSerializationSpec.kt new file mode 100644 index 0000000..92ab8a5 --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/postgres/EnumSerializationSpec.kt @@ -0,0 +1,281 @@ +package io.exoquery.r2dbc.postgres + +import io.exoquery.controller.TerpalSqlUnsafe +import io.exoquery.controller.runOn +import io.exoquery.controller.runActionsUnsafe +import io.exoquery.sql.Sql +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerialName +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.r2dbc.TestDatabasesR2dbc + +// Define test enums +@Serializable +enum class ProfileType { + Admin, Retailer, Supplier +} + +@Serializable +enum class Status { + @SerialName("active") + Active, + + @SerialName("inactive") + Inactive, + + @SerialName("pending") + Pending +} + +class EnumSerializationSpec : FreeSpec({ + + val cf = TestDatabasesR2dbc.postgres + val ctx: R2dbcController by lazy { R2dbcControllers.Postgres(connectionFactory = cf) } + + @OptIn(TerpalSqlUnsafe::class) + suspend fun runActions(actions: String) = ctx.runActionsUnsafe(actions) + + beforeSpec { + runActions( + """ + -- Drop existing types and tables if they exist + DROP TABLE IF EXISTS user_notification_preferences; + DROP TABLE IF EXISTS order_status_test; + DROP TYPE IF EXISTS profile_type_enum; + DROP TYPE IF EXISTS status_enum; + + -- Create enum types + CREATE TYPE profile_type_enum AS ENUM ('Admin', 'Retailer', 'Supplier'); + CREATE TYPE status_enum AS ENUM ('active', 'inactive', 'pending'); + + -- Create tables + CREATE TABLE user_notification_preferences ( + id SERIAL PRIMARY KEY, + user_name TEXT NOT NULL, + profile_type profile_type_enum NOT NULL + ); + + CREATE TABLE order_status_test ( + id SERIAL PRIMARY KEY, + order_name TEXT NOT NULL, + status status_enum NOT NULL, + optional_status status_enum + ); + + -- Insert test data + INSERT INTO user_notification_preferences (user_name, profile_type) + VALUES ('Alice', 'Admin'), ('Bob', 'Retailer'), ('Charlie', 'Supplier'); + + INSERT INTO order_status_test (order_name, status, optional_status) + VALUES + ('Order1', 'active', 'pending'), + ('Order2', 'inactive', NULL), + ('Order3', 'pending', 'active'); + """.trimIndent() + ) + } + + afterSpec { + runActions( + """ + DROP TABLE IF EXISTS user_notification_preferences; + DROP TABLE IF EXISTS order_status_test; + DROP TYPE IF EXISTS profile_type_enum; + DROP TYPE IF EXISTS status_enum; + """.trimIndent() + ) + } + + "Basic enum deserialization" - { + "should deserialize single enum field" { + @Serializable + data class UserPref(val profileType: ProfileType) + + val result = Sql("select profile_type from user_notification_preferences where user_name = 'Alice' limit 1") + .queryOf() + .runOn(ctx) + .first() + + result.profileType shouldBe ProfileType.Admin + } + + "should deserialize all enum values correctly" { + @Serializable + data class UserNotification(val id: Int, val userName: String, val profileType: ProfileType) + + val results = Sql("select id, user_name, profile_type from user_notification_preferences order by id") + .queryOf() + .runOn(ctx) + + results.size shouldBe 3 + results[0].profileType shouldBe ProfileType.Admin + results[1].profileType shouldBe ProfileType.Retailer + results[2].profileType shouldBe ProfileType.Supplier + } + } + + "Enum with @SerialName annotation" - { + "should deserialize enum with custom names" { + @Serializable + data class OrderStatus(val id: Int, val orderName: String, val status: Status) + + val results = Sql("select id, order_name, status from order_status_test order by id") + .queryOf() + .runOn(ctx) + + results.size shouldBe 3 + results[0].status shouldBe Status.Active + results[1].status shouldBe Status.Inactive + results[2].status shouldBe Status.Pending + } + } + + "Nullable enum support" - { + "should handle nullable enum fields" { + @Serializable + data class OrderWithOptionalStatus( + val id: Int, + val orderName: String, + val status: Status, + val optionalStatus: Status? + ) + + val results = Sql("select id, order_name, status, optional_status from order_status_test order by id") + .queryOf() + .runOn(ctx) + + results.size shouldBe 3 + results[0].optionalStatus shouldBe Status.Pending + results[1].optionalStatus shouldBe null + results[2].optionalStatus shouldBe Status.Active + } + } + + "Enum serialization (INSERT/UPDATE)" - { + "should insert enum values correctly" { + @Serializable + data class UserPref(val userName: String, val profileType: ProfileType) + + val newUserPref = ProfileType.Admin + val newUserPrefString = newUserPref.name + Sql("INSERT INTO user_notification_preferences (user_name, profile_type) VALUES ('Diana', $newUserPrefString)").action() + .runOn(ctx) + + val result = Sql("select user_name, profile_type from user_notification_preferences where user_name = 'Diana'") + .queryOf() + .runOn(ctx) + .first() + + result.profileType shouldBe ProfileType.Admin + } + + "should update enum values correctly" { + val newStatus = Status.Inactive + val newStatusString = newStatus.name + Sql("UPDATE order_status_test SET status = $newStatusString WHERE id = 1").action().runOn(ctx) + + @Serializable + data class OrderStatus(val status: Status) + + val result = Sql("select status from order_status_test where id = 1") + .queryOf() + .runOn(ctx) + .first() + + result.status shouldBe Status.Inactive + } + + "should handle enum in WHERE clause" { + val targetType = ProfileType.Retailer + val targetTypeString = targetType.name + + @Serializable + data class UserName(val userName: String) + + val result = Sql("SELECT user_name FROM user_notification_preferences WHERE profile_type = $targetTypeString") + .queryOf() + .runOn(ctx) + .first() + + result.userName shouldBe "Bob" + } + } + + "Batch operations with enums" - { + "should handle batch inserts with enums" { + @Serializable + data class NewUser(val userName: String, val profileType: ProfileType) + + val newUsers = listOf( + NewUser("Eve", ProfileType.Admin), + NewUser("Frank", ProfileType.Supplier) + ) + + // Insert using batch - note: this test depends on batch support + for (user in newUsers) { + val userName = user.userName + val userProfileTypeString = user.profileType.name + Sql("INSERT INTO user_notification_preferences (user_name, profile_type) VALUES ($userName, $userProfileTypeString)") + .action() + .runOn(ctx) + } + + @Serializable + data class UserCount(val count: Long) + + val count = Sql("select count(*) as count from user_notification_preferences where user_name in ('Eve', 'Frank')") + .queryOf() + .runOn(ctx) + .first() + + count.count shouldBe 2 + } + } + + "Complex enum queries" - { + "should work with CASE statements" { + @Serializable + data class UserCategory(val userName: String, val category: String) + + val results = Sql( + """ + select + user_name, + case profile_type + when 'Admin' then 'Administrator' + when 'Retailer' then 'Retail User' + when 'Supplier' then 'Supply User' + end as category + from user_notification_preferences + order by id + """ + ).queryOf().runOn(ctx) + + results[0].category shouldBe "Administrator" + results[1].category shouldBe "Retail User" + results[2].category shouldBe "Supply User" + } + + "should work with GROUP BY on enum column" { + @Serializable + data class ProfileCount(val profileType: ProfileType, val count: Long) + + val results = Sql( + """ + select profile_type, count(*) as count + from user_notification_preferences + group by profile_type + order by profile_type::text + """ + ).queryOf().runOn(ctx) + + // After our inserts we should have at least 1 of each type + results.find { it.profileType == ProfileType.Admin }?.count?.let { it >= 1 } shouldBe true + results.find { it.profileType == ProfileType.Retailer }?.count shouldBe 1 + results.find { it.profileType == ProfileType.Supplier }?.count?.let { it >= 1 } shouldBe true + } + } +})