From e18d91ac9214c62938921a8f84a2d4e68049c75a Mon Sep 17 00:00:00 2001 From: Alexander Ioffe Date: Wed, 12 Nov 2025 16:54:08 -0500 Subject: [PATCH] R2DBC supp --- .../io/exoquery/controller/Controller.kt | 7 +- .../io/exoquery/controller/ControllerError.kt | 4 +- .../io/exoquery/controller/DecoderAny.kt | 12 +- .../io/exoquery/controller/EncoderAny.kt | 3 +- .../io/exoquery/controller/EncodingContext.kt | 17 +- .../PreparedStatementElementEncoder.kt | 2 +- .../io/exoquery/controller/RowDecoder.kt | 15 +- controller-r2dbc/build.gradle.kts | 1 + .../controller/r2dbc/R2dbcController.kt | 4 +- .../controller/r2dbc/R2dbcControllers.kt | 112 ++++--- .../controller/r2dbc/R2dbcDecoders.kt | 8 +- .../controller/r2dbc/R2dbcEncoders.kt | 286 +++++++++++++++++- .../controller/r2dbc/R2dbcPlaceholderUtil.kt | 23 ++ .../test/resources/db/sqlserver-schema.sql | 4 +- terpal-sql-r2dbc/build.gradle.kts | 6 + .../io/exoquery/r2dbc/BatchActionSpecData.kt | 11 + .../io/exoquery/r2dbc/TestDatabasesR2dbc.kt | 37 +++ .../r2dbc/encodingdata/JavaEntities.kt | 20 +- .../io/exoquery/r2dbc/h2/BasicActionSpec.kt | 57 ++++ .../io/exoquery/r2dbc/h2/BasicQuerySpec.kt | 139 +++++++++ .../io/exoquery/r2dbc/h2/BatchValuesSpec.kt | 41 +++ .../io/exoquery/r2dbc/h2/EncodingSpec.kt | 156 ++++++++++ .../io/exoquery/r2dbc/h2/InQuerySpec.kt | 64 ++++ .../io/exoquery/r2dbc/h2/TransactionSpec.kt | 66 ++++ .../exoquery/r2dbc/mysql/BasicActionSpec.kt | 40 +++ .../io/exoquery/r2dbc/mysql/BasicQuerySpec.kt | 139 +++++++++ .../io/exoquery/r2dbc/mysql/EncodingSpec.kt | 158 ++++++++++ .../io/exoquery/r2dbc/mysql/InQuerySpec.kt | 65 ++++ .../io/exoquery/r2dbc/mysql/InjectionSpec.kt | 45 +++ .../exoquery/r2dbc/mysql/TransactionSpec.kt | 67 ++++ .../exoquery/r2dbc/oracle/BasicActionSpec.kt | 64 ++++ .../exoquery/r2dbc/oracle/BasicQuerySpec.kt | 138 +++++++++ .../io/exoquery/r2dbc/oracle/EncodingSpec.kt | 158 ++++++++++ .../io/exoquery/r2dbc/oracle/InQuerySpec.kt | 64 ++++ .../io/exoquery/r2dbc/oracle/InjectionSpec.kt | 44 +++ .../exoquery/r2dbc/oracle/TransactionSpec.kt | 66 ++++ .../exoquery/r2dbc/sqlserver/EncodingSpec.kt | 158 ++++++++++ .../exoquery/r2dbc/sqlserver/InjectionSpec.kt | 44 +++ .../r2dbc/sqlserver/TransactionSpec.kt | 66 ++++ .../src/test/resources/db/h2-schema.sql | 76 +++++ 40 files changed, 2395 insertions(+), 92 deletions(-) create mode 100644 controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcPlaceholderUtil.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/BasicActionSpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/BasicQuerySpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/BatchValuesSpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/EncodingSpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/InQuerySpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/TransactionSpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/BasicActionSpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/BasicQuerySpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/EncodingSpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/InQuerySpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/InjectionSpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/TransactionSpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/BasicActionSpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/BasicQuerySpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/EncodingSpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/InQuerySpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/InjectionSpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/TransactionSpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/sqlserver/EncodingSpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/sqlserver/InjectionSpec.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/sqlserver/TransactionSpec.kt create mode 100644 terpal-sql-r2dbc/src/test/resources/db/h2-schema.sql diff --git a/controller-core/src/commonMain/kotlin/io/exoquery/controller/Controller.kt b/controller-core/src/commonMain/kotlin/io/exoquery/controller/Controller.kt index 25267fb..a5672eb 100644 --- a/controller-core/src/commonMain/kotlin/io/exoquery/controller/Controller.kt +++ b/controller-core/src/commonMain/kotlin/io/exoquery/controller/Controller.kt @@ -39,8 +39,13 @@ interface WithEncoding { val startingStatementIndex: StartingIndex get() = StartingIndex.Zero // default for JDBC is 1 so this needs to be overrideable val startingResultRowIndex: StartingIndex get() = StartingIndex.Zero // default for JDBC is 1 so this needs to be overrideable + /** + * Strictly for error reporting purposes. Some databases have types that are relevant to encoding/decoding that the encoder should show when an error occurs. + */ + fun dbTypeIsRelevant(): Boolean = true + fun createEncodingContext(session: Session, stmt: Stmt) = - EncodingContext(session, stmt, encodingConfig.timezone) + EncodingContext(session, stmt, encodingConfig.timezone, startingStatementIndex, dbTypeIsRelevant()) fun createDecodingContext(session: Session, row: ResultRow, debugInfo: QueryDebugInfo?) = DecodingContext(session, row, encodingConfig.timezone, startingResultRowIndex, catchRethrowColumnInfoExtractError { extractColumnInfo(row) }, debugInfo) diff --git a/controller-core/src/commonMain/kotlin/io/exoquery/controller/ControllerError.kt b/controller-core/src/commonMain/kotlin/io/exoquery/controller/ControllerError.kt index dddfe70..8e19178 100644 --- a/controller-core/src/commonMain/kotlin/io/exoquery/controller/ControllerError.kt +++ b/controller-core/src/commonMain/kotlin/io/exoquery/controller/ControllerError.kt @@ -1,3 +1,5 @@ package io.exoquery.controller -class ControllerError(message: String, cause: Throwable? = null) : Exception(message, cause) +open class ControllerError(message: String, cause: Throwable? = null) : Exception(message, cause) { + class DecodingError(message: String, cause: Throwable? = null) : ControllerError(message, cause) +} 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 0c6574c..c9acfe2 100644 --- a/controller-core/src/commonMain/kotlin/io/exoquery/controller/DecoderAny.kt +++ b/controller-core/src/commonMain/kotlin/io/exoquery/controller/DecoderAny.kt @@ -9,11 +9,19 @@ open class DecoderAny( ): SqlDecoder() { override fun isNullable(): Boolean = false override fun decode(ctx: DecodingContext, index: Int): T { - val value = f(ctx, index) + val value = + try { + f(ctx, index) + } catch (ex: Exception) { + val msg = + "Error decoding column at index $index for type ${type.simpleName}" + + (ctx.columnInfoSafe(index)?.let { " (${it.name}:${it.type})" } ?: "") + throw ControllerError.DecodingError(msg, ex) + } if (value == null && !isNullable()) { val msg = "Got null value for non-nullable column of type ${type.simpleName} at index $index" + - (ctx.columnInfo(index-1)?.let { " (${it.name}:${it.type})" } ?: "") + (ctx.columnInfoSafe(index)?.let { " (${it.name}:${it.type})" } ?: "") throw NullPointerException(msg) } 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 959c42c..17244b2 100644 --- a/controller-core/src/commonMain/kotlin/io/exoquery/controller/EncoderAny.kt +++ b/controller-core/src/commonMain/kotlin/io/exoquery/controller/EncoderAny.kt @@ -24,7 +24,8 @@ open class EncoderAny( else setNull(index, ctx.stmt, jdbcType) } catch (e: Throwable) { - throw EncodingException("Error encoding ${type} value: $value at index: $index (whose jdbc-type: ${jdbcType})", e) + val jdbcTypeInfo = if (ctx.dbTypeIsRelevant) " (whose database-type: ${jdbcType})" else "" + throw EncodingException("Error encoding ${type} value: $value at (${ctx.startingIndex.description}) index: $index${jdbcTypeInfo}", e) } } diff --git a/controller-core/src/commonMain/kotlin/io/exoquery/controller/EncodingContext.kt b/controller-core/src/commonMain/kotlin/io/exoquery/controller/EncodingContext.kt index b370ae5..4c58805 100644 --- a/controller-core/src/commonMain/kotlin/io/exoquery/controller/EncodingContext.kt +++ b/controller-core/src/commonMain/kotlin/io/exoquery/controller/EncodingContext.kt @@ -4,13 +4,19 @@ import kotlinx.datetime.TimeZone data class QueryDebugInfo(val query: String) -open class EncodingContext(open val session: Session, open val stmt: Stmt, open val timeZone: TimeZone) +open class EncodingContext( + open val session: Session, + open val stmt: Stmt, + open val timeZone: TimeZone, + open val startingIndex: StartingIndex, + open val dbTypeIsRelevant: Boolean +) open class DecodingContext( open val session: Session, open val row: Row, open val timeZone: TimeZone, open val startingIndex: StartingIndex, - val columnInfos: List?, + open val columnInfos: List?, open val debugInfo: QueryDebugInfo? ) { /** @@ -19,4 +25,11 @@ open class DecodingContext( */ fun columnInfo(index: Int): ColumnInfo? = columnInfos?.get(index-startingIndex.value) + + fun columnInfoSafe(index: Int): ColumnInfo? = + try { + columnInfo(index) + } catch (ex: IndexOutOfBoundsException) { + null + } } 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 4fb07b6..35a7ab6 100644 --- a/controller-core/src/commonMain/kotlin/io/exoquery/controller/PreparedStatementElementEncoder.kt +++ b/controller-core/src/commonMain/kotlin/io/exoquery/controller/PreparedStatementElementEncoder.kt @@ -132,7 +132,7 @@ class PreparedStatementElementEncoder( serializer.serialize(this, value) else (serializer as? KSerializer)?.nullable?.serialize(this, value) - ?: throw IllegalArgumentException("Cannot encode null value at index ${index} with the descriptor ${desc}. The serializer ${serializer} could not be converted into a KSerializer.") + ?: throw IllegalArgumentException("cannot encode null value at (${ctx.startingIndex.value}) index ${index} with the descriptor ${desc}. The serializer ${serializer} could not be converted into a KSerializer.") } else -> { 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 00026f8..ed3fee2 100644 --- a/controller-core/src/commonMain/kotlin/io/exoquery/controller/RowDecoder.kt +++ b/controller-core/src/commonMain/kotlin/io/exoquery/controller/RowDecoder.kt @@ -76,9 +76,10 @@ fun SerialDescriptor.verifyColumns(columns: List): Unit { sealed interface StartingIndex { val value: Int + val description: String - object Zero: StartingIndex { override val value: Int = 0 } - object One: StartingIndex { override val value: Int = 1 } + object Zero: StartingIndex { override val value: Int = 0; override val description: String = "zero-based" } + object One: StartingIndex { override val value: Int = 1; override val description: String = "one-based" } } sealed interface RowDecoderType { @@ -120,7 +121,7 @@ class RowDecoder private constructor( RowDecoder(ctx, this.serializersModule, initialRowIndex, api, decoders, type, json, debugMode, endCallback) // helper to get column names - fun colName(index: Int) = ctx.columnInfos?.get(index)?.name ?: "" + fun colName(index: Int) = ctx.columnInfoSafe(index)?.name ?: "" var rowIndex: Int = initialRowIndex var classIndex: Int = 0 @@ -128,7 +129,7 @@ class RowDecoder private constructor( fun nextRowIndex(desc: SerialDescriptor, descIndex: Int, note: String = ""): Int { val curr = rowIndex if (debugMode) { - println("[RowDecoder] Get Row ${ctx.columnInfo(rowIndex)}, Index: ${curr} - (${descIndex}) ${desc.getElementDescriptor(descIndex)} - (Preview:${api.preview(rowIndex, ctx.row)})" + (if (note != "") " - ${note}" else "")) + println("[RowDecoder] Get Row ${ctx.columnInfoSafe(rowIndex)}, Index: ${curr} - (${descIndex}) ${desc.getElementDescriptor(descIndex)} - (Preview:${api.preview(rowIndex, ctx.row)})" + (if (note != "") " - ${note}" else "")) } rowIndex += 1 return curr @@ -137,7 +138,7 @@ class RowDecoder private constructor( fun nextRowIndex(note: String = ""): Int { val curr = rowIndex if (debugMode) { - println("[RowDecoder] Get Next Row Index ${ctx.columnInfo(rowIndex)?.name} - (Preview:${api.preview(rowIndex, ctx.row)})" + (if (note != "") " - ${note}" else "")) + println("[RowDecoder] Get Next Row Index ${ctx.columnInfoSafe(rowIndex)?.name} - (Preview:${api.preview(rowIndex, ctx.row)})" + (if (note != "") " - ${note}" else "")) } rowIndex += 1 return curr @@ -390,7 +391,7 @@ class RowDecoder private constructor( } else -> - throw IllegalArgumentException("Unsupported kind: `${desc.kind}` at index: ${index} (info:${ctx.columnInfos?.get(index)})") + throw IllegalArgumentException("Unsupported kind: `${desc.kind}` at (${ctx.startingIndex.description}) index: ${index} (info:${ctx.columnInfos?.get(index)})") } } @@ -418,7 +419,7 @@ class RowDecoder private constructor( element != null -> element //now: element == null must be true descriptor.getElementDescriptor(index).isNullable -> null as T - else -> throw IllegalArgumentException("Error at column ${ctx.columnInfos?.get(index)}. Found null element at index ${index} of descriptor ${descriptor.getElementDescriptor(index)} (of ${descriptor}) where null values are not allowed.") + else -> throw IllegalArgumentException("Error at column ${ctx.columnInfos?.get(index)}. Found null element at (${ctx.startingIndex.description}) index ${index} of descriptor ${descriptor.getElementDescriptor(index)} (of ${descriptor}) where null values are not allowed.") } } diff --git a/controller-r2dbc/build.gradle.kts b/controller-r2dbc/build.gradle.kts index eefd5a1..d6b3b94 100644 --- a/controller-r2dbc/build.gradle.kts +++ b/controller-r2dbc/build.gradle.kts @@ -31,6 +31,7 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactive:1.8.1") // R2DBC SPI only (no specific driver) api("io.r2dbc:r2dbc-spi:1.0.0.RELEASE") + // Need to pull in Postgres driver to use it's Json object for wrapping compileOnly("org.postgresql:r2dbc-postgresql:1.0.5.RELEASE") } } diff --git a/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcController.kt b/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcController.kt index 25af63b..d17e860 100644 --- a/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcController.kt +++ b/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcController.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.reactive.awaitFirstOrNull -import kotlinx.coroutines.reactive.collect abstract class R2dbcController( override val encodingConfig: R2dbcEncodingConfig = R2dbcEncodingConfig.Default(), @@ -25,12 +24,13 @@ abstract class R2dbcController( HasTransactionalityR2dbc { override fun DefaultOpts(): R2dbcExecutionOptions = R2dbcExecutionOptions.Default() + override fun dbTypeIsRelevant(): Boolean = false override val encodingApi: R2dbcSqlEncoding = object: JavaSqlEncoding, BasicEncoding by R2dbcBasicEncoding, JavaTimeEncoding by R2dbcTimeEncoding, - JavaUuidEncoding by R2dbcUuidEncoding {} + JavaUuidEncoding by R2dbcUuidEncodingNative {} override val allEncoders: Set> by lazy { encodingApi.computeEncoders() + encodingConfig.additionalEncoders } override val allDecoders: Set> by lazy { encodingApi.computeDecoders() + encodingConfig.additionalDecoders } diff --git a/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcControllers.kt b/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcControllers.kt index 3dc06d3..1a808b2 100644 --- a/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcControllers.kt +++ b/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcControllers.kt @@ -4,8 +4,7 @@ import io.exoquery.controller.BasicEncoding import io.exoquery.controller.JavaSqlEncoding import io.exoquery.controller.JavaTimeEncoding import io.exoquery.controller.JavaUuidEncoding -import io.exoquery.controller.SqlDecoder -import io.exoquery.controller.SqlEncoder +import io.exoquery.controller.StartingIndex import io.r2dbc.spi.Connection import io.r2dbc.spi.ConnectionFactory import io.r2dbc.spi.Row @@ -27,53 +26,78 @@ object R2dbcControllers { object: JavaSqlEncoding, BasicEncoding by R2dbcBasicEncoding, JavaTimeEncoding by R2dbcTimeEncoding, - JavaUuidEncoding by R2dbcUuidEncoding {} - - override protected fun changePlaceholders(sql: String): String { - // Postgres R2DBC uses $1, $2... for placeholders. - // Most other R2DBC drivers (e.g. MSSQL) use '?', so do not rewrite for them. - val sb = StringBuilder() - var paramIndex = 1 - var i = 0 - while (i < sql.length) { - val c = sql[i] - if (c == '?') { - sb.append('$').append(paramIndex) - paramIndex++ - i++ - } else { - sb.append(c) - i++ - } - } - return sb.toString() - } + JavaUuidEncoding by R2dbcUuidEncodingNative {} + + override protected fun changePlaceholders(sql: String): String = + changePlaceholdersIn(sql) { index -> "$${index + 1}" } } class SqlServer( encodingConfig: R2dbcEncodingConfig = R2dbcEncodingConfig.Default(), override val connectionFactory: ConnectionFactory ): R2dbcController(encodingConfig,connectionFactory) { - override protected fun changePlaceholders(sql: String): String { - // MSSQL R2DBC uses @1, @2... for placeholders. - // Most other R2DBC drivers (e.g. MSSQL) use '?', so do not rewrite for them. - val sb = StringBuilder() - var paramIndex = 0 - var i = 0 - while (i < sql.length) { - val c = sql[i] - if (c == '?') { - // Params are named like @Param0, @Param1, ... parameter - // binding is indexed based. SqlServer R2DBC supports this. - sb.append("@Param${paramIndex}") - paramIndex++ - i++ - } else { - sb.append(c) - i++ - } - } - return sb.toString() - } + + override val encodingApi: R2dbcSqlEncoding = + object: JavaSqlEncoding, + BasicEncoding by R2dbcBasicEncoding, + JavaTimeEncoding by R2dbcTimeEncodingSqlServer, + JavaUuidEncoding by R2dbcUuidEncodingString {} + + /** Change the names of the variable params so they can be used by the SQL Server R2DBC driver + * The SQL Server R2DBC driver supports named-parameter binding i.e. row.bind("@firstName", value) + * as well as positional binding i.e. row.bind(0, value). When positional binding is done, the names + * of ther parameters in the SQL string are ignored. Since we are using positional binding, + * we can use any names we want so we want to choose names that are user friendly to debug. + * Therefore we choose @ParamX where X is the index-kind that the context actually uses. + */ + override protected fun changePlaceholders(sql: String): String = + changePlaceholdersIn(sql) { index -> "@Param${index + startingStatementIndex.value}" } + } + + class Mysql( + encodingConfig: R2dbcEncodingConfig = R2dbcEncodingConfig.Default(), + override val connectionFactory: ConnectionFactory + ): R2dbcController(encodingConfig, connectionFactory) { + + override val encodingApi: R2dbcSqlEncoding = + object: JavaSqlEncoding, + BasicEncoding by R2dbcBasicEncoding, + JavaTimeEncoding by R2dbcTimeEncoding, + JavaUuidEncoding by R2dbcUuidEncodingString {} + + // MySQL R2DBC uses '?' positional parameters, so no change + override fun changePlaceholders(sql: String): String = sql + } + + class H2( + encodingConfig: R2dbcEncodingConfig = R2dbcEncodingConfig.Default(), + override val connectionFactory: ConnectionFactory + ): R2dbcController(encodingConfig, connectionFactory) { + + override val startingResultRowIndex: StartingIndex get() = StartingIndex.Zero + + override val encodingApi: R2dbcSqlEncoding = + object: JavaSqlEncoding, + BasicEncoding by R2dbcBasicEncodingH2, // Need to override Int encoders with Long + JavaTimeEncoding by R2dbcTimeEncodingH2, + JavaUuidEncoding by R2dbcUuidEncodingNative {} + + override protected fun changePlaceholders(sql: String): String = + changePlaceholdersIn(sql) { index -> "$${index + 1}" } + } + + class Oracle( + encodingConfig: R2dbcEncodingConfig = R2dbcEncodingConfig.Default(), + override val connectionFactory: ConnectionFactory + ): R2dbcController(encodingConfig, connectionFactory) { + + override val encodingApi: R2dbcSqlEncoding = + object: JavaSqlEncoding, + BasicEncoding by R2dbcBasicEncodingOracle, + JavaTimeEncoding by R2dbcTimeEncodingOracle, + JavaUuidEncoding by R2dbcUuidEncodingString {} + + override protected fun changePlaceholders(sql: String): String = + changePlaceholdersIn(sql) { index -> ":${index + 1}" } } } 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 904acba..9357fe6 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 @@ -4,12 +4,6 @@ import io.exoquery.controller.DecoderAny import io.exoquery.controller.SqlDecoder import io.r2dbc.spi.Connection import io.r2dbc.spi.Row -import kotlinx.datetime.toKotlinInstant -import kotlinx.datetime.toKotlinLocalDate -import kotlinx.datetime.toKotlinLocalDateTime -import kotlinx.datetime.toKotlinLocalTime -import java.time.* -import java.util.* import kotlin.reflect.KClass class R2dbcDecoderAny( @@ -51,7 +45,7 @@ object R2dbcDecoders { R2dbcTimeEncoding.JOffsetTimeDecoder, R2dbcTimeEncoding.JOffsetDateTimeDecoder, R2dbcTimeEncoding.JDateDecoder, - R2dbcUuidEncoding.JUuidDecoder, + R2dbcUuidEncodingNative.JUuidDecoder, R2dbcAdditionalEncoding.BigDecimalDecoder ) 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 f4c7dbd..2369d3b 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 @@ -1,13 +1,12 @@ package io.exoquery.controller.r2dbc import io.exoquery.controller.BasicEncoding +import io.exoquery.controller.ControllerError import io.exoquery.controller.EncoderAny import io.exoquery.controller.JavaTimeEncoding import io.exoquery.controller.JavaUuidEncoding import io.exoquery.controller.SqlDecoder import io.exoquery.controller.SqlEncoder -import io.exoquery.controller.SqlJson -import io.exoquery.controller.r2dbc.R2dbcTimeEncoding.NA import io.r2dbc.spi.Connection import io.r2dbc.spi.Row import io.r2dbc.spi.Statement @@ -20,13 +19,12 @@ import kotlinx.datetime.toKotlinInstant import kotlinx.datetime.toKotlinLocalDate import kotlinx.datetime.toKotlinLocalDateTime import kotlinx.datetime.toKotlinLocalTime -import java.sql.Types import java.time.* import java.util.* import kotlin.reflect.KClass // Note: R2DBC has no java.sql.Types. We keep an Int id for compatibility but do not use it. -class R2dbcEncoderAny( +open class R2dbcEncoderAny( override val dataType: Int, override val type: KClass, override val f: (R2dbcEncodingContext, T, Int) -> Unit, @@ -39,9 +37,102 @@ class R2dbcEncoderAny( f ) -object R2dbcBasicEncoding: BasicEncoding { - private const val NA = 0 +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 = + R2dbcDecoderAny(Byte::class) { ctx, i -> ctx.row.get(i, java.lang.Short::class.java)?.toByte() } + + override val FloatDecoder: SqlDecoder = + R2dbcDecoderAny(Float::class) { ctx, i -> + when (val value = ctx.row.get(i)) { + null -> null + is Float -> value + is Double -> value.toFloat() + is String -> value.toFloat() + else -> throw ControllerError.DecodingError( + "Cannot decode H2 FLOAT column at index $i: unsupported underlying type ${value::class.simpleName}" + ) + } + } + + override val IntDecoder: SqlDecoder = + R2dbcDecoderAny(Int::class) { ctx, i -> + when (val value = ctx.row.get(i)) { + null -> null + is Int -> value.toInt() + is Long -> value.toInt() + is Short -> value.toInt() + is String -> value.toInt() + else -> throw ControllerError.DecodingError( + "Cannot decode H2 INT column at index $i: unsupported underlying type ${value?.let { it::class.simpleName }}" + ) + } + } + + override val LongDecoder: SqlDecoder = + R2dbcDecoderAny(Long::class) { ctx, i -> + when (val value = ctx.row.get(i)) { + null -> null + is Long -> value.toLong() + is Int -> value.toLong() + is Short -> value.toLong() + is String -> value.toLong() + else -> throw ControllerError.DecodingError( + "Cannot decode H2 BIGINT column at index $i: unsupported underlying type ${value?.let { it::class.simpleName }}" + ) + } + } + override val ShortDecoder: SqlDecoder = + R2dbcDecoderAny(Short::class) { ctx, i -> + when (val value = ctx.row.get(i)) { + null -> null + is Short -> value.toShort() + is Int -> value.toShort() + is Long -> value.toShort() + is String -> value.toShort() + else -> throw ControllerError.DecodingError( + "Cannot decode H2 SMALLINT column at index $i: unsupported underlying type ${value?.let { it::class.simpleName }}" + ) + } + } +} + +// Oracle has this crazy behavior where empty strings are treated as NULLs in R2DBC. Need to account for that by converting to "" when +// the get method returns null. Need to account for this behavior by turning null values from getString into empty strings. +// The get(i, String::class.java) function is used in the StringDecoder as well as the CharDecoder. +// Note that this will not mess up the functionality of a Nullable decoder (i.e. the result of R2dbcEncoder.asNullable()) because the +// nullable decoder first checks the row using row.get(index) == null before calling the non-nullable decoder. If the row is null then the non-null +// decoder is not invoked so we would not care about it converting a `null` value to an empty String either way. +// 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 = + R2dbcDecoderAny(Char::class) { ctx, i -> ctx.row.get(i, String::class.java)?.let { it[0] } ?: Char.MIN_VALUE } + override val StringDecoder: SqlDecoder = + R2dbcDecoderAny(String::class) { ctx, i -> ctx.row.get(i, String::class.java) ?: "" } + override val ByteArrayDecoder: SqlDecoder = + 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 = + // R2dbcEncoderAny(NA, Boolean::class) { ctx, v, i -> ctx.stmt.bind(i, if (v) 1 else 0) } + //override val BooleanDecoder: SqlDecoder = + // 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 = R2dbcEncoderAny(NA, Boolean::class) { ctx, v, i -> ctx.stmt.bind(i, v) } override val ByteEncoder: SqlEncoder = @@ -92,8 +183,150 @@ object R2dbcBasicEncoding: BasicEncoding { private fun kotlinx.datetime.TimeZone.toJava(): TimeZone = TimeZone.getTimeZone(this.toJavaZoneId()) -object R2dbcTimeEncoding: JavaTimeEncoding { - private const val NA = 0 +object R2dbcTimeEncoding: R2dbcTimeEncodingBase() + +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) } + } + + /** 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()) + } + } +} + +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 JDateDecoder: SqlDecoder = + R2dbcDecoderAny(Date::class) { ctx, i -> + ctx.row.get(i, OffsetDateTime::class.java)?.toInstant()?.let { Date.from(it) } + } + + // 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 InstantDecoder: SqlDecoder = + R2dbcDecoderAny(kotlinx.datetime.Instant::class) { ctx, i -> + ctx.row.get(i, OffsetDateTime::class.java)?.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 JInstantDecoder: SqlDecoder = + R2dbcDecoderAny(Instant::class) { ctx, i -> + ctx.row.get(i, OffsetDateTime::class.java)?.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 JOffsetTimeDecoder: SqlDecoder = + R2dbcDecoderAny(OffsetTime::class) { ctx, i -> + ctx.row.get(i, OffsetDateTime::class.java)?.toOffsetTime() + } +} + + + +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 JDateDecoder: SqlDecoder = + R2dbcDecoderAny(Date::class) { ctx, i -> + ctx.row.get(i, OffsetDateTime::class.java)?.toInstant()?.let { Date.from(it) } + } + + // 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 InstantDecoder: SqlDecoder = + R2dbcDecoderAny(kotlinx.datetime.Instant::class) { ctx, i -> + ctx.row.get(i, OffsetDateTime::class.java)?.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 JZonedDateTimeDecoder: SqlDecoder = + R2dbcDecoderAny(ZonedDateTime::class) { ctx, i -> + ctx.row.get(i, OffsetDateTime::class.java)?.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 JInstantDecoder: SqlDecoder = + R2dbcDecoderAny(Instant::class) { ctx, i -> + ctx.row.get(i, OffsetDateTime::class.java)?.toInstant() + } +} + +abstract class R2dbcTimeEncodingBase: JavaTimeEncoding { // KMP datetime -> convert to java.time before binding override val LocalDateEncoder: SqlEncoder = @@ -129,10 +362,6 @@ object R2dbcTimeEncoding: JavaTimeEncoding { override val JOffsetDateTimeEncoder: SqlEncoder = R2dbcEncoderAny(NA, OffsetDateTime::class) { ctx, v, i -> ctx.stmt.bind(i, v) } - // java.util.Date -> bind as Instant (supported type) - override val JDateEncoder: SqlEncoder = - R2dbcEncoderAny(NA, Date::class) { ctx, v, i -> ctx.stmt.bind(i, Instant.ofEpochMilli(v.getTime())) } - // KMP datetime decoders via java.time override val LocalDateDecoder: SqlDecoder = R2dbcDecoderAny(kotlinx.datetime.LocalDate::class) { ctx, i -> ctx.row.get(i, LocalDate::class.java)?.toKotlinLocalDate() } @@ -159,12 +388,20 @@ object R2dbcTimeEncoding: JavaTimeEncoding { override val JOffsetDateTimeDecoder: SqlDecoder = R2dbcDecoderAny(OffsetDateTime::class) { ctx, i -> ctx.row.get(i, OffsetDateTime::class.java) } - // java.util.Date from Instant - override val JDateDecoder: SqlDecoder = + + /** 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 = + 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 = R2dbcDecoderAny(Date::class) { ctx, i -> ctx.row.get(i, Instant::class.java)?.let { Date.from(it) } } } -object R2dbcUuidEncoding: JavaUuidEncoding { +object R2dbcUuidEncodingNative: JavaUuidEncoding { private const val NA = 0 override val JUuidEncoder: SqlEncoder = @@ -174,6 +411,23 @@ object R2dbcUuidEncoding: JavaUuidEncoding { 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 JUuidDecoder: SqlDecoder = + R2dbcDecoderAny(UUID::class) { ctx, i -> + ctx.row.get(i, String::class.java)?.let { UUID.fromString(it) } + } +} + object R2dbcAdditionalEncoding { private const val NA = 0 @@ -209,7 +463,7 @@ object R2dbcEncoders { R2dbcTimeEncoding.JOffsetTimeEncoder, R2dbcTimeEncoding.JOffsetDateTimeEncoder, R2dbcTimeEncoding.JDateEncoder, - R2dbcUuidEncoding.JUuidEncoder, + R2dbcUuidEncodingNative.JUuidEncoder, R2dbcAdditionalEncoding.BigDecimalEncoder ) diff --git a/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcPlaceholderUtil.kt b/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcPlaceholderUtil.kt new file mode 100644 index 0000000..7a9537a --- /dev/null +++ b/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcPlaceholderUtil.kt @@ -0,0 +1,23 @@ +package io.exoquery.controller.r2dbc + +internal fun changePlaceholdersIn(sql: String, changeTo: (Int) -> String): String { + // MSSQL R2DBC uses @1, @2... for placeholders. + // Most other R2DBC drivers (e.g. MSSQL) use '?', so do not rewrite for them. + val sb = StringBuilder() + var paramIndex = 0 + var i = 0 + while (i < sql.length) { + val c = sql[i] + if (c == '?') { + // Params are named like @Param0, @Param1, ... parameter + // binding is indexed based. SqlServer R2DBC supports this. + sb.append(changeTo(paramIndex)) + paramIndex++ + i++ + } else { + sb.append(c) + i++ + } + } + return sb.toString() +} diff --git a/terpal-sql-jdbc/src/test/resources/db/sqlserver-schema.sql b/terpal-sql-jdbc/src/test/resources/db/sqlserver-schema.sql index baae803..6126dbd 100644 --- a/terpal-sql-jdbc/src/test/resources/db/sqlserver-schema.sql +++ b/terpal-sql-jdbc/src/test/resources/db/sqlserver-schema.sql @@ -68,9 +68,9 @@ CREATE TABLE EncodingTestEntity( CREATE TABLE JavaTestEntity( bigDecimalMan DECIMAL(5,2), - javaUtilDateMan DATETIME, + javaUtilDateMan DATETIMEOFFSET, -- java.util.Date i.e. legacy date with time zone uuidMan VARCHAR(255), bigDecimalOpt DECIMAL(5,2), - javaUtilDateOpt DATETIME, + javaUtilDateOpt DATETIMEOFFSET, -- java.util.Date i.e. legacy date with time zone uuidOpt VARCHAR(255) ); diff --git a/terpal-sql-r2dbc/build.gradle.kts b/terpal-sql-r2dbc/build.gradle.kts index c06f746..a930be2 100644 --- a/terpal-sql-r2dbc/build.gradle.kts +++ b/terpal-sql-r2dbc/build.gradle.kts @@ -59,6 +59,12 @@ kotlin { api("org.postgresql:r2dbc-postgresql:1.0.5.RELEASE") // R2DBC SQL Server driver api("io.r2dbc:r2dbc-mssql:1.0.2.RELEASE") + // R2DBC MySQL driver + api("io.asyncer:r2dbc-mysql:1.1.0") + // R2DBC H2 driver + api("io.r2dbc:r2dbc-h2:1.0.0.RELEASE") + // R2DBC Oracle driver + api("com.oracle.database.r2dbc:oracle-r2dbc:1.2.0") } } val jvmTest by getting { diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/BatchActionSpecData.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/BatchActionSpecData.kt index 23de474..e3ef3f1 100644 --- a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/BatchActionSpecData.kt +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/BatchActionSpecData.kt @@ -33,6 +33,17 @@ object Ex3_BatchReturnIds { val result = products.mapIndexed { i, p -> p.copy(id = i + 1) } } +// TODO in upcoming Version-Bump, line up the batch.actionReturning("col") on batch queries with regularAction.actionReturningIds("col") +object Ex3_BatchReturnIdsExplicit { + val products = makeProducts(20) + val op = + SqlBatch { p: Product -> "INSERT INTO Product (description, sku) VALUES (${p.description}, ${p.sku})" } + .values(products.asSequence()).actionReturning("id") + val get = Sql("SELECT id, description, sku FROM Product").queryOf() + val opResult = (1..20).toList() + val result = products.mapIndexed { i, p -> p.copy(id = i + 1) } +} + object Ex4_BatchReturnRecord { val products = makeProducts(20) val op = diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/TestDatabasesR2dbc.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/TestDatabasesR2dbc.kt index 3458481..9ca0405 100644 --- a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/TestDatabasesR2dbc.kt +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/TestDatabasesR2dbc.kt @@ -53,4 +53,41 @@ object TestDatabasesR2dbc { .build() ) } + + val mysql: ConnectionFactory by lazy { + // Matches docker-compose and setup scripts for MySQL + ConnectionFactories.get( + ConnectionFactoryOptions.builder() + .option(ConnectionFactoryOptions.DRIVER, "mysql") + .option(ConnectionFactoryOptions.HOST, "localhost") + .option(ConnectionFactoryOptions.PORT, 33306) + .option(ConnectionFactoryOptions.DATABASE, "exoquery_test") + .option(ConnectionFactoryOptions.USER, "root") + .option(ConnectionFactoryOptions.PASSWORD, "root") + // SSL disabled for local docker testing + // .option(Option.valueOf("sslMode"), "disable") + .build() + ) + } + + val h2: ConnectionFactory by lazy { + // A private DB via jdbc:h2:mem is not possible as R2DBC H2. A DB name is required. + // Since the init-script runs every time a new connection is made, we need to + // add CREATE TABLE IF NOT EXISTS to everything in db/h2-schema.sql. + ConnectionFactories.get("r2dbc:h2:mem:///exoquery_test;DB_CLOSE_DELAY=-1;INIT=RUNSCRIPT%20FROM%20'classpath:db/h2-schema.sql'") + } + + val oracle: ConnectionFactory by lazy { + // Matches docker-compose and setup scripts for Oracle + ConnectionFactories.get( + ConnectionFactoryOptions.builder() + .option(ConnectionFactoryOptions.DRIVER, "oracle") + .option(ConnectionFactoryOptions.HOST, "localhost") + .option(ConnectionFactoryOptions.PORT, 31521) + .option(ConnectionFactoryOptions.DATABASE, "xe") + .option(ConnectionFactoryOptions.USER, "exoquery_test") + .option(ConnectionFactoryOptions.PASSWORD, "ExoQueryRocks!") + .build() + ) + } } diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/encodingdata/JavaEntities.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/encodingdata/JavaEntities.kt index 237f582..22fa059 100644 --- a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/encodingdata/JavaEntities.kt +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/encodingdata/JavaEntities.kt @@ -51,11 +51,19 @@ fun insert(e: JavaTestEntity): ControllerAction { return Sql("INSERT INTO JavaTestEntity VALUES (${e.bigDecimalMan}, ${e.javaUtilDateMan}, ${wrap(e.uuidMan)}, ${e.bigDecimalOpt}, ${e.javaUtilDateOpt}, ${wrap(e.uuidOpt)})").action() } + + + 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 } } diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/BasicActionSpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/BasicActionSpec.kt new file mode 100644 index 0000000..fdade7c --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/BasicActionSpec.kt @@ -0,0 +1,57 @@ +package io.exoquery.r2dbc.h2 + +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.controller.runActions +import io.exoquery.controller.runOn +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Sql +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class BasicActionSpec : FreeSpec({ + val cf = TestDatabasesR2dbc.h2 + val ctx: R2dbcController by lazy { R2dbcControllers.H2(connectionFactory = cf) } + + suspend fun runActions(actions: String) = ctx.runActions(actions) + + beforeEach { + runActions( + """ + TRUNCATE TABLE Person; + ALTER TABLE Person ALTER COLUMN id RESTART WITH 1; + TRUNCATE TABLE Address; + """.trimIndent() + ) + } + + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + + val joe = Person(1, "Joe", "Bloggs", 111) + val jim = Person(2, "Jim", "Roogs", 222) + + "Basic Insert" { + Sql("INSERT INTO Person (id, firstName, lastName, age) VALUES (${joe.id}, ${joe.firstName}, ${joe.lastName}, ${joe.age})").action().runOn(ctx) + Sql("INSERT INTO Person (id, firstName, lastName, age) VALUES (${jim.id}, ${jim.firstName}, ${jim.lastName}, ${jim.age})").action().runOn(ctx) + Sql("SELECT id, firstName, lastName, age FROM Person").queryOf().runOn(ctx) shouldBe listOf(joe, jim) + } + + // TODO change controller to return T? not T because R2DBC controller for H2 requires explicit column specification + "Insert Returning Ids".config(enabled = false) { + val id1 = Sql("INSERT INTO Person (firstName, lastName, age) VALUES (${joe.firstName}, ${joe.lastName}, ${joe.age})").actionReturningId().runOn(ctx) + val id2 = Sql("INSERT INTO Person (firstName, lastName, age) VALUES (${jim.firstName}, ${jim.lastName}, ${jim.age})").actionReturningId().runOn(ctx) + id1 shouldBe null + id2 shouldBe null + Sql("SELECT id, firstName, lastName, age FROM Person").queryOf().runOn(ctx) shouldBe listOf(joe, jim) + } + + "Insert Returning Ids - explicit" { + val id1 = Sql("INSERT INTO Person (firstName, lastName, age) VALUES (${joe.firstName}, ${joe.lastName}, ${joe.age})").actionReturningId("id").runOn(ctx) + val id2 = Sql("INSERT INTO Person (firstName, lastName, age) VALUES (${jim.firstName}, ${jim.lastName}, ${jim.age})").actionReturningId("id").runOn(ctx) + id1 shouldBe 1 + id2 shouldBe 2 + Sql("SELECT id, firstName, lastName, age FROM Person").queryOf().runOn(ctx) shouldBe listOf(joe, jim) + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/BasicQuerySpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/BasicQuerySpec.kt new file mode 100644 index 0000000..ed8e658 --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/BasicQuerySpec.kt @@ -0,0 +1,139 @@ +package io.exoquery.r2dbc.h2 + +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.controller.runActions +import io.exoquery.controller.runOn +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Sql +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class BasicQuerySpec : FreeSpec({ + + val cf = TestDatabasesR2dbc.h2 + val ctx: R2dbcController by lazy { R2dbcControllers.H2(connectionFactory = cf) } + + suspend fun runActions(actions: String) = ctx.runActions(actions) + + beforeSpec { + runActions( + """ + DELETE FROM Person; + DELETE FROM Address; + INSERT INTO Person (id, firstName, lastName, age) VALUES (1, 'Joe', 'Bloggs', 111); + INSERT INTO Person (id, firstName, lastName, age) VALUES (2, 'Jim', 'Roogs', 222); + INSERT INTO Address (ownerId, street, zip) VALUES (1, '123 Main St', '12345'); + """.trimIndent() + ) + } + + "SELECT Person - simple" { + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + + Sql("SELECT id, firstName, lastName, age FROM Person").queryOf().runOn(ctx) shouldBe listOf( + Person(1, "Joe", "Bloggs", 111), + Person(2, "Jim", "Roogs", 222) + ) + } + + "joins" - { + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + @Serializable + data class Address(val ownerId: Int, val street: String, val zip: Int) + + "SELECT Person, Address - join" { + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.ownerId, a.street, a.zip FROM Person p JOIN Address a ON p.id = a.ownerId").queryOf>().runOn(ctx) shouldBe listOf( + Person(1, "Joe", "Bloggs", 111) to Address(1, "123 Main St", 12345) + ) + } + + "SELECT Person, Address - leftJoin + null" { + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.ownerId, a.street, a.zip FROM Person p LEFT JOIN Address a ON p.id = a.ownerId").queryOf>().runOn(ctx) shouldBe listOf( + Person(1, "Joe", "Bloggs", 111) to Address(1, "123 Main St", 12345), + Person(2, "Jim", "Roogs", 222) to null + ) + } + + "SELECT Person, Address - leftJoin + null (Triple(NN,null,null))" { + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.ownerId, a.street, a.zip, aa.ownerId, aa.street, aa.zip FROM Person p LEFT JOIN Address a ON p.id = a.ownerId LEFT JOIN Address aa ON p.id = aa.ownerId").queryOf>().runOn(ctx) shouldBe listOf( + Triple(Person(1, "Joe", "Bloggs", 111), Address(1, "123 Main St", 12345), Address(1, "123 Main St", 12345)), + Triple(Person(2, "Jim", "Roogs", 222), null, null) + ) + } + + // Advancement of child decoder indices when nulls + "SELECT Person, Address - leftJoin + null (Triple(NN,null,NN))" { + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.ownerId, a.street, a.zip, aa.ownerId, aa.street, aa.zip FROM Person p LEFT JOIN Address a ON p.id = a.ownerId LEFT JOIN Address aa ON 1 = aa.ownerId").queryOf>().runOn(ctx) shouldBe listOf( + Triple(Person(1, "Joe", "Bloggs", 111), Address(1, "123 Main St", 12345), Address(1, "123 Main St", 12345)), + Triple(Person(2, "Jim", "Roogs", 222), null, Address(1, "123 Main St", 12345)) + ) + } + + @Serializable + data class CustomRow1(val Person: Person, val Address: Address) + @Serializable + data class CustomRow2(val Person: Person, val Address: Address?) + + "SELECT Person, Address - join - custom row" { + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.ownerId, a.street, a.zip FROM Person p JOIN Address a ON p.id = a.ownerId").queryOf().runOn(ctx) shouldBe listOf( + CustomRow1(Person(1, "Joe", "Bloggs", 111), Address(1, "123 Main St", 12345)) + ) + } + + "SELECT Person, Address - leftJoin + null - custom row" { + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.ownerId, a.street, a.zip FROM Person p LEFT JOIN Address a ON p.id = a.ownerId").queryOf().runOn(ctx) shouldBe listOf( + CustomRow2(Person(1, "Joe", "Bloggs", 111), Address(1, "123 Main St", 12345)), + CustomRow2(Person(2, "Jim", "Roogs", 222), null) + ) + } + } + + "joins + null complex" - { + @Serializable + data class Person(val id: Int, val firstName: String?, val lastName: String, val age: Int) + @Serializable + data class Address(val ownerId: Int?, val street: String, val zip: Int) + + "SELECT Person, Address - join" { + Sql("SELECT p.id, null as firstName, p.lastName, p.age, null as ownerId, a.street, a.zip FROM Person p JOIN Address a ON p.id = a.ownerId").queryOf>().runOn(ctx) shouldBe listOf( + Person(1, null, "Bloggs", 111) to Address(null, "123 Main St", 12345) + ) + } + + "SELECT Person, Address - leftJoin + null" { + Sql("SELECT p.id, null as firstName, p.lastName, p.age, null as ownerId, a.street, a.zip FROM Person p LEFT JOIN Address a ON p.id = a.ownerId").queryOf>().runOn(ctx) shouldBe listOf( + Person(1, null, "Bloggs", 111) to Address(null, "123 Main St", 12345), + Person(2, null, "Roogs", 222) to null + ) + } + } + + "SELECT Person - nested" { + @Serializable + data class Name(val firstName: String, val lastName: String) + @Serializable + data class Person(val id: Int, val name: Name, val age: Int) + + Sql("SELECT id, firstName, lastName, age FROM Person").queryOf().runOn(ctx) shouldBe listOf( + Person(1, Name("Joe", "Bloggs"), 111), + Person(2, Name("Jim", "Roogs"), 222) + ) + } + + "SELECT Person - nested with join" { + @Serializable + data class Name(val firstName: String, val lastName: String) + @Serializable + data class Person(val id: Int, val name: Name, val age: Int) + @Serializable + data class Address(val street: String, val zip: Int) + + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.street, a.zip FROM Person p JOIN Address a ON p.id = a.ownerId").queryOf>().runOn(ctx) shouldBe listOf( + Person(1, Name("Joe", "Bloggs"), 111) to Address("123 Main St", 12345) + ) + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/BatchValuesSpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/BatchValuesSpec.kt new file mode 100644 index 0000000..c37507e --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/BatchValuesSpec.kt @@ -0,0 +1,41 @@ +package io.exoquery.r2dbc.h2 + +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.controller.runActions +import io.exoquery.controller.runOn +import io.exoquery.r2dbc.Ex1_BatchInsertNormal +import io.exoquery.r2dbc.Ex2_BatchInsertMixed +import io.exoquery.r2dbc.Ex3_BatchReturnIds +import io.exoquery.r2dbc.Ex3_BatchReturnIdsExplicit +import io.exoquery.r2dbc.Ex4_BatchReturnRecord +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe + +class BatchValuesSpec: FreeSpec ({ + + val cf = TestDatabasesR2dbc.h2 + val ctx: R2dbcController by lazy { R2dbcControllers.H2(connectionFactory = cf) } + + suspend fun runActions(actions: String) = ctx.runActions(actions) + + beforeEach { + runActions("TRUNCATE TABLE Product; ALTER TABLE Product ALTER COLUMN id RESTART WITH 1;") + } + + "Ex 1 - Batch Insert Normal" { + Ex1_BatchInsertNormal.op.runOn(ctx) + Ex1_BatchInsertNormal.get.runOn(ctx) shouldBe Ex1_BatchInsertNormal.result + } + + "Ex 2 - Batch Insert Mixed" { + Ex2_BatchInsertMixed.op.runOn(ctx) + Ex2_BatchInsertMixed.get.runOn(ctx) shouldBe Ex2_BatchInsertMixed.result + } + + "Ex 3 - Batch Return Ids Explicit" { + Ex3_BatchReturnIdsExplicit.op.runOn(ctx) shouldBe Ex3_BatchReturnIdsExplicit.opResult + Ex3_BatchReturnIdsExplicit.get.runOn(ctx) shouldBe Ex3_BatchReturnIdsExplicit.result + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/EncodingSpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/EncodingSpec.kt new file mode 100644 index 0000000..43d3992 --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/EncodingSpec.kt @@ -0,0 +1,156 @@ +package io.exoquery.r2dbc.h2 + +import io.exoquery.sql.encodingdata.* +import io.exoquery.sql.Sql +import io.exoquery.controller.runOn +import io.exoquery.controller.runActions +import io.kotest.core.spec.style.FreeSpec +import java.time.ZoneId +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.r2dbc.encodingdata.JavaTestEntity +import io.exoquery.r2dbc.encodingdata.SimpleTimeEntity +import io.exoquery.r2dbc.encodingdata.encodingConfig +import io.exoquery.r2dbc.encodingdata.insert +import io.exoquery.r2dbc.encodingdata.verify + +class EncodingSpec: FreeSpec({ + + val cf = TestDatabasesR2dbc.h2 + val ctx: R2dbcController by lazy { R2dbcControllers.H2(connectionFactory = cf) } + + suspend fun runActions(actions: String) = ctx.runActions(actions) + + beforeEach { + // The main table used across many tests + runActions("DELETE FROM EncodingTestEntity") + } + + "encodes and decodes nullables - not nulls" { + insert(EncodingTestEntity.regular).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntity.regular) + } + + "encodes and decodes custom impls nullables - not nulls" { + insert(EncodingTestEntityImp.regular).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntityImp.regular) + } + + "encodes and decodes custom impls nullables - nulls" { + insert(EncodingTestEntityImp.empty).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntityImp.empty) + } + + "encodes and decodes custom value-classes nullables - not nulls" { + insert(EncodingTestEntityVal.regular).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntityVal.regular) + } + + "encodes and decodes custom value-classes nullables - nulls" { + insert(EncodingTestEntityVal.empty).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntityVal.empty) + } + + "encodes and decodes batch" { + insertBatch(listOf(EncodingTestEntity.regular, EncodingTestEntity.regular)).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res[0], EncodingTestEntity.regular) + verify(res[1], EncodingTestEntity.regular) + } + + "encodes and decodes nullables - nulls" { + insert(EncodingTestEntity.empty).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntity.empty) + } + + "Encode/Decode Additional Java Types - regular" { + runActions("DELETE FROM JavaTestEntity") + insert(JavaTestEntity.Companion.regular).runOn(ctx) + val actual = Sql("SELECT * FROM JavaTestEntity").queryOf().runOn(ctx).first() + verify(actual, JavaTestEntity.Companion.regular) + } + + "Encode/Decode Additional Java Types - empty" { + runActions("DELETE FROM JavaTestEntity") + insert(JavaTestEntity.Companion.empty).runOn(ctx) + val actual = Sql("SELECT * FROM JavaTestEntity").queryOf().runOn(ctx).first() + verify(actual, JavaTestEntity.Companion.empty) + } + + "Encode/Decode KMP Types" { + runActions("DELETE FROM KmpTestEntity") + insert(KmpTestEntity.regular).runOn(ctx) + val actual = Sql("SELECT * FROM KmpTestEntity").queryOf().runOn(ctx).first() + verify(actual, KmpTestEntity.regular) + } + + "Encode/Decode Other Time Types" { + runActions("DELETE FROM TimeEntity") + val zid = ZoneId.systemDefault() + val timeEntity = SimpleTimeEntity.Companion.make(zid) + insert(timeEntity).runOn(ctx) + val actual = Sql(""" + SELECT + timeLocalDate, + timeLocalTime, + timeLocalDateTime, + timeZonedDateTime, + timeInstant, + timeOffsetTime, + timeOffsetDateTime + FROM TimeEntity + """).queryOf().runOn(ctx).first() + assert(timeEntity == actual) + } + + "Encode/Decode Other Time Types ordering" { + runActions("DELETE FROM TimeEntity") + + val zid = ZoneId.systemDefault() + val timeEntityA = SimpleTimeEntity.make(zid, SimpleTimeEntity.TimeEntityInput(2022, 1, 1, 1, 1, 1, 0)) + val timeEntityB = SimpleTimeEntity.make(zid, SimpleTimeEntity.TimeEntityInput(2022, 2, 2, 2, 2, 2, 0)) + + insert(timeEntityA).runOn(ctx) + insert(timeEntityB).runOn(ctx) + + assert(timeEntityB.timeLocalDate > timeEntityA.timeLocalDate) + assert(timeEntityB.timeLocalTime > timeEntityA.timeLocalTime) + assert(timeEntityB.timeLocalDateTime > timeEntityA.timeLocalDateTime) + assert(timeEntityB.timeZonedDateTime > timeEntityA.timeZonedDateTime) + assert(timeEntityB.timeInstant > timeEntityA.timeInstant) + assert(timeEntityB.timeOffsetTime > timeEntityA.timeOffsetTime) + assert(timeEntityB.timeOffsetDateTime > timeEntityA.timeOffsetDateTime) + + val actual = + Sql( + """ + SELECT + timeLocalDate, + timeLocalTime, + timeLocalDateTime, + timeZonedDateTime, + timeInstant, + timeOffsetTime, + timeOffsetDateTime + FROM TimeEntity + WHERE + timeLocalDate > ${timeEntityA.timeLocalDate} + AND timeLocalTime > ${timeEntityA.timeLocalTime} + AND timeLocalDateTime > ${timeEntityA.timeLocalDateTime} + AND timeZonedDateTime > ${timeEntityA.timeZonedDateTime} + AND timeInstant > ${timeEntityA.timeInstant} + AND timeOffsetTime > ${timeEntityA.timeOffsetTime} + AND timeOffsetDateTime > ${timeEntityA.timeOffsetDateTime} + """ + ).queryOf().runOn(ctx).first() + + assert(actual == timeEntityB) + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/InQuerySpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/InQuerySpec.kt new file mode 100644 index 0000000..7fab28b --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/InQuerySpec.kt @@ -0,0 +1,64 @@ +package io.exoquery.r2dbc.h2 + +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.controller.runActions +import io.exoquery.controller.runOn +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Params +import io.exoquery.sql.Sql +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class InQuerySpec : FreeSpec({ + + val cf = TestDatabasesR2dbc.h2 + val ctx: R2dbcController by lazy { R2dbcControllers.H2(connectionFactory = cf) } + + suspend fun runActions(actions: String) = ctx.runActions(actions) + + beforeSpec { + runActions( + """ + DELETE FROM Person; + DELETE FROM Address; + INSERT INTO Person (id, firstName, lastName, age) VALUES (1, 'Joe', 'Bloggs', 111); + INSERT INTO Person (id, firstName, lastName, age) VALUES (2, 'Jim', 'Roogs', 222); + INSERT INTO Person (id, firstName, lastName, age) VALUES (3, 'Jill', 'Doogs', 222); + INSERT INTO Address (ownerId, street, zip) VALUES (1, '123 Main St', '12345'); + """.trimIndent() + ) + } + + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + + "Person IN (names) - simple" { + val sql = Sql("SELECT id, firstName, lastName, age FROM Person WHERE firstName IN ${Params("Joe", "Jim")}").queryOf() + sql.sql shouldBe "SELECT id, firstName, lastName, age FROM Person WHERE firstName IN (?, ?)" + sql.runOn(ctx) shouldBe listOf( + Person(1, "Joe", "Bloggs", 111), + Person(2, "Jim", "Roogs", 222) + ) + } + + "Person IN (names) - single" { + val sql = Sql("SELECT id, firstName, lastName, age FROM Person WHERE firstName IN ${Params("Joe")}").queryOf() + sql.sql shouldBe "SELECT id, firstName, lastName, age FROM Person WHERE firstName IN (?)" + sql.runOn(ctx) shouldBe listOf( + Person(1, "Joe", "Bloggs", 111) + ) + } + + "Person IN (names) - empty" { + val sql = Sql("SELECT id, firstName, lastName, age FROM Person WHERE firstName IN ${Params.empty()}").queryOf() + sql.sql shouldBe "SELECT id, firstName, lastName, age FROM Person WHERE firstName IN (null)" + sql.runOn(ctx) shouldBe listOf() + } + + "Person IN (names) - empty list" { + val names: List = emptyList() + Sql("SELECT id, firstName, lastName, age FROM Person WHERE firstName IN ${Params.list(names)}").queryOf().runOn(ctx) shouldBe listOf() + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/TransactionSpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/TransactionSpec.kt new file mode 100644 index 0000000..10c6c90 --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/TransactionSpec.kt @@ -0,0 +1,66 @@ +package io.exoquery.r2dbc.h2 + +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.controller.runActions +import io.exoquery.controller.runOn +import io.exoquery.controller.transaction +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Sql +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class TransactionSpec: FreeSpec({ + val cf = TestDatabasesR2dbc.h2 + val ctx: R2dbcController by lazy { R2dbcControllers.H2(connectionFactory = cf) } + beforeEach { + ctx.runActions( + """ + DELETE FROM Person; + DELETE FROM Address; + """ + ) + } + + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + + "provides transaction support" - { + val joe = Person(1, "Joe", "Bloggs", 111) + val jack = Person(2, "Jack", "Roogs", 222) + + // Note the string elements ${...} should not have quotes around them or else they are interpreted as literals + fun insert(p: Person) = + Sql("INSERT INTO Person (id, firstName, lastName, age) VALUES (${p.id}, ${p.firstName}, ${p.lastName}, ${p.age})").action() + + + fun select() = Sql("SELECT id, firstName, lastName, age FROM Person").queryOf() + + "success" { + ctx.transaction { + insert(joe).run() + } + select().runOn(ctx) shouldBe listOf(joe) + } + "failure" { + insert(joe).runOn(ctx) + shouldThrow { + ctx.transaction { + insert(jack).run() + throw IllegalStateException() + } + } + select().runOn(ctx) shouldBe listOf(joe) + } + "nested" { + ctx.transaction { + ctx.transaction { + insert(joe).run() + } + } + select().runOn(ctx) shouldBe listOf(joe) + } + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/BasicActionSpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/BasicActionSpec.kt new file mode 100644 index 0000000..23bfa03 --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/BasicActionSpec.kt @@ -0,0 +1,40 @@ +package io.exoquery.r2dbc.mysql + +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.controller.runActions +import io.exoquery.controller.runOn +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Sql +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class BasicActionSpec : FreeSpec({ + val cf = TestDatabasesR2dbc.mysql + val ctx: R2dbcController by lazy { R2dbcControllers.Mysql(connectionFactory = cf) } + + suspend fun runActions(actions: String) = ctx.runActions(actions) + + beforeEach { + runActions( + """ + DELETE FROM Person; + ALTER TABLE Person AUTO_INCREMENT = 1; + DELETE FROM Address; + """.trimIndent() + ) + } + + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + + val joe = Person(1, "Joe", "Bloggs", 111) + val jim = Person(2, "Jim", "Roogs", 222) + + "Basic Insert" { + Sql("INSERT INTO Person (firstName, lastName, age) VALUES (${joe.firstName}, ${joe.lastName}, ${joe.age})").action().runOn(ctx) + Sql("INSERT INTO Person (firstName, lastName, age) VALUES (${jim.firstName}, ${jim.lastName}, ${jim.age})").action().runOn(ctx) + Sql("SELECT id, firstName, lastName, age FROM Person").queryOf().runOn(ctx) shouldBe listOf(joe, jim) + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/BasicQuerySpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/BasicQuerySpec.kt new file mode 100644 index 0000000..9be243f --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/BasicQuerySpec.kt @@ -0,0 +1,139 @@ +package io.exoquery.r2dbc.mysql + +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.controller.runActions +import io.exoquery.controller.runOn +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Sql +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class BasicQuerySpec : FreeSpec({ + + val cf = TestDatabasesR2dbc.mysql + val ctx: R2dbcController by lazy { R2dbcControllers.Mysql(connectionFactory = cf) } + + suspend fun runActions(actions: String) = ctx.runActions(actions) + + beforeSpec { + runActions( + """ + DELETE FROM Person; + ALTER TABLE Person AUTO_INCREMENT = 1; + DELETE FROM Address; + INSERT INTO Person (firstName, lastName, age) VALUES ('Joe', 'Bloggs', 111); + INSERT INTO Person (firstName, lastName, age) VALUES ('Jim', 'Roogs', 222); + INSERT INTO Address (ownerId, street, zip) VALUES (1, '123 Main St', 12345); + """.trimIndent() + ) + } + + "SELECT Person - simple" { + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + Sql("SELECT id, firstName, lastName, age FROM Person").queryOf().runOn(ctx) shouldBe listOf( + Person(1, "Joe", "Bloggs", 111), + Person(2, "Jim", "Roogs", 222) + ) + } + + "joins" - { + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + @Serializable + data class Address(val ownerId: Int, val street: String, val zip: Int) + + "SELECT Person, Address - join" { + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.ownerId, a.street, a.zip FROM Person p JOIN Address a ON p.id = a.ownerId").queryOf>().runOn(ctx) shouldBe listOf( + Person(1, "Joe", "Bloggs", 111) to Address(1, "123 Main St", 12345) + ) + } + + "SELECT Person, Address - leftJoin + null" { + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.ownerId, a.street, a.zip FROM Person p LEFT JOIN Address a ON p.id = a.ownerId").queryOf>().runOn(ctx) shouldBe listOf( + Person(1, "Joe", "Bloggs", 111) to Address(1, "123 Main St", 12345), + Person(2, "Jim", "Roogs", 222) to null + ) + } + + "SELECT Person, Address - leftJoin + null (Triple(NN,null,null))" { + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.ownerId, a.street, a.zip, aa.ownerId, aa.street, aa.zip FROM Person p LEFT JOIN Address a ON p.id = a.ownerId LEFT JOIN Address aa ON p.id = aa.ownerId").queryOf>().runOn(ctx) shouldBe listOf( + Triple(Person(1, "Joe", "Bloggs", 111), Address(1, "123 Main St", 12345), Address(1, "123 Main St", 12345)), + Triple(Person(2, "Jim", "Roogs", 222), null, null) + ) + } + + // advancement of child decoder indices when nulls + "SELECT Person, Address - leftJoin + null (Triple(NN,null,NN))" { + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.ownerId, a.street, a.zip, aa.ownerId, aa.street, aa.zip FROM Person p LEFT JOIN Address a ON p.id = a.ownerId LEFT JOIN Address aa ON 1 = aa.ownerId").queryOf>().runOn(ctx) shouldBe listOf( + Triple(Person(1, "Joe", "Bloggs", 111), Address(1, "123 Main St", 12345), Address(1, "123 Main St", 12345)), + Triple(Person(2, "Jim", "Roogs", 222), null, Address(1, "123 Main St", 12345)) + ) + } + + @Serializable + data class CustomRow1(val Person: Person, val Address: Address) + @Serializable + data class CustomRow2(val Person: Person, val Address: Address?) + + "SELECT Person, Address - join - custom row" { + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.ownerId, a.street, a.zip FROM Person p JOIN Address a ON p.id = a.ownerId").queryOf().runOn(ctx) shouldBe listOf( + CustomRow1(Person(1, "Joe", "Bloggs", 111), Address(1, "123 Main St", 12345)) + ) + } + + "SELECT Person, Address - leftJoin + null - custom row" { + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.ownerId, a.street, a.zip FROM Person p LEFT JOIN Address a ON p.id = a.ownerId").queryOf().runOn(ctx) shouldBe listOf( + CustomRow2(Person(1, "Joe", "Bloggs", 111), Address(1, "123 Main St", 12345)), + CustomRow2(Person(2, "Jim", "Roogs", 222), null) + ) + } + } + + "joins + null complex" - { + @Serializable + data class Person(val id: Int, val firstName: String?, val lastName: String, val age: Int) + @Serializable + data class Address(val ownerId: Int?, val street: String, val zip: Int) + + "SELECT Person, Address - join" { + Sql("SELECT p.id, null as firstName, p.lastName, p.age, null as ownerId, a.street, a.zip FROM Person p JOIN Address a ON p.id = a.ownerId").queryOf>().runOn(ctx) shouldBe listOf( + Person(1, null, "Bloggs", 111) to Address(null, "123 Main St", 12345) + ) + } + + "SELECT Person, Address - leftJoin + null" { + Sql("SELECT p.id, null as firstName, p.lastName, p.age, null as ownerId, a.street, a.zip FROM Person p LEFT JOIN Address a ON p.id = a.ownerId").queryOf>().runOn(ctx) shouldBe listOf( + Person(1, null, "Bloggs", 111) to Address(null, "123 Main St", 12345), + Person(2, null, "Roogs", 222) to null + ) + } + } + + "SELECT Person - nested" { + @Serializable + data class Name(val firstName: String, val lastName: String) + @Serializable + data class Person(val id: Int, val name: Name, val age: Int) + + Sql("SELECT id, firstName, lastName, age FROM Person").queryOf().runOn(ctx) shouldBe listOf( + Person(1, Name("Joe", "Bloggs"), 111), + Person(2, Name("Jim", "Roogs"), 222) + ) + } + + "SELECT Person - nested with join" { + @Serializable + data class Name(val firstName: String, val lastName: String) + @Serializable + data class Person(val id: Int, val name: Name, val age: Int) + @Serializable + data class Address(val street: String, val zip: Int) + + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.street, a.zip FROM Person p JOIN Address a ON p.id = a.ownerId").queryOf>().runOn(ctx) shouldBe listOf( + Person(1, Name("Joe", "Bloggs"), 111) to Address("123 Main St", 12345) + ) + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/EncodingSpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/EncodingSpec.kt new file mode 100644 index 0000000..0643f69 --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/EncodingSpec.kt @@ -0,0 +1,158 @@ +package io.exoquery.r2dbc.mysql + +import io.exoquery.sql.encodingdata.* +import io.exoquery.sql.Sql +import io.exoquery.controller.runOn +import io.exoquery.controller.runActions +import io.kotest.core.spec.style.FreeSpec +import java.time.ZoneId +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.r2dbc.encodingdata.JavaTestEntity +import io.exoquery.r2dbc.encodingdata.SimpleTimeEntity +import io.exoquery.r2dbc.encodingdata.encodingConfig +import io.exoquery.r2dbc.encodingdata.insert +import io.exoquery.r2dbc.encodingdata.verify + +class EncodingSpec: FreeSpec({ + + val cf = TestDatabasesR2dbc.mysql + val ctx: R2dbcController by lazy { R2dbcControllers.Mysql(connectionFactory = cf) } + + suspend fun runActions(actions: String) = ctx.runActions(actions) + + beforeEach { + // The main table used across many tests + runActions("DELETE FROM EncodingTestEntity") + } + + "encodes and decodes nullables - not nulls" { + insert(EncodingTestEntity.regular).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntity.regular) + } + + "encodes and decodes custom impls nullables - not nulls" { + insert(EncodingTestEntityImp.regular).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntityImp.regular) + } + + "encodes and decodes custom impls nullables - nulls" { + insert(EncodingTestEntityImp.empty).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntityImp.empty) + } + + "encodes and decodes custom value-classes nullables - not nulls" { + insert(EncodingTestEntityVal.regular).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntityVal.regular) + } + + "encodes and decodes custom value-classes nullables - nulls" { + insert(EncodingTestEntityVal.empty).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntityVal.empty) + } + + "encodes and decodes batch" { + insertBatch(listOf(EncodingTestEntity.regular, EncodingTestEntity.regular)).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res[0], EncodingTestEntity.regular) + verify(res[1], EncodingTestEntity.regular) + } + + "encodes and decodes nullables - nulls" { + insert(EncodingTestEntity.empty).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntity.empty) + } + + "Encode/Decode Additional Java Types - regular" { + runActions("DELETE FROM JavaTestEntity") + insert(JavaTestEntity.Companion.regular).runOn(ctx) + val actual = Sql("SELECT * FROM JavaTestEntity").queryOf().runOn(ctx).first() + verify(actual, JavaTestEntity.Companion.regular) + } + + "Encode/Decode Additional Java Types - empty" { + runActions("DELETE FROM JavaTestEntity") + insert(JavaTestEntity.Companion.empty).runOn(ctx) + val actual = Sql("SELECT * FROM JavaTestEntity").queryOf().runOn(ctx).first() + verify(actual, JavaTestEntity.Companion.empty) + } + + "Encode/Decode KMP Types" { + runActions("DELETE FROM KmpTestEntity") + insert(KmpTestEntity.regular).runOn(ctx) + val actual = Sql("SELECT * FROM KmpTestEntity").queryOf().runOn(ctx).first() + verify(actual, KmpTestEntity.regular) + } + + "Encode/Decode Other Time Types" { + runActions("DELETE FROM TimeEntity") + val zid = ZoneId.systemDefault() + val timeEntity = SimpleTimeEntity.Companion.make(zid) + insert(timeEntity).runOn(ctx) + val actual = Sql( + """ + SELECT + timeLocalDate, + timeLocalTime, + timeLocalDateTime, + timeZonedDateTime, + timeInstant, + timeOffsetTime, + timeOffsetDateTime + FROM TimeEntity + """ + ).queryOf().runOn(ctx).first() + assert(timeEntity == actual) + } + + "Encode/Decode Other Time Types ordering" { + runActions("DELETE FROM TimeEntity") + + val zid = ZoneId.systemDefault() + val timeEntityA = SimpleTimeEntity.make(zid, SimpleTimeEntity.TimeEntityInput(2022, 1, 1, 1, 1, 1, 0)) + val timeEntityB = SimpleTimeEntity.make(zid, SimpleTimeEntity.TimeEntityInput(2022, 2, 2, 2, 2, 2, 0)) + + insert(timeEntityA).runOn(ctx) + insert(timeEntityB).runOn(ctx) + + assert(timeEntityB.timeLocalDate > timeEntityA.timeLocalDate) + assert(timeEntityB.timeLocalTime > timeEntityA.timeLocalTime) + assert(timeEntityB.timeLocalDateTime > timeEntityA.timeLocalDateTime) + assert(timeEntityB.timeZonedDateTime > timeEntityA.timeZonedDateTime) + assert(timeEntityB.timeInstant > timeEntityA.timeInstant) + assert(timeEntityB.timeOffsetTime > timeEntityA.timeOffsetTime) + assert(timeEntityB.timeOffsetDateTime > timeEntityA.timeOffsetDateTime) + + val actual = + Sql( + """ + SELECT + timeLocalDate, + timeLocalTime, + timeLocalDateTime, + timeZonedDateTime, + timeInstant, + timeOffsetTime, + timeOffsetDateTime + FROM TimeEntity + WHERE + timeLocalDate > ${timeEntityA.timeLocalDate} + AND timeLocalTime > ${timeEntityA.timeLocalTime} + AND timeLocalDateTime > ${timeEntityA.timeLocalDateTime} + AND timeZonedDateTime > ${timeEntityA.timeZonedDateTime} + AND timeInstant > ${timeEntityA.timeInstant} + AND timeOffsetTime > ${timeEntityA.timeOffsetTime} + AND timeOffsetDateTime > ${timeEntityA.timeOffsetDateTime} + """ + ).queryOf().runOn(ctx).first() + + assert(actual == timeEntityB) + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/InQuerySpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/InQuerySpec.kt new file mode 100644 index 0000000..c211a25 --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/InQuerySpec.kt @@ -0,0 +1,65 @@ +package io.exoquery.r2dbc.mysql + +import io.exoquery.controller.runActions +import io.exoquery.controller.runOn +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Params +import io.exoquery.sql.Sql +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class InQuerySpec : FreeSpec({ + + val cf = TestDatabasesR2dbc.mysql + val ctx: R2dbcController by lazy { R2dbcControllers.Mysql(connectionFactory = cf) } + + suspend fun runActions(actions: String) = ctx.runActions(actions) + + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + + beforeSpec { + runActions( + """ + DELETE FROM Person; + ALTER TABLE Person AUTO_INCREMENT = 1; + DELETE FROM Address; + INSERT INTO Person (firstName, lastName, age) VALUES ('Joe', 'Bloggs', 111); + INSERT INTO Person (firstName, lastName, age) VALUES ('Jim', 'Roogs', 222); + INSERT INTO Person (firstName, lastName, age) VALUES ('Jill', 'Doogs', 222); + INSERT INTO Address (ownerId, street, zip) VALUES (1, '123 Main St', 12345); + """.trimIndent() + ) + } + + "Person IN (names) - simple" { + val sql = Sql("SELECT id, firstName, lastName, age FROM Person WHERE firstName IN ${Params("Joe", "Jim")}").queryOf() + sql.sql shouldBe "SELECT id, firstName, lastName, age FROM Person WHERE firstName IN (?, ?)" + sql.runOn(ctx) shouldBe listOf( + Person(1, "Joe", "Bloggs", 111), + Person(2, "Jim", "Roogs", 222) + ) + } + + "Person IN (names) - single" { + val sql = Sql("SELECT id, firstName, lastName, age FROM Person WHERE firstName IN ${Params("Joe")}").queryOf() + sql.sql shouldBe "SELECT id, firstName, lastName, age FROM Person WHERE firstName IN (?)" + sql.runOn(ctx) shouldBe listOf( + Person(1, "Joe", "Bloggs", 111) + ) + } + + "Person IN (names) - empty" { + val sql = Sql("SELECT id, firstName, lastName, age FROM Person WHERE firstName IN ${Params.empty()}").queryOf() + sql.sql shouldBe "SELECT id, firstName, lastName, age FROM Person WHERE firstName IN (null)" + sql.runOn(ctx) shouldBe listOf() + } + + "Person IN (names) - empty list" { + val names: List = emptyList() + Sql("SELECT id, firstName, lastName, age FROM Person WHERE firstName IN ${Params.list(names)}").queryOf().runOn(ctx) shouldBe listOf() + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/InjectionSpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/InjectionSpec.kt new file mode 100644 index 0000000..3a5f11d --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/InjectionSpec.kt @@ -0,0 +1,45 @@ +package io.exoquery.r2dbc.mysql + +import io.exoquery.controller.runActions +import io.exoquery.controller.runOn +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Param +import io.exoquery.sql.Sql +import io.exoquery.sql.encodingdata.EncodingTestEntity +import io.exoquery.sql.encodingdata.insert +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class InjectionSpec: FreeSpec({ + + val cf = TestDatabasesR2dbc.mysql + val ctx: R2dbcController by lazy { R2dbcControllers.Mysql(connectionFactory = cf) } + + suspend fun runActions(actions: String) = ctx.runActions(actions) + + beforeEach { + runActions( + """ + DELETE FROM Person; + ALTER TABLE Person AUTO_INCREMENT = 1; + """.trimIndent() + ) + // Insert a single person row (id should be 1 after reseed) + runActions("INSERT INTO Person (firstName, lastName, age) VALUES ('Joe', 'Blogs', 123)") + } + + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + + "escapes column meant to be an injection attack" { + insert(EncodingTestEntity.regular).runOn(ctx) + val name = "'Joe'; DROP TABLE Person;" + Sql("SELECT * FROM Person WHERE firstName = ${Param.withSer(name)}").queryOf().runOn(ctx) shouldBe listOf() + + // verify table still exists and is intact + Sql("SELECT * FROM Person").queryOf().runOn(ctx) shouldBe listOf(Person(1, "Joe", "Blogs", 123)) + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/TransactionSpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/TransactionSpec.kt new file mode 100644 index 0000000..c3493ad --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/TransactionSpec.kt @@ -0,0 +1,67 @@ +package io.exoquery.r2dbc.mysql + +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.controller.runActions +import io.exoquery.controller.runOn +import io.exoquery.controller.transaction +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Sql +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class TransactionSpec: FreeSpec({ + val cf = TestDatabasesR2dbc.mysql + val ctx: R2dbcController by lazy { R2dbcControllers.Mysql(connectionFactory = cf) } + beforeEach { + ctx.runActions( + """ + DELETE FROM Person; + ALTER TABLE Person AUTO_INCREMENT = 1; + DELETE FROM Address; + """ + ) + } + + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + + "provides transaction support" - { + val joe = Person(1, "Joe", "Bloggs", 111) + val jack = Person(2, "Jack", "Roogs", 222) + + // Note the string elements ${...} should not have quotes around them or else they are interpreted as literals + fun insert(p: Person) = + Sql("INSERT INTO Person (firstName, lastName, age) VALUES (${p.firstName}, ${p.lastName}, ${p.age})").action() + + + fun select() = Sql("SELECT id, firstName, lastName, age FROM Person").queryOf() + + "success" { + ctx.transaction { + insert(joe).run() + } + select().runOn(ctx) shouldBe listOf(joe) + } + "failure" { + insert(joe).runOn(ctx) + shouldThrow { + ctx.transaction { + insert(jack).run() + throw IllegalStateException() + } + } + select().runOn(ctx) shouldBe listOf(joe) + } + "nested" { + ctx.transaction { + ctx.transaction { + insert(joe).run() + } + } + select().runOn(ctx) shouldBe listOf(joe) + } + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/BasicActionSpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/BasicActionSpec.kt new file mode 100644 index 0000000..38995b5 --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/BasicActionSpec.kt @@ -0,0 +1,64 @@ +package io.exoquery.r2dbc.oracle + +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.controller.runActions +import io.exoquery.controller.runOn +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Sql +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class BasicActionSpec : FreeSpec({ + val cf = TestDatabasesR2dbc.oracle + val ctx: R2dbcController by lazy { R2dbcControllers.Oracle(connectionFactory = cf) } + + suspend fun runActions(actions: String) = ctx.runActions(actions) + + beforeEach { + runActions( + """ + DELETE FROM Person; + ALTER TABLE Person MODIFY (id GENERATED BY DEFAULT ON NULL AS IDENTITY (START WITH 1)); + DELETE FROM Address; + """.trimIndent() + ) + } + + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + + val joe = Person(1, "Joe", "Bloggs", 111) + val jim = Person(2, "Jim", "Roogs", 222) + + "Basic Insert" { + Sql("INSERT INTO Person (id, firstName, lastName, age) VALUES (${joe.id}, ${joe.firstName}, ${joe.lastName}, ${joe.age})").action().runOn(ctx) + Sql("INSERT INTO Person (id, firstName, lastName, age) VALUES (${jim.id}, ${jim.firstName}, ${jim.lastName}, ${jim.age})").action().runOn(ctx) + Sql("SELECT id, firstName, lastName, age FROM Person").queryOf().runOn(ctx) shouldBe listOf(joe, jim) + } + + "Insert Returning" { + val id1 = Sql("INSERT INTO Person (firstName, lastName, age) VALUES (${joe.firstName}, ${joe.lastName}, ${joe.age})").actionReturning("id").runOn(ctx) + val id2 = Sql("INSERT INTO Person (firstName, lastName, age) VALUES (${jim.firstName}, ${jim.lastName}, ${jim.age})").actionReturning("id").runOn(ctx) + id1 shouldBe 1 + id2 shouldBe 2 + Sql("SELECT id, firstName, lastName, age FROM Person").queryOf().runOn(ctx) shouldBe listOf(joe, jim) + } + + "Insert Returning Record" { + val person1 = Sql("INSERT INTO Person (firstName, lastName, age) VALUES (${joe.firstName}, ${joe.lastName}, ${joe.age})").actionReturning("id", "firstName", "lastName", "age").runOn(ctx) + val person2 = Sql("INSERT INTO Person (firstName, lastName, age) VALUES (${jim.firstName}, ${jim.lastName}, ${jim.age})").actionReturning("id", "firstName", "lastName", "age").runOn(ctx) + person1 shouldBe joe + person2 shouldBe jim + Sql("SELECT id, firstName, lastName, age FROM Person").queryOf().runOn(ctx) shouldBe listOf(joe, jim) + } + + "Insert Returning Ids" { + val id1 = Sql("INSERT INTO Person (firstName, lastName, age) VALUES (${joe.firstName}, ${joe.lastName}, ${joe.age})").actionReturningId("id").runOn(ctx) + val id2 = Sql("INSERT INTO Person (firstName, lastName, age) VALUES (${jim.firstName}, ${jim.lastName}, ${jim.age})").actionReturningId("id").runOn(ctx) + id1 shouldBe 1 + id2 shouldBe 2 + Sql("SELECT id, firstName, lastName, age FROM Person").queryOf().runOn(ctx) shouldBe listOf(joe, jim) + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/BasicQuerySpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/BasicQuerySpec.kt new file mode 100644 index 0000000..e8c6dae --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/BasicQuerySpec.kt @@ -0,0 +1,138 @@ +package io.exoquery.r2dbc.oracle + +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.controller.runActions +import io.exoquery.controller.runOn +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Sql +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class BasicQuerySpec : FreeSpec({ + + val cf = TestDatabasesR2dbc.oracle + val ctx: R2dbcController by lazy { R2dbcControllers.Oracle(connectionFactory = cf) } + + suspend fun runActions(actions: String) = ctx.runActions(actions) + + beforeSpec { + runActions( + """ + DELETE FROM Person; + DELETE FROM Address; + INSERT INTO Person (id, firstName, lastName, age) VALUES (1, 'Joe', 'Bloggs', 111); + INSERT INTO Person (id, firstName, lastName, age) VALUES (2, 'Jim', 'Roogs', 222); + INSERT INTO Address (ownerId, street, zip) VALUES (1, '123 Main St', 12345); + """.trimIndent() + ) + } + + "SELECT Person - simple" { + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + Sql("SELECT id, firstName, lastName, age FROM Person ORDER BY id").queryOf().runOn(ctx) shouldBe listOf( + Person(1, "Joe", "Bloggs", 111), + Person(2, "Jim", "Roogs", 222) + ) + } + + "joins" - { + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + @Serializable + data class Address(val ownerId: Int, val street: String, val zip: Int) + + "SELECT Person, Address - join" { + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.ownerId, a.street, a.zip FROM Person p JOIN Address a ON p.id = a.ownerId ORDER BY p.id").queryOf>().runOn(ctx) shouldBe listOf( + Person(1, "Joe", "Bloggs", 111) to Address(1, "123 Main St", 12345) + ) + } + + "SELECT Person, Address - leftJoin + null" { + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.ownerId, a.street, a.zip FROM Person p LEFT JOIN Address a ON p.id = a.ownerId ORDER BY p.id").queryOf>().runOn(ctx) shouldBe listOf( + Person(1, "Joe", "Bloggs", 111) to Address(1, "123 Main St", 12345), + Person(2, "Jim", "Roogs", 222) to null + ) + } + + "SELECT Person, Address - leftJoin + null (Triple(NN,null,null))" { + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.ownerId, a.street, a.zip, aa.ownerId, aa.street, aa.zip FROM Person p LEFT JOIN Address a ON p.id = a.ownerId LEFT JOIN Address aa ON p.id = aa.ownerId ORDER BY p.id").queryOf>().runOn(ctx) shouldBe listOf( + Triple(Person(1, "Joe", "Bloggs", 111), Address(1, "123 Main St", 12345), Address(1, "123 Main St", 12345)), + Triple(Person(2, "Jim", "Roogs", 222), null, null) + ) + } + + // advancement of child decoder indices when nulls + "SELECT Person, Address - leftJoin + null (Triple(NN,null,NN))" { + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.ownerId, a.street, a.zip, aa.ownerId, aa.street, aa.zip FROM Person p LEFT JOIN Address a ON p.id = a.ownerId LEFT JOIN Address aa ON 1 = aa.ownerId ORDER BY p.id").queryOf>().runOn(ctx) shouldBe listOf( + Triple(Person(1, "Joe", "Bloggs", 111), Address(1, "123 Main St", 12345), Address(1, "123 Main St", 12345)), + Triple(Person(2, "Jim", "Roogs", 222), null, Address(1, "123 Main St", 12345)) + ) + } + + @Serializable + data class CustomRow1(val Person: Person, val Address: Address) + @Serializable + data class CustomRow2(val Person: Person, val Address: Address?) + + "SELECT Person, Address - join - custom row" { + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.ownerId, a.street, a.zip FROM Person p JOIN Address a ON p.id = a.ownerId ORDER BY p.id").queryOf().runOn(ctx) shouldBe listOf( + CustomRow1(Person(1, "Joe", "Bloggs", 111), Address(1, "123 Main St", 12345)) + ) + } + + "SELECT Person, Address - leftJoin + null - custom row" { + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.ownerId, a.street, a.zip FROM Person p LEFT JOIN Address a ON p.id = a.ownerId ORDER BY p.id").queryOf().runOn(ctx) shouldBe listOf( + CustomRow2(Person(1, "Joe", "Bloggs", 111), Address(1, "123 Main St", 12345)), + CustomRow2(Person(2, "Jim", "Roogs", 222), null) + ) + } + } + + "joins + null complex" - { + @Serializable + data class Person(val id: Int, val firstName: String?, val lastName: String, val age: Int) + @Serializable + data class Address(val ownerId: Int?, val street: String, val zip: Int) + + "SELECT Person, Address - join" { + Sql("SELECT p.id, null as firstName, p.lastName, p.age, null as ownerId, a.street, a.zip FROM Person p JOIN Address a ON p.id = a.ownerId ORDER BY p.id").queryOf>().runOn(ctx) shouldBe listOf( + Person(1, null, "Bloggs", 111) to Address(null, "123 Main St", 12345) + ) + } + + "SELECT Person, Address - leftJoin + null" { + Sql("SELECT p.id, null as firstName, p.lastName, p.age, null as ownerId, a.street, a.zip FROM Person p LEFT JOIN Address a ON p.id = a.ownerId ORDER BY p.id").queryOf>().runOn(ctx) shouldBe listOf( + Person(1, null, "Bloggs", 111) to Address(null, "123 Main St", 12345), + Person(2, null, "Roogs", 222) to null + ) + } + } + + "SELECT Person - nested" { + @Serializable + data class Name(val firstName: String, val lastName: String) + @Serializable + data class Person(val id: Int, val name: Name, val age: Int) + + Sql("SELECT id, firstName, lastName, age FROM Person ORDER BY id").queryOf().runOn(ctx) shouldBe listOf( + Person(1, Name("Joe", "Bloggs"), 111), + Person(2, Name("Jim", "Roogs"), 222) + ) + } + + "SELECT Person - nested with join" { + @Serializable + data class Name(val firstName: String, val lastName: String) + @Serializable + data class Person(val id: Int, val name: Name, val age: Int) + @Serializable + data class Address(val street: String, val zip: Int) + + Sql("SELECT p.id, p.firstName, p.lastName, p.age, a.street, a.zip FROM Person p JOIN Address a ON p.id = a.ownerId ORDER BY p.id").queryOf>().runOn(ctx) shouldBe listOf( + Person(1, Name("Joe", "Bloggs"), 111) to Address("123 Main St", 12345) + ) + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/EncodingSpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/EncodingSpec.kt new file mode 100644 index 0000000..14ee9cb --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/EncodingSpec.kt @@ -0,0 +1,158 @@ +package io.exoquery.r2dbc.oracle + +import io.exoquery.sql.encodingdata.* +import io.exoquery.sql.Sql +import io.exoquery.controller.runOn +import io.exoquery.controller.runActions +import io.kotest.core.spec.style.FreeSpec +import java.time.ZoneId +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.r2dbc.encodingdata.JavaTestEntity +import io.exoquery.r2dbc.encodingdata.SimpleTimeEntity +import io.exoquery.r2dbc.encodingdata.encodingConfig +import io.exoquery.r2dbc.encodingdata.insert +import io.exoquery.r2dbc.encodingdata.verify + +class EncodingSpec: FreeSpec({ + + val cf = TestDatabasesR2dbc.oracle + val ctx: R2dbcController by lazy { R2dbcControllers.Oracle(connectionFactory = cf) } + + suspend fun runActions(actions: String) = ctx.runActions(actions) + + beforeEach { + // The main table used across many tests + runActions("DELETE FROM EncodingTestEntity") + } + + "encodes and decodes nullables - not nulls" { + insert(EncodingTestEntity.regular).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntity.regular) + } + + "encodes and decodes custom impls nullables - not nulls" { + insert(EncodingTestEntityImp.regular).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntityImp.regular) + } + + "encodes and decodes custom impls nullables - nulls" { + insert(EncodingTestEntityImp.empty).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntityImp.empty) + } + + "encodes and decodes custom value-classes nullables - not nulls" { + insert(EncodingTestEntityVal.regular).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntityVal.regular) + } + + "encodes and decodes custom value-classes nullables - nulls" { + insert(EncodingTestEntityVal.empty).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntityVal.empty) + } + + "encodes and decodes batch" { + insertBatch(listOf(EncodingTestEntity.regular, EncodingTestEntity.regular)).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res[0], EncodingTestEntity.regular) + verify(res[1], EncodingTestEntity.regular) + } + + "encodes and decodes nullables - nulls" { + insert(EncodingTestEntity.empty).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntity.empty) + } + + "Encode/Decode Additional Java Types - regular" { + runActions("DELETE FROM JavaTestEntity") + insert(JavaTestEntity.Companion.regular).runOn(ctx) + val actual = Sql("SELECT * FROM JavaTestEntity").queryOf().runOn(ctx).first() + verify(actual, JavaTestEntity.Companion.regular) + } + + "Encode/Decode Additional Java Types - empty" { + runActions("DELETE FROM JavaTestEntity") + insert(JavaTestEntity.Companion.empty).runOn(ctx) + val actual = Sql("SELECT * FROM JavaTestEntity").queryOf().runOn(ctx).first() + verify(actual, JavaTestEntity.Companion.empty) + } + + "Encode/Decode KMP Types" { + runActions("DELETE FROM KmpTestEntity") + insert(KmpTestEntity.regular).runOn(ctx) + val actual = Sql("SELECT * FROM KmpTestEntity").queryOf().runOn(ctx).first() + verify(actual, KmpTestEntity.regular) + } + + "Encode/Decode Other Time Types" { + runActions("DELETE FROM TimeEntity") + val zid = ZoneId.systemDefault() + val timeEntity = SimpleTimeEntity.Companion.make(zid) + insert(timeEntity).runOn(ctx) + val actual = Sql( + """ + SELECT + timeLocalDate, + timeLocalTime, + timeLocalDateTime, + timeZonedDateTime, + timeInstant, + timeOffsetTime, + timeOffsetDateTime + FROM TimeEntity + """ + ).queryOf().runOn(ctx).first() + assert(timeEntity == actual) + } + + "Encode/Decode Other Time Types ordering" { + runActions("DELETE FROM TimeEntity") + + val zid = ZoneId.systemDefault() + val timeEntityA = SimpleTimeEntity.make(zid, SimpleTimeEntity.TimeEntityInput(2022, 1, 1, 1, 1, 1, 0)) + val timeEntityB = SimpleTimeEntity.make(zid, SimpleTimeEntity.TimeEntityInput(2022, 2, 2, 2, 2, 2, 0)) + + insert(timeEntityA).runOn(ctx) + insert(timeEntityB).runOn(ctx) + + assert(timeEntityB.timeLocalDate > timeEntityA.timeLocalDate) + assert(timeEntityB.timeLocalTime > timeEntityA.timeLocalTime) + assert(timeEntityB.timeLocalDateTime > timeEntityA.timeLocalDateTime) + assert(timeEntityB.timeZonedDateTime > timeEntityA.timeZonedDateTime) + assert(timeEntityB.timeInstant > timeEntityA.timeInstant) + assert(timeEntityB.timeOffsetTime > timeEntityA.timeOffsetTime) + assert(timeEntityB.timeOffsetDateTime > timeEntityA.timeOffsetDateTime) + + val actual = + Sql( + """ + SELECT + timeLocalDate, + timeLocalTime, + timeLocalDateTime, + timeZonedDateTime, + timeInstant, + timeOffsetTime, + timeOffsetDateTime + FROM TimeEntity + WHERE + timeLocalDate > ${timeEntityA.timeLocalDate} + AND timeLocalTime > ${timeEntityA.timeLocalTime} + AND timeLocalDateTime > ${timeEntityA.timeLocalDateTime} + AND timeZonedDateTime > ${timeEntityA.timeZonedDateTime} + AND timeInstant > ${timeEntityA.timeInstant} + AND timeOffsetTime > ${timeEntityA.timeOffsetTime} + AND timeOffsetDateTime > ${timeEntityA.timeOffsetDateTime} + """ + ).queryOf().runOn(ctx).first() + + assert(actual == timeEntityB) + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/InQuerySpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/InQuerySpec.kt new file mode 100644 index 0000000..9d5718a --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/InQuerySpec.kt @@ -0,0 +1,64 @@ +package io.exoquery.r2dbc.oracle + +import io.exoquery.controller.runActions +import io.exoquery.controller.runOn +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Params +import io.exoquery.sql.Sql +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class InQuerySpec : FreeSpec({ + + val cf = TestDatabasesR2dbc.oracle + val ctx: R2dbcController by lazy { R2dbcControllers.Oracle(connectionFactory = cf) } + + suspend fun runActions(actions: String) = ctx.runActions(actions) + + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + + beforeSpec { + runActions( + """ + DELETE FROM Person; + DELETE FROM Address; + INSERT INTO Person (id, firstName, lastName, age) VALUES (1, 'Joe', 'Bloggs', 111); + INSERT INTO Person (id, firstName, lastName, age) VALUES (2, 'Jim', 'Roogs', 222); + INSERT INTO Person (id, firstName, lastName, age) VALUES (3, 'Jill', 'Doogs', 222); + INSERT INTO Address (ownerId, street, zip) VALUES (1, '123 Main St', 12345); + """.trimIndent() + ) + } + + "Person IN (names) - simple" { + val sql = Sql("SELECT id, firstName, lastName, age FROM Person WHERE firstName IN ${Params("Joe", "Jim")} ORDER BY id").queryOf() + sql.sql shouldBe "SELECT id, firstName, lastName, age FROM Person WHERE firstName IN (?, ?) ORDER BY id" + sql.runOn(ctx) shouldBe listOf( + Person(1, "Joe", "Bloggs", 111), + Person(2, "Jim", "Roogs", 222) + ) + } + + "Person IN (names) - single" { + val sql = Sql("SELECT id, firstName, lastName, age FROM Person WHERE firstName IN ${Params("Joe")} ORDER BY id").queryOf() + sql.sql shouldBe "SELECT id, firstName, lastName, age FROM Person WHERE firstName IN (?) ORDER BY id" + sql.runOn(ctx) shouldBe listOf( + Person(1, "Joe", "Bloggs", 111) + ) + } + + "Person IN (names) - empty" { + val sql = Sql("SELECT id, firstName, lastName, age FROM Person WHERE firstName IN ${Params.empty()} ORDER BY id").queryOf() + sql.sql shouldBe "SELECT id, firstName, lastName, age FROM Person WHERE firstName IN (null) ORDER BY id" + sql.runOn(ctx) shouldBe listOf() + } + + "Person IN (names) - empty list" { + val names: List = emptyList() + Sql("SELECT id, firstName, lastName, age FROM Person WHERE firstName IN ${Params.list(names)} ORDER BY id").queryOf().runOn(ctx) shouldBe listOf() + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/InjectionSpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/InjectionSpec.kt new file mode 100644 index 0000000..961ac25 --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/InjectionSpec.kt @@ -0,0 +1,44 @@ +package io.exoquery.r2dbc.oracle + +import io.exoquery.controller.runActions +import io.exoquery.controller.runOn +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Param +import io.exoquery.sql.Sql +import io.exoquery.sql.encodingdata.EncodingTestEntity +import io.exoquery.sql.encodingdata.insert +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class InjectionSpec: FreeSpec({ + + val cf = TestDatabasesR2dbc.oracle + val ctx: R2dbcController by lazy { R2dbcControllers.Oracle(connectionFactory = cf) } + + suspend fun runActions(actions: String) = ctx.runActions(actions) + + beforeEach { + runActions( + """ + DELETE FROM Person; + """.trimIndent() + ) + // Insert a single person row + runActions("INSERT INTO Person (id, firstName, lastName, age) VALUES (1, 'Joe', 'Blogs', 123)") + } + + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + + "escapes column meant to be an injection attack" { + insert(EncodingTestEntity.regular).runOn(ctx) + val name = "'Joe'; DROP TABLE Person;" + Sql("SELECT * FROM Person WHERE firstName = ${Param.withSer(name)}").queryOf().runOn(ctx) shouldBe listOf() + + // verify table still exists and is intact + Sql("SELECT * FROM Person ORDER BY id").queryOf().runOn(ctx) shouldBe listOf(Person(1, "Joe", "Blogs", 123)) + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/TransactionSpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/TransactionSpec.kt new file mode 100644 index 0000000..7614ac5 --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/TransactionSpec.kt @@ -0,0 +1,66 @@ +package io.exoquery.r2dbc.oracle + +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.controller.runActions +import io.exoquery.controller.runOn +import io.exoquery.controller.transaction +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Sql +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class TransactionSpec: FreeSpec({ + val cf = TestDatabasesR2dbc.oracle + val ctx: R2dbcController by lazy { R2dbcControllers.Oracle(connectionFactory = cf) } + beforeEach { + ctx.runActions( + """ + DELETE FROM Person; + DELETE FROM Address; + """ + ) + } + + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + + "provides transaction support" - { + val joe = Person(1, "Joe", "Bloggs", 111) + val jack = Person(2, "Jack", "Roogs", 222) + + // Note the string elements ${...} should not have quotes around them or else they are interpreted as literals + fun insert(p: Person) = + Sql("INSERT INTO Person (id, firstName, lastName, age) VALUES (${p.id}, ${p.firstName}, ${p.lastName}, ${p.age})").action() + + + fun select() = Sql("SELECT id, firstName, lastName, age FROM Person ORDER BY id").queryOf() + + "success" { + ctx.transaction { + insert(joe).run() + } + select().runOn(ctx) shouldBe listOf(joe) + } + "failure" { + insert(joe).runOn(ctx) + shouldThrow { + ctx.transaction { + insert(jack).run() + throw IllegalStateException() + } + } + select().runOn(ctx) shouldBe listOf(joe) + } + "nested" { + ctx.transaction { + ctx.transaction { + insert(joe).run() + } + } + select().runOn(ctx) shouldBe listOf(joe) + } + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/sqlserver/EncodingSpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/sqlserver/EncodingSpec.kt new file mode 100644 index 0000000..fa9f4fd --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/sqlserver/EncodingSpec.kt @@ -0,0 +1,158 @@ +package io.exoquery.r2dbc.sqlserver + +import io.exoquery.sql.encodingdata.* +import io.exoquery.sql.Sql +import io.exoquery.controller.runOn +import io.exoquery.controller.runActions +import io.kotest.core.spec.style.FreeSpec +import java.time.ZoneId +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.r2dbc.encodingdata.JavaTestEntity +import io.exoquery.r2dbc.encodingdata.SimpleTimeEntity +import io.exoquery.r2dbc.encodingdata.encodingConfig +import io.exoquery.r2dbc.encodingdata.insert +import io.exoquery.r2dbc.encodingdata.verify + +class EncodingSpec: FreeSpec({ + + val cf = TestDatabasesR2dbc.sqlServer + val ctx: R2dbcController by lazy { R2dbcControllers.SqlServer(connectionFactory = cf) } + + suspend fun runActions(actions: String) = ctx.runActions(actions) + + beforeEach { + // The main table used across many tests + runActions("DELETE FROM EncodingTestEntity") + } + + "encodes and decodes nullables - not nulls" { + insert(EncodingTestEntity.regular).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntity.regular) + } + + "encodes and decodes custom impls nullables - not nulls" { + insert(EncodingTestEntityImp.regular).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntityImp.regular) + } + + "encodes and decodes custom impls nullables - nulls" { + insert(EncodingTestEntityImp.empty).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntityImp.empty) + } + + "encodes and decodes custom value-classes nullables - not nulls" { + insert(EncodingTestEntityVal.regular).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntityVal.regular) + } + + "encodes and decodes custom value-classes nullables - nulls" { + insert(EncodingTestEntityVal.empty).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntityVal.empty) + } + + "encodes and decodes batch" { + insertBatch(listOf(EncodingTestEntity.regular, EncodingTestEntity.regular)).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res[0], EncodingTestEntity.regular) + verify(res[1], EncodingTestEntity.regular) + } + + "encodes and decodes nullables - nulls" { + insert(EncodingTestEntity.empty).runOn(ctx) + val res = Sql("SELECT * FROM EncodingTestEntity").queryOf().runOn(ctx) + verify(res.first(), EncodingTestEntity.empty) + } + + "Encode/Decode Additional Java Types - regular" { + runActions("DELETE FROM JavaTestEntity") + insert(JavaTestEntity.Companion.regular).runOn(ctx) + val actual = Sql("SELECT * FROM JavaTestEntity").queryOf().runOn(ctx).first() + verify(actual, JavaTestEntity.Companion.regular) + } + + "Encode/Decode Additional Java Types - empty" { + runActions("DELETE FROM JavaTestEntity") + insert(JavaTestEntity.Companion.empty).runOn(ctx) + val actual = Sql("SELECT * FROM JavaTestEntity").queryOf().runOn(ctx).first() + verify(actual, JavaTestEntity.Companion.empty) + } + + "Encode/Decode KMP Types" { + runActions("DELETE FROM KmpTestEntity") + insert(KmpTestEntity.regular).runOn(ctx) + val actual = Sql("SELECT * FROM KmpTestEntity").queryOf().runOn(ctx).first() + verify(actual, KmpTestEntity.regular) + } + + "Encode/Decode Other Time Types" { + runActions("DELETE FROM TimeEntity") + val zid = ZoneId.systemDefault() + val timeEntity = SimpleTimeEntity.Companion.make(zid) + insert(timeEntity).runOn(ctx) + val actual = Sql( + """ + SELECT + timeLocalDate, + timeLocalTime, + timeLocalDateTime, + timeZonedDateTime, + timeInstant, + timeOffsetTime, + timeOffsetDateTime + FROM TimeEntity + """ + ).queryOf().runOn(ctx).first() + assert(timeEntity == actual) + } + + "Encode/Decode Other Time Types ordering" { + runActions("DELETE FROM TimeEntity") + + val zid = ZoneId.systemDefault() + val timeEntityA = SimpleTimeEntity.make(zid, SimpleTimeEntity.TimeEntityInput(2022, 1, 1, 1, 1, 1, 0)) + val timeEntityB = SimpleTimeEntity.make(zid, SimpleTimeEntity.TimeEntityInput(2022, 2, 2, 2, 2, 2, 0)) + + insert(timeEntityA).runOn(ctx) + insert(timeEntityB).runOn(ctx) + + assert(timeEntityB.timeLocalDate > timeEntityA.timeLocalDate) + assert(timeEntityB.timeLocalTime > timeEntityA.timeLocalTime) + assert(timeEntityB.timeLocalDateTime > timeEntityA.timeLocalDateTime) + assert(timeEntityB.timeZonedDateTime > timeEntityA.timeZonedDateTime) + assert(timeEntityB.timeInstant > timeEntityA.timeInstant) + assert(timeEntityB.timeOffsetTime > timeEntityA.timeOffsetTime) + assert(timeEntityB.timeOffsetDateTime > timeEntityA.timeOffsetDateTime) + + val actual = + Sql( + """ + SELECT + timeLocalDate, + timeLocalTime, + timeLocalDateTime, + timeZonedDateTime, + timeInstant, + timeOffsetTime, + timeOffsetDateTime + FROM TimeEntity + WHERE + timeLocalDate > ${timeEntityA.timeLocalDate} + AND timeLocalTime > ${timeEntityA.timeLocalTime} + AND timeLocalDateTime > ${timeEntityA.timeLocalDateTime} + AND timeZonedDateTime > ${timeEntityA.timeZonedDateTime} + AND timeInstant > ${timeEntityA.timeInstant} + AND timeOffsetTime > ${timeEntityA.timeOffsetTime} + AND timeOffsetDateTime > ${timeEntityA.timeOffsetDateTime} + """ + ).queryOf().runOn(ctx).first() + + assert(actual == timeEntityB) + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/sqlserver/InjectionSpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/sqlserver/InjectionSpec.kt new file mode 100644 index 0000000..2ac2742 --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/sqlserver/InjectionSpec.kt @@ -0,0 +1,44 @@ +package io.exoquery.r2dbc.sqlserver + +import io.exoquery.controller.runActions +import io.exoquery.controller.runOn +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Param +import io.exoquery.sql.Sql +import io.exoquery.sql.encodingdata.EncodingTestEntity +import io.exoquery.sql.encodingdata.insert +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class InjectionSpec: FreeSpec({ + + val cf = TestDatabasesR2dbc.sqlServer + val ctx: R2dbcController by lazy { R2dbcControllers.SqlServer(connectionFactory = cf) } + + suspend fun runActions(actions: String) = ctx.runActions(actions) + + beforeEach { + runActions( + """ + TRUNCATE TABLE Person; DBCC CHECKIDENT ('Person', RESEED, 1); + """ + ) + // Insert a single person row (id should be 1 after reseed) + runActions("INSERT INTO Person (firstName, lastName, age) VALUES ('Joe', 'Blogs', 123)") + } + + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + + "escapes column meant to be an injection attack" { + insert(EncodingTestEntity.regular).runOn(ctx) + val name = "'Joe'; DROP TABLE Person;" + Sql("SELECT * FROM Person WHERE firstName = ${Param.withSer(name)}").queryOf().runOn(ctx) shouldBe listOf() + + // verify table still exists and is intact + Sql("SELECT * FROM Person").queryOf().runOn(ctx) shouldBe listOf(Person(1, "Joe", "Blogs", 123)) + } +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/sqlserver/TransactionSpec.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/sqlserver/TransactionSpec.kt new file mode 100644 index 0000000..36492fc --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/sqlserver/TransactionSpec.kt @@ -0,0 +1,66 @@ +package io.exoquery.r2dbc.sqlserver + +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.controller.runActions +import io.exoquery.controller.runOn +import io.exoquery.controller.transaction +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Sql +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class TransactionSpec: FreeSpec({ + val cf = TestDatabasesR2dbc.sqlServer + val ctx: R2dbcController by lazy { R2dbcControllers.SqlServer(connectionFactory = cf) } + beforeEach { + ctx.runActions( + """ + TRUNCATE TABLE Person; DBCC CHECKIDENT ('Person', RESEED, 1); + DELETE FROM Address; + """ + ) + } + + @Serializable + data class Person(val id: Int, val firstName: String, val lastName: String, val age: Int) + + "provides transaction support" - { + val joe = Person(1, "Joe", "Bloggs", 111) + val jack = Person(2, "Jack", "Roogs", 222) + + // Note the string elements ${...} should not have quotes around them or else they are interpreted as literals + fun insert(p: Person) = + Sql("INSERT INTO Person (firstName, lastName, age) VALUES (${p.firstName}, ${p.lastName}, ${p.age})").action() + + + fun select() = Sql("SELECT id, firstName, lastName, age FROM Person").queryOf() + + "success" { + ctx.transaction { + insert(joe).run() + } + select().runOn(ctx) shouldBe listOf(joe) + } + "failure" { + insert(joe).runOn(ctx) + shouldThrow { + ctx.transaction { + insert(jack).run() + throw IllegalStateException() + } + } + select().runOn(ctx) shouldBe listOf(joe) + } + "nested" { + ctx.transaction { + ctx.transaction { + insert(joe).run() + } + } + select().runOn(ctx) shouldBe listOf(joe) + } + } +}) diff --git a/terpal-sql-r2dbc/src/test/resources/db/h2-schema.sql b/terpal-sql-r2dbc/src/test/resources/db/h2-schema.sql new file mode 100644 index 0000000..ac268a9 --- /dev/null +++ b/terpal-sql-r2dbc/src/test/resources/db/h2-schema.sql @@ -0,0 +1,76 @@ +CREATE TABLE IF NOT EXISTS person ( + id IDENTITY, + firstName VARCHAR(255), + lastName VARCHAR(255), + age INT +); + +CREATE TABLE IF NOT EXISTS address ( + ownerId INT, + street VARCHAR, + zip INT +); + +CREATE TABLE IF NOT EXISTS Product( + description VARCHAR(255), + id IDENTITY, + sku BIGINT +); + +CREATE TABLE IF NOT EXISTS KmpTestEntity( + timeLocalDate DATE, -- java.time.LocalDate + timeLocalTime TIME, -- java.time.LocalTime + timeLocalDateTime TIMESTAMP, -- java.time.LocalDateTime + timeInstant TIMESTAMP WITH TIME ZONE, -- java.time.Instant + timeLocalDateOpt DATE, + timeLocalTimeOpt TIME, + timeLocalDateTimeOpt TIMESTAMP, + timeInstantOpt TIMESTAMP WITH TIME ZONE + +); + +CREATE TABLE IF NOT EXISTS TimeEntity( + sqlDate DATE, -- java.sql.Date + sqlTime TIME, -- java.sql.Time + sqlTimestamp TIMESTAMP, -- java.sql.Timestamp + timeLocalDate DATE, -- java.time.LocalDate + timeLocalTime TIME, -- java.time.LocalTime + timeLocalDateTime TIMESTAMP, -- java.time.LocalDateTime + timeZonedDateTime TIMESTAMP WITH TIME ZONE, -- java.time.ZonedDateTime + timeInstant TIMESTAMP WITH TIME ZONE, -- java.time.Instant + -- Like Postgres, H2 actually has a notion of a Time+Timezone type unlike most DBs + timeOffsetTime TIME WITH TIME ZONE, -- java.time.OffsetTime + timeOffsetDateTime TIMESTAMP WITH TIME ZONE -- java.time.OffsetDateTime +); + +CREATE TABLE IF NOT EXISTS EncodingTestEntity( + stringMan VARCHAR(255), + booleanMan BOOLEAN, + byteMan SMALLINT, + shortMan SMALLINT, + intMan INTEGER, + longMan BIGINT, + floatMan FLOAT, + doubleMan DOUBLE PRECISION, + byteArrayMan BYTEA, + customMan VARCHAR(255), + stringOpt VARCHAR(255), + booleanOpt BOOLEAN, + byteOpt SMALLINT, + shortOpt SMALLINT, + intOpt INTEGER, + longOpt BIGINT, + floatOpt FLOAT, + doubleOpt DOUBLE PRECISION, + byteArrayOpt BYTEA, + customOpt VARCHAR(255) +); + +CREATE TABLE IF NOT EXISTS JavaTestEntity( + bigDecimalMan DECIMAL(5,2), + javaUtilDateMan TIMESTAMP, + uuidMan UUID, + bigDecimalOpt DECIMAL(5,2), + javaUtilDateOpt TIMESTAMP, + uuidOpt UUID +);