diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 83bafda5..21501fb8 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -39,7 +39,7 @@ jobs: - name: Build & Test with Gradle env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./gradlew build sonar + run: ./gradlew build sonar -Ddb=all - name: Upload Test Report uses: actions/upload-artifact@v4 diff --git a/.run/Integration Tests (ALL).run.xml b/.run/Integration Tests (ALL).run.xml new file mode 100644 index 00000000..803b7136 --- /dev/null +++ b/.run/Integration Tests (ALL).run.xml @@ -0,0 +1,24 @@ + + + + + + + false + true + false + true + + + \ No newline at end of file diff --git a/.run/Integration Tests (MS SQL Server).run.xml b/.run/Integration Tests (MS SQL Server).run.xml new file mode 100644 index 00000000..ab74d743 --- /dev/null +++ b/.run/Integration Tests (MS SQL Server).run.xml @@ -0,0 +1,24 @@ + + + + + + + false + true + false + true + + + \ No newline at end of file diff --git a/.run/Integration Tests (Oracle).run.xml b/.run/Integration Tests (Oracle).run.xml new file mode 100644 index 00000000..01e2de95 --- /dev/null +++ b/.run/Integration Tests (Oracle).run.xml @@ -0,0 +1,24 @@ + + + + + + + false + true + false + true + + + \ No newline at end of file diff --git a/.run/Integration Tests.run.xml b/.run/Integration Tests.run.xml new file mode 100644 index 00000000..7469c60a --- /dev/null +++ b/.run/Integration Tests.run.xml @@ -0,0 +1,24 @@ + + + + + + + false + true + false + true + + + \ No newline at end of file diff --git a/README.md b/README.md index 0f040f52..b2817d51 100644 --- a/README.md +++ b/README.md @@ -372,7 +372,7 @@ If you need support for another DB, and/or find an issue with a particular DB, f Kapper is in its early stages of development. The following will be worked on in the next few releases: -- [ ] Create a benchmark suite to validate performance. +- [x] Create a benchmark suite to validate performance. - [x] Add co-routine support. - [x] Add flow support. - [ ] Improve and additional support for date/time conversion. @@ -381,7 +381,7 @@ The following will be worked on in the next few releases: - [ ] Cache query parsing. - [ ] Custom SQL type conversion. - [ ] Bulk operations support -- [ ] Add MS SQL Server and Oracle integration tests. +- [x] Add MS SQL Server, Oracle and SQLite integration tests. - [ ] Tests & examples in other JVM languages. - [x] Create transaction syntax sugar. - [ ] Support DTO argument for `execute`. diff --git a/buildSrc/src/main/kotlin/kapper.library-conventions.gradle.kts b/buildSrc/src/main/kotlin/kapper.library-conventions.gradle.kts index 2b03d30d..463dc868 100644 --- a/buildSrc/src/main/kotlin/kapper.library-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/kapper.library-conventions.gradle.kts @@ -89,6 +89,8 @@ tasks.register("integrationTest") { systemProperties( "junit.jupiter.execution.parallel.enabled" to "false", ) + + systemProperty("db", System.getProperty("db", "")) } tasks.named("test") { diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt index 4c3599be..75500481 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt @@ -1,159 +1,147 @@ package net.samyn.kapper -import io.kotest.matchers.booleans.shouldBeTrue +import net.samyn.kapper.internal.DbFlavour +import net.samyn.kapper.internal.getDbFlavour +import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Named.named -import org.junit.jupiter.api.Order import org.junit.jupiter.api.TestInstance -import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.Arguments.arguments -import org.junit.jupiter.params.provider.MethodSource import org.testcontainers.containers.JdbcDatabaseContainer +import org.testcontainers.containers.MSSQLServerContainer import org.testcontainers.containers.MySQLContainer import org.testcontainers.containers.PostgreSQLContainer -import org.testcontainers.junit.jupiter.Container -import org.testcontainers.junit.jupiter.Testcontainers +import org.testcontainers.oracle.OracleContainer +import java.sql.Connection import java.sql.DriverManager +import java.time.Duration import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Level +import java.util.logging.Logger +import kotlin.random.Random import kotlin.use -@Testcontainers @TestInstance(TestInstance.Lifecycle.PER_CLASS) abstract class AbstractDbTests { companion object { - val specialTypes = - mapOf( - "UUID" to - mapOf( - MySQLContainer::class to "VARCHAR(36)", - ), - "CLOB" to - mapOf( - MySQLContainer::class to "TEXT", - PostgreSQLContainer::class to "TEXT", - ), - "BINARY" to - mapOf( - PostgreSQLContainer::class to "BYTEA", - ), - "VARBINARY" to - mapOf( - PostgreSQLContainer::class to "BYTEA", - ), - "BLOB" to - mapOf( - PostgreSQLContainer::class to "BYTEA", - ), - "FLOAT" to - mapOf( - PostgreSQLContainer::class to "NUMERIC", - ), - "REAL" to - mapOf( - MySQLContainer::class to "FLOAT", - ), - ) + init { + Class.forName("org.sqlite.JDBC") + } + + private val postgresql by lazy { + PostgreSQLContainer("postgres:16").also { it.start() } + } + + private val mysql by lazy { + MySQLContainer("mysql:8.4").also { it.start() } + } - @Container - val postgresql = PostgreSQLContainer("postgres:16") + private val oracle by lazy { + OracleContainer("gvenzl/oracle-free:23-slim-faststart") + // pfff, this container is sloooow + .withStartupTimeout(Duration.ofMinutes(2)) + .also { it.start() } + } + + private val msSqlServer by lazy { + MSSQLServerContainer("mcr.microsoft.com/mssql/server:2017-CU12") + .acceptLicense() + .also { + // pfff, this container is noisy + val logger = Logger.getLogger("com.microsoft.sqlserver.jdbc.internals.SQLServerConnection") + logger.level = Level.SEVERE + it.start() + } + } - @Container - val mysql = MySQLContainer("mysql:8.4") + private val connections = ConcurrentHashMap() - val allContainers = + private fun getConnection(container: JdbcDatabaseContainer<*>): Connection { + Class.forName(container.driverClassName) + return DriverManager.getConnection(container.jdbcUrl, container.username, container.password) + } + + val dbs = mapOf( - "PostgreSQL" to postgresql, - "MySQL" to mysql, - ) + DbFlavour.POSTGRESQL to { getConnection(postgresql) }, + DbFlavour.MYSQL to { getConnection(mysql) }, + DbFlavour.SQLITE to { DriverManager.getConnection("jdbc:sqlite::memory:") }, + DbFlavour.MSSQLSERVER to { getConnection(msSqlServer) }, + DbFlavour.ORACLE to { getConnection(oracle) }, + ).filter { + // by default run against SQLite and PG only + // this allows parallel runs for different int tests. + when (System.getProperty("db", "").uppercase()) { + "" -> it.key == DbFlavour.SQLITE || it.key == DbFlavour.POSTGRESQL + "ALL" -> true + else -> it.key == DbFlavour.valueOf(System.getProperty("db").uppercase()) + } + } @JvmStatic - fun databaseContainers() = allContainers.map { arguments(named(it.key, it.value)) } + fun databaseContainers(): List { + println("--------------------------------") + println("Running tests against:") + val connections = + connections + .map { + println(" ${it.key}") + arguments(named(it.key.toString(), it.value)) + } + println("--------------------------------") + return connections + } } - private fun convertDbColumnType( - name: String, - container: JdbcDatabaseContainer<*>, - suffix: String = "", - ) = specialTypes[name]?.get(container::class) ?: (name + suffix) - + val testId = randomUpperCaseString(10) // pfff, Oracle has a limit of 30 chars for table names val superman = SuperHero(UUID.randomUUID(), "Superman", "superman@dc.com", 86) val batman = SuperHero(UUID.randomUUID(), "Batman", "batman@dc.com", 85) val spiderMan = SuperHero(UUID.randomUUID(), "Spider-man", "spider@marvel.com", 62) @BeforeAll fun setup() { - allContainers.values.forEach { container -> - setupDatabase(container) + dbs.forEach { container -> + setupDatabase(connections.computeIfAbsent(container.key) { container.value() }) } } - protected open fun setupDatabase(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - connection.createStatement().use { statement -> - statement.execute( - """ - CREATE TABLE super_heroes ( - id ${convertDbColumnType("UUID", container)} PRIMARY KEY, - name VARCHAR(100), - email VARCHAR(100), - age INT - ); - """.trimIndent().also { - println(it) - }, - ) - statement.execute( - """ - INSERT INTO super_heroes (id, name, email, age) VALUES - ('${superman.id}', '${superman.name}', '${superman.email}', ${superman.age}), - ('${batman.id}', '${batman.name}', '${batman.email}', ${batman.age}), - ('${spiderMan.id}', '${spiderMan.name}', '${spiderMan.email}', ${spiderMan.age}); - """.trimIndent().also { - println(it) - }, - ) - statement.execute( - """ - CREATE TABLE types_test ( - t_uuid ${convertDbColumnType("UUID", container)}, - t_char CHAR, - t_varchar VARCHAR(120), - t_clob ${convertDbColumnType("CLOB", container)}, - t_binary ${convertDbColumnType("BINARY", container, "(16)")}, - t_varbinary ${convertDbColumnType("VARBINARY", container, "(128)")}, - t_large_binary ${convertDbColumnType("BLOB", container)}, - t_numeric NUMERIC(12,6), - t_decimal DECIMAL(12,6), - t_smallint SMALLINT, - t_int INT, - t_bigint BIGINT, - t_float ${convertDbColumnType("FLOAT", container, "(8)")}, - t_real ${convertDbColumnType("REAL", container)}, - t_double DOUBLE PRECISION, - t_date DATE, - t_local_date DATE, - t_local_time TIME, - t_timestamp TIMESTAMP, - t_boolean BOOLEAN - ) - """.trimIndent().also { - println(it) - }, - ) - } + @AfterAll + fun tearDown() { + connections.forEach { (_, connection) -> + connection.close() } + connections.clear() } - @ParameterizedTest() - @MethodSource("databaseContainers") - @Order(1) - fun `database should be running`(container: JdbcDatabaseContainer<*>) { - container.isRunning.shouldBeTrue() + protected open fun setupDatabase(connection: Connection) { + val dbFlavour = connection.getDbFlavour() + connection.createStatement().use { statement -> + statement.execute( + """ + CREATE TABLE super_heroes_$testId ( + id ${convertDbColumnType("UUID", dbFlavour)} PRIMARY KEY, + name VARCHAR(100), + email VARCHAR(100), + age ${convertDbColumnType("INT", dbFlavour)} + ) + """.trimIndent().also { + println("------------ $dbFlavour --------------") + println(it) + }, + ) + statement.execute( + """ + INSERT INTO super_heroes_$testId (id, name, email, age) VALUES + (${convertUUIDString(superman.id, dbFlavour)}, '${superman.name}', '${superman.email}', ${superman.age}), + (${convertUUIDString(batman.id, dbFlavour)}, '${batman.name}', '${batman.email}', ${batman.age}), + (${convertUUIDString(spiderMan.id, dbFlavour)}, '${spiderMan.name}', '${spiderMan.email}', ${spiderMan.age}); + """, + ) + } } - protected fun createConnection(container: JdbcDatabaseContainer<*>) = - DriverManager.getConnection(container.jdbcUrl, container.username, container.password) - data class SuperHero(val id: UUID, val name: String, val email: String? = null, val age: Int? = null) class Villain { @@ -161,3 +149,11 @@ abstract class AbstractDbTests { var name: String? = null } } + +fun randomUpperCaseString(size: Int): String { + val charPool = ('A'..'Z') + ('0'..'9') + return (1..size) + .map { Random.nextInt(0, charPool.size) } + .map(charPool::get) + .joinToString("") +} diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/DbTypeConverter.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/DbTypeConverter.kt new file mode 100644 index 00000000..683c56ef --- /dev/null +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/DbTypeConverter.kt @@ -0,0 +1,92 @@ +package net.samyn.kapper + +import net.samyn.kapper.internal.DbFlavour +import java.util.UUID + +private val specialTypes = + mapOf( + "UUID" to + mapOf( + DbFlavour.MYSQL to "VARCHAR(36)", + DbFlavour.MSSQLSERVER to "UNIQUEIDENTIFIER", + DbFlavour.ORACLE to "RAW(16)", + ), + "CLOB" to + mapOf( + DbFlavour.MYSQL to "TEXT", + DbFlavour.POSTGRESQL to "TEXT", + DbFlavour.MSSQLSERVER to "NVARCHAR(MAX)", + ), + "BINARY" to + mapOf( + DbFlavour.POSTGRESQL to "BYTEA", + DbFlavour.ORACLE to "RAW(128)", + ), + "VARBINARY" to + mapOf( + DbFlavour.POSTGRESQL to "BYTEA", + DbFlavour.ORACLE to "RAW(128)", + ), + "BLOB" to + mapOf( + DbFlavour.POSTGRESQL to "BYTEA", + DbFlavour.MSSQLSERVER to "VARBINARY(MAX)", + ), + "FLOAT" to + mapOf( + DbFlavour.POSTGRESQL to "NUMERIC", + DbFlavour.ORACLE to "FLOAT", + ), + "REAL" to + mapOf( + DbFlavour.MYSQL to "FLOAT", + DbFlavour.ORACLE to "BINARY_FLOAT", + ), + "BOOLEAN" to + mapOf( + DbFlavour.MSSQLSERVER to "BIT", + DbFlavour.ORACLE to "NUMBER(1)", + ), + "TIMESTAMP" to + mapOf( + DbFlavour.MSSQLSERVER to "DATETIME", + ), + "INT" to + mapOf( + DbFlavour.ORACLE to "NUMBER(10)", + ), + "BIGINT" to + mapOf( + DbFlavour.ORACLE to "NUMBER(19)", + ), + "DOUBLE PRECISION" to + mapOf( + DbFlavour.ORACLE to "BINARY_DOUBLE", + ), + "SMALLINT" to + mapOf( + DbFlavour.ORACLE to "NUMBER(5)", + ), + "TIME" to + mapOf( + DbFlavour.ORACLE to "TIMESTAMP", + ), + "VARCHAR" to + mapOf( + DbFlavour.ORACLE to "VARCHAR2(120)", + ), + ) + +fun convertDbColumnType( + name: String, + flavour: DbFlavour, + suffix: String = "", +) = specialTypes[name]?.get(flavour) ?: (name + suffix) + +fun convertUUIDString( + id: UUID, + flavour: DbFlavour, +) = when (flavour) { + DbFlavour.ORACLE -> "HEXTORAW('${id.toString().replace("-", "")}')" + else -> "'$id'" +} diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/ExecuteTests.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/ExecuteTests.kt index d7d6ac09..b12a9583 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/ExecuteTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/ExecuteTests.kt @@ -4,218 +4,207 @@ import io.kotest.assertions.assertSoftly import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.shouldBe +import net.samyn.kapper.internal.getDbFlavour import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource -import org.testcontainers.containers.JdbcDatabaseContainer -import org.testcontainers.containers.MySQLContainer +import java.sql.Connection import java.sql.SQLException import java.util.UUID -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.toKotlinUuid class ExecuteTests : AbstractDbTests() { - @OptIn(ExperimentalUuidApi::class) - val table = "super_heros_${UUID.randomUUID().toKotlinUuid().toHexString()}" - - override fun setupDatabase(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - connection.createStatement().use { statement -> - statement.execute( - """ - CREATE TABLE $table ( - id ${if (container is MySQLContainer) "VARCHAR(36)" else "UUID"} PRIMARY KEY, - name VARCHAR(100), - email VARCHAR(100), - age INT - ); - """.trimIndent().also { - println(it) - }, - ) - } - } - } - @ParameterizedTest() @MethodSource("databaseContainers") - fun `SQL Insert single`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - val results = - connection.execute( - """ - INSERT INTO $table(id, name, email, age) VALUES(:id, :name, :email, :age); - """.trimIndent(), - "id" to superman.id, - "name" to superman.name, - "email" to superman.email, - "age" to superman.age, - ) - - results.shouldBe(1) - - connection.prepareStatement("SELECT name, email, age FROM $table WHERE id = '${superman.id}'").use { stmt -> + fun `SQL Insert single`(connection: Connection) { + val supermanClone = superman.copy(id = UUID.randomUUID()) + val results = + connection.execute( + """ + INSERT INTO super_heroes_$testId(id, name, email, age) VALUES(:id, :name, :email, :age) + """.trimIndent(), + "id" to supermanClone.id, + "name" to supermanClone.name, + "email" to supermanClone.email, + "age" to supermanClone.age, + ) + + results.shouldBe(1) + + connection.prepareStatement( + "SELECT name, email, age FROM super_heroes_$testId " + + "WHERE id = ${convertUUIDString(supermanClone.id, connection.getDbFlavour())}", + ) + .use { stmt -> val resultSet = stmt.executeQuery() + resultSet.next().shouldBe(true) assertSoftly(resultSet) { - next().shouldBe(true) - getString(1).shouldBe(superman.name) - getString(2).shouldBe(superman.email) - getInt(3).shouldBe(superman.age) + getString(1).shouldBe(supermanClone.name) + getString(2).shouldBe(supermanClone.email) + getInt(3).shouldBe(supermanClone.age) } } - } } @ParameterizedTest() @MethodSource("databaseContainers") - fun `SQL Update single`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - connection.createStatement().use { stmt -> - stmt.execute("INSERT INTO $table(id, name) VALUES('${batman.id}', 'foo');") - } - - val results = - connection.execute( - "UPDATE $table SET name = :name, email = :email, age = :age WHERE id = :id;", - "id" to batman.id, - "name" to batman.name, - "email" to batman.email, - "age" to batman.age, - ) - - results.shouldBe(1) + fun `SQL Update single`(connection: Connection) { + val batmanClone = batman.copy(id = UUID.randomUUID()) + connection.createStatement().use { stmt -> + stmt.execute( + "INSERT INTO super_heroes_$testId(id, name) " + + "VALUES(${convertUUIDString(batmanClone.id, connection.getDbFlavour())}, 'foo')", + ) + } - connection.prepareStatement("SELECT name, email, age FROM $table WHERE id = '${batman.id}'").use { stmt -> + val results = + connection.execute( + "UPDATE super_heroes_$testId SET name = :name, email = :email, age = :age WHERE id = :id", + "id" to batmanClone.id, + "name" to batmanClone.name, + "email" to batmanClone.email, + "age" to batmanClone.age, + ) + + results.shouldBe(1) + + connection.prepareStatement( + "SELECT name, email, age FROM super_heroes_$testId " + + "WHERE id = ${convertUUIDString(batmanClone.id, connection.getDbFlavour())}", + ) + .use { stmt -> val resultSet = stmt.executeQuery() assertSoftly(resultSet) { next().shouldBe(true) - getString(1).shouldBe(batman.name) - getString(2).shouldBe(batman.email) - getInt(3).shouldBe(batman.age) + getString(1).shouldBe(batmanClone.name) + getString(2).shouldBe(batmanClone.email) + getInt(3).shouldBe(batmanClone.age) } } - } } @ParameterizedTest() @MethodSource("databaseContainers") - fun `SQL Update multiple`(container: JdbcDatabaseContainer<*>) { + fun `SQL Update multiple`(connection: Connection) { val ids = listOf(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID()) val name = "foo-${UUID.randomUUID()}" - createConnection(container).use { connection -> - connection.createStatement().use { stmt -> - ids.forEach { - stmt.execute("INSERT INTO $table(id, name) VALUES('$it', '$name');") - } + connection.createStatement().use { stmt -> + ids.forEach { + stmt.execute( + "INSERT INTO super_heroes_$testId(id, name) " + + "VALUES(${convertUUIDString(it, connection.getDbFlavour())}, '$name')", + ) } + } - val results = - connection.execute( - "UPDATE $table SET name = :newName WHERE name = :name;", - "name" to name, - "newName" to "bar", - ) + val results = + connection.execute( + "UPDATE super_heroes_$testId SET name = :newName WHERE name = :name", + "name" to name, + "newName" to "bar", + ) - results.shouldBe(ids.size) - } + results.shouldBe(ids.size) } @ParameterizedTest() @MethodSource("databaseContainers") - fun `SQL Delete single`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - connection.createStatement().use { stmt -> - stmt.execute("INSERT INTO $table(id, name) VALUES('${spiderMan.id}', 'foo');") - } + fun `SQL Delete single`(connection: Connection) { + val spidermanClone = spiderMan.copy(id = UUID.randomUUID()) + connection.createStatement().use { stmt -> + stmt.execute( + "INSERT INTO super_heroes_$testId(id, name) " + + "VALUES(${convertUUIDString(spidermanClone.id, connection.getDbFlavour())}, 'foo')", + ) + } - val results = - connection.execute( - "DELETE FROM $table WHERE id = :id;", - "id" to spiderMan.id, - ) + val results = + connection.execute( + "DELETE FROM super_heroes_$testId WHERE id = :id", + "id" to spidermanClone.id, + ) - results.shouldBe(1) + results.shouldBe(1) - connection.prepareStatement("SELECT * FROM $table WHERE id = '${batman.id}'").use { stmt -> + connection.prepareStatement( + "SELECT * FROM super_heroes_$testId " + + "WHERE id = ${convertUUIDString(spidermanClone.id, connection.getDbFlavour())}", + ) + .use { stmt -> val resultSet = stmt.executeQuery() assertSoftly(resultSet) { next().shouldBe(false) } } - } } @ParameterizedTest() @MethodSource("databaseContainers") - fun `SQL Delete multiple`(container: JdbcDatabaseContainer<*>) { + fun `SQL Delete multiple`(connection: Connection) { val ids = listOf(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID()) val name = "bar-${UUID.randomUUID()}" - createConnection(container).use { connection -> - connection.createStatement().use { stmt -> - ids.forEach { - stmt.execute("INSERT INTO $table(id, name) VALUES('$it', '$name');") - } + connection.createStatement().use { stmt -> + ids.forEach { + stmt.execute( + "INSERT INTO super_heroes_$testId(id, name) " + + "VALUES(${convertUUIDString(it, connection.getDbFlavour())}, '$name')", + ) } + } - val results = - connection.execute( - "DELETE FROM $table WHERE name = :name;", - "name" to name, - ) + val results = + connection.execute( + "DELETE FROM super_heroes_$testId WHERE name = :name", + "name" to name, + ) - results.shouldBe(ids.size) - } + results.shouldBe(ids.size) } @ParameterizedTest() @MethodSource("databaseContainers") - fun `with TX completes`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - connection.withTransaction { - val results = + fun `with TX completes`(connection: Connection) { + connection.withTransaction { + val results = + connection.execute( + """ + INSERT INTO super_heroes_$testId(id, name, email) VALUES(:id, :name, :email) + """.trimIndent(), + "id" to UUID.randomUUID(), + "name" to "thor", + "email" to "thor@world.com", + ) + connection.execute( """ - INSERT INTO $table(id, name, email) VALUES(:id, :name, :email); + INSERT INTO super_heroes_$testId(id, name, email) VALUES(:id, :name, :email) """.trimIndent(), "id" to UUID.randomUUID(), "name" to "thor", "email" to "thor@world.com", - ) + - connection.execute( - """ - INSERT INTO $table(id, name, email) VALUES(:id, :name, :email); - """.trimIndent(), - "id" to UUID.randomUUID(), - "name" to "thor", - "email" to "thor@world.com", - ) - results shouldBe 2 - } + ) + results shouldBe 2 } } @ParameterizedTest() @MethodSource("databaseContainers") - fun `with TX rolls back`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - val id = UUID.randomUUID() - shouldThrow { - connection.withTransaction { - repeat(2) { - connection.execute( - """ - INSERT INTO $table(id, name, email) VALUES(:id, :name, :email); - """.trimIndent(), - "id" to id, - "name" to "thor", - "email" to "thor@world.com", - ) - } + fun `with TX rolls back`(connection: Connection) { + val id = UUID.randomUUID() + shouldThrow { + connection.withTransaction { + repeat(2) { + connection.execute( + """ + INSERT INTO super_heroes_$testId(id, name, email) VALUES(:id, :name, :email) + """.trimIndent(), + "id" to id, + "name" to "thor", + "email" to "thor@world.com", + ) } } - connection.query( - "SELECT * FROM $table WHERE id = :id", - "id" to id, - ).shouldBeEmpty() } + connection.query( + "SELECT * FROM super_heroes_$testId WHERE id = :id", + "id" to id, + ).shouldBeEmpty() } } diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/QuerySingleTests.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/QuerySingleTests.kt index 86420b8d..6034d65c 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/QuerySingleTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/QuerySingleTests.kt @@ -5,98 +5,94 @@ import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain +import net.samyn.kapper.internal.DbFlavour +import net.samyn.kapper.internal.getDbFlavour import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource -import org.testcontainers.containers.JdbcDatabaseContainer +import java.sql.Connection import java.sql.ResultSet class QuerySingleTests : AbstractDbTests() { @ParameterizedTest() @MethodSource("databaseContainers") - fun `should query 1 heros`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - val hero = connection.querySingle("SELECT * FROM super_heroes ORDER BY age DESC LIMIT 1") - hero.shouldBe(superman) - } + fun `should query 1 heros`(connection: Connection) { + val sql = + if (DbFlavour.MSSQLSERVER == connection.getDbFlavour()) { + "SELECT TOP 1 * FROM super_heroes_$testId ORDER BY age DESC" + } else if (DbFlavour.ORACLE == connection.getDbFlavour()) { + "SELECT * FROM super_heroes_$testId ORDER BY age DESC FETCH FIRST 1 ROWS ONLY" + } else { + "SELECT * FROM super_heroes_$testId ORDER BY age DESC LIMIT 1" + } + val hero = connection.querySingle(sql) + hero.shouldBe(superman) } @ParameterizedTest @MethodSource("databaseContainers") - fun `should query hero with condition`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - val hero = connection.querySingle("SELECT * FROM super_heroes WHERE age > :age", "age" to 85) - hero.shouldBe(superman) - } + fun `should query hero with condition`(connection: Connection) { + val hero = connection.querySingle("SELECT * FROM super_heroes_$testId WHERE age > :age", "age" to 85) + hero.shouldBe(superman) } @ParameterizedTest @MethodSource("databaseContainers") - fun `should query specific columns`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - val hero = - connection.querySingle( - "SELECT id, name FROM super_heroes WHERE name = :name", - "name" to superman.name, - ) - hero.shouldBe(SuperHero(superman.id, superman.name)) - } + fun `should query specific columns`(connection: Connection) { + val hero = + connection.querySingle( + "SELECT id, name FROM super_heroes_$testId WHERE name = :name", + "name" to superman.name, + ) + hero.shouldBe(SuperHero(superman.id, superman.name)) } @ParameterizedTest @MethodSource("databaseContainers") - fun `should handle empty result set`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - val hero = - connection.querySingle( - "SELECT * FROM super_heroes WHERE name = :name", - "name" to "joker", - ) - hero.shouldBeNull() - } + fun `should handle empty result set`(connection: Connection) { + val hero = + connection.querySingle( + "SELECT * FROM super_heroes_$testId WHERE name = :name", + "name" to "joker", + ) + hero.shouldBeNull() } @ParameterizedTest @MethodSource("databaseContainers") - fun `should throw when larger than one result`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - val ex = - shouldThrow { - connection.querySingle( - "SELECT * FROM super_heroes", - ) - } - ex.message.shouldContain("3") - } + fun `should throw when larger than one result`(connection: Connection) { + val ex = + shouldThrow { + connection.querySingle( + "SELECT * FROM super_heroes_$testId", + ) + } + ex.message.shouldContain("3") } @ParameterizedTest @MethodSource("databaseContainers") - fun `should query with multiple conditions`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - val hero = - connection.querySingle( - "SELECT * FROM super_heroes WHERE age BETWEEN :fromAge AND :toAge", - "fromAge" to 86, - "toAge" to 89, - ) - hero.shouldBe(superman) - } + fun `should query with multiple conditions`(connection: Connection) { + val hero = + connection.querySingle( + "SELECT * FROM super_heroes_$testId WHERE age BETWEEN :fromAge AND :toAge", + "fromAge" to 86, + "toAge" to 89, + ) + hero.shouldBe(superman) } @ParameterizedTest @MethodSource("databaseContainers") - fun `can use custom mapper`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - val villain = - connection.querySingle( - "SELECT id, name FROM super_heroes WHERE name = :name", - ::createVillain, - "name" to superman.name, - ) - villain.shouldNotBeNull() - villain.id.shouldBe(superman.id.toString()) - villain.name.shouldBe(superman.name) - } + fun `can use custom mapper`(connection: Connection) { + val villain = + connection.querySingle( + "SELECT id, name FROM super_heroes_$testId WHERE name = :name", + ::createVillain, + "name" to superman.name, + ) + villain.shouldNotBeNull() + villain.id!!.shouldBe(superman.id.toString().lowercase().replace("-", "")) + villain.name.shouldBe(superman.name) } private fun createVillain( @@ -104,7 +100,7 @@ class QuerySingleTests : AbstractDbTests() { fields: Map, ): Villain = Villain().also { - it.id = resultSet.getString("id") + it.id = resultSet.getString("id").lowercase().replace("-", "") it.name = resultSet.getString("name") } } diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt index 28151ab7..d5a58dab 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt @@ -6,111 +6,97 @@ import io.kotest.matchers.collections.shouldContainOnly import io.kotest.matchers.shouldBe import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource -import org.testcontainers.containers.JdbcDatabaseContainer +import java.sql.Connection import java.sql.ResultSet class QueryTests : AbstractDbTests() { @ParameterizedTest() @MethodSource("databaseContainers") - fun `should query all heros`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - val heroes = connection.query("SELECT * FROM super_heroes") - heroes.shouldContainExactlyInAnyOrder( - superman, - batman, - spiderMan, - ) - } + fun `should query all heros`(connection: Connection) { + val heroes = connection.query("SELECT * FROM super_heroes_$testId") + heroes.shouldContainExactlyInAnyOrder( + superman, + batman, + spiderMan, + ) } @ParameterizedTest @MethodSource("databaseContainers") - fun `should query heros with condition`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - val heroes = connection.query("SELECT * FROM super_heroes WHERE age > :age", "age" to 80) - heroes.shouldContainExactlyInAnyOrder( - superman, - batman, - ) - } + fun `should query heros with condition`(connection: Connection) { + val heroes = connection.query("SELECT * FROM super_heroes_$testId WHERE age > :age", "age" to 80) + heroes.shouldContainExactlyInAnyOrder( + superman, + batman, + ) } @ParameterizedTest @MethodSource("databaseContainers") - fun `should query specific columns`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - val heroes = - connection.query( - "SELECT id, name FROM super_heroes WHERE name = :name", - "name" to superman.name, - ) - heroes.shouldContainExactlyInAnyOrder( - SuperHero(superman.id, superman.name), + fun `should query specific columns`(connection: Connection) { + val heroes = + connection.query( + "SELECT id, name FROM super_heroes_$testId WHERE name = :name", + "name" to superman.name, ) - } + heroes.shouldContainExactlyInAnyOrder( + SuperHero(superman.id, superman.name), + ) } @ParameterizedTest @MethodSource("databaseContainers") - fun `should handle empty result set`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - val heroes = - connection.query( - "SELECT * FROM super_heroes WHERE name = :name", - "name" to "joker", - ) - heroes.shouldBeEmpty() - } + fun `should handle empty result set`(connection: Connection) { + val heroes = + connection.query( + "SELECT * FROM super_heroes_$testId WHERE name = :name", + "name" to "joker", + ) + heroes.shouldBeEmpty() } @ParameterizedTest @MethodSource("databaseContainers") - fun `should query with multiple conditions`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - val heroes = - connection.query( - "SELECT * FROM super_heroes WHERE age BETWEEN :fromAge AND :toAge", - "fromAge" to 80, - "toAge" to 89, - ) - heroes.shouldContainExactlyInAnyOrder( - superman, - batman, + fun `should query with multiple conditions`(connection: Connection) { + val heroes = + connection.query( + "SELECT * FROM super_heroes_$testId WHERE age BETWEEN :fromAge AND :toAge", + "fromAge" to 80, + "toAge" to 89, ) - } + heroes.shouldContainExactlyInAnyOrder( + superman, + batman, + ) } @ParameterizedTest @MethodSource("databaseContainers") - fun `support field labels`(container: JdbcDatabaseContainer<*>) { + fun `support field labels`(connection: Connection) { data class SimpleClass(val superHeroName: String) - createConnection(container).use { connection -> - val hero = - connection.query( - "SELECT name as superHeroName FROM super_heroes WHERE id = :id", - "id" to superman.id, - ) - hero.shouldContainOnly( - SimpleClass(superman.name), + val hero = + connection.query( + "SELECT name as superHeroName FROM super_heroes_$testId WHERE id = :id", + "id" to superman.id, ) - } + hero.shouldContainOnly( + SimpleClass(superman.name), + ) } @ParameterizedTest @MethodSource("databaseContainers") - fun `can use custom mapper`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - val villain = - connection.query( - "SELECT id, name FROM super_heroes WHERE name = :name", - ::createVillain, - "name" to superman.name, - ) + fun `can use custom mapper`(connection: Connection) { + val villain = + connection.query( + "SELECT id, name FROM super_heroes_$testId WHERE name = :name", + ::createVillain, + "name" to superman.name, + ) - villain.size.shouldBe(1) - villain.first().id.shouldBe(superman.id.toString()) - villain.first().name.shouldBe(superman.name) - } + villain.size.shouldBe(1) + villain.first().id!!.shouldBe(superman.id.toString().lowercase().replace("-", "")) + villain.first().name.shouldBe(superman.name) } private fun createVillain( @@ -118,7 +104,7 @@ class QueryTests : AbstractDbTests() { fields: Map, ): Villain = Villain().also { - it.id = resultSet.getString("id") + it.id = resultSet.getString("id").lowercase().replace("-", "") it.name = resultSet.getString("name") } } diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/SqlInjectionTest.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/SqlInjectionTest.kt index 773d1c55..69af678e 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/SqlInjectionTest.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/SqlInjectionTest.kt @@ -3,19 +3,17 @@ package net.samyn.kapper import io.kotest.matchers.collections.shouldNotBeEmpty import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource -import org.testcontainers.containers.JdbcDatabaseContainer +import java.sql.Connection class SqlInjectionTest : AbstractDbTests() { @ParameterizedTest() @MethodSource("databaseContainers") - fun `cannot inject SQL with parameter`(container: JdbcDatabaseContainer<*>) { - createConnection(container).use { connection -> - connection.execute( - "UPDATE super_heroes SET name = 'foo' WHERE name = :name;", - "name" to "bar; DROP TABLE super_heroes;", - ) + fun `cannot inject SQL with parameter`(connection: Connection) { + connection.execute( + "UPDATE super_heroes_$testId SET name = 'foo' WHERE name = :name", + "name" to "bar; DROP TABLE super_heroes", + ) - connection.query("SELECT * FROM super_heroes").shouldNotBeEmpty() - } + connection.query("SELECT * FROM super_heroes_$testId").shouldNotBeEmpty() } } diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/TypeCastTest.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/TypeCastTest.kt index 9bad678b..eb1ea6a1 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/TypeCastTest.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/TypeCastTest.kt @@ -4,15 +4,26 @@ import io.kotest.matchers.should import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import org.junit.jupiter.api.Test +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import java.sql.DriverManager import java.time.Instant -class TypeCastTest : AbstractDbTests() { +@Testcontainers +class TypeCastTest { + @Container + val postgresql = PostgreSQLContainer("postgres:16") + + private fun createConnection() = DriverManager.getConnection(postgresql.jdbcUrl, postgresql.username, postgresql.password) + @Test fun `postgresql uuid can cast to string`() { + postgresql.start() // replicate isssue https://github.com/driessamyn/kapper/issues/77 // create table - createConnection(postgresql).use { connection -> + createConnection().use { connection -> connection.execute( """ CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- Ensures the UUID extension is available @@ -28,7 +39,7 @@ class TypeCastTest : AbstractDbTests() { } // insert - createConnection(postgresql).use { connection -> + createConnection().use { connection -> connection.execute( """ INSERT INTO blogs (title, content) VALUES ('First blog', 'This is the first blog'); @@ -40,7 +51,7 @@ class TypeCastTest : AbstractDbTests() { data class Blog(val id: String, val title: String, val createdAt: Instant, val content: String) val result = - createConnection(postgresql).use { connection -> + createConnection().use { connection -> connection.query("SELECT id::text, title, created_at as createdAt, content FROM blogs;") } diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt index 84f81820..1daa0a2d 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt @@ -1,9 +1,10 @@ package net.samyn.kapper import io.kotest.matchers.shouldBe +import net.samyn.kapper.internal.getDbFlavour import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource -import org.testcontainers.containers.JdbcDatabaseContainer +import java.sql.Connection import java.time.Instant import java.time.LocalDate import java.time.LocalTime @@ -14,89 +15,121 @@ import java.util.UUID import kotlin.random.Random class TypesTest : AbstractDbTests() { + override fun setupDatabase(connection: Connection) { + val dbFlavour = connection.getDbFlavour() + connection.createStatement().use { statement -> + statement.execute( + """ + CREATE TABLE types_test_$testId ( + t_uuid ${convertDbColumnType("UUID", dbFlavour)}, + t_char CHAR, + t_varchar ${convertDbColumnType("VARCHAR", dbFlavour, "(120)")}, + t_clob ${convertDbColumnType("CLOB", dbFlavour)}, + t_binary ${convertDbColumnType("BINARY", dbFlavour, "(16)")}, + t_varbinary ${convertDbColumnType("VARBINARY", dbFlavour, "(128)")}, + t_large_binary ${convertDbColumnType("BLOB", dbFlavour)}, + t_numeric NUMERIC(12,6), + t_decimal DECIMAL(12,6), + t_smallint ${convertDbColumnType("SMALLINT", dbFlavour)}, + t_int ${convertDbColumnType("INT", dbFlavour)}, + t_bigint ${convertDbColumnType("BIGINT", dbFlavour)}, + t_float ${convertDbColumnType("FLOAT", dbFlavour, "(8)")}, + t_real ${convertDbColumnType("REAL", dbFlavour)}, + t_double ${convertDbColumnType("DOUBLE PRECISION", dbFlavour)}, + t_date DATE, + t_local_date DATE, + t_local_time ${convertDbColumnType("TIME", dbFlavour)}, + t_timestamp ${convertDbColumnType("TIMESTAMP", dbFlavour)}, + t_boolean ${convertDbColumnType("BOOLEAN", dbFlavour)} + ) + """.trimIndent().also { + println(it) + }, + ) + } + } + @ParameterizedTest() @MethodSource("databaseContainers") - fun `can insert and retreive the same types`(container: JdbcDatabaseContainer<*>) { + fun `can insert and retreive the same types`(connection: Connection) { val testData = createTestObject() - createConnection(container).use { connection -> - val result = - connection.execute( - """ - INSERT INTO types_test ( - t_uuid, - t_char, - t_varchar, - t_clob, - t_binary, - t_varbinary, - t_large_binary, - t_numeric, - t_decimal, - t_smallint, - t_int, - t_bigint, - t_float, - t_real, - t_double, - t_date, - t_local_date, - t_local_time, - t_timestamp, - t_boolean - ) VALUES ( - :uuid, - :char, - :varchar, - :clob, - :binary, - :varbinary, - :large_binary, - :numeric, - :decimal, - :smallint, - :int, - :bigint, - :float, - :real, - :double, - :date, - :local_date, - :local_time, - :timestamp, - :boolean - ); - """.trimIndent(), - "uuid" to testData.t_uuid, - "char" to testData.t_char, - "varchar" to testData.t_varchar, - "clob" to testData.t_clob, - "binary" to testData.t_binary, - "varbinary" to testData.t_varbinary, - "large_binary" to testData.t_large_binary, - "numeric" to testData.t_numeric, - "decimal" to testData.t_decimal, - "smallint" to testData.t_smallint, - "int" to testData.t_int, - "bigint" to testData.t_bigint, - "float" to testData.t_float, - "real" to testData.t_real, - "double" to testData.t_double, - "date" to testData.t_date, - "local_date" to testData.t_local_date, - "local_time" to testData.t_local_time, - "timestamp" to testData.t_timestamp, - "boolean" to testData.t_boolean, - ) + val result = + connection.execute( + """ + INSERT INTO types_test_$testId ( + t_uuid, + t_char, + t_varchar, + t_clob, + t_binary, + t_varbinary, + t_large_binary, + t_numeric, + t_decimal, + t_smallint, + t_int, + t_bigint, + t_float, + t_real, + t_double, + t_date, + t_local_date, + t_local_time, + t_timestamp, + t_boolean + ) VALUES ( + :uuid, + :char, + :varchar, + :clob, + :binary, + :varbinary, + :large_binary, + :numeric, + :decimal, + :smallint, + :int, + :bigint, + :float, + :real, + :double, + :date, + :local_date, + :local_time, + :timestamp, + :boolean + ) + """.trimIndent(), + "uuid" to testData.t_uuid, + "char" to testData.t_char, + "varchar" to testData.t_varchar, + "clob" to testData.t_clob, + "binary" to testData.t_binary, + "varbinary" to testData.t_varbinary, + "large_binary" to testData.t_large_binary, + "numeric" to testData.t_numeric, + "decimal" to testData.t_decimal, + "smallint" to testData.t_smallint, + "int" to testData.t_int, + "bigint" to testData.t_bigint, + "float" to testData.t_float, + "real" to testData.t_real, + "double" to testData.t_double, + "date" to testData.t_date, + "local_date" to testData.t_local_date, + "local_time" to testData.t_local_time, + "timestamp" to testData.t_timestamp, + "boolean" to testData.t_boolean, + ) - result.shouldBe(1) + result.shouldBe(1) - val selectResult = - connection.querySingle( - "SELECT * FROM types_test where t_uuid = :uuid", - "uuid" to testData.t_uuid, - ) - selectResult.shouldBe(testData) - } + val selectResult = + connection.querySingle( + "SELECT * FROM types_test_$testId where t_uuid = :uuid", + "uuid" to testData.t_uuid, + ) + selectResult.shouldBe(testData) } data class TypeTest( diff --git a/core/src/main/kotlin/net/samyn/kapper/Field.kt b/core/src/main/kotlin/net/samyn/kapper/Field.kt index 935f1082..aa4fc3a0 100644 --- a/core/src/main/kotlin/net/samyn/kapper/Field.kt +++ b/core/src/main/kotlin/net/samyn/kapper/Field.kt @@ -1,5 +1,6 @@ package net.samyn.kapper +import net.samyn.kapper.internal.DbFlavour import java.sql.JDBCType /** @@ -7,5 +8,6 @@ import java.sql.JDBCType * @param columnIndex index of the column. * @param type JDBCType * @param typeName of the DB type as returned by the JDBC driver. + * @param dbFlavour the database flavour. */ -data class Field(val columnIndex: Int, val type: JDBCType, val typeName: String) +data class Field(val columnIndex: Int, val type: JDBCType, val typeName: String, val dbFlavour: DbFlavour) diff --git a/core/src/main/kotlin/net/samyn/kapper/internal/AutoConverter.kt b/core/src/main/kotlin/net/samyn/kapper/internal/AutoConverter.kt index 7055d2c0..19c0e26d 100644 --- a/core/src/main/kotlin/net/samyn/kapper/internal/AutoConverter.kt +++ b/core/src/main/kotlin/net/samyn/kapper/internal/AutoConverter.kt @@ -7,6 +7,7 @@ import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime +import java.util.Date import java.util.UUID import kotlin.reflect.KClass @@ -20,6 +21,11 @@ internal class AutoConverter( LocalDateTime::class to ::convertLocalDateTime, LocalTime::class to ::convertLocalTime, Instant::class to ::convertInstant, + Char::class to ::convertChar, + Int::class to ::convertInt, + Long::class to ::convertLong, + Date::class to ::convertDate, + Boolean::class to ::convertBoolean, ), ) { fun convert( diff --git a/core/src/main/kotlin/net/samyn/kapper/internal/Converters.kt b/core/src/main/kotlin/net/samyn/kapper/internal/Converters.kt index 4308b232..41ea020f 100644 --- a/core/src/main/kotlin/net/samyn/kapper/internal/Converters.kt +++ b/core/src/main/kotlin/net/samyn/kapper/internal/Converters.kt @@ -20,7 +20,7 @@ internal fun convertInstant(value: Any): Instant = } is LocalDateTime -> { - value.atZone(java.time.ZoneOffset.UTC).toInstant() + value.atZone(java.time.ZoneOffset.systemDefault()).toInstant() } else -> { @@ -31,32 +31,85 @@ internal fun convertInstant(value: Any): Instant = } internal fun convertLocalTime(value: Any): LocalTime = - if (value is Instant) { - LocalTime.ofInstant(value, java.time.ZoneOffset.UTC) - } else { - throw KapperUnsupportedOperationException( - "Cannot auto-convert from ${value.javaClass} to LocalTime", - ) + when (value) { + is Instant -> { + LocalTime.ofInstant(value, java.time.ZoneOffset.systemDefault()) + } + + is String -> { + LocalTime.parse(value) + } + + is Long -> { + LocalTime.ofInstant(Instant.ofEpochMilli(value), java.time.ZoneOffset.systemDefault()) + } + + is Int -> { + LocalTime.ofInstant(Instant.ofEpochMilli(value.toLong()), java.time.ZoneOffset.systemDefault()) + } + + else -> { + throw KapperUnsupportedOperationException( + "Cannot auto-convert value '$value' from ${value.javaClass} to LocalTime", + ) + } } internal fun convertLocalDateTime(value: Any): LocalDateTime = - if (value is Instant) { - LocalDateTime.ofInstant(value, java.time.ZoneOffset.UTC) - } else { - throw KapperUnsupportedOperationException( - "Cannot auto-convert from ${value.javaClass} to LocalDateTime", - ) + when (value) { + is Instant -> { + LocalDateTime.ofInstant(value, java.time.ZoneOffset.systemDefault()) + } + + is String -> { + LocalDateTime.parse(value) + } + + else -> { + throw KapperUnsupportedOperationException( + "Cannot auto-convert from ${value.javaClass} to LocalDateTime", + ) + } } internal fun convertLocalDate(value: Any): LocalDate = - if (value is Date) { - val cal = Calendar.getInstance() - cal.time = value - LocalDate.of(cal[Calendar.YEAR], cal[Calendar.MONTH] + 1, cal[Calendar.DAY_OF_MONTH]) - } else { - throw KapperUnsupportedOperationException( - "Cannot auto-convert from ${value.javaClass} to LocalDate", - ) + when (value) { + is Date -> { + val cal = Calendar.getInstance() + cal.time = value + LocalDate.of(cal[Calendar.YEAR], cal[Calendar.MONTH] + 1, cal[Calendar.DAY_OF_MONTH]) + } + is LocalDateTime -> { + value.toLocalDate() + } + is String -> { + LocalDate.parse(value) + } + is Instant -> { + LocalDate.ofInstant(value, java.time.ZoneOffset.systemDefault()) + } + else -> { + throw KapperUnsupportedOperationException( + "Cannot auto-convert from ${value.javaClass} to LocalDate", + ) + } + } + +internal fun convertDate(value: Any): Date = + when (value) { + is Instant -> { + Date.from(value) + } + + is LocalDateTime -> { + Date.from(value.atZone(java.time.ZoneOffset.systemDefault()).toInstant()) + } + + else -> { + throw KapperUnsupportedOperationException( + "Cannot auto-convert from ${value.javaClass} to Date", + ) + } } internal fun convertUUID(value: Any): UUID = @@ -72,6 +125,17 @@ internal fun convertUUID(value: Any): UUID = } } + is CharArray -> { + try { + UUID.fromString(String(value)) + } catch (e: Exception) { + throw KapperParseException( + "Cannot parse $value to UUID", + e, + ) + } + } + is ByteArray -> { value.asUUID() } @@ -83,6 +147,86 @@ internal fun convertUUID(value: Any): UUID = } } +internal fun convertChar(value: Any): Char = + if (value is String) { + if (value.length != 1) { + throw KapperParseException( + "Cannot parse $value to Char (length != 1)", + ) + } + value[0] + } else if (value is CharArray) { + if (value.size != 1) { + throw KapperParseException( + "Cannot parse $value to Char (size != 1)", + ) + } + value[0] + } else { + throw KapperUnsupportedOperationException( + "Cannot auto-convert from ${value.javaClass} to Char", + ) + } + +internal fun convertInt(value: Any): Int = + when (value) { + is Float -> { + value.toInt() + } + + else -> { + throw KapperUnsupportedOperationException( + "Cannot auto-convert from ${value.javaClass} to Int", + ) + } + } + +internal fun convertLong(value: Any): Long = + when (value) { + is Float -> { + value.toLong() + } + + else -> { + throw KapperUnsupportedOperationException( + "Cannot auto-convert from ${value.javaClass} to Long", + ) + } + } + +internal fun convertBoolean(value: Any): Boolean = + when (value) { + is String -> { + value == "1" || value.toBoolean() + } + + is Int -> { + value != 0 + } + + is Byte -> { + value != 0.toByte() + } + + is Short -> { + value != 0.toShort() + } + + is Long -> { + value != 0L + } + + is Float -> { + value != 0.0f + } + + else -> { + throw KapperUnsupportedOperationException( + "Cannot auto-convert from ${value.javaClass} to Boolean", + ) + } + } + fun ByteArray.asUUID(): UUID { val b = ByteBuffer.wrap(this) return UUID(b.getLong(), b.getLong()) diff --git a/core/src/main/kotlin/net/samyn/kapper/internal/DbFlavour.kt b/core/src/main/kotlin/net/samyn/kapper/internal/DbFlavour.kt index 209a5753..a343cb37 100644 --- a/core/src/main/kotlin/net/samyn/kapper/internal/DbFlavour.kt +++ b/core/src/main/kotlin/net/samyn/kapper/internal/DbFlavour.kt @@ -12,6 +12,10 @@ fun Connection.getDbFlavour(): DbFlavour { DbFlavour.MYSQL } else if (productName.contains("sqlite")) { DbFlavour.SQLITE + } else if (productName.contains("oracle")) { + DbFlavour.ORACLE + } else if (productName.contains("sql server") || productName.contains("mssql")) { + DbFlavour.MSSQLSERVER } else { DbFlavour.UNKNOWN } @@ -21,5 +25,7 @@ enum class DbFlavour { POSTGRESQL, MYSQL, SQLITE, + ORACLE, + MSSQLSERVER, UNKNOWN, } diff --git a/core/src/main/kotlin/net/samyn/kapper/internal/KapperImpl.kt b/core/src/main/kotlin/net/samyn/kapper/internal/KapperImpl.kt index 0bf3a918..821b91a2 100644 --- a/core/src/main/kotlin/net/samyn/kapper/internal/KapperImpl.kt +++ b/core/src/main/kotlin/net/samyn/kapper/internal/KapperImpl.kt @@ -31,7 +31,7 @@ internal class KapperImpl( return buildList { connection.executeQuery(queryFactory(sql), args).use { rs -> try { - val fields = rs.extractFields() + val fields = rs.extractFields(connection.getDbFlavour()) while (rs.next()) { add(mapper(rs, fields)) } diff --git a/core/src/main/kotlin/net/samyn/kapper/internal/KotlinDataClassMapper.kt b/core/src/main/kotlin/net/samyn/kapper/internal/KotlinDataClassMapper.kt index f9f29b8b..8a182fa3 100644 --- a/core/src/main/kotlin/net/samyn/kapper/internal/KotlinDataClassMapper.kt +++ b/core/src/main/kotlin/net/samyn/kapper/internal/KotlinDataClassMapper.kt @@ -14,7 +14,7 @@ import kotlin.reflect.full.primaryConstructor class KotlinDataClassMapper( private val clazz: Class, val autoConverter: (Any, KClass<*>) -> Any = AutoConverter()::convert, - val sqlTypesConverter: (JDBCType, String, ResultSet, Int) -> Any? = SQLTypesConverter::convertSQLType, + val sqlTypesConverter: (JDBCType, String, ResultSet, Int, DbFlavour) -> Any? = SQLTypesConverter::convertSQLType, ) : Mapper { private val constructor: KFunction = clazz.kotlin.primaryConstructor @@ -29,8 +29,8 @@ class KotlinDataClassMapper( "Constructor for ${clazz.name} only has: ${properties.keys}", ) } else if (columns.size < properties.size) { - val all = columns.map { it.name } - val missing = properties.filter { !it.value.isOptional && !all.contains(it.value.name) } + val all = columns.map { it.name.lowercase() } + val missing = properties.filter { !it.value.isOptional && !all.contains(it.key) } if (missing.isNotEmpty()) { throw KapperMappingException("The following properties are non-optional and missing: ${missing.keys}") } @@ -60,7 +60,13 @@ class KotlinDataClassMapper( fields.map { field -> ColumnValue( field.key, - sqlTypesConverter(field.value.type, field.value.typeName, resultSet, field.value.columnIndex), + sqlTypesConverter( + field.value.type, + field.value.typeName, + resultSet, + field.value.columnIndex, + field.value.dbFlavour, + ), ) }, ) diff --git a/core/src/main/kotlin/net/samyn/kapper/internal/Metadata.kt b/core/src/main/kotlin/net/samyn/kapper/internal/Metadata.kt index ae2cdf01..526522bc 100644 --- a/core/src/main/kotlin/net/samyn/kapper/internal/Metadata.kt +++ b/core/src/main/kotlin/net/samyn/kapper/internal/Metadata.kt @@ -6,12 +6,17 @@ import net.samyn.kapper.Field import java.sql.JDBCType import java.sql.ResultSet -fun ResultSet.extractFields(): Map = +fun ResultSet.extractFields(dbFlavour: DbFlavour): Map = (1..this.metaData.columnCount).associate { this.metaData.getColumnLabel(it) to Field( it, - JDBCType.valueOf(this.metaData.getColumnType(it)), + this.metaData.getColumnType(it).jdbcType(), this.metaData.getColumnTypeName(it), + dbFlavour, ) } + +internal fun Int.jdbcType() = + JDBCType.entries.firstOrNull { it.vendorTypeNumber == this } + ?: JDBCType.OTHER diff --git a/core/src/main/kotlin/net/samyn/kapper/internal/SQLTypesConverter.kt b/core/src/main/kotlin/net/samyn/kapper/internal/SQLTypesConverter.kt index 04b5feb5..efc016c2 100644 --- a/core/src/main/kotlin/net/samyn/kapper/internal/SQLTypesConverter.kt +++ b/core/src/main/kotlin/net/samyn/kapper/internal/SQLTypesConverter.kt @@ -3,23 +3,28 @@ package net.samyn.kapper.internal import net.samyn.kapper.KapperUnsupportedOperationException +import java.nio.ByteBuffer import java.sql.JDBCType import java.sql.PreparedStatement import java.sql.ResultSet import java.sql.Timestamp +import java.text.SimpleDateFormat import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime import java.util.Date import java.util.UUID // TODO: this could be more sophisticated by allowing type conversion hints. -// TODO: check what hibernate does for these conversions. internal object SQLTypesConverter { fun convertSQLType( sqlType: JDBCType, sqlTypeName: String, resultSet: ResultSet, fieldIndex: Int, - ): Any { + dbFlavour: DbFlavour, + ): Any? { val result = when (sqlType) { JDBCType.ARRAY -> resultSet.getArray(fieldIndex) @@ -29,7 +34,7 @@ internal object SQLTypesConverter { in listOf(JDBCType.BIT, JDBCType.BOOLEAN) -> resultSet.getBoolean(fieldIndex) in listOf(JDBCType.CHAR), - -> resultSet.getString(fieldIndex).toCharArray()[0] + -> resultSet.getString(fieldIndex)?.toCharArray() in listOf( JDBCType.CLOB, JDBCType.LONGNVARCHAR, JDBCType.LONGVARCHAR, @@ -37,7 +42,7 @@ internal object SQLTypesConverter { ), -> resultSet.getString(fieldIndex) in listOf(JDBCType.DATE), - -> Date(resultSet.getDate(fieldIndex).time) + -> convertDate(resultSet, fieldIndex, dbFlavour) in listOf(JDBCType.DECIMAL, JDBCType.FLOAT, JDBCType.NUMERIC, JDBCType.REAL), -> resultSet.getFloat(fieldIndex) JDBCType.DOUBLE -> @@ -51,19 +56,22 @@ internal object SQLTypesConverter { JDBCType.TIME, JDBCType.TIME_WITH_TIMEZONE, ), - -> resultSet.getTime(fieldIndex).toLocalTime() + -> resultSet.getTime(fieldIndex)?.toLocalTime() in listOf( JDBCType.TIMESTAMP, JDBCType.TIMESTAMP_WITH_TIMEZONE, ), - -> resultSet.getTimestamp(fieldIndex).toInstant() + -> convertTimestamp(resultSet, fieldIndex, sqlTypeName) // includes: DATALINK, DISTINCT, OTHER, REF, REF_CURSOR, STRUCT, NULL else -> { // use name if type is when (sqlTypeName.lowercase()) { - "uuid" -> UUID.fromString(resultSet.getString(fieldIndex)) + "uuid" -> resultSet.getString(fieldIndex)?.let { UUID.fromString(it) } + // oracle types + "binary_float" -> resultSet.getFloat(fieldIndex) + "binary_double" -> resultSet.getDouble(fieldIndex) else -> throw KapperUnsupportedOperationException("Conversion from type $sqlType is not supported") } @@ -72,6 +80,18 @@ internal object SQLTypesConverter { return result } + private fun convertTimestamp( + resultSet: ResultSet, + fieldIndex: Int, + sqlTypeName: String, + ): Any? = + when (sqlTypeName.uppercase()) { + "DATE" -> { + resultSet.getTimestamp(fieldIndex)?.toLocalDateTime() + } + else -> resultSet.getTimestamp(fieldIndex)?.toInstant() + } + fun PreparedStatement.setParameter( index: Int, value: Any?, @@ -88,16 +108,100 @@ internal object SQLTypesConverter { is String -> setString(index, value) is ByteArray -> setBytes(index, value) is Boolean -> setBoolean(index, value) - is UUID -> { - if (DbFlavour.MYSQL == dbFlavour) { - setString(index, value.toString()) - } else { - setObject(index, value) + is UUID -> + when (dbFlavour) { + DbFlavour.MYSQL -> setString(index, value.toString()) + DbFlavour.ORACLE -> setBytes(index, value.toBytes()) + else -> setObject(index, value) } - } is Instant -> setTimestamp(index, Timestamp.from(value)) is Date -> setDate(index, java.sql.Date(value.time)) + is LocalDate -> setDate(index, java.sql.Date.valueOf(value)) + is LocalDateTime -> setTimestamp(index, Timestamp.from(value.atZone(java.time.ZoneOffset.systemDefault()).toInstant())) + is LocalTime -> setTime(index, java.sql.Time.valueOf(value)) else -> setObject(index, value) } } } + +fun UUID.toBytes(): ByteArray { + val buffer = ByteBuffer.wrap(ByteArray(16)) + buffer.putLong(this.mostSignificantBits) + buffer.putLong(this.leastSignificantBits) + return buffer.array() +} + +val formatters = + mapOf( + "yyyy-MM-dd" to SimpleDateFormat("yyyy-MM-dd"), + "yyyy-MM-dd HH:mm" to SimpleDateFormat("yyyy-MM-dd HH:mm"), + "yyyy-MM-dd HH:mm:ss" to SimpleDateFormat("yyyy-MM-dd HH:mm:ss"), + "yyyy-MM-dd HH:mm:ss.SSS" to SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"), + "yyyy-MM-dd'T'HH:mm" to SimpleDateFormat("yyyy-MM-dd'T'HH:mm"), + "yyyy-MM-dd'T'HH:mm:ss" to SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"), + "yyyy-MM-dd'T'HH:mm:ss.SSS" to SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"), + "HH:mm" to SimpleDateFormat("HH:mm"), + "HH:mm:ss" to SimpleDateFormat("HH:mm:ss"), + "HH:mm:ss.SSS" to SimpleDateFormat("HH:mm:ss.SSS"), + "HH:mm'Z'" to SimpleDateFormat("HH:mm'Z'"), + "HH:mm:ss'Z'" to SimpleDateFormat("HH:mm:ss'Z'"), + "HH:mm:ss.SSS'Z'" to SimpleDateFormat("HH:mm:ss.SSS'Z'"), + "yyyy-MM-dd HH:mm'Z'" to SimpleDateFormat("yyyy-MM-dd HH:mm'Z'"), + "yyyy-MM-dd HH:mm:ss'Z'" to SimpleDateFormat("yyyy-MM-dd HH:mm:ss'Z'"), + "yyyy-MM-dd HH:mm:ss.SSS'Z'" to SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS'Z'"), + "yyyy-MM-dd'T'HH:mm'Z'" to SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'"), + "yyyy-MM-dd'T'HH:mm:ss'Z'" to SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"), + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" to SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"), + ) + +fun convertDate( + resultSet: ResultSet, + fieldIndex: Int, + dbFlavour: DbFlavour, +): Date? = + when (dbFlavour) { + DbFlavour.SQLITE -> { + val date = resultSet.getObject(fieldIndex) ?: return null + when (date) { + is Long -> Date(date) + is String -> convertSQliteDate(date) + else -> throw KapperUnsupportedOperationException("Conversion from type ${date.javaClass} to Date is not supported") + } + } + else -> resultSet.getDate(fieldIndex)?.let { Date(it.time) } + } + +private fun convertSQliteDate(date: String): Date? = + when { + date[2] == ':' -> + when (date.length) { + 5 -> formatters["HH:mm"]!!.parse(date) + 6 -> formatters["HH:mm'Z'"]!!.parse(date) + 8 -> formatters["HH:mm:ss"]!!.parse(date) + 9 -> formatters["HH:mm:ss'Z'"]!!.parse(date) + 12 -> formatters["HH:mm:ss.SSS"]!!.parse(date) + 13 -> formatters["HH:mm:ss.SSS'Z'"]!!.parse(date) + else -> null + } + date.length > 10 && date[10] == 'T' -> + when (date.length) { + 16 -> formatters["yyyy-MM-dd'T'HH:mm"]!!.parse(date) + 17 -> formatters["yyyy-MM-dd'T'HH:mm'Z'"]!!.parse(date) + 19 -> formatters["yyyy-MM-dd'T'HH:mm:ss"]!!.parse(date) + 20 -> formatters["yyyy-MM-dd'T'HH:mm:ss'Z'"]!!.parse(date) + 23 -> formatters["yyyy-MM-dd'T'HH:mm:ss.SSS"]!!.parse(date) + 24 -> formatters["yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"]!!.parse(date) + else -> null + } + else -> + when (date.length) { + 10 -> formatters["yyyy-MM-dd"]!!.parse(date) + 16 -> formatters["yyyy-MM-dd HH:mm"]!!.parse(date) + 17 -> formatters["yyyy-MM-dd HH:mm'Z'"]!!.parse(date) + 19 -> formatters["yyyy-MM-dd HH:mm:ss"]!!.parse(date) + 20 -> formatters["yyyy-MM-dd HH:mm:ss'Z'"]!!.parse(date) + 23 -> formatters["yyyy-MM-dd HH:mm:ss.SSS"]!!.parse(date) + 24 -> formatters["yyyy-MM-dd HH:mm:ss.SSS'Z'"]!!.parse(date) + else -> null + } + } ?: throw KapperUnsupportedOperationException("Cannot convert $date to Date") diff --git a/core/src/test/kotlin/net/samyn/kapper/internal/ConverterTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/ConverterTest.kt deleted file mode 100644 index 2efd2590..00000000 --- a/core/src/test/kotlin/net/samyn/kapper/internal/ConverterTest.kt +++ /dev/null @@ -1,145 +0,0 @@ -package net.samyn.kapper.internal - -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.matchers.shouldBe -import net.samyn.kapper.KapperParseException -import net.samyn.kapper.KapperUnsupportedOperationException -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource -import java.nio.ByteBuffer -import java.time.Instant -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.LocalTime -import java.util.Date -import java.util.UUID - -class ConverterTest { - @Nested - inner class UUIDConverter { - @Test - fun `when string UUID convert`() { - val uuid = "123e4567-e89b-12d3-a456-426614174000" - val autoConvertedUuid = convertUUID(uuid) - autoConvertedUuid.shouldBe(UUID.fromString(uuid)) - } - - @Test - fun `when binary UUID convert`() { - val uuid = UUID.fromString("123e4567-e89b-12d3-a456-426614174000") - val autoConvertedUuid = convertUUID(uuid.asBytes()) - autoConvertedUuid.shouldBe(uuid) - } - - @Test - fun `when int cannot convert to UUID`() { - shouldThrow { - convertUUID(123) - } - } - - @ParameterizedTest - @ValueSource(strings = ["", "123e4567-e89b-12d3-a456-426614ZZZZZZ"]) - fun `when invalid string cannot convert to UUID`(input: String) { - shouldThrow { - convertUUID(input) - } - } - } - - @Nested - inner class LocalDateConverter { - @Test - fun `convert valid Date to LocalDate`() { - val date = Date.from(Instant.parse("2023-10-01T00:00:00Z")) - val localDate = convertLocalDate(date) - localDate.shouldBe(LocalDate.of(2023, 10, 1)) - } - - @Test - fun `throw exception when invalid type is converted to LocalDate`() { - shouldThrow { - convertLocalDate("2023-01-01") // Invalid input type - } - } - } - - @Nested - inner class InstantConverter { - @Test - fun `convert valid LocalTime to Instant`() { - val time = LocalTime.of(12, 30) // 12:30 PM - val instant = convertInstant(time) as Instant - val expectedInstant = LocalDate.now().atTime(12, 30).toInstant(java.time.ZoneOffset.UTC) - instant.shouldBe(expectedInstant) - } - - @Test - fun `convert LocalTime at midnight to Instant`() { - val midnight = LocalTime.MIDNIGHT - val instant = convertInstant(midnight) as Instant - val expectedInstant = LocalDate.now().atTime(0, 0).toInstant(java.time.ZoneOffset.UTC) - instant.shouldBe(expectedInstant) - } - - @Test - fun `convert valid LocalDateTime to Instant`() { - val now = LocalDateTime.now() - val instant = convertInstant(now) as Instant - val expectedInstant = now.toInstant(java.time.ZoneOffset.UTC) - instant.shouldBe(expectedInstant) - } - - @Test - fun `throw exception when invalid type is converted to Instant`() { - shouldThrow { - convertInstant(Date()) // Invalid input type - } - } - } - - @Nested - inner class LocalDateTimeConverter { - @Test - fun `convert valid Instant to LocalDateTime`() { - val now = Instant.now() - val localDateTime = convertLocalDateTime(now) as LocalDateTime - val expectedDt = LocalDateTime.ofInstant(now, java.time.ZoneOffset.UTC) - localDateTime.shouldBe(expectedDt) - } - - @Test - fun `throw exception when invalid type is converted to LocalDateTime`() { - shouldThrow { - convertLocalDateTime(Date()) // Invalid input type - } - } - } - - @Nested - inner class LocalTimeConverter { - @Test - fun `convert valid Instant to LocalTime`() { - val now = Instant.now() - val locaTime = convertLocalTime(now) as LocalTime - val expectedTime = LocalTime.ofInstant(now, java.time.ZoneOffset.UTC) - locaTime.shouldBe(expectedTime) - } - - @Test - fun `throw exception when invalid type is converted to LocalTime`() { - shouldThrow { - convertLocalTime(Date()) // Invalid input type - } - } - } - - private fun UUID.asBytes(): ByteArray { - val b = ByteBuffer.wrap(ByteArray(16)) - b.putLong(mostSignificantBits) - b.putLong(leastSignificantBits) - return b.array() - } -} diff --git a/core/src/test/kotlin/net/samyn/kapper/internal/DbFlavourTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/DbFlavourTest.kt index 0fc5370b..e8769af3 100644 --- a/core/src/test/kotlin/net/samyn/kapper/internal/DbFlavourTest.kt +++ b/core/src/test/kotlin/net/samyn/kapper/internal/DbFlavourTest.kt @@ -66,13 +66,33 @@ class DbFlavourTest { "Oracle Database 18c", "Oracle Database 19c", "Oracle Database 21c", + ], + ) + fun `when databaseProductName is oracle then getDbFlavour returns ORACLE`(value: String) { + every { connection.metaData.databaseProductName } returns value + connection.getDbFlavour() shouldBe DbFlavour.ORACLE + } + @ParameterizedTest + @ValueSource( + strings = + [ // Microsoft SQL Server variants "Microsoft SQL Server", "SQL Server", "MS SQL Server", "MSSQL", + ], + ) + fun `when databaseProductName is sql server then getDbFlavour returns MSSQLSERVER`(value: String) { + every { connection.metaData.databaseProductName } returns value + connection.getDbFlavour() shouldBe DbFlavour.MSSQLSERVER + } + @ParameterizedTest + @ValueSource( + strings = + [ // IBM DB2 variants "DB2", "DB2/NT", diff --git a/core/src/test/kotlin/net/samyn/kapper/internal/KapperImplQueryTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/KapperImplQueryTest.kt index 276c685d..0992aca3 100644 --- a/core/src/test/kotlin/net/samyn/kapper/internal/KapperImplQueryTest.kt +++ b/core/src/test/kotlin/net/samyn/kapper/internal/KapperImplQueryTest.kt @@ -35,7 +35,7 @@ class KapperImplQueryTest { private val mockMapper = mockk<(ResultSet, Map) -> TestEntity>(relaxed = true) private val testFieldMeta = mapOf( - "id" to Field(1, JDBCType.INTEGER, "INTEGER"), + "id" to Field(1, JDBCType.INTEGER, "INTEGER", DbFlavour.UNKNOWN), ) companion object { @@ -52,7 +52,7 @@ class KapperImplQueryTest { mockkStatic(ResultSet::extractFields) every { mockConnection.executeQuery(any(), any()) } returns mockResultSet every { mockConnection.getDbFlavour() } returns DbFlavour.UNKNOWN - every { mockResultSet.extractFields() } returns testFieldMeta + every { mockResultSet.extractFields(any()) } returns testFieldMeta every { mockMapper.invoke(any(), any()) } returns TestEntity(1, "test") every { mockQueryBuilder(mockSqlTemplate) } returns mockQuery } @@ -120,6 +120,20 @@ class KapperImplQueryTest { }.cause shouldBe ex } + @Test + fun `when query blank throw`() { + shouldThrow { + kapper + .query( + TestEntity::class.java, + mockConnection, + "", + mockMapper, + mapOf("id" to 3), + ) + } + } + @Test fun `querySingle execute query`() { every { mockResultSet.next() } returns true andThen false diff --git a/core/src/test/kotlin/net/samyn/kapper/internal/KotlinDataClassMapperTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/KotlinDataClassMapperTest.kt index 63c0b76e..8ae2191b 100644 --- a/core/src/test/kotlin/net/samyn/kapper/internal/KotlinDataClassMapperTest.kt +++ b/core/src/test/kotlin/net/samyn/kapper/internal/KotlinDataClassMapperTest.kt @@ -19,7 +19,7 @@ import kotlin.reflect.KClass class KotlinDataClassMapperTest { private val autoMapperMock = mockk<(Any, KClass<*>) -> Any>(relaxed = true) private val resultSet = mockk(relaxed = true) - val sqlTypeConverterMock = mockk<(JDBCType, String, ResultSet, Int) -> Any?>(relaxed = true) + val sqlTypeConverterMock = mockk<(JDBCType, String, ResultSet, Int, DbFlavour) -> Any?>(relaxed = true) @ParameterizedTest @ValueSource(strings = ["email", "EMAIL", "eMail"]) @@ -27,18 +27,18 @@ class KotlinDataClassMapperTest { val batman = SuperHero(UUID.randomUUID(), "Batman", "batman@dc.com", 85) val fields = mapOf( - "id" to Field(1, JDBCType.BIT, "SomeType"), - "name" to Field(2, JDBCType.BIT, "SomeType"), - emailParam to Field(3, JDBCType.BIT, "SomeType"), - "age" to Field(4, JDBCType.BIT, "SomeType"), + "id" to Field(1, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), + "name" to Field(2, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), + emailParam to Field(3, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), + "age" to Field(4, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), ) - every { sqlTypeConverterMock(any(), any(), any(), eq(1)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(1), any()) } returns batman.id - every { sqlTypeConverterMock(any(), any(), any(), eq(2)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(2), any()) } returns batman.name - every { sqlTypeConverterMock(any(), any(), any(), eq(3)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(3), any()) } returns batman.email!! - every { sqlTypeConverterMock(any(), any(), any(), eq(4)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(4), any()) } returns batman.age!! val kotlinDataClassMapper = KotlinDataClassMapper(SuperHero::class.java, autoMapperMock, sqlTypeConverterMock) @@ -52,12 +52,12 @@ class KotlinDataClassMapperTest { val batman = SuperHero(UUID.randomUUID(), "Batman", "batman@dc.com", 85) val fields = mapOf( - "id" to Field(1, JDBCType.BIT, "SomeType"), - "name" to Field(2, JDBCType.BIT, "SomeType"), + "id" to Field(1, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), + "name" to Field(2, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), ) - every { sqlTypeConverterMock(any(), any(), any(), eq(1)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(1), any()) } returns batman.id - every { sqlTypeConverterMock(any(), any(), any(), eq(2)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(2), any()) } returns batman.name val kotlinDataClassMapper = KotlinDataClassMapper(SuperHero::class.java, autoMapperMock, sqlTypeConverterMock) @@ -70,21 +70,21 @@ class KotlinDataClassMapperTest { fun `should throw when too many columns`() { val fields = mapOf( - "id" to Field(1, JDBCType.BIT, "SomeType"), - "name" to Field(2, JDBCType.BIT, "SomeType"), - "email" to Field(3, JDBCType.BIT, "SomeType"), - "age" to Field(4, JDBCType.BIT, "SomeType"), - "foo" to Field(5, JDBCType.BIT, "SomeType"), + "id" to Field(1, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), + "name" to Field(2, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), + "email" to Field(3, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), + "age" to Field(4, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), + "foo" to Field(5, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), ) - every { sqlTypeConverterMock(any(), any(), any(), eq(1)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(1), any()) } returns UUID.randomUUID() - every { sqlTypeConverterMock(any(), any(), any(), eq(2)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(2), any()) } returns "joker" - every { sqlTypeConverterMock(any(), any(), any(), eq(3)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(3), any()) } returns "joker@dc.com" - every { sqlTypeConverterMock(any(), any(), any(), eq(4)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(4), any()) } returns 85 - every { sqlTypeConverterMock(any(), any(), any(), eq(5)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(5), any()) } returns "bar" val kotlinDataClassMapper = KotlinDataClassMapper(SuperHero::class.java, autoMapperMock, sqlTypeConverterMock) @@ -100,15 +100,15 @@ class KotlinDataClassMapperTest { val batman = SuperHero(UUID.randomUUID(), "Batman", "batman@dc.com", 85) val fields = mapOf( - "name" to Field(1, JDBCType.BIT, "SomeType"), - "email" to Field(2, JDBCType.BIT, "SomeType"), - "age" to Field(3, JDBCType.BIT, "SomeType"), + "name" to Field(1, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), + "email" to Field(2, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), + "age" to Field(3, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), ) - every { sqlTypeConverterMock(any(), any(), any(), eq(1)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(1), any()) } returns batman.name - every { sqlTypeConverterMock(any(), any(), any(), eq(2)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(2), any()) } returns batman.email!! - every { sqlTypeConverterMock(any(), any(), any(), eq(3)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(3), any()) } returns batman.age!! val kotlinDataClassMapper = KotlinDataClassMapper(SuperHero::class.java, autoMapperMock, sqlTypeConverterMock) @@ -124,12 +124,12 @@ class KotlinDataClassMapperTest { every { autoMapperMock(any(), any>()) } returns "Foo" val fields = mapOf( - "id" to Field(1, JDBCType.BIT, "SomeType"), - "name" to Field(2, JDBCType.BIT, "SomeType"), + "id" to Field(1, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), + "name" to Field(2, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), ) - every { sqlTypeConverterMock(any(), any(), any(), eq(1)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(1), any()) } returns UUID.randomUUID() - every { sqlTypeConverterMock(any(), any(), any(), eq(2)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(2), any()) } returns 123 val kotlinDataClassMapper = KotlinDataClassMapper(SuperHero::class.java, autoMapperMock, sqlTypeConverterMock) @@ -142,15 +142,15 @@ class KotlinDataClassMapperTest { val batman = SuperHero(UUID.randomUUID(), "Batman", "batman@dc.com", 85) val fields = mapOf( - "id" to Field(1, JDBCType.BIT, "SomeType"), - "name" to Field(2, JDBCType.BIT, "SomeType"), - "email" to Field(3, JDBCType.BIT, "SomeType"), + "id" to Field(1, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), + "name" to Field(2, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), + "email" to Field(3, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), ) - every { sqlTypeConverterMock(any(), any(), any(), eq(1)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(1), any()) } returns batman.id - every { sqlTypeConverterMock(any(), any(), any(), eq(2)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(2), any()) } returns batman.name - every { sqlTypeConverterMock(any(), any(), any(), eq(3)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(3), any()) } returns null val kotlinDataClassMapper = KotlinDataClassMapper(SuperHero::class.java, autoMapperMock, sqlTypeConverterMock) @@ -175,11 +175,11 @@ class KotlinDataClassMapperTest { fun `should throw when no property found`() { val fields = mapOf( - "id" to Field(1, JDBCType.BIT, "SomeType"), - "name" to Field(2, JDBCType.BIT, "SomeType"), - "foo" to Field(1, JDBCType.BIT, "SomeType"), + "id" to Field(1, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), + "name" to Field(2, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), + "foo" to Field(1, JDBCType.BIT, "SomeType", DbFlavour.UNKNOWN), ) - every { sqlTypeConverterMock(any(), any(), any(), eq(1)) } returns + every { sqlTypeConverterMock(any(), any(), any(), eq(1), any()) } returns UUID.randomUUID() val kotlinDataClassMapper = KotlinDataClassMapper(SuperHero::class.java, autoMapperMock, sqlTypeConverterMock) diff --git a/core/src/test/kotlin/net/samyn/kapper/internal/MetadataTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/MetadataTest.kt index d6e884e3..733ac00f 100644 --- a/core/src/test/kotlin/net/samyn/kapper/internal/MetadataTest.kt +++ b/core/src/test/kotlin/net/samyn/kapper/internal/MetadataTest.kt @@ -1,6 +1,7 @@ package net.samyn.kapper.internal import io.kotest.matchers.maps.shouldContainExactly +import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk import net.samyn.kapper.Field @@ -28,12 +29,26 @@ class MetadataTest { every { metaData } returns mockMetadata } - val fields = mockResultSet.extractFields() + val fields = mockResultSet.extractFields(DbFlavour.UNKNOWN) fields.shouldContainExactly( mapOf( - "id" to Field(1, JDBCType.INTEGER, "INTEGER"), - "name" to Field(2, JDBCType.VARCHAR, "VARCHAR"), + "id" to Field(1, JDBCType.INTEGER, "INTEGER", DbFlavour.UNKNOWN), + "name" to Field(2, JDBCType.VARCHAR, "VARCHAR", DbFlavour.UNKNOWN), ), ) } + + @Test + fun `when jdbcType returns JDBCType`() { + val jdbcType = JDBCType.INTEGER.vendorTypeNumber + val result = jdbcType.jdbcType() + result shouldBe JDBCType.INTEGER + } + + @Test + fun `when jdbcType not known returns OTHER`() { + val jdbcType = 999 + val result = jdbcType.jdbcType() + result shouldBe JDBCType.OTHER + } } diff --git a/core/src/test/kotlin/net/samyn/kapper/internal/SQLTypesConverterTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/SQLTypesConverterTest.kt index fdbb0ab2..0e493333 100644 --- a/core/src/test/kotlin/net/samyn/kapper/internal/SQLTypesConverterTest.kt +++ b/core/src/test/kotlin/net/samyn/kapper/internal/SQLTypesConverterTest.kt @@ -1,5 +1,6 @@ package net.samyn.kapper.internal +import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk @@ -12,13 +13,19 @@ import org.junit.jupiter.api.assertThrows import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments.arguments import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource import java.sql.JDBCType import java.sql.PreparedStatement import java.sql.ResultSet import java.sql.Time import java.sql.Timestamp +import java.text.SimpleDateFormat import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime import java.time.LocalTime +import java.time.ZoneId +import java.time.ZoneOffset import java.time.temporal.ChronoUnit import java.util.Date import java.util.UUID @@ -52,6 +59,23 @@ class SQLTypesConverterTest { named("DATE", Date.from(Instant.now())), { i: Int, v: Date -> statement.setDate(i, java.sql.Date(v.time)) }, ), + arguments( + named("LOCALDATE", LocalDate.now()), + { i: Int, v: LocalDate -> statement.setDate(i, java.sql.Date.valueOf(v)) }, + ), + arguments( + named("LOCALDATETIME", LocalDateTime.now()), + { + i: Int, + v: LocalDateTime, + -> + statement.setTimestamp(i, Timestamp.from(v.atZone(ZoneOffset.systemDefault()).toInstant())) + }, + ), + arguments( + named("LOCALTIME", LocalTime.now()), + { i: Int, v: LocalTime -> statement.setTime(i, Time.valueOf(v)) }, + ), ) private val uuid = UUID.randomUUID() @@ -66,6 +90,7 @@ class SQLTypesConverterTest { DbFlavour.POSTGRESQL, ), arguments(named("UUID", uuid), statement::setString, uuid.toString(), DbFlavour.MYSQL), + arguments(named("UUID", uuid), statement::setBytes, uuid.toBytes(), DbFlavour.ORACLE), ) @JvmStatic @@ -180,7 +205,7 @@ class SQLTypesConverterTest { sqlTypeName: String, expectedGetter: (Int) -> Any, ) { - SQLTypesConverter.convertSQLType(jdbcType, sqlTypeName, resultSet, 1) + SQLTypesConverter.convertSQLType(jdbcType, sqlTypeName, resultSet, 1, DbFlavour.UNKNOWN) verify { expectedGetter(1) } } @@ -188,60 +213,176 @@ class SQLTypesConverterTest { @MethodSource("convertSQLTypeUnsupportedTests") fun `unsupported SQL types throws`(jdbcType: JDBCType) { assertThrows { - SQLTypesConverter.convertSQLType(jdbcType, jdbcType.toString(), resultSet, 1) + SQLTypesConverter.convertSQLType(jdbcType, jdbcType.toString(), resultSet, 1, DbFlavour.UNKNOWN) } } @Test - fun `char needs truncating`() { + fun `char needs converting`() { every { resultSet.getString(1) } returns "example" - val result = SQLTypesConverter.convertSQLType(JDBCType.CHAR, "CHAR", resultSet, 1) + val result = SQLTypesConverter.convertSQLType(JDBCType.CHAR, "CHAR", resultSet, 1, DbFlavour.UNKNOWN) - result.shouldBe('e') + result.shouldBe("example".toCharArray()) + } + + @Test + fun `char can return null`() { + every { resultSet.getString(1) } returns null + val result = SQLTypesConverter.convertSQLType(JDBCType.CHAR, "CHAR", resultSet, 1, DbFlavour.UNKNOWN) + + result.shouldBe(null) } @Test fun `uuid needs parsing`() { val id = UUID.randomUUID() every { resultSet.getString(2) } returns id.toString() - val result = SQLTypesConverter.convertSQLType(JDBCType.OTHER, "UUID", resultSet, 2) + val result = SQLTypesConverter.convertSQLType(JDBCType.OTHER, "UUID", resultSet, 2, DbFlavour.UNKNOWN) result.shouldBe(id) } + @Test + fun `uuid supports null`() { + every { resultSet.getString(2) } returns null + val result = SQLTypesConverter.convertSQLType(JDBCType.OTHER, "UUID", resultSet, 2, DbFlavour.UNKNOWN) + + result.shouldBe(null) + } + + @Test + fun `binary_float needs parsing`() { + val f = 123.45F + every { resultSet.getFloat(2) } returns f + val result = SQLTypesConverter.convertSQLType(JDBCType.OTHER, "binary_float", resultSet, 2, DbFlavour.UNKNOWN) + + result.shouldBe(f) + } + + @Test + fun `binary_double needs parsing`() { + val d = 123.45 + every { resultSet.getDouble(2) } returns d + val result = SQLTypesConverter.convertSQLType(JDBCType.OTHER, "binary_double", resultSet, 2, DbFlavour.UNKNOWN) + + result.shouldBe(d) + } + @Test fun `time needs converting`() { val time = LocalTime.now().truncatedTo(ChronoUnit.SECONDS) every { resultSet.getTime(3) } returns Time.valueOf(time) - val result = SQLTypesConverter.convertSQLType(JDBCType.TIME, "time", resultSet, 3) + val result = SQLTypesConverter.convertSQLType(JDBCType.TIME, "time", resultSet, 3, DbFlavour.UNKNOWN) result.shouldBe(time) } + @Test + fun `time supports null`() { + every { resultSet.getTime(3) } returns null + val result = SQLTypesConverter.convertSQLType(JDBCType.TIME, "time", resultSet, 3, DbFlavour.UNKNOWN) + + result.shouldBe(null) + } + @Test fun `time with zone needs converting`() { val time = LocalTime.now().truncatedTo(ChronoUnit.SECONDS) every { resultSet.getTime(4) } returns Time.valueOf(time) - val result = SQLTypesConverter.convertSQLType(JDBCType.TIME_WITH_TIMEZONE, "time", resultSet, 4) + val result = SQLTypesConverter.convertSQLType(JDBCType.TIME_WITH_TIMEZONE, "time", resultSet, 4, DbFlavour.UNKNOWN) result.shouldBe(time) } @Test - fun `timestamp needs converting`() { - val timestamp = Instant.now() - every { resultSet.getTimestamp(5) } returns Timestamp.from(timestamp) - val result = SQLTypesConverter.convertSQLType(JDBCType.TIMESTAMP, "timestamp", resultSet, 5) + fun `time with zone supports null`() { + every { resultSet.getTime(4) } returns null + val result = SQLTypesConverter.convertSQLType(JDBCType.TIME_WITH_TIMEZONE, "time", resultSet, 4, DbFlavour.UNKNOWN) - result.shouldBe(timestamp) + result.shouldBe(null) } @Test - fun `timestamp with zone needs converting`() { + fun `timestamp as DATE needs converting`() { val timestamp = Instant.now() every { resultSet.getTimestamp(6) } returns Timestamp.from(timestamp) - val result = SQLTypesConverter.convertSQLType(JDBCType.TIMESTAMP_WITH_TIMEZONE, "timestamp", resultSet, 6) + val result = SQLTypesConverter.convertSQLType(JDBCType.TIMESTAMP_WITH_TIMEZONE, "DATE", resultSet, 6, DbFlavour.UNKNOWN) + + result.shouldBe(LocalDateTime.ofInstant(timestamp, ZoneId.systemDefault())) + } + + @Test + fun `timestamp as DATE supports null`() { + every { resultSet.getTimestamp(6) } returns null + val result = SQLTypesConverter.convertSQLType(JDBCType.TIMESTAMP_WITH_TIMEZONE, "DATE", resultSet, 6, DbFlavour.UNKNOWN) + + result.shouldBe(null) + } + + @Test + fun `sqlite long date needs converting`() { + val date = Instant.now().toEpochMilli() + every { resultSet.getObject(7) } returns date + val result = SQLTypesConverter.convertSQLType(JDBCType.DATE, "DATE", resultSet, 7, DbFlavour.SQLITE) + + result.shouldBe(Date(date)) + } + + @Test + fun `sqlite long date supports null`() { + every { resultSet.getObject(7) } returns null + val result = SQLTypesConverter.convertSQLType(JDBCType.DATE, "DATE", resultSet, 7, DbFlavour.SQLITE) - result.shouldBe(timestamp) + result.shouldBe(null) + } + + @ParameterizedTest + // https://sqlite.org/lang_datefunc.html#tmval + @ValueSource( + strings = [ + "yyyy-MM-dd", + "yyyy-MM-dd HH:mm", + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd HH:mm:ss.SSS", + "yyyy-MM-dd'T'HH:mm", + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd'T'HH:mm:ss.SSS", + "HH:mm", + "HH:mm:ss", + "HH:mm:ss.SSS", + "yyyy-MM-dd HH:mm'Z'", + "yyyy-MM-dd HH:mm:ss'Z'", + "yyyy-MM-dd HH:mm:ss.SSS'Z'", + "yyyy-MM-dd'T'HH:mm'Z'", + "yyyy-MM-dd'T'HH:mm:ss'Z'", + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", + "HH:mm'Z'", + "HH:mm:ss'Z'", + "HH:mm:ss.SSS'Z'", + ], + ) + fun `sqlite string date needs converting`(format: String) { + val df = SimpleDateFormat(format) + val date = df.format(Date.from(Instant.now())) + every { resultSet.getObject(7) } returns date + val result = SQLTypesConverter.convertSQLType(JDBCType.DATE, "DATE", resultSet, 7, DbFlavour.SQLITE) + + df.format(result).shouldBe(date) + } + + @Test + fun `sqlite invalid date throws`() { + every { resultSet.getObject(7) } returns "Thu Aug 08 17:26:32 GMT+08:00 2013" + shouldThrow { + SQLTypesConverter.convertSQLType(JDBCType.DATE, "DATE", resultSet, 7, DbFlavour.SQLITE) + } + } + + @Test + fun `sqlite invalid date type throws`() { + every { resultSet.getObject(7) } returns 123 + shouldThrow { + SQLTypesConverter.convertSQLType(JDBCType.DATE, "DATE", resultSet, 7, DbFlavour.SQLITE) + } } } diff --git a/core/src/test/kotlin/net/samyn/kapper/internal/converter/BooleanConverterTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/converter/BooleanConverterTest.kt new file mode 100644 index 00000000..a56cf0f7 --- /dev/null +++ b/core/src/test/kotlin/net/samyn/kapper/internal/converter/BooleanConverterTest.kt @@ -0,0 +1,61 @@ +package net.samyn.kapper.internal.converter + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import net.samyn.kapper.KapperUnsupportedOperationException +import net.samyn.kapper.internal.convertBoolean +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.Date +import java.util.stream.Stream + +class BooleanConverterTest { + companion object { + @JvmStatic + fun booleanTrueValues(): Stream = + Stream.of( + Arguments.of(1), + Arguments.of(1F), + Arguments.of(1L), + Arguments.of(1.toByte()), + Arguments.of(1.toShort()), + Arguments.of("true"), + Arguments.of("1"), + ) + + @JvmStatic + fun booleanFalseValues(): Stream = + Stream.of( + Arguments.of(0), + Arguments.of(0F), + Arguments.of(0L), + Arguments.of(0.toByte()), + Arguments.of(0.toShort()), + Arguments.of("false"), + Arguments.of("0"), + ) + } + + @ParameterizedTest + @MethodSource("booleanTrueValues") + fun `convert valid true to Bool`(value: Any) { + val bool = convertBoolean(value) + bool.shouldBe(true) + } + + @ParameterizedTest + @MethodSource("booleanFalseValues") + fun `convert valid false to Bool`(value: Any) { + val bool = convertBoolean(value) + bool.shouldBe(false) + } + + @Test + fun `throw exception when invalid type is converted to Bool`() { + shouldThrow { + convertBoolean(Date()) // Invalid input type + } + } +} diff --git a/core/src/test/kotlin/net/samyn/kapper/internal/converter/CharConverterTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/converter/CharConverterTest.kt new file mode 100644 index 00000000..03d694bc --- /dev/null +++ b/core/src/test/kotlin/net/samyn/kapper/internal/converter/CharConverterTest.kt @@ -0,0 +1,63 @@ +package net.samyn.kapper.internal.converter + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import net.samyn.kapper.KapperParseException +import net.samyn.kapper.KapperUnsupportedOperationException +import net.samyn.kapper.internal.convertChar +import org.junit.jupiter.api.Test + +class CharConverterTest { + @Test + fun `convert valid String to Char`() { + val charString = "A" + val char = convertChar(charString) + char.shouldBe('A') + } + + @Test + fun `String too short for Char`() { + val charString = "" + shouldThrow { + convertChar(charString) + } + } + + @Test + fun `String too long for Char`() { + val charString = "AB" + shouldThrow { + convertChar(charString) + } + } + + @Test + fun `convert valid CharArray to Char`() { + val charArray = charArrayOf('A') + val char = convertChar(charArray) + char.shouldBe('A') + } + + @Test + fun `CharArray too short for Char`() { + val charArray = charArrayOf() + shouldThrow { + convertChar(charArray) + } + } + + @Test + fun `CharArray too long for Char`() { + val charArray = charArrayOf('A', 'B') + shouldThrow { + convertChar(charArray) + } + } + + @Test + fun `throw exception when invalid type is converted to Char`() { + shouldThrow { + convertChar(123) // Invalid input type + } + } +} diff --git a/core/src/test/kotlin/net/samyn/kapper/internal/converter/DateConverterTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/converter/DateConverterTest.kt new file mode 100644 index 00000000..02433418 --- /dev/null +++ b/core/src/test/kotlin/net/samyn/kapper/internal/converter/DateConverterTest.kt @@ -0,0 +1,33 @@ +package net.samyn.kapper.internal.converter + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import net.samyn.kapper.KapperUnsupportedOperationException +import net.samyn.kapper.internal.convertDate +import org.junit.jupiter.api.Test +import java.time.Instant +import java.time.LocalDateTime +import java.util.Date + +class DateConverterTest { + @Test + fun `convert valid Instant to Long`() { + val instant = Instant.now() + val dateValue = convertDate(instant) + dateValue.shouldBe(Date.from(instant)) + } + + @Test + fun `convert valid LocalDateTime to Long`() { + val instant = LocalDateTime.now() + val dateValue = convertDate(instant) + dateValue.shouldBe(Date.from(instant.atZone(java.time.ZoneOffset.systemDefault()).toInstant())) + } + + @Test + fun `throw exception when invalid type is converted to Date`() { + shouldThrow { + convertDate("") // Invalid input type + } + } +} diff --git a/core/src/test/kotlin/net/samyn/kapper/internal/converter/InstantConverterTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/converter/InstantConverterTest.kt new file mode 100644 index 00000000..16a3dac9 --- /dev/null +++ b/core/src/test/kotlin/net/samyn/kapper/internal/converter/InstantConverterTest.kt @@ -0,0 +1,45 @@ +package net.samyn.kapper.internal.converter + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import net.samyn.kapper.KapperUnsupportedOperationException +import net.samyn.kapper.internal.convertInstant +import org.junit.jupiter.api.Test +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneOffset +import java.util.Date + +class InstantConverterTest { + @Test + fun `convert valid LocalTime to Instant`() { + val time = LocalTime.of(12, 30) // 12:30 PM + val instant = convertInstant(time) as Instant + val expectedInstant = LocalDate.now().atTime(12, 30).toInstant(ZoneOffset.UTC) + instant.shouldBe(expectedInstant) + } + + @Test + fun `convert LocalTime at midnight to Instant`() { + val midnight = LocalTime.MIDNIGHT + val instant = convertInstant(midnight) as Instant + val expectedInstant = LocalDate.now().atTime(0, 0).toInstant(ZoneOffset.UTC) + instant.shouldBe(expectedInstant) + } + + @Test + fun `convert valid LocalDateTime to Instant`() { + val now = Instant.now() + val instant = convertInstant(LocalDateTime.ofInstant(now, ZoneOffset.systemDefault())) as Instant + instant.shouldBe(now) + } + + @Test + fun `throw exception when invalid type is converted to Instant`() { + shouldThrow { + convertInstant(Date()) // Invalid input type + } + } +} diff --git a/core/src/test/kotlin/net/samyn/kapper/internal/converter/IntConverterTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/converter/IntConverterTest.kt new file mode 100644 index 00000000..51ec5b2c --- /dev/null +++ b/core/src/test/kotlin/net/samyn/kapper/internal/converter/IntConverterTest.kt @@ -0,0 +1,24 @@ +package net.samyn.kapper.internal.converter + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import net.samyn.kapper.KapperUnsupportedOperationException +import net.samyn.kapper.internal.convertInt +import org.junit.jupiter.api.Test +import java.util.Date + +class IntConverterTest { + @Test + fun `convert valid Float to Int`() { + val float = 123F + val intValue = convertInt(float) + intValue.shouldBe(123) + } + + @Test + fun `throw exception when invalid type is converted to Int`() { + shouldThrow { + convertInt(Date()) // Invalid input type + } + } +} diff --git a/core/src/test/kotlin/net/samyn/kapper/internal/converter/LocalDateConverterTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/converter/LocalDateConverterTest.kt new file mode 100644 index 00000000..7e87516c --- /dev/null +++ b/core/src/test/kotlin/net/samyn/kapper/internal/converter/LocalDateConverterTest.kt @@ -0,0 +1,48 @@ +package net.samyn.kapper.internal.converter + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import net.samyn.kapper.KapperUnsupportedOperationException +import net.samyn.kapper.internal.convertLocalDate +import org.junit.jupiter.api.Test +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.util.Date + +class LocalDateConverterTest { + @Test + fun `convert valid Date to LocalDate`() { + val date = Date.from(Instant.parse("2023-10-01T00:00:00Z")) + val localDate = convertLocalDate(date) + localDate.shouldBe(LocalDate.of(2023, 10, 1)) + } + + @Test + fun `convert valid LocalDateTime to LocalDate`() { + val date = LocalDateTime.of(2023, 10, 1, 0, 0) + val localDate = convertLocalDate(date) + localDate.shouldBe(LocalDate.of(2023, 10, 1)) + } + + @Test + fun `convert valid Instant to LocalDate`() { + val date = Instant.parse("2023-10-01T00:00:00Z") + val localDate = convertLocalDate(date) + localDate.shouldBe(LocalDate.of(2023, 10, 1)) + } + + @Test + fun `convert valid string to LocalDate`() { + val date = "2023-10-01" + val localDate = convertLocalDate(date) + localDate.shouldBe(LocalDate.of(2023, 10, 1)) + } + + @Test + fun `throw exception when invalid type is converted to LocalDate`() { + shouldThrow { + convertLocalDate(2023) // Invalid input type + } + } +} diff --git a/core/src/test/kotlin/net/samyn/kapper/internal/converter/LocalDateTimeConverterTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/converter/LocalDateTimeConverterTest.kt new file mode 100644 index 00000000..3fe0591b --- /dev/null +++ b/core/src/test/kotlin/net/samyn/kapper/internal/converter/LocalDateTimeConverterTest.kt @@ -0,0 +1,35 @@ +package net.samyn.kapper.internal.converter + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import net.samyn.kapper.KapperUnsupportedOperationException +import net.samyn.kapper.internal.convertLocalDateTime +import org.junit.jupiter.api.Test +import java.time.Instant +import java.time.LocalDateTime +import java.util.Date + +class LocalDateTimeConverterTest { + @Test + fun `convert valid Instant to LocalDateTime`() { + val now = Instant.now() + val localDateTime = convertLocalDateTime(now) as LocalDateTime + val expectedDt = LocalDateTime.ofInstant(now, java.time.ZoneOffset.systemDefault()) + localDateTime.shouldBe(expectedDt) + } + + @Test + fun `convert valid String to LocalDateTime`() { + val dateTimeString = "2023-10-01T12:30:00" + val localDateTime = convertLocalDateTime(dateTimeString) as LocalDateTime + val expectedDt = LocalDateTime.parse(dateTimeString) + localDateTime.shouldBe(expectedDt) + } + + @Test + fun `throw exception when invalid type is converted to LocalDateTime`() { + shouldThrow { + convertLocalDateTime(Date()) // Invalid input type + } + } +} diff --git a/core/src/test/kotlin/net/samyn/kapper/internal/converter/LocalTimeConverterTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/converter/LocalTimeConverterTest.kt new file mode 100644 index 00000000..ae9d7a4d --- /dev/null +++ b/core/src/test/kotlin/net/samyn/kapper/internal/converter/LocalTimeConverterTest.kt @@ -0,0 +1,51 @@ +package net.samyn.kapper.internal.converter + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import net.samyn.kapper.KapperUnsupportedOperationException +import net.samyn.kapper.internal.convertLocalTime +import org.junit.jupiter.api.Test +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneOffset +import java.util.Date + +class LocalTimeConverterTest { + @Test + fun `convert valid Instant to LocalTime`() { + val now = Instant.now() + val localTime = convertLocalTime(now) + val expectedTime = LocalTime.ofInstant(now, ZoneOffset.systemDefault()) + localTime.shouldBe(expectedTime) + } + + @Test + fun `convert valid String to LocalTime`() { + val timeString = "12:30:00" + val localTime = convertLocalTime(timeString) + val expectedTime = LocalTime.parse(timeString) + localTime.shouldBe(expectedTime) + } + + @Test + fun `convert valid Long to LocalTime`() { + val now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS) + val localTime = convertLocalTime(now.toEpochMilli()) + val expectedTime = LocalTime.ofInstant(now, ZoneOffset.systemDefault()) + localTime.shouldBe(expectedTime) + } + + @Test + fun `convert valid Int to LocalTime`() { + val twoOclockPmUTC = 14 * 60 * 60 * 1000 // 14:00:00 - SQLite stores time as ms from 1970 + val localTime = convertLocalTime(twoOclockPmUTC) + localTime.shouldBe(Instant.ofEpochMilli(twoOclockPmUTC.toLong()).atZone(ZoneOffset.systemDefault()).toLocalTime()) + } + + @Test + fun `throw exception when invalid type is converted to LocalTime`() { + shouldThrow { + convertLocalTime(Date()) // Invalid input type + } + } +} diff --git a/core/src/test/kotlin/net/samyn/kapper/internal/converter/LongConverterTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/converter/LongConverterTest.kt new file mode 100644 index 00000000..def0d8d3 --- /dev/null +++ b/core/src/test/kotlin/net/samyn/kapper/internal/converter/LongConverterTest.kt @@ -0,0 +1,23 @@ +package net.samyn.kapper.internal.converter + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import net.samyn.kapper.KapperUnsupportedOperationException +import net.samyn.kapper.internal.convertLong +import org.junit.jupiter.api.Test + +class LongConverterTest { + @Test + fun `convert valid Float to Long`() { + val float = 123F + val longValue = convertLong(float) + longValue.shouldBe(123) + } + + @Test + fun `throw exception when invalid type is converted to Long`() { + shouldThrow { + convertLong("") // Invalid input type + } + } +} diff --git a/core/src/test/kotlin/net/samyn/kapper/internal/converter/UUIDConverterTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/converter/UUIDConverterTest.kt new file mode 100644 index 00000000..dc6bb148 --- /dev/null +++ b/core/src/test/kotlin/net/samyn/kapper/internal/converter/UUIDConverterTest.kt @@ -0,0 +1,57 @@ +package net.samyn.kapper.internal.converter + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import net.samyn.kapper.KapperParseException +import net.samyn.kapper.KapperUnsupportedOperationException +import net.samyn.kapper.internal.convertUUID +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.nio.ByteBuffer +import java.util.UUID + +class UUIDConverterTest { + @Test + fun `when string UUID convert`() { + val uuid = "123e4567-e89b-12d3-a456-426614174000" + val autoConvertedUuid = convertUUID(uuid) + autoConvertedUuid.shouldBe(UUID.fromString(uuid)) + } + + @Test + fun `when char array UUID convert`() { + val uuid = "123e4567-e89b-12d3-a456-426614174000" + val autoConvertedUuid = convertUUID(uuid.toCharArray()) + autoConvertedUuid.shouldBe(UUID.fromString(uuid)) + } + + @Test + fun `when binary UUID convert`() { + val uuid = UUID.fromString("123e4567-e89b-12d3-a456-426614174000") + val autoConvertedUuid = convertUUID(uuid.asBytes()) + autoConvertedUuid.shouldBe(uuid) + } + + @Test + fun `when int cannot convert to UUID`() { + shouldThrow { + convertUUID(123) + } + } + + @ParameterizedTest + @ValueSource(strings = ["", "123e4567-e89b-12d3-a456-426614ZZZZZZ"]) + fun `when invalid string cannot convert to UUID`(input: String) { + shouldThrow { + convertUUID(input) + } + } + + private fun UUID.asBytes(): ByteArray { + val b = ByteBuffer.wrap(ByteArray(16)) + b.putLong(mostSignificantBits) + b.putLong(leastSignificantBits) + return b.array() + } +} diff --git a/coroutines/src/main/kotlin/net/samyn/kapper/coroutines/KapperKotlinFlowQueryFun.kt b/coroutines/src/main/kotlin/net/samyn/kapper/coroutines/KapperKotlinFlowQueryFun.kt index 4e530e26..2e714f3c 100644 --- a/coroutines/src/main/kotlin/net/samyn/kapper/coroutines/KapperKotlinFlowQueryFun.kt +++ b/coroutines/src/main/kotlin/net/samyn/kapper/coroutines/KapperKotlinFlowQueryFun.kt @@ -6,9 +6,11 @@ import kotlinx.coroutines.flow.flow import net.samyn.kapper.Field import net.samyn.kapper.KapperQueryException import net.samyn.kapper.createMapper +import net.samyn.kapper.internal.DbFlavour import net.samyn.kapper.internal.Query import net.samyn.kapper.internal.executeQuery import net.samyn.kapper.internal.extractFields +import net.samyn.kapper.internal.getDbFlavour import net.samyn.kapper.internal.logger import java.sql.Connection import java.sql.ResultSet @@ -82,7 +84,7 @@ inline fun Connection.queryAsFlow( ): Flow { require(sql.isNotBlank()) { "SQL query cannot be empty or blank" } this.executeQuery(Query(sql), args.toMap(), fetchSize).let { rs -> - return queryFlow(rs, mapper, sql) + return queryFlow(rs, mapper, sql, this.getDbFlavour()) } } @@ -93,8 +95,9 @@ fun queryFlow( rs: ResultSet, mapper: (ResultSet, Map) -> T, sql: String, + dbFlavour: DbFlavour, ): Flow { - val fields = rs.extractFields() + val fields = rs.extractFields(dbFlavour) return flow { try { while (rs.next()) { diff --git a/coroutines/src/test/kotlin/net/samyn/kapper/coroutines/FlowQueryTest.kt b/coroutines/src/test/kotlin/net/samyn/kapper/coroutines/FlowQueryTest.kt index 5cca524b..ae556af4 100644 --- a/coroutines/src/test/kotlin/net/samyn/kapper/coroutines/FlowQueryTest.kt +++ b/coroutines/src/test/kotlin/net/samyn/kapper/coroutines/FlowQueryTest.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.runBlocking import net.samyn.kapper.Field import net.samyn.kapper.KapperQueryException import net.samyn.kapper.createMapper +import net.samyn.kapper.internal.DbFlavour import net.samyn.kapper.internal.Query import net.samyn.kapper.internal.executeQuery import net.samyn.kapper.internal.extractFields @@ -24,8 +25,8 @@ import java.sql.SQLException class FlowQueryTest { private val fields = mapOf( - "id" to Field(1, java.sql.JDBCType.INTEGER, "id"), - "name" to Field(1, java.sql.JDBCType.VARCHAR, "name"), + "id" to Field(1, java.sql.JDBCType.INTEGER, "id", DbFlavour.UNKNOWN), + "name" to Field(1, java.sql.JDBCType.VARCHAR, "name", DbFlavour.UNKNOWN), ) private val result = Hero(1, "Superman") private val resultSet = @@ -41,7 +42,7 @@ class FlowQueryTest { init { mockkStatic(Connection::executeQuery) mockkStatic(ResultSet::extractFields) - every { resultSet.extractFields() } returns fields + every { resultSet.extractFields(any()) } returns fields every { connection.executeQuery(any(), any(), any()) } returns resultSet every { mapper.invoke(resultSet, fields) } returns result } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index febb826f..1ef64cbb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,9 @@ kotlin = "2.1.20" kotlinx-coroutines = "1.10.2" mockito = "5.17.0" mockk = "1.14.0" +mssql-server-driver = "12.10.0.jre11" mysql-driver = "9.2.0" +oracle-driver = "23.7.0.25.01" postgresql-driver = "42.7.5" slf4j = "2.0.17" sqlite = "3.49.1.0" @@ -25,14 +27,18 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t # mockito only to be used from Java code (API usability test) mockito = { module = "org.mockito:mockito-core", version.ref = "mockito" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +mssql-server-driver = { module = "com.microsoft.sqlserver:mssql-jdbc", version.ref = "mssql-server-driver" } mysql-driver = { module = "com.mysql:mysql-connector-j", version.ref = "mysql-driver" } +oracle-driver = { module = "com.oracle.database.jdbc:ojdbc11", version.ref = "oracle-driver" } postgresql-driver = { module = "org.postgresql:postgresql", version.ref = "postgresql-driver" } slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite" } test-containers = { module = "org.testcontainers:testcontainers", version.ref = "test-containers" } test-containers-junit = { module = "org.testcontainers:junit-jupiter", version.ref = "test-containers" } +test-containers-mssqlserver = { module = "org.testcontainers:mssqlserver", version.ref = "test-containers" } test-containers-mysql = { module = "org.testcontainers:mysql", version.ref = "test-containers" } +test-containers-oracle = { module = "org.testcontainers:oracle-free", version.ref = "test-containers" } test-containers-postgresql = { module = "org.testcontainers:postgresql", version.ref = "test-containers" } # PLUGINS @@ -47,8 +53,14 @@ kover-plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version = [bundles] jmh = ["jmh-core", "jmh-generator-annprocess"] test = ["junit-jupiter", "mockk", "kotest-assertions-core"] -test-containers = ["test-containers-junit", "test-containers-mysql", "test-containers-postgresql"] -test-dbs = ["mysql-driver", "postgresql-driver"] +test-containers = [ + "test-containers-junit", + "test-containers-mysql", + "test-containers-postgresql", + "test-containers-mssqlserver", + "test-containers-oracle" +] +test-dbs = ["mysql-driver", "postgresql-driver", "sqlite-jdbc", "mssql-server-driver", "oracle-driver"] [plugins] sonar = { id = "org.sonarqube", version = "6.1.0.5360" }