diff --git a/controller-core/src/commonMain/kotlin/io/exoquery/controller/DecoderAny.kt b/controller-core/src/commonMain/kotlin/io/exoquery/controller/DecoderAny.kt index c9acfe2..e56938a 100644 --- a/controller-core/src/commonMain/kotlin/io/exoquery/controller/DecoderAny.kt +++ b/controller-core/src/commonMain/kotlin/io/exoquery/controller/DecoderAny.kt @@ -1,12 +1,26 @@ package io.exoquery.controller +import kotlin.jvm.JvmInline import kotlin.reflect.KClass -open class DecoderAny( +open class DecoderAny @PublishedApi internal constructor( open override val type: KClass, + /** + * The original type of this decoder at the time of its initial creation. + * This field remains unchanged through transformations like [map] and [transformInto]. + * Used in R2DBC's get operation where the driver requires the actual Java class + * of the original type for proper type conversion. + */ + open val originalType: KClass<*>, open val isNull: (Int, Row) -> Boolean, open val f: (DecodingContext, Int) -> T?, ): SqlDecoder() { + + constructor( + type: KClass, + isNull: (Int, Row) -> Boolean, + f: (DecodingContext, Int) -> T? + ) : this(type, type, isNull, f) override fun isNullable(): Boolean = false override fun decode(ctx: DecodingContext, index: Int): T { val value = @@ -32,11 +46,14 @@ open class DecoderAny( * Transforms this decoder into another decoder by applying the given function to the decoded value. * Alias for [map]. */ - inline fun transformInto(crossinline into: (T) -> R): DecoderAny = + inline fun transformInto(crossinline into: MapContext.(T) -> R): DecoderAny = map(into) - inline fun map(crossinline into: (T) -> R): DecoderAny = - DecoderAny(R::class, isNull) { ctx, index -> into(this.decode(ctx, index)) } + @JvmInline + final value class MapContext(val ctx: DecodingContext) + + inline fun map(crossinline into: MapContext.(T) -> R): DecoderAny = + DecoderAny(R::class, this@DecoderAny.originalType, isNull) { ctx, index -> into(MapContext(ctx), this.decode(ctx, index)) } override fun asNullable(): SqlDecoder = object: SqlDecoder() { diff --git a/controller-core/src/commonMain/kotlin/io/exoquery/controller/EncoderAny.kt b/controller-core/src/commonMain/kotlin/io/exoquery/controller/EncoderAny.kt index 17244b2..a67f047 100644 --- a/controller-core/src/commonMain/kotlin/io/exoquery/controller/EncoderAny.kt +++ b/controller-core/src/commonMain/kotlin/io/exoquery/controller/EncoderAny.kt @@ -1,13 +1,28 @@ package io.exoquery.controller +import kotlin.jvm.JvmInline import kotlin.reflect.KClass -open class EncoderAny( +open class EncoderAny @PublishedApi internal constructor( open val dataType: TypeId, open override val type: KClass, + /** + * The original type of this encoder at the time of its initial creation. + * This field remains unchanged through transformations like [contramap] and [transformFrom]. + * Used in R2DBC's bindNull operation where the driver requires the actual Java class + * of the original type rather than a JDBC type integer. + */ + open val originalType: KClass<*>, open val setNull: (Int, Stmt, TypeId) -> Unit, open val f: (EncodingContext, T, Int) -> Unit ): SqlEncoder() { + + constructor( + dataType: TypeId, + type: KClass, + setNull: (Int, Stmt, TypeId) -> Unit, + f: (EncodingContext, T, Int) -> Unit + ) : this(dataType, type, type, setNull, f) override fun encode(ctx: EncodingContext, value: T, index: Int) = f(ctx, value, index) @@ -33,9 +48,12 @@ open class EncoderAny( * Transforms this encoder into another encoder by applying the given function to the value before encoding it. * Alias for [contramap]. */ - inline fun transformFrom(crossinline from: (R) -> T): EncoderAny = + inline fun transformFrom(crossinline from: ContrmapContext.(R) -> T): EncoderAny = contramap(from) - inline fun contramap(crossinline from: (R) -> T): EncoderAny = - EncoderAny(this@EncoderAny.dataType, R::class, this@EncoderAny.setNull) { ctx, value, i -> this.f(ctx, from(value), i) } + @JvmInline + final value class ContrmapContext(val ctx: EncodingContext) + + inline fun contramap(crossinline from: ContrmapContext.(R) -> T): EncoderAny = + EncoderAny(this@EncoderAny.dataType, R::class, this@EncoderAny.originalType, this@EncoderAny.setNull) { ctx, value, i -> this.f(ctx, from(ContrmapContext(ctx), value), i) } } diff --git a/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcControllers.kt b/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcControllers.kt index 9ef9ce3..cda0daf 100644 --- a/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcControllers.kt +++ b/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcControllers.kt @@ -5,6 +5,7 @@ import io.exoquery.controller.JavaTimeEncoding import io.exoquery.controller.JavaUuidEncoding import io.exoquery.controller.* import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.toJavaZoneId import java.sql.Connection import java.sql.PreparedStatement import java.sql.ResultSet @@ -197,9 +198,22 @@ object JdbcControllers { override val encodingApi: JdbcSqlEncoding = object : JavaSqlEncoding, BasicEncoding by JdbcBasicEncoding, - JavaTimeEncoding by JdbcTimeEncoding(), + JavaTimeEncoding by SqlServerTimeEncoding, JavaUuidEncoding by JdbcUuidStringEncoding {} + object SqlServerTimeEncoding: JdbcTimeEncoding() { + // Override java.util.Date encoding to use TIMESTAMP_WITH_TIMEZONE for DATETIMEOFFSET columns + override val JDateEncoder: JdbcEncoderAny = JdbcEncoderAny(Types.TIMESTAMP_WITH_TIMEZONE, java.util.Date::class) { ctx, v, i -> + val instant = v.toInstant() + val offsetDateTime = java.time.OffsetDateTime.ofInstant(instant, ctx.timeZone.toJavaZoneId()) + ctx.stmt.setObject(i, offsetDateTime, Types.TIMESTAMP_WITH_TIMEZONE) + } + override val JDateDecoder: JdbcDecoderAny = JdbcDecoderAny(java.util.Date::class) { ctx, i -> + val offsetDateTime = ctx.row.getObject(i, java.time.OffsetDateTime::class.java) + java.util.Date.from(offsetDateTime.toInstant()) + } + } + override suspend fun runActionReturningScoped(act: ControllerActionReturning, options: JdbcExecutionOptions): Flow = flowWithConnection(options) { val conn = localConnection() diff --git a/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcDecoders.kt b/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcDecoders.kt index 9357fe6..cafcb71 100644 --- a/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcDecoders.kt +++ b/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcDecoders.kt @@ -6,6 +6,8 @@ import io.r2dbc.spi.Connection import io.r2dbc.spi.Row import kotlin.reflect.KClass +typealias R2dbcDecoder = DecoderAny + class R2dbcDecoderAny( override val type: KClass, override val f: (R2dbcDecodingContext, Int) -> T? @@ -21,7 +23,7 @@ class R2dbcDecoderAny( object R2dbcDecoders { @Suppress("UNCHECKED_CAST") - val decoders: Set> = setOf( + val decoders: Set> = setOf( R2dbcBasicEncoding.BooleanDecoder, R2dbcBasicEncoding.ByteDecoder, R2dbcBasicEncoding.CharDecoder, 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 2369d3b..bcf2a2f 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 @@ -2,6 +2,7 @@ package io.exoquery.controller.r2dbc import io.exoquery.controller.BasicEncoding import io.exoquery.controller.ControllerError +import io.exoquery.controller.DecoderAny import io.exoquery.controller.EncoderAny import io.exoquery.controller.JavaTimeEncoding import io.exoquery.controller.JavaUuidEncoding @@ -23,6 +24,8 @@ import java.time.* import java.util.* import kotlin.reflect.KClass +typealias R2dbcEncoder = EncoderAny + // Note: R2DBC has no java.sql.Types. We keep an Int id for compatibility but do not use it. open class R2dbcEncoderAny( override val dataType: Int, @@ -42,18 +45,10 @@ private const val NA = 0 object R2dbcBasicEncoding: R2dbcBasicEncodingBase() object R2dbcBasicEncodingH2: R2dbcBasicEncodingBase() { - //override val IntEncoder: SqlEncoder = - // object: R2dbcEncoderAny(NA, Int::class, { ctx, v, i -> ctx.stmt.bind(i, v.toLong()) }) { - // /** The bindNull implementation for Int must bind as Long to satisfy - // * driver since the driver only cares about the Java type ultimately set for the column */ - // override val setNull: (Int, Statement, Int) -> Unit = - // { index, stmt, _ -> stmt.bindNull(index, java.lang.Long::class.java) } - // } - - override val ByteDecoder: SqlDecoder = + override val ByteDecoder: R2dbcDecoder = R2dbcDecoderAny(Byte::class) { ctx, i -> ctx.row.get(i, java.lang.Short::class.java)?.toByte() } - override val FloatDecoder: SqlDecoder = + override val FloatDecoder: R2dbcDecoder = R2dbcDecoderAny(Float::class) { ctx, i -> when (val value = ctx.row.get(i)) { null -> null @@ -66,7 +61,7 @@ object R2dbcBasicEncodingH2: R2dbcBasicEncodingBase() { } } - override val IntDecoder: SqlDecoder = + override val IntDecoder: R2dbcDecoder = R2dbcDecoderAny(Int::class) { ctx, i -> when (val value = ctx.row.get(i)) { null -> null @@ -80,7 +75,7 @@ object R2dbcBasicEncodingH2: R2dbcBasicEncodingBase() { } } - override val LongDecoder: SqlDecoder = + override val LongDecoder: R2dbcDecoder = R2dbcDecoderAny(Long::class) { ctx, i -> when (val value = ctx.row.get(i)) { null -> null @@ -94,7 +89,7 @@ object R2dbcBasicEncodingH2: R2dbcBasicEncodingBase() { } } - override val ShortDecoder: SqlDecoder = + override val ShortDecoder: R2dbcDecoder = R2dbcDecoderAny(Short::class) { ctx, i -> when (val value = ctx.row.get(i)) { null -> null @@ -118,40 +113,40 @@ object R2dbcBasicEncodingH2: R2dbcBasicEncodingBase() { // This same logic applies to the ByteArrayDecoder as well. // More oracle crazy behavior that requires encoding/decoding booleans as ints (0/1). object R2dbcBasicEncodingOracle: R2dbcBasicEncodingBase() { - override val CharDecoder: SqlDecoder = + override val CharDecoder: R2dbcDecoder = R2dbcDecoderAny(Char::class) { ctx, i -> ctx.row.get(i, String::class.java)?.let { it[0] } ?: Char.MIN_VALUE } - override val StringDecoder: SqlDecoder = + override val StringDecoder: R2dbcDecoder = R2dbcDecoderAny(String::class) { ctx, i -> ctx.row.get(i, String::class.java) ?: "" } - override val ByteArrayDecoder: SqlDecoder = + override val ByteArrayDecoder: R2dbcDecoder = R2dbcDecoderAny(ByteArray::class) { ctx, i -> ctx.row.get(i, ByteArray::class.java) ?: byteArrayOf() } // More oracle crazy behavior that requires encoding booleans as ints - //override val BooleanEncoder: SqlEncoder = + //override val BooleanEncoder: R2dbcEncoderAny = // R2dbcEncoderAny(NA, Boolean::class) { ctx, v, i -> ctx.stmt.bind(i, if (v) 1 else 0) } - //override val BooleanDecoder: SqlDecoder = + //override val BooleanDecoder: R2dbcDecoder = // R2dbcDecoderAny(Boolean::class) { ctx, i -> ctx.row.get(i, java.lang.Integer::class.java)?.let { it == 1 } } } abstract class R2dbcBasicEncodingBase: BasicEncoding { - override val BooleanEncoder: SqlEncoder = + override val BooleanEncoder: R2dbcEncoderAny = R2dbcEncoderAny(NA, Boolean::class) { ctx, v, i -> ctx.stmt.bind(i, v) } - override val ByteEncoder: SqlEncoder = + override val ByteEncoder: R2dbcEncoderAny = R2dbcEncoderAny(NA, Byte::class) { ctx, v, i -> ctx.stmt.bind(i, v) } - override val CharEncoder: SqlEncoder = + override val CharEncoder: R2dbcEncoderAny = R2dbcEncoderAny(NA, Char::class) { ctx, v, i -> ctx.stmt.bind(i, v.toString()) } - override val DoubleEncoder: SqlEncoder = + override val DoubleEncoder: R2dbcEncoderAny = R2dbcEncoderAny(NA, Double::class) { ctx, v, i -> ctx.stmt.bind(i, v) } - override val FloatEncoder: SqlEncoder = + override val FloatEncoder: R2dbcEncoderAny = R2dbcEncoderAny(NA, Float::class) { ctx, v, i -> ctx.stmt.bind(i, v) } - override val IntEncoder: SqlEncoder = + override val IntEncoder: R2dbcEncoderAny = R2dbcEncoderAny(NA, Int::class) { ctx, v, i -> ctx.stmt.bind(i, v) } - override val LongEncoder: SqlEncoder = + override val LongEncoder: R2dbcEncoderAny = R2dbcEncoderAny(NA, Long::class) { ctx, v, i -> ctx.stmt.bind(i, v) } - override val ShortEncoder: SqlEncoder = + override val ShortEncoder: R2dbcEncoderAny = R2dbcEncoderAny(NA, Short::class) { ctx, v, i -> ctx.stmt.bind(i, v) } - override val StringEncoder: SqlEncoder = + override val StringEncoder: R2dbcEncoderAny = R2dbcEncoderAny(NA, String::class) { ctx, v, i -> ctx.stmt.bind(i, v) } - override val ByteArrayEncoder: SqlEncoder = + override val ByteArrayEncoder: R2dbcEncoderAny = R2dbcEncoderAny(NA, ByteArray::class) { ctx, v, i -> ctx.stmt.bind(i, v) } override fun preview(index: Int, row: Row): String? = @@ -159,25 +154,25 @@ abstract class R2dbcBasicEncodingBase: BasicEncoding override fun isNull(index: Int, row: Row): Boolean = row.get(index) == null - override val BooleanDecoder: SqlDecoder = + override val BooleanDecoder: R2dbcDecoder = R2dbcDecoderAny(Boolean::class) { ctx, i -> ctx.row.get(i, java.lang.Boolean::class.java)?.booleanValue() } - override val ByteDecoder: SqlDecoder = + override val ByteDecoder: R2dbcDecoder = R2dbcDecoderAny(Byte::class) { ctx, i -> ctx.row.get(i, java.lang.Byte::class.java)?.toByte() } - override val CharDecoder: SqlDecoder = + override val CharDecoder: R2dbcDecoder = R2dbcDecoderAny(Char::class) { ctx, i -> ctx.row.get(i, String::class.java)?.let { it[0] } ?: Char.MIN_VALUE } - override val DoubleDecoder: SqlDecoder = + override val DoubleDecoder: R2dbcDecoder = R2dbcDecoderAny(Double::class) { ctx, i -> ctx.row.get(i, java.lang.Double::class.java)?.toDouble() } - override val FloatDecoder: SqlDecoder = + override val FloatDecoder: R2dbcDecoder = R2dbcDecoderAny(Float::class) { ctx, i -> ctx.row.get(i, java.lang.Float::class.java)?.toFloat() } - override val IntDecoder: SqlDecoder = + override val IntDecoder: R2dbcDecoder = R2dbcDecoderAny(Int::class) { ctx, i -> ctx.row.get(i, java.lang.Integer::class.java)?.toInt() } - override val LongDecoder: SqlDecoder = + override val LongDecoder: R2dbcDecoder = R2dbcDecoderAny(Long::class) { ctx, i -> ctx.row.get(i, java.lang.Long::class.java)?.toLong() } - override val ShortDecoder: SqlDecoder = + override val ShortDecoder: R2dbcDecoder = R2dbcDecoderAny(Short::class) { ctx, i -> ctx.row.get(i, java.lang.Short::class.java)?.toShort() } - override val StringDecoder: SqlDecoder = + override val StringDecoder: R2dbcDecoder = R2dbcDecoderAny(String::class) { ctx, i -> ctx.row.get(i, String::class.java) } - override val ByteArrayDecoder: SqlDecoder = + override val ByteArrayDecoder: R2dbcDecoder = R2dbcDecoderAny(ByteArray::class) { ctx, i -> ctx.row.get(i, ByteArray::class.java) } } @@ -189,81 +184,62 @@ object R2dbcTimeEncodingH2: R2dbcTimeEncodingBase() { /** java.util.Date -> bind as Instant (supported type) * original behavior is to assume the field actually supports timestamp with timezone */ - override val JDateEncoder: SqlEncoder = - object: R2dbcEncoderAny(NA, Date::class, { ctx, v, i -> - ctx.stmt.bind(i, Instant.ofEpochMilli(v.time).atZone(ZoneId.systemDefault()).toLocalDateTime()) - }) { - override val setNull: (Int, Statement, Int) -> Unit = - { index, stmt, _ -> stmt.bindNull(index, LocalDateTime::class.java) } + override val JDateEncoder: R2dbcEncoder = + JLocalDateTimeEncoder.contramap { v: Date -> + Instant.ofEpochMilli(v.time).atZone(ZoneId.systemDefault()).toLocalDateTime() } /** java.util.Date from LocalDateTime * H2 R2DBC doesn't support Instant directly for TIMESTAMP columns, so we decode via LocalDateTime */ - override val JDateDecoder: SqlDecoder = - R2dbcDecoderAny(Date::class) { ctx, i -> - ctx.row.get(i, LocalDateTime::class.java)?.let { - Date.from(it.atZone(ZoneId.systemDefault()).toInstant()) - } + override val JDateDecoder: R2dbcDecoder = + JLocalDateTimeDecoder.map { localDateTime -> + Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant()) } } object R2dbcTimeEncodingSqlServer: R2dbcTimeEncodingBase() { // java.util.Date -> bind as OffsetDateTime (supported by SQL Server and Postgres) - override val JDateEncoder: SqlEncoder = - object: R2dbcEncoderAny(NA, Date::class, { ctx, v, i -> - val odt = OffsetDateTime.ofInstant(Instant.ofEpochMilli(v.time), ctx.timeZone.toJavaZoneId()) - ctx.stmt.bind(i, odt) - }) { - /** The bindNull implementation for Date must bind as OffsetDateTime to satisfy - * driver since the driver only cares about the Java type ultimately set for the column */ - override val setNull: (Int, Statement, Int) -> Unit = - { index, stmt, _ -> stmt.bindNull(index, OffsetDateTime::class.java) } + override val JDateEncoder: R2dbcEncoder = + JOffsetDateTimeEncoder.contramap { v: Date -> + OffsetDateTime.ofInstant(Instant.ofEpochMilli(v.time), ctx.timeZone.toJavaZoneId()) } - override val JDateDecoder: SqlDecoder = - R2dbcDecoderAny(Date::class) { ctx, i -> - ctx.row.get(i, OffsetDateTime::class.java)?.toInstant()?.let { Date.from(it) } + override val JDateDecoder: R2dbcDecoder = + JOffsetDateTimeDecoder.map { odt: OffsetDateTime -> + Date.from(odt.toInstant()) } // SQL Server does not support Instant binding, so bind as OffsetDateTime in UTC - override val InstantEncoder: SqlEncoder = - R2dbcEncoderAny(NA, kotlinx.datetime.Instant::class) { ctx, v, i -> - val odt = OffsetDateTime.ofInstant(v.toJavaInstant(), ZoneOffset.UTC) - ctx.stmt.bind(i, odt) + override val InstantEncoder: R2dbcEncoder = + JOffsetDateTimeEncoder.contramap { v: kotlinx.datetime.Instant -> + OffsetDateTime.ofInstant(v.toJavaInstant(), ZoneOffset.UTC) } - override val InstantDecoder: SqlDecoder = - R2dbcDecoderAny(kotlinx.datetime.Instant::class) { ctx, i -> - ctx.row.get(i, OffsetDateTime::class.java)?.toInstant()?.toKotlinInstant() + override val InstantDecoder: R2dbcDecoder = + JOffsetDateTimeDecoder.map { odt: OffsetDateTime -> + odt.toInstant().toKotlinInstant() } - override val JInstantEncoder: SqlEncoder = - R2dbcEncoderAny(NA, Instant::class) { ctx, v, i -> - val odt = OffsetDateTime.ofInstant(v, ZoneOffset.UTC) - ctx.stmt.bind(i, odt) + override val JInstantEncoder: R2dbcEncoder = + JOffsetDateTimeEncoder.contramap { v: Instant -> + OffsetDateTime.ofInstant(v, ZoneOffset.UTC) } - override val JInstantDecoder: SqlDecoder = - R2dbcDecoderAny(Instant::class) { ctx, i -> - ctx.row.get(i, OffsetDateTime::class.java)?.toInstant() + override val JInstantDecoder: R2dbcDecoder = + JOffsetDateTimeDecoder.map { odt: OffsetDateTime -> + odt.toInstant() } // Convert OffsetTime -> OffsetDateTime on a fixed date (SQL Server DATETIMEOFFSET) - override val JOffsetTimeEncoder: SqlEncoder = - object: R2dbcEncoderAny(NA, OffsetTime::class, { ctx, v, i -> - val odt = OffsetDateTime.of(LocalDate.of(1970, 1, 1), v.toLocalTime(), v.offset) - ctx.stmt.bind(i, odt) - }) { - /** The bindNull implementation for OffsetTime must bind as OffsetDateTime to satisfy - * driver since the driver only cares about the Java type ultimately set for the column */ - override val setNull: (Int, Statement, Int) -> Unit = - { index, stmt, _ -> stmt.bindNull(index, OffsetDateTime::class.java) } + override val JOffsetTimeEncoder: R2dbcEncoder = + JOffsetDateTimeEncoder.contramap { v: OffsetTime -> + OffsetDateTime.of(LocalDate.of(1970, 1, 1), v.toLocalTime(), v.offset) } - override val JOffsetTimeDecoder: SqlDecoder = - R2dbcDecoderAny(OffsetTime::class) { ctx, i -> - ctx.row.get(i, OffsetDateTime::class.java)?.toOffsetTime() + override val JOffsetTimeDecoder: R2dbcDecoder = + JOffsetDateTimeDecoder.map { odt: OffsetDateTime -> + odt.toOffsetTime() } } @@ -272,159 +248,145 @@ object R2dbcTimeEncodingSqlServer: R2dbcTimeEncodingBase() { object R2dbcTimeEncodingOracle: R2dbcTimeEncodingBase() { // Oracle supports binding via a OffsetDateTime but ironically, it's TIMESTAMP does not have a TimeZone. Therefore // when the row.get happens the OffsetDateTime translates as UTC! The simplest way to deal with that is setting it initially to UTC - override val JDateEncoder: SqlEncoder = - object: R2dbcEncoderAny(NA, Date::class, { ctx, v, i -> - val odt = OffsetDateTime.ofInstant(Instant.ofEpochMilli(v.time), ZoneOffset.UTC) - ctx.stmt.bind(i, odt) - }) { - /** The bindNull implementation for Date must bind as OffsetDateTime to satisfy - * driver since the driver only cares about the Java type ultimately set for the column */ - override val setNull: (Int, Statement, Int) -> Unit = - { index, stmt, _ -> stmt.bindNull(index, OffsetDateTime::class.java) } + override val JDateEncoder: R2dbcEncoder = + JOffsetDateTimeEncoder.contramap { v: Date -> + OffsetDateTime.ofInstant(Instant.ofEpochMilli(v.time), ZoneOffset.UTC) } - override val JDateDecoder: SqlDecoder = - R2dbcDecoderAny(Date::class) { ctx, i -> - ctx.row.get(i, OffsetDateTime::class.java)?.toInstant()?.let { Date.from(it) } + override val JDateDecoder: R2dbcDecoder = + JOffsetDateTimeDecoder.map { odt: OffsetDateTime -> + Date.from(odt.toInstant()) } - // SQL Server does not support Instant binding, so bind as OffsetDateTime in UTC - override val InstantEncoder: SqlEncoder = - R2dbcEncoderAny(NA, kotlinx.datetime.Instant::class) { ctx, v, i -> - val odt = OffsetDateTime.ofInstant(v.toJavaInstant(), ZoneOffset.UTC) - ctx.stmt.bind(i, odt) + // Oracle does not support Instant binding, so bind as OffsetDateTime in UTC + override val InstantEncoder: R2dbcEncoder = + JOffsetDateTimeEncoder.contramap { v: kotlinx.datetime.Instant -> + OffsetDateTime.ofInstant(v.toJavaInstant(), ZoneOffset.UTC) } - override val InstantDecoder: SqlDecoder = - R2dbcDecoderAny(kotlinx.datetime.Instant::class) { ctx, i -> - ctx.row.get(i, OffsetDateTime::class.java)?.toInstant()?.toKotlinInstant() + override val InstantDecoder: R2dbcDecoder = + JOffsetDateTimeDecoder.map { odt: OffsetDateTime -> + odt.toInstant().toKotlinInstant() } // Oracle R2DBC does not support ZonedDateTime directly, convert to OffsetDateTime - override val JZonedDateTimeEncoder: SqlEncoder = - object: R2dbcEncoderAny(NA, ZonedDateTime::class, { ctx, v, i -> - ctx.stmt.bind(i, v.toOffsetDateTime()) - }) { - override val setNull: (Int, Statement, Int) -> Unit = - { index, stmt, _ -> stmt.bindNull(index, OffsetDateTime::class.java) } + override val JZonedDateTimeEncoder: R2dbcEncoder = + JOffsetDateTimeEncoder.contramap { v: ZonedDateTime -> + v.toOffsetDateTime() } - override val JZonedDateTimeDecoder: SqlDecoder = - R2dbcDecoderAny(ZonedDateTime::class) { ctx, i -> - ctx.row.get(i, OffsetDateTime::class.java)?.toZonedDateTime() + override val JZonedDateTimeDecoder: R2dbcDecoder = + JOffsetDateTimeDecoder.map { odt: OffsetDateTime -> + odt.toZonedDateTime() } - override val JInstantEncoder: SqlEncoder = - R2dbcEncoderAny(NA, Instant::class) { ctx, v, i -> - val odt = OffsetDateTime.ofInstant(v, ZoneOffset.UTC) - ctx.stmt.bind(i, odt) + override val JInstantEncoder: R2dbcEncoder = + JOffsetDateTimeEncoder.contramap { v: Instant -> + OffsetDateTime.ofInstant(v, ZoneOffset.UTC) } - override val JInstantDecoder: SqlDecoder = - R2dbcDecoderAny(Instant::class) { ctx, i -> - ctx.row.get(i, OffsetDateTime::class.java)?.toInstant() + override val JInstantDecoder: R2dbcDecoder = + JOffsetDateTimeDecoder.map { odt: OffsetDateTime -> + odt.toInstant() } } abstract class R2dbcTimeEncodingBase: JavaTimeEncoding { // KMP datetime -> convert to java.time before binding - override val LocalDateEncoder: SqlEncoder = + override val LocalDateEncoder: R2dbcEncoder = R2dbcEncoderAny(NA, kotlinx.datetime.LocalDate::class) { ctx, v, i -> ctx.stmt.bind(i, v.toJavaLocalDate()) } - override val LocalDateTimeEncoder: SqlEncoder = + override val LocalDateTimeEncoder: R2dbcEncoder = R2dbcEncoderAny(NA, kotlinx.datetime.LocalDateTime::class) { ctx, v, i -> ctx.stmt.bind(i, v.toJavaLocalDateTime()) } - override val LocalTimeEncoder: SqlEncoder = + override val LocalTimeEncoder: R2dbcEncoder = R2dbcEncoderAny(NA, kotlinx.datetime.LocalTime::class) { ctx, v, i -> ctx.stmt.bind(i, v.toJavaLocalTime()) } - override val InstantEncoder: SqlEncoder = + override val InstantEncoder: R2dbcEncoder = R2dbcEncoderAny(NA, kotlinx.datetime.Instant::class) { ctx, v, i -> ctx.stmt.bind(i, v.toJavaInstant()) } // Java time types can be bound directly - override val JLocalDateEncoder: SqlEncoder = + override val JLocalDateEncoder: R2dbcEncoder = R2dbcEncoderAny(NA, LocalDate::class) { ctx, v, i -> ctx.stmt.bind(i, v) } - override val JLocalTimeEncoder: SqlEncoder = + override val JLocalTimeEncoder: R2dbcEncoder = R2dbcEncoderAny(NA, LocalTime::class) { ctx, v, i -> ctx.stmt.bind(i, v) } - override val JLocalDateTimeEncoder: SqlEncoder = + override val JLocalDateTimeEncoder: R2dbcEncoder = R2dbcEncoderAny(NA, LocalDateTime::class) { ctx, v, i -> ctx.stmt.bind(i, v) } - override val JZonedDateTimeEncoder: SqlEncoder = + override val JZonedDateTimeEncoder: R2dbcEncoder = R2dbcEncoderAny(NA, ZonedDateTime::class) { ctx, v, i -> ctx.stmt.bind(i, v) } - override val JInstantEncoder: SqlEncoder = + override val JInstantEncoder: R2dbcEncoder = R2dbcEncoderAny(NA, Instant::class) { ctx, v, i -> ctx.stmt.bind(i, v) } - override val JOffsetTimeEncoder: SqlEncoder = + override val JOffsetTimeEncoder: R2dbcEncoder = R2dbcEncoderAny(NA, OffsetTime::class) { ctx, v, i -> ctx.stmt.bind(i, v) } - override val JOffsetDateTimeEncoder: SqlEncoder = + override val JOffsetDateTimeEncoder: R2dbcEncoder = R2dbcEncoderAny(NA, OffsetDateTime::class) { ctx, v, i -> ctx.stmt.bind(i, v) } // KMP datetime decoders via java.time - override val LocalDateDecoder: SqlDecoder = + override val LocalDateDecoder: R2dbcDecoder = R2dbcDecoderAny(kotlinx.datetime.LocalDate::class) { ctx, i -> ctx.row.get(i, LocalDate::class.java)?.toKotlinLocalDate() } - override val LocalDateTimeDecoder: SqlDecoder = + override val LocalDateTimeDecoder: R2dbcDecoder = R2dbcDecoderAny(kotlinx.datetime.LocalDateTime::class) { ctx, i -> ctx.row.get(i, LocalDateTime::class.java)?.toKotlinLocalDateTime() } - override val LocalTimeDecoder: SqlDecoder = + override val LocalTimeDecoder: R2dbcDecoder = R2dbcDecoderAny(kotlinx.datetime.LocalTime::class) { ctx, i -> ctx.row.get(i, LocalTime::class.java)?.toKotlinLocalTime() } - override val InstantDecoder: SqlDecoder = + override val InstantDecoder: R2dbcDecoder = R2dbcDecoderAny(kotlinx.datetime.Instant::class) { ctx, i -> ctx.row.get(i, OffsetDateTime::class.java)?.toInstant()?.toKotlinInstant() } // Java time decoders - override val JLocalDateDecoder: SqlDecoder = + override val JLocalDateDecoder: R2dbcDecoder = R2dbcDecoderAny(LocalDate::class) { ctx, i -> ctx.row.get(i, LocalDate::class.java) } - override val JLocalTimeDecoder: SqlDecoder = + override val JLocalTimeDecoder: R2dbcDecoder = R2dbcDecoderAny(LocalTime::class) { ctx, i -> ctx.row.get(i, LocalTime::class.java) } - override val JLocalDateTimeDecoder: SqlDecoder = + override val JLocalDateTimeDecoder: R2dbcDecoder = R2dbcDecoderAny(LocalDateTime::class) { ctx, i -> ctx.row.get(i, LocalDateTime::class.java) } - override val JZonedDateTimeDecoder: SqlDecoder = + override val JZonedDateTimeDecoder: R2dbcDecoder = R2dbcDecoderAny(ZonedDateTime::class) { ctx, i -> ctx.row.get(i, OffsetDateTime::class.java)?.toZonedDateTime() } - override val JInstantDecoder: SqlDecoder = + override val JInstantDecoder: R2dbcDecoder = R2dbcDecoderAny(Instant::class) { ctx, i -> ctx.row.get(i, OffsetDateTime::class.java)?.toInstant() } - override val JOffsetTimeDecoder: SqlDecoder = + override val JOffsetTimeDecoder: R2dbcDecoder = R2dbcDecoderAny(OffsetTime::class) { ctx, i -> ctx.row.get(i, OffsetTime::class.java) } - override val JOffsetDateTimeDecoder: SqlDecoder = + override val JOffsetDateTimeDecoder: R2dbcDecoder = R2dbcDecoderAny(OffsetDateTime::class) { ctx, i -> ctx.row.get(i, OffsetDateTime::class.java) } /** java.util.Date -> bind as Instant (supported type) * original behavior is to assume the field actually supports timestamp with timezone */ - open override val JDateEncoder: SqlEncoder = + open override val JDateEncoder: R2dbcEncoder = R2dbcEncoderAny(NA, Date::class) { ctx, v, i -> ctx.stmt.bind(i, Instant.ofEpochMilli(v.getTime())) } /** java.util.Date from Instant * original behavior is to assume the field actually supports timestamp with timezone */ - open override val JDateDecoder: SqlDecoder = + open override val JDateDecoder: R2dbcDecoder = R2dbcDecoderAny(Date::class) { ctx, i -> ctx.row.get(i, Instant::class.java)?.let { Date.from(it) } } } object R2dbcUuidEncodingNative: JavaUuidEncoding { private const val NA = 0 - override val JUuidEncoder: SqlEncoder = + override val JUuidEncoder: R2dbcEncoder = R2dbcEncoderAny(NA, UUID::class) { ctx, v, i -> ctx.stmt.bind(i, v) } - override val JUuidDecoder: SqlDecoder = + override val JUuidDecoder: R2dbcDecoder = R2dbcDecoderAny(UUID::class) { ctx, i -> ctx.row.get(i, UUID::class.java) } } object R2dbcUuidEncodingString: JavaUuidEncoding { private const val NA = 0 - override val JUuidEncoder: SqlEncoder = - object: R2dbcEncoderAny(NA, UUID::class, { ctx, v, i -> ctx.stmt.bind(i, v.toString()) }) { - /** The bindNull implementation for UUID must bind as String to satisfy - * driver since the driver only cares about the Java type ultimately set for the column */ - override val setNull: (Int, Statement, Int) -> Unit = - { index, stmt, _ -> stmt.bindNull(index, String::class.java) } + override val JUuidEncoder: R2dbcEncoder = + R2dbcBasicEncoding.StringEncoder.contramap { v: UUID -> + v.toString() } - override val JUuidDecoder: SqlDecoder = - R2dbcDecoderAny(UUID::class) { ctx, i -> - ctx.row.get(i, String::class.java)?.let { UUID.fromString(it) } + override val JUuidDecoder: R2dbcDecoder = + R2dbcBasicEncoding.StringDecoder.map { s: String -> + UUID.fromString(s) } } @@ -439,7 +401,7 @@ object R2dbcAdditionalEncoding { object R2dbcEncoders { @Suppress("UNCHECKED_CAST") - val encoders: Set> = setOf( + val encoders: Set> = setOf( R2dbcBasicEncoding.BooleanEncoder, R2dbcBasicEncoding.ByteEncoder, R2dbcBasicEncoding.CharEncoder, diff --git a/terpal-sql-core-testing/src/commonMain/kotlin/io/exoquery/sql/encodingdata/EncodingTestEntityImp.kt b/terpal-sql-core-testing/src/commonMain/kotlin/io/exoquery/sql/encodingdata/EncodingTestEntityImp.kt index 1ce99d7..317843b 100644 --- a/terpal-sql-core-testing/src/commonMain/kotlin/io/exoquery/sql/encodingdata/EncodingTestEntityImp.kt +++ b/terpal-sql-core-testing/src/commonMain/kotlin/io/exoquery/sql/encodingdata/EncodingTestEntityImp.kt @@ -58,28 +58,33 @@ fun insert(e: EncodingTestEntityImp): ControllerAction { } fun verify(e1: EncodingTestEntityImp, e2: EncodingTestEntityImp, oracleStrings: Boolean = false) { - e1.stringMan.value shouldBeEqualEmptyNullable e2.stringMan.value - e1.booleanMan shouldBeEqual e2.booleanMan - e1.byteMan shouldBeEqual e2.byteMan - e1.shortMan shouldBeEqual e2.shortMan - e1.intMan shouldBeEqual e2.intMan - e1.long shouldBeEqual e2.long - e1.floatMan shouldBeEqual e2.floatMan - e1.double shouldBeEqual e2.double - e1.byteArrayMan.toList() shouldBeEqual e2.byteArrayMan.toList() - e1.customMan shouldBeEqual e2.customMan + fun catchRewrapAssert(msg: String, assertFun: () -> Unit) = + try { assertFun() } catch (e: AssertionError) { + throw AssertionError(msg, e) + } - if (!oracleStrings) e1.stringOpt shouldBeEqualNullable e2.stringOpt - else e1.stringOpt?.value shouldBeEqualEmptyNullable(e2.stringOpt?.value) - e1.booleanOpt shouldBeEqualNullable e2.booleanOpt - e1.byteOpt shouldBeEqualNullable e2.byteOpt - e1.shortOpt shouldBeEqualNullable e2.shortOpt - e1.intOpt shouldBeEqualNullable e2.intOpt - e1.longOpt shouldBeEqualNullable e2.longOpt - e1.floatOpt shouldBeEqualNullable(e2.floatOpt) - e1.doubleOpt shouldBeEqualNullable(e2.doubleOpt) - (e1.byteArrayOpt?.toList() ?: emptyList()) shouldBeEqualNullable (e2.byteArrayOpt?.toList() ?: emptyList()) - e1.customOpt shouldBeEqualNullable e2.customOpt + catchRewrapAssert("Error Comparing: stringMan.value") { e1.stringMan.value shouldBeEqualEmptyNullable e2.stringMan.value } + catchRewrapAssert("Error Comparing: booleanMan") { e1.booleanMan shouldBeEqual e2.booleanMan } + catchRewrapAssert("Error Comparing: byteMan") { e1.byteMan shouldBeEqual e2.byteMan } + catchRewrapAssert("Error Comparing: shortMan") { e1.shortMan shouldBeEqual e2.shortMan } + catchRewrapAssert("Error Comparing: intMan") { e1.intMan shouldBeEqual e2.intMan } + catchRewrapAssert("Error Comparing: long") { e1.long shouldBeEqual e2.long } + catchRewrapAssert("Error Comparing: floatMan") { e1.floatMan shouldBeEqual e2.floatMan } + catchRewrapAssert("Error Comparing: double") { e1.double shouldBeEqual e2.double } + catchRewrapAssert("Error Comparing: byteArrayMan") { e1.byteArrayMan.toList() shouldBeEqual e2.byteArrayMan.toList() } + catchRewrapAssert("Error Comparing: customMan") { e1.customMan shouldBeEqual e2.customMan } + + if (!oracleStrings) catchRewrapAssert("Error Comparing: stringOpt") { e1.stringOpt shouldBeEqualNullable e2.stringOpt } + else catchRewrapAssert("Error Comparing: stringOpt.value") { e1.stringOpt?.value shouldBeEqualEmptyNullable(e2.stringOpt?.value) } + catchRewrapAssert("Error Comparing: booleanOpt") { e1.booleanOpt shouldBeEqualNullable e2.booleanOpt } + catchRewrapAssert("Error Comparing: byteOpt") { e1.byteOpt shouldBeEqualNullable e2.byteOpt } + catchRewrapAssert("Error Comparing: shortOpt") { e1.shortOpt shouldBeEqualNullable e2.shortOpt } + catchRewrapAssert("Error Comparing: intOpt") { e1.intOpt shouldBeEqualNullable e2.intOpt } + catchRewrapAssert("Error Comparing: longOpt") { e1.longOpt shouldBeEqualNullable e2.longOpt } + catchRewrapAssert("Error Comparing: floatOpt") { e1.floatOpt shouldBeEqualNullable(e2.floatOpt) } + catchRewrapAssert("Error Comparing: doubleOpt") { e1.doubleOpt shouldBeEqualNullable(e2.doubleOpt) } + catchRewrapAssert("Error Comparing: byteArrayOpt") { (e1.byteArrayOpt?.toList() ?: emptyList()) shouldBeEqualNullable (e2.byteArrayOpt?.toList() ?: emptyList()) } + catchRewrapAssert("Error Comparing: customOpt") { e1.customOpt shouldBeEqualNullable e2.customOpt } } @Serializable diff --git a/terpal-sql-core-testing/src/commonMain/kotlin/io/exoquery/sql/encodingdata/MiscOps.kt b/terpal-sql-core-testing/src/commonMain/kotlin/io/exoquery/sql/encodingdata/MiscOps.kt index b11e247..63b518f 100644 --- a/terpal-sql-core-testing/src/commonMain/kotlin/io/exoquery/sql/encodingdata/MiscOps.kt +++ b/terpal-sql-core-testing/src/commonMain/kotlin/io/exoquery/sql/encodingdata/MiscOps.kt @@ -5,7 +5,7 @@ import kotlin.test.assertEquals infix fun T.shouldBe(other: T) = assertEquals(other, this) infix fun A.shouldBeEqual(expected: A): A { - assertEquals(this, expected) + assertEquals(expected, this) return this } @@ -29,4 +29,4 @@ infix fun SerializeableTestType?.shouldBeEqualEmptyNullable(expected: Serializea val actualOrEmpty = this ?: SerializeableTestType("") val expectedOrEmpty = expected ?: SerializeableTestType("") assertEquals(expectedOrEmpty, actualOrEmpty) -} \ No newline at end of file +} diff --git a/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/encodingdata/JavaEntities.kt b/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/encodingdata/JavaEntities.kt index 3af29ca..43cfa93 100644 --- a/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/encodingdata/JavaEntities.kt +++ b/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/encodingdata/JavaEntities.kt @@ -7,7 +7,6 @@ import io.kotest.matchers.bigdecimal.shouldBeEqualIgnoringScale import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import java.math.BigDecimal -import java.sql.Date import java.time.LocalDateTime import java.time.ZoneOffset import java.util.* @@ -50,10 +49,15 @@ fun insert(e: JavaTestEntity): ControllerAction { } fun verify(e: JavaTestEntity, expected: JavaTestEntity) { - e.bigDecimalMan shouldBeEqualIgnoringScale expected.bigDecimalMan - e.javaUtilDateMan shouldBeEqual expected.javaUtilDateMan - e.uuidMan shouldBeEqual expected.uuidMan - e.bigDecimalOpt shouldBeEqualIgnoringScaleNullable expected.bigDecimalOpt - e.javaUtilDateOpt shouldBeEqualNullable expected.javaUtilDateOpt - e.uuidOpt shouldBeEqualNullable expected.uuidOpt + fun catchRewrapAssert(msg: String, assertFun: () -> Unit) = + try { assertFun() } catch (e: java.lang.AssertionError) { + throw java.lang.AssertionError(msg, e) + } + + catchRewrapAssert("Error Comparing: bigDecimalMan") { e.bigDecimalMan shouldBeEqualIgnoringScale expected.bigDecimalMan } + catchRewrapAssert("Error Comparing: javaUtilDateMan") { e.javaUtilDateMan shouldBeEqual expected.javaUtilDateMan } + catchRewrapAssert("Error Comparing: uuidMan") { e.uuidMan shouldBeEqual expected.uuidMan } + catchRewrapAssert("Error Comparing: bigDecimalOpt") { e.bigDecimalOpt shouldBeEqualIgnoringScaleNullable expected.bigDecimalOpt } + catchRewrapAssert("Error Comparing: javaUtilDateOpt") { e.javaUtilDateOpt shouldBeEqualNullable expected.javaUtilDateOpt } + catchRewrapAssert("Error Comparing: uuidOpt") { e.uuidOpt shouldBeEqualNullable expected.uuidOpt } }