From 977f5eda8ee3b49796a17d657981327bea3221f0 Mon Sep 17 00:00:00 2001 From: Dries Samyn <5557551+driessamyn@users.noreply.github.com> Date: Fri, 18 Apr 2025 19:25:06 +0200 Subject: [PATCH 1/6] refactor: refactor integration test ahead of adding sqlite. --- README.md | 4 +- .../net/samyn/kapper/AbstractDbTests.kt | 177 +++++++----- .../kotlin/net/samyn/kapper/ExecuteTests.kt | 269 +++++++++--------- .../net/samyn/kapper/QuerySingleTests.kt | 108 +++---- .../kotlin/net/samyn/kapper/QueryTests.kt | 128 ++++----- .../net/samyn/kapper/SqlInjectionTest.kt | 16 +- .../kotlin/net/samyn/kapper/TypeCastTest.kt | 19 +- .../kotlin/net/samyn/kapper/TypesTest.kt | 156 +++++----- .../net/samyn/kapper/internal/DbFlavour.kt | 6 + .../samyn/kapper/internal/DbFlavourTest.kt | 20 ++ gradle/libs.versions.toml | 16 +- 11 files changed, 471 insertions(+), 448 deletions(-) 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/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt index 4c3599be..58ca1465 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt @@ -1,20 +1,21 @@ 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.arguments -import org.junit.jupiter.params.provider.MethodSource import org.testcontainers.containers.JdbcDatabaseContainer import org.testcontainers.containers.MySQLContainer import org.testcontainers.containers.PostgreSQLContainer import org.testcontainers.junit.jupiter.Container import org.testcontainers.junit.jupiter.Testcontainers +import java.sql.Connection import java.sql.DriverManager import java.util.UUID +import java.util.concurrent.ConcurrentHashMap import kotlin.use @Testcontainers @@ -25,32 +26,32 @@ abstract class AbstractDbTests { mapOf( "UUID" to mapOf( - MySQLContainer::class to "VARCHAR(36)", + DbFlavour.MYSQL to "VARCHAR(36)", ), "CLOB" to mapOf( - MySQLContainer::class to "TEXT", - PostgreSQLContainer::class to "TEXT", + DbFlavour.MYSQL to "TEXT", + DbFlavour.POSTGRESQL to "TEXT", ), "BINARY" to mapOf( - PostgreSQLContainer::class to "BYTEA", + DbFlavour.POSTGRESQL to "BYTEA", ), "VARBINARY" to mapOf( - PostgreSQLContainer::class to "BYTEA", + DbFlavour.POSTGRESQL to "BYTEA", ), "BLOB" to mapOf( - PostgreSQLContainer::class to "BYTEA", + DbFlavour.POSTGRESQL to "BYTEA", ), "FLOAT" to mapOf( - PostgreSQLContainer::class to "NUMERIC", + DbFlavour.POSTGRESQL to "NUMERIC", ), "REAL" to mapOf( - MySQLContainer::class to "FLOAT", + DbFlavour.MYSQL to "FLOAT", ), ) @@ -60,21 +61,41 @@ abstract class AbstractDbTests { @Container val mysql = MySQLContainer("mysql:8.4") +// @Container +// val oracle = OracleContainer("gvenzl/oracle-free:23.4-slim-faststart") +// +// @Container +// val msSqlServer = +// MSSQLServerContainer("mcr.microsoft.com/mssql/server:2017-CU12").acceptLicense() + val allContainers = mapOf( "PostgreSQL" to postgresql, "MySQL" to mysql, +// "Oracle" to oracle, +// "MSSQLServer" to msSqlServer, ) @JvmStatic - fun databaseContainers() = allContainers.map { arguments(named(it.key, it.value)) } + fun databaseContainers() = + allContainers.map { + arguments(named(it.key, getConnection(it.value))) + } + + private val connections = ConcurrentHashMap, Connection>() + + private fun getConnection(container: JdbcDatabaseContainer<*>) = + connections.computeIfAbsent(container.javaClass) { + Class.forName(container.driverClassName) + DriverManager.getConnection(container.jdbcUrl, container.username, container.password) + } } private fun convertDbColumnType( name: String, - container: JdbcDatabaseContainer<*>, + flavour: DbFlavour, suffix: String = "", - ) = specialTypes[name]?.get(container::class) ?: (name + suffix) + ) = specialTypes[name]?.get(flavour) ?: (name + suffix) val superman = SuperHero(UUID.randomUUID(), "Superman", "superman@dc.com", 86) val batman = SuperHero(UUID.randomUUID(), "Batman", "batman@dc.com", 85) @@ -83,77 +104,77 @@ abstract class AbstractDbTests { @BeforeAll fun setup() { allContainers.values.forEach { container -> - setupDatabase(container) + setupDatabase(getConnection(container)) } } - 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() + allContainers.values.forEach { container -> + container.stop() } } - @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 ( + id ${convertDbColumnType("UUID", dbFlavour)} 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", dbFlavour)}, + t_char CHAR, + t_varchar VARCHAR(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 SMALLINT, + t_int INT, + t_bigint BIGINT, + t_float ${convertDbColumnType("FLOAT", dbFlavour, "(8)")}, + t_real ${convertDbColumnType("REAL", dbFlavour)}, + 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) + }, + ) + } } - 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 { diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/ExecuteTests.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/ExecuteTests.kt index d7d6ac09..e4db06be 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/ExecuteTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/ExecuteTests.kt @@ -4,10 +4,11 @@ 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.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 org.testcontainers.containers.MySQLContainer +import java.sql.Connection import java.sql.SQLException import java.util.UUID import kotlin.uuid.ExperimentalUuidApi @@ -17,161 +18,156 @@ 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) - }, - ) - } + override fun setupDatabase(connection: Connection) { + connection.createStatement().use { statement -> + statement.execute( + """ + CREATE TABLE $table ( + id ${if (connection.getDbFlavour() == DbFlavour.MYSQL) "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 -> - val resultSet = stmt.executeQuery() - assertSoftly(resultSet) { - next().shouldBe(true) - getString(1).shouldBe(superman.name) - getString(2).shouldBe(superman.email) - getInt(3).shouldBe(superman.age) - } + fun `SQL Insert single`(connection: 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 -> + val resultSet = stmt.executeQuery() + assertSoftly(resultSet) { + next().shouldBe(true) + getString(1).shouldBe(superman.name) + getString(2).shouldBe(superman.email) + getInt(3).shouldBe(superman.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');") - } + fun `SQL Update single`(connection: 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) - - connection.prepareStatement("SELECT name, email, age FROM $table WHERE id = '${batman.id}'").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) - } + 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) + + connection.prepareStatement("SELECT name, email, age FROM $table WHERE id = '${batman.id}'").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) } } } @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 $table(id, name) VALUES('$it', '$name');") } + } - val results = - connection.execute( - "UPDATE $table SET name = :newName WHERE name = :name;", - "name" to name, - "newName" to "bar", - ) + val results = + connection.execute( + "UPDATE $table 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) { + connection.createStatement().use { stmt -> + stmt.execute("INSERT INTO $table(id, name) VALUES('${spiderMan.id}', 'foo');") + } - val results = - connection.execute( - "DELETE FROM $table WHERE id = :id;", - "id" to spiderMan.id, - ) + val results = + connection.execute( + "DELETE FROM $table WHERE id = :id;", + "id" to spiderMan.id, + ) - results.shouldBe(1) + results.shouldBe(1) - connection.prepareStatement("SELECT * FROM $table WHERE id = '${batman.id}'").use { stmt -> - val resultSet = stmt.executeQuery() - assertSoftly(resultSet) { - next().shouldBe(false) - } + connection.prepareStatement("SELECT * FROM $table WHERE id = '${batman.id}'").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 $table(id, name) VALUES('$it', '$name');") } + } - val results = - connection.execute( - "DELETE FROM $table WHERE name = :name;", - "name" to name, - ) + val results = + connection.execute( + "DELETE FROM $table 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 $table(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); @@ -179,43 +175,32 @@ class ExecuteTests : AbstractDbTests() { "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 $table(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 $table 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..d193ed2c 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/QuerySingleTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/QuerySingleTests.kt @@ -7,96 +7,82 @@ import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain 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 hero = connection.querySingle("SELECT * FROM super_heroes ORDER BY age DESC LIMIT 1") + 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 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 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 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", + ) + } + 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 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 WHERE name = :name", + ::createVillain, + "name" to superman.name, + ) + villain.shouldNotBeNull() + villain.id.shouldBe(superman.id.toString()) + villain.name.shouldBe(superman.name) } private fun createVillain( diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt index 28151ab7..6535d433 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") + 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 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 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 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 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 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 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()) + villain.first().name.shouldBe(superman.name) } private fun createVillain( diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/SqlInjectionTest.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/SqlInjectionTest.kt index 773d1c55..a5c77b17 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 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").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..9286d317 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt @@ -3,7 +3,7 @@ package net.samyn.kapper 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.time.Instant import java.time.LocalDate import java.time.LocalTime @@ -16,87 +16,85 @@ import kotlin.random.Random class TypesTest : AbstractDbTests() { @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 ( + 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 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/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/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/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" } From e4a7c5495bd4175ac22d37af34753ed877a7923c Mon Sep 17 00:00:00 2001 From: Dries Samyn <5557551+driessamyn@users.noreply.github.com> Date: Sat, 19 Apr 2025 15:52:47 +0200 Subject: [PATCH 2/6] feat: SQLite support Support for SQLite, integrating it in the Integration Tests and enhance date/time support to handle SQLite storing date/time as strings/Long. --- .../net/samyn/kapper/AbstractDbTests.kt | 41 +++++---- .../kotlin/net/samyn/kapper/TypesTest.kt | 2 +- .../src/main/kotlin/net/samyn/kapper/Field.kt | 4 +- .../net/samyn/kapper/internal/Converters.kt | 8 +- .../net/samyn/kapper/internal/KapperImpl.kt | 2 +- .../kapper/internal/KotlinDataClassMapper.kt | 10 ++- .../net/samyn/kapper/internal/Metadata.kt | 3 +- .../kapper/internal/SQLTypesConverter.kt | 76 +++++++++++++++- .../samyn/kapper/internal/ConverterTest.kt | 25 +++++- .../kapper/internal/KapperImplQueryTest.kt | 4 +- .../internal/KotlinDataClassMapperTest.kt | 86 +++++++++---------- .../net/samyn/kapper/internal/MetadataTest.kt | 6 +- .../kapper/internal/SQLTypesConverterTest.kt | 79 +++++++++++++++-- .../coroutines/KapperKotlinFlowQueryFun.kt | 7 +- .../samyn/kapper/coroutines/FlowQueryTest.kt | 7 +- 15 files changed, 272 insertions(+), 88 deletions(-) diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt index 58ca1465..6d424f96 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt @@ -22,6 +22,10 @@ import kotlin.use @TestInstance(TestInstance.Lifecycle.PER_CLASS) abstract class AbstractDbTests { companion object { + init { + Class.forName("org.sqlite.JDBC") + } + val specialTypes = mapOf( "UUID" to @@ -68,26 +72,30 @@ abstract class AbstractDbTests { // val msSqlServer = // MSSQLServerContainer("mcr.microsoft.com/mssql/server:2017-CU12").acceptLicense() - val allContainers = + private val connections = ConcurrentHashMap() + + 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:") }, // "Oracle" to oracle, // "MSSQLServer" to msSqlServer, ) +// private val sqliteConnection = +// { +// Class.forName("org.sqlite.JDBC") +// }.let { DriverManager.getConnection("jdbc:sqlite::memory:") } @JvmStatic fun databaseContainers() = - allContainers.map { - arguments(named(it.key, getConnection(it.value))) - } - - private val connections = ConcurrentHashMap, Connection>() - - private fun getConnection(container: JdbcDatabaseContainer<*>) = - connections.computeIfAbsent(container.javaClass) { - Class.forName(container.driverClassName) - DriverManager.getConnection(container.jdbcUrl, container.username, container.password) + connections.map { + arguments(named(it.key.toString(), it.value)) } } @@ -103,8 +111,8 @@ abstract class AbstractDbTests { @BeforeAll fun setup() { - allContainers.values.forEach { container -> - setupDatabase(getConnection(container)) + dbs.forEach { container -> + setupDatabase(connections.computeIfAbsent(container.key) { container.value() }) } } @@ -114,9 +122,6 @@ abstract class AbstractDbTests { connection.close() } connections.clear() - allContainers.values.forEach { container -> - container.stop() - } } protected open fun setupDatabase(connection: Connection) { diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt index 9286d317..9a0d35a5 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt @@ -26,7 +26,7 @@ class TypesTest : AbstractDbTests() { t_char, t_varchar, t_clob, - t_binary, + t_binary, t_varbinary, t_large_binary, t_numeric, 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/Converters.kt b/core/src/main/kotlin/net/samyn/kapper/internal/Converters.kt index 4308b232..50d12af4 100644 --- a/core/src/main/kotlin/net/samyn/kapper/internal/Converters.kt +++ b/core/src/main/kotlin/net/samyn/kapper/internal/Converters.kt @@ -33,15 +33,19 @@ internal fun convertInstant(value: Any): Instant = internal fun convertLocalTime(value: Any): LocalTime = if (value is Instant) { LocalTime.ofInstant(value, java.time.ZoneOffset.UTC) + } else if (value is String) { + LocalTime.parse(value) } else { throw KapperUnsupportedOperationException( - "Cannot auto-convert from ${value.javaClass} to LocalTime", + "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 if (value is String) { + LocalDateTime.parse(value) } else { throw KapperUnsupportedOperationException( "Cannot auto-convert from ${value.javaClass} to LocalDateTime", @@ -53,6 +57,8 @@ internal fun convertLocalDate(value: Any): LocalDate = val cal = Calendar.getInstance() cal.time = value LocalDate.of(cal[Calendar.YEAR], cal[Calendar.MONTH] + 1, cal[Calendar.DAY_OF_MONTH]) + } else if (value is String) { + LocalDate.parse(value) } else { throw KapperUnsupportedOperationException( "Cannot auto-convert from ${value.javaClass} to LocalDate", 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..9bf5b28c 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 @@ -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..f8664c0e 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,13 @@ 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.getColumnTypeName(it), + dbFlavour, ) } 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..7b70ded5 100644 --- a/core/src/main/kotlin/net/samyn/kapper/internal/SQLTypesConverter.kt +++ b/core/src/main/kotlin/net/samyn/kapper/internal/SQLTypesConverter.kt @@ -7,6 +7,7 @@ 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.util.Date import java.util.UUID @@ -19,6 +20,7 @@ internal object SQLTypesConverter { sqlTypeName: String, resultSet: ResultSet, fieldIndex: Int, + dbFlavour: DbFlavour, ): Any { val result = when (sqlType) { @@ -37,7 +39,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 -> @@ -101,3 +103,75 @@ internal object SQLTypesConverter { } } } + +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) + if (date is Long) { + return Date(date) + } + if (date is String) { + // https://sqlite.org/lang_datefunc.html#tmval + if (date[2] == ':') { + // time formats + when (date.length) { + 5 -> return formatters["HH:mm"]!!.parse(date) + 6 -> return formatters["HH:mm'Z'"]!!.parse(date) + 8 -> return formatters["HH:mm:ss"]!!.parse(date) + 9 -> return formatters["HH:mm:ss'Z'"]!!.parse(date) + 12 -> return formatters["HH:mm:ss.SSS"]!!.parse(date) + 13 -> return formatters["HH:mm:ss.SSS'Z'"]!!.parse(date) + } + } else if (date.length > 10 && date[10] == 'T') { + when (date.length) { + 16 -> return formatters["yyyy-MM-dd'T'HH:mm"]!!.parse(date) + 17 -> return formatters["yyyy-MM-dd'T'HH:mm'Z'"]!!.parse(date) + 19 -> return formatters["yyyy-MM-dd'T'HH:mm:ss"]!!.parse(date) + 20 -> return formatters["yyyy-MM-dd'T'HH:mm:ss'Z'"]!!.parse(date) + 23 -> return formatters["yyyy-MM-dd'T'HH:mm:ss.SSS"]!!.parse(date) + 24 -> return formatters["yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"]!!.parse(date) + } + } + when (date.length) { + 10 -> return formatters["yyyy-MM-dd"]!!.parse(date) + 16 -> return formatters["yyyy-MM-dd HH:mm"]!!.parse(date) + 17 -> return formatters["yyyy-MM-dd HH:mm'Z'"]!!.parse(date) + 19 -> return formatters["yyyy-MM-dd HH:mm:ss"]!!.parse(date) + 20 -> return formatters["yyyy-MM-dd HH:mm:ss'Z'"]!!.parse(date) + 23 -> return formatters["yyyy-MM-dd HH:mm:ss.SSS"]!!.parse(date) + 24 -> return formatters["yyyy-MM-dd HH:mm:ss.SSS'Z'"]!!.parse(date) + } + } + throw KapperUnsupportedOperationException("Conversion from type ${date.javaClass} to Date is not supported") + } + else -> return Date(resultSet.getDate(fieldIndex).time) + } +} diff --git a/core/src/test/kotlin/net/samyn/kapper/internal/ConverterTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/ConverterTest.kt index 2efd2590..d5c75232 100644 --- a/core/src/test/kotlin/net/samyn/kapper/internal/ConverterTest.kt +++ b/core/src/test/kotlin/net/samyn/kapper/internal/ConverterTest.kt @@ -58,10 +58,17 @@ class ConverterTest { 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-01-01") // Invalid input type + convertLocalDate(2023) // Invalid input type } } } @@ -110,6 +117,14 @@ class ConverterTest { 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 { @@ -128,6 +143,14 @@ class ConverterTest { locaTime.shouldBe(expectedTime) } + @Test + fun `convert valid String to LocalTime`() { + val timeString = "12:30:00" + val locaTime = convertLocalTime(timeString) + val expectedTime = LocalTime.parse(timeString) + locaTime.shouldBe(expectedTime) + } + @Test fun `throw exception when invalid type is converted to LocalTime`() { shouldThrow { 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..3f904ff3 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 } 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..62457cd9 100644 --- a/core/src/test/kotlin/net/samyn/kapper/internal/MetadataTest.kt +++ b/core/src/test/kotlin/net/samyn/kapper/internal/MetadataTest.kt @@ -28,11 +28,11 @@ 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), ), ) } 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..d64dc3e9 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,15 +13,18 @@ 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.LocalTime import java.time.temporal.ChronoUnit import java.util.Date +import java.util.TimeZone import java.util.UUID class SQLTypesConverterTest { @@ -180,7 +184,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,14 +192,14 @@ 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`() { 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') } @@ -204,7 +208,7 @@ class SQLTypesConverterTest { 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) } @@ -213,7 +217,7 @@ class SQLTypesConverterTest { 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) } @@ -222,7 +226,7 @@ class SQLTypesConverterTest { 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) } @@ -231,7 +235,7 @@ class SQLTypesConverterTest { 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) + val result = SQLTypesConverter.convertSQLType(JDBCType.TIMESTAMP, "timestamp", resultSet, 5, DbFlavour.UNKNOWN) result.shouldBe(timestamp) } @@ -240,8 +244,67 @@ class SQLTypesConverterTest { fun `timestamp with zone 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, "timestamp", resultSet, 6, DbFlavour.UNKNOWN) result.shouldBe(timestamp) } + + @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)) + } + + @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).also { it.timeZone = TimeZone.getTimeZone("CET") } + 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/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 } From 074bdd3a8cf80c4b39b77ef79f96e8e8646082b6 Mon Sep 17 00:00:00 2001 From: Dries Samyn <5557551+driessamyn@users.noreply.github.com> Date: Sat, 19 Apr 2025 17:04:51 +0200 Subject: [PATCH 3/6] refactor: Optimise test container lifecycle for integration tests Only start test container when needed. --- .github/workflows/build-and-test.yml | 2 +- .../kapper.library-conventions.gradle.kts | 2 + .../net/samyn/kapper/AbstractDbTests.kt | 66 ++++++++++++------- .../kotlin/net/samyn/kapper/ExecuteTests.kt | 39 +++++------ .../net/samyn/kapper/QuerySingleTests.kt | 14 ++-- .../kotlin/net/samyn/kapper/QueryTests.kt | 14 ++-- .../net/samyn/kapper/SqlInjectionTest.kt | 4 +- .../kotlin/net/samyn/kapper/TypesTest.kt | 4 +- 8 files changed, 80 insertions(+), 65 deletions(-) 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/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 6d424f96..c7f2b0a3 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt @@ -6,19 +6,20 @@ import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Named.named import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.Arguments.arguments 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 java.sql.Connection import java.sql.DriverManager import java.util.UUID import java.util.concurrent.ConcurrentHashMap import kotlin.use +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.toKotlinUuid -@Testcontainers @TestInstance(TestInstance.Lifecycle.PER_CLASS) abstract class AbstractDbTests { companion object { @@ -59,18 +60,21 @@ abstract class AbstractDbTests { ), ) - @Container - val postgresql = PostgreSQLContainer("postgres:16") + private val postgresql by lazy { + PostgreSQLContainer("postgres:16").also { it.start() } + } - @Container - val mysql = MySQLContainer("mysql:8.4") + private val mysql by lazy { + MySQLContainer("mysql:8.4").also { it.start() } + } // @Container // val oracle = OracleContainer("gvenzl/oracle-free:23.4-slim-faststart") -// -// @Container -// val msSqlServer = -// MSSQLServerContainer("mcr.microsoft.com/mssql/server:2017-CU12").acceptLicense() + + private val msSqlServer by lazy { + MSSQLServerContainer("mcr.microsoft.com/mssql/server:2017-CU12") + .acceptLicense().also { it.start() } + } private val connections = ConcurrentHashMap() @@ -85,18 +89,30 @@ abstract class AbstractDbTests { DbFlavour.MYSQL to { getConnection(mysql) }, DbFlavour.SQLITE to { DriverManager.getConnection("jdbc:sqlite::memory:") }, // "Oracle" to oracle, -// "MSSQLServer" to msSqlServer, - ) -// private val sqliteConnection = -// { -// Class.forName("org.sqlite.JDBC") -// }.let { DriverManager.getConnection("jdbc:sqlite::memory:") } +// DbFlavour.MSSQLSERVER to { getConnection(msSqlServer) }, + ).filter { + // by default run against SQLite 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() = - connections.map { - arguments(named(it.key.toString(), 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( @@ -105,6 +121,8 @@ abstract class AbstractDbTests { suffix: String = "", ) = specialTypes[name]?.get(flavour) ?: (name + suffix) + @OptIn(ExperimentalUuidApi::class) + val testId = UUID.randomUUID().toKotlinUuid().toHexString() 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) @@ -129,7 +147,7 @@ abstract class AbstractDbTests { connection.createStatement().use { statement -> statement.execute( """ - CREATE TABLE super_heroes ( + CREATE TABLE super_heroes_$testId ( id ${convertDbColumnType("UUID", dbFlavour)} PRIMARY KEY, name VARCHAR(100), email VARCHAR(100), @@ -141,7 +159,7 @@ abstract class AbstractDbTests { ) statement.execute( """ - INSERT INTO super_heroes (id, name, email, age) VALUES + INSERT INTO super_heroes_$testId (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}); @@ -151,7 +169,7 @@ abstract class AbstractDbTests { ) statement.execute( """ - CREATE TABLE types_test ( + CREATE TABLE types_test_$testId ( t_uuid ${convertDbColumnType("UUID", dbFlavour)}, t_char CHAR, t_varchar VARCHAR(120), diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/ExecuteTests.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/ExecuteTests.kt index e4db06be..37304385 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/ExecuteTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/ExecuteTests.kt @@ -11,18 +11,13 @@ import org.junit.jupiter.params.provider.MethodSource 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(connection: Connection) { connection.createStatement().use { statement -> statement.execute( """ - CREATE TABLE $table ( + CREATE TABLE super_heroes_$testId ( id ${if (connection.getDbFlavour() == DbFlavour.MYSQL) "VARCHAR(36)" else "UUID"} PRIMARY KEY, name VARCHAR(100), email VARCHAR(100), @@ -41,7 +36,7 @@ class ExecuteTests : AbstractDbTests() { val results = connection.execute( """ - INSERT INTO $table(id, name, email, age) VALUES(:id, :name, :email, :age); + INSERT INTO super_heroes_$testId(id, name, email, age) VALUES(:id, :name, :email, :age); """.trimIndent(), "id" to superman.id, "name" to superman.name, @@ -51,7 +46,7 @@ class ExecuteTests : AbstractDbTests() { results.shouldBe(1) - connection.prepareStatement("SELECT name, email, age FROM $table WHERE id = '${superman.id}'").use { stmt -> + connection.prepareStatement("SELECT name, email, age FROM super_heroes_$testId WHERE id = '${superman.id}'").use { stmt -> val resultSet = stmt.executeQuery() assertSoftly(resultSet) { next().shouldBe(true) @@ -66,12 +61,12 @@ class ExecuteTests : AbstractDbTests() { @MethodSource("databaseContainers") fun `SQL Update single`(connection: Connection) { connection.createStatement().use { stmt -> - stmt.execute("INSERT INTO $table(id, name) VALUES('${batman.id}', 'foo');") + stmt.execute("INSERT INTO super_heroes_$testId(id, name) VALUES('${batman.id}', 'foo');") } val results = connection.execute( - "UPDATE $table SET name = :name, email = :email, age = :age WHERE id = :id;", + "UPDATE super_heroes_$testId SET name = :name, email = :email, age = :age WHERE id = :id;", "id" to batman.id, "name" to batman.name, "email" to batman.email, @@ -80,7 +75,7 @@ class ExecuteTests : AbstractDbTests() { results.shouldBe(1) - connection.prepareStatement("SELECT name, email, age FROM $table WHERE id = '${batman.id}'").use { stmt -> + connection.prepareStatement("SELECT name, email, age FROM super_heroes_$testId WHERE id = '${batman.id}'").use { stmt -> val resultSet = stmt.executeQuery() assertSoftly(resultSet) { next().shouldBe(true) @@ -98,13 +93,13 @@ class ExecuteTests : AbstractDbTests() { val name = "foo-${UUID.randomUUID()}" connection.createStatement().use { stmt -> ids.forEach { - stmt.execute("INSERT INTO $table(id, name) VALUES('$it', '$name');") + stmt.execute("INSERT INTO super_heroes_$testId(id, name) VALUES('$it', '$name');") } } val results = connection.execute( - "UPDATE $table SET name = :newName WHERE name = :name;", + "UPDATE super_heroes_$testId SET name = :newName WHERE name = :name;", "name" to name, "newName" to "bar", ) @@ -116,18 +111,18 @@ class ExecuteTests : AbstractDbTests() { @MethodSource("databaseContainers") fun `SQL Delete single`(connection: Connection) { connection.createStatement().use { stmt -> - stmt.execute("INSERT INTO $table(id, name) VALUES('${spiderMan.id}', 'foo');") + stmt.execute("INSERT INTO super_heroes_$testId(id, name) VALUES('${spiderMan.id}', 'foo');") } val results = connection.execute( - "DELETE FROM $table WHERE id = :id;", + "DELETE FROM super_heroes_$testId WHERE id = :id;", "id" to spiderMan.id, ) results.shouldBe(1) - connection.prepareStatement("SELECT * FROM $table WHERE id = '${batman.id}'").use { stmt -> + connection.prepareStatement("SELECT * FROM super_heroes_$testId WHERE id = '${batman.id}'").use { stmt -> val resultSet = stmt.executeQuery() assertSoftly(resultSet) { next().shouldBe(false) @@ -142,13 +137,13 @@ class ExecuteTests : AbstractDbTests() { val name = "bar-${UUID.randomUUID()}" connection.createStatement().use { stmt -> ids.forEach { - stmt.execute("INSERT INTO $table(id, name) VALUES('$it', '$name');") + stmt.execute("INSERT INTO super_heroes_$testId(id, name) VALUES('$it', '$name');") } } val results = connection.execute( - "DELETE FROM $table WHERE name = :name;", + "DELETE FROM super_heroes_$testId WHERE name = :name;", "name" to name, ) @@ -162,7 +157,7 @@ class ExecuteTests : AbstractDbTests() { val results = 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", @@ -170,7 +165,7 @@ class ExecuteTests : AbstractDbTests() { ) + 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", @@ -189,7 +184,7 @@ class ExecuteTests : AbstractDbTests() { repeat(2) { 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 id, "name" to "thor", @@ -199,7 +194,7 @@ class ExecuteTests : AbstractDbTests() { } } connection.query( - "SELECT * FROM $table WHERE id = :id", + "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 d193ed2c..24b8e193 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/QuerySingleTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/QuerySingleTests.kt @@ -14,14 +14,14 @@ class QuerySingleTests : AbstractDbTests() { @ParameterizedTest() @MethodSource("databaseContainers") fun `should query 1 heros`(connection: Connection) { - val hero = connection.querySingle("SELECT * FROM super_heroes ORDER BY age DESC LIMIT 1") + val hero = connection.querySingle("SELECT * FROM super_heroes_$testId ORDER BY age DESC LIMIT 1") hero.shouldBe(superman) } @ParameterizedTest @MethodSource("databaseContainers") fun `should query hero with condition`(connection: Connection) { - val hero = connection.querySingle("SELECT * FROM super_heroes WHERE age > :age", "age" to 85) + val hero = connection.querySingle("SELECT * FROM super_heroes_$testId WHERE age > :age", "age" to 85) hero.shouldBe(superman) } @@ -30,7 +30,7 @@ class QuerySingleTests : AbstractDbTests() { fun `should query specific columns`(connection: Connection) { val hero = connection.querySingle( - "SELECT id, name FROM super_heroes WHERE name = :name", + "SELECT id, name FROM super_heroes_$testId WHERE name = :name", "name" to superman.name, ) hero.shouldBe(SuperHero(superman.id, superman.name)) @@ -41,7 +41,7 @@ class QuerySingleTests : AbstractDbTests() { fun `should handle empty result set`(connection: Connection) { val hero = connection.querySingle( - "SELECT * FROM super_heroes WHERE name = :name", + "SELECT * FROM super_heroes_$testId WHERE name = :name", "name" to "joker", ) hero.shouldBeNull() @@ -53,7 +53,7 @@ class QuerySingleTests : AbstractDbTests() { val ex = shouldThrow { connection.querySingle( - "SELECT * FROM super_heroes", + "SELECT * FROM super_heroes_$testId", ) } ex.message.shouldContain("3") @@ -64,7 +64,7 @@ class QuerySingleTests : AbstractDbTests() { fun `should query with multiple conditions`(connection: Connection) { val hero = connection.querySingle( - "SELECT * FROM super_heroes WHERE age BETWEEN :fromAge AND :toAge", + "SELECT * FROM super_heroes_$testId WHERE age BETWEEN :fromAge AND :toAge", "fromAge" to 86, "toAge" to 89, ) @@ -76,7 +76,7 @@ class QuerySingleTests : AbstractDbTests() { fun `can use custom mapper`(connection: Connection) { val villain = connection.querySingle( - "SELECT id, name FROM super_heroes WHERE name = :name", + "SELECT id, name FROM super_heroes_$testId WHERE name = :name", ::createVillain, "name" to superman.name, ) diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt index 6535d433..69b0523d 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt @@ -13,7 +13,7 @@ class QueryTests : AbstractDbTests() { @ParameterizedTest() @MethodSource("databaseContainers") fun `should query all heros`(connection: Connection) { - val heroes = connection.query("SELECT * FROM super_heroes") + val heroes = connection.query("SELECT * FROM super_heroes_$testId") heroes.shouldContainExactlyInAnyOrder( superman, batman, @@ -24,7 +24,7 @@ class QueryTests : AbstractDbTests() { @ParameterizedTest @MethodSource("databaseContainers") fun `should query heros with condition`(connection: Connection) { - val heroes = connection.query("SELECT * FROM super_heroes WHERE age > :age", "age" to 80) + val heroes = connection.query("SELECT * FROM super_heroes_$testId WHERE age > :age", "age" to 80) heroes.shouldContainExactlyInAnyOrder( superman, batman, @@ -36,7 +36,7 @@ class QueryTests : AbstractDbTests() { fun `should query specific columns`(connection: Connection) { val heroes = connection.query( - "SELECT id, name FROM super_heroes WHERE name = :name", + "SELECT id, name FROM super_heroes_$testId WHERE name = :name", "name" to superman.name, ) heroes.shouldContainExactlyInAnyOrder( @@ -49,7 +49,7 @@ class QueryTests : AbstractDbTests() { fun `should handle empty result set`(connection: Connection) { val heroes = connection.query( - "SELECT * FROM super_heroes WHERE name = :name", + "SELECT * FROM super_heroes_$testId WHERE name = :name", "name" to "joker", ) heroes.shouldBeEmpty() @@ -60,7 +60,7 @@ class QueryTests : AbstractDbTests() { fun `should query with multiple conditions`(connection: Connection) { val heroes = connection.query( - "SELECT * FROM super_heroes WHERE age BETWEEN :fromAge AND :toAge", + "SELECT * FROM super_heroes_$testId WHERE age BETWEEN :fromAge AND :toAge", "fromAge" to 80, "toAge" to 89, ) @@ -76,7 +76,7 @@ class QueryTests : AbstractDbTests() { data class SimpleClass(val superHeroName: String) val hero = connection.query( - "SELECT name as superHeroName FROM super_heroes WHERE id = :id", + "SELECT name as superHeroName FROM super_heroes_$testId WHERE id = :id", "id" to superman.id, ) hero.shouldContainOnly( @@ -89,7 +89,7 @@ class QueryTests : AbstractDbTests() { fun `can use custom mapper`(connection: Connection) { val villain = connection.query( - "SELECT id, name FROM super_heroes WHERE name = :name", + "SELECT id, name FROM super_heroes_$testId WHERE name = :name", ::createVillain, "name" to superman.name, ) diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/SqlInjectionTest.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/SqlInjectionTest.kt index a5c77b17..d0d4cb05 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/SqlInjectionTest.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/SqlInjectionTest.kt @@ -10,10 +10,10 @@ class SqlInjectionTest : AbstractDbTests() { @MethodSource("databaseContainers") fun `cannot inject SQL with parameter`(connection: Connection) { connection.execute( - "UPDATE super_heroes SET name = 'foo' WHERE name = :name;", + "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/TypesTest.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt index 9a0d35a5..fd977e9a 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt @@ -21,7 +21,7 @@ class TypesTest : AbstractDbTests() { val result = connection.execute( """ - INSERT INTO types_test ( + INSERT INTO types_test_$testId ( t_uuid, t_char, t_varchar, @@ -91,7 +91,7 @@ class TypesTest : AbstractDbTests() { val selectResult = connection.querySingle( - "SELECT * FROM types_test where t_uuid = :uuid", + "SELECT * FROM types_test_$testId where t_uuid = :uuid", "uuid" to testData.t_uuid, ) selectResult.shouldBe(testData) From 88a10a1da4638ee4f64d40f705cb331250843d99 Mon Sep 17 00:00:00 2001 From: Dries Samyn <5557551+driessamyn@users.noreply.github.com> Date: Sun, 20 Apr 2025 16:56:26 +0200 Subject: [PATCH 4/6] feat: Support for MS SQL Server bugfix: char converter - Update integration tests to support MS SQL Server - Update UUID Converter to support SQL server using JDBC type CHAR - Fix char conversion. --- .../net/samyn/kapper/AbstractDbTests.kt | 56 +++++++---------- .../kotlin/net/samyn/kapper/ExecuteTests.kt | 62 +++++++------------ .../net/samyn/kapper/QuerySingleTests.kt | 12 +++- .../kotlin/net/samyn/kapper/QueryTests.kt | 2 +- .../kotlin/net/samyn/kapper/TypesTest.kt | 35 +++++++++++ .../samyn/kapper/internal/AutoConverter.kt | 1 + .../net/samyn/kapper/internal/Converters.kt | 32 ++++++++++ .../kapper/internal/SQLTypesConverter.kt | 2 +- .../samyn/kapper/internal/ConverterTest.kt | 40 ++++++++++++ .../kapper/internal/SQLTypesConverterTest.kt | 4 +- 10 files changed, 167 insertions(+), 79 deletions(-) diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt index c7f2b0a3..3c87f689 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt @@ -16,6 +16,8 @@ import java.sql.Connection import java.sql.DriverManager import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Level +import java.util.logging.Logger import kotlin.use import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.toKotlinUuid @@ -32,11 +34,13 @@ abstract class AbstractDbTests { "UUID" to mapOf( DbFlavour.MYSQL to "VARCHAR(36)", + DbFlavour.MSSQLSERVER to "UNIQUEIDENTIFIER", ), "CLOB" to mapOf( DbFlavour.MYSQL to "TEXT", DbFlavour.POSTGRESQL to "TEXT", + DbFlavour.MSSQLSERVER to "NVARCHAR(MAX)", ), "BINARY" to mapOf( @@ -49,6 +53,7 @@ abstract class AbstractDbTests { "BLOB" to mapOf( DbFlavour.POSTGRESQL to "BYTEA", + DbFlavour.MSSQLSERVER to "VARBINARY(MAX)", ), "FLOAT" to mapOf( @@ -58,6 +63,14 @@ abstract class AbstractDbTests { mapOf( DbFlavour.MYSQL to "FLOAT", ), + "BOOLEAN" to + mapOf( + DbFlavour.MSSQLSERVER to "BIT", + ), + "TIMESTAMP" to + mapOf( + DbFlavour.MSSQLSERVER to "DATETIME", + ), ) private val postgresql by lazy { @@ -73,7 +86,12 @@ abstract class AbstractDbTests { private val msSqlServer by lazy { MSSQLServerContainer("mcr.microsoft.com/mssql/server:2017-CU12") - .acceptLicense().also { it.start() } + .acceptLicense() + .also { + val logger = Logger.getLogger("com.microsoft.sqlserver.jdbc.internals.SQLServerConnection") + logger.level = Level.SEVERE + it.start() + } } private val connections = ConcurrentHashMap() @@ -89,7 +107,7 @@ abstract class AbstractDbTests { DbFlavour.MYSQL to { getConnection(mysql) }, DbFlavour.SQLITE to { DriverManager.getConnection("jdbc:sqlite::memory:") }, // "Oracle" to oracle, -// DbFlavour.MSSQLSERVER to { getConnection(msSqlServer) }, + DbFlavour.MSSQLSERVER to { getConnection(msSqlServer) }, ).filter { // by default run against SQLite only // this allows parallel runs for different int tests. @@ -115,7 +133,7 @@ abstract class AbstractDbTests { } } - private fun convertDbColumnType( + protected fun convertDbColumnType( name: String, flavour: DbFlavour, suffix: String = "", @@ -163,37 +181,7 @@ abstract class AbstractDbTests { ('${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_$testId ( - t_uuid ${convertDbColumnType("UUID", dbFlavour)}, - t_char CHAR, - t_varchar VARCHAR(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 SMALLINT, - t_int INT, - t_bigint BIGINT, - t_float ${convertDbColumnType("FLOAT", dbFlavour, "(8)")}, - t_real ${convertDbColumnType("REAL", dbFlavour)}, - 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) - }, + """, ) } } diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/ExecuteTests.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/ExecuteTests.kt index 37304385..b1bf12c8 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/ExecuteTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/ExecuteTests.kt @@ -4,8 +4,6 @@ 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.DbFlavour -import net.samyn.kapper.internal.getDbFlavour import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource import java.sql.Connection @@ -13,46 +11,30 @@ import java.sql.SQLException import java.util.UUID class ExecuteTests : AbstractDbTests() { - override fun setupDatabase(connection: Connection) { - connection.createStatement().use { statement -> - statement.execute( - """ - CREATE TABLE super_heroes_$testId ( - id ${if (connection.getDbFlavour() == DbFlavour.MYSQL) "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`(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 superman.id, - "name" to superman.name, - "email" to superman.email, - "age" to superman.age, + "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 = '${superman.id}'").use { stmt -> + connection.prepareStatement("SELECT name, email, age FROM super_heroes_$testId WHERE id = '${supermanClone.id}'").use { stmt -> val resultSet = stmt.executeQuery() 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) } } } @@ -60,28 +42,29 @@ class ExecuteTests : AbstractDbTests() { @ParameterizedTest() @MethodSource("databaseContainers") 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('${batman.id}', 'foo');") + stmt.execute("INSERT INTO super_heroes_$testId(id, name) VALUES('${batmanClone.id}', 'foo');") } val results = connection.execute( "UPDATE super_heroes_$testId 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, + "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 = '${batman.id}'").use { stmt -> + connection.prepareStatement("SELECT name, email, age FROM super_heroes_$testId WHERE id = '${batmanClone.id}'").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) } } } @@ -110,19 +93,20 @@ class ExecuteTests : AbstractDbTests() { @ParameterizedTest() @MethodSource("databaseContainers") 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('${spiderMan.id}', 'foo');") + stmt.execute("INSERT INTO super_heroes_$testId(id, name) VALUES('${spidermanClone.id}', 'foo');") } val results = connection.execute( "DELETE FROM super_heroes_$testId WHERE id = :id;", - "id" to spiderMan.id, + "id" to spidermanClone.id, ) results.shouldBe(1) - connection.prepareStatement("SELECT * FROM super_heroes_$testId WHERE id = '${batman.id}'").use { stmt -> + connection.prepareStatement("SELECT * FROM super_heroes_$testId WHERE id = '${spidermanClone.id}'").use { stmt -> val resultSet = stmt.executeQuery() assertSoftly(resultSet) { next().shouldBe(false) diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/QuerySingleTests.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/QuerySingleTests.kt index 24b8e193..588aa4a4 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/QuerySingleTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/QuerySingleTests.kt @@ -5,6 +5,8 @@ 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 java.sql.Connection @@ -14,7 +16,13 @@ class QuerySingleTests : AbstractDbTests() { @ParameterizedTest() @MethodSource("databaseContainers") fun `should query 1 heros`(connection: Connection) { - val hero = connection.querySingle("SELECT * FROM super_heroes_$testId ORDER BY age DESC LIMIT 1") + val sql = + if (DbFlavour.MSSQLSERVER == connection.getDbFlavour()) { + "SELECT TOP 1 * FROM super_heroes_$testId ORDER BY age DESC" + } else { + "SELECT * FROM super_heroes_$testId ORDER BY age DESC LIMIT 1" + } + val hero = connection.querySingle(sql) hero.shouldBe(superman) } @@ -81,7 +89,7 @@ class QuerySingleTests : AbstractDbTests() { "name" to superman.name, ) villain.shouldNotBeNull() - villain.id.shouldBe(superman.id.toString()) + villain.id!!.lowercase().shouldBe(superman.id.toString().lowercase()) villain.name.shouldBe(superman.name) } diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt index 69b0523d..1a171fa7 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt @@ -95,7 +95,7 @@ class QueryTests : AbstractDbTests() { ) villain.size.shouldBe(1) - villain.first().id.shouldBe(superman.id.toString()) + villain.first().id!!.lowercase().shouldBe(superman.id.toString().lowercase()) villain.first().name.shouldBe(superman.name) } diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt index fd977e9a..8f13f7ce 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt @@ -1,6 +1,7 @@ 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 java.sql.Connection @@ -14,6 +15,40 @@ 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 VARCHAR(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 SMALLINT, + t_int INT, + t_bigint BIGINT, + t_float ${convertDbColumnType("FLOAT", dbFlavour, "(8)")}, + t_real ${convertDbColumnType("REAL", dbFlavour)}, + t_double DOUBLE PRECISION, + t_date DATE, + t_local_date DATE, + t_local_time TIME, + 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`(connection: Connection) { 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..7298518c 100644 --- a/core/src/main/kotlin/net/samyn/kapper/internal/AutoConverter.kt +++ b/core/src/main/kotlin/net/samyn/kapper/internal/AutoConverter.kt @@ -20,6 +20,7 @@ internal class AutoConverter( LocalDateTime::class to ::convertLocalDateTime, LocalTime::class to ::convertLocalTime, Instant::class to ::convertInstant, + Char::class to ::convertChar, ), ) { 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 50d12af4..23a802bc 100644 --- a/core/src/main/kotlin/net/samyn/kapper/internal/Converters.kt +++ b/core/src/main/kotlin/net/samyn/kapper/internal/Converters.kt @@ -78,6 +78,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() } @@ -89,6 +100,27 @@ 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", + ) + } + 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/SQLTypesConverter.kt b/core/src/main/kotlin/net/samyn/kapper/internal/SQLTypesConverter.kt index 7b70ded5..953493e7 100644 --- a/core/src/main/kotlin/net/samyn/kapper/internal/SQLTypesConverter.kt +++ b/core/src/main/kotlin/net/samyn/kapper/internal/SQLTypesConverter.kt @@ -31,7 +31,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, diff --git a/core/src/test/kotlin/net/samyn/kapper/internal/ConverterTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/ConverterTest.kt index d5c75232..f1793093 100644 --- a/core/src/test/kotlin/net/samyn/kapper/internal/ConverterTest.kt +++ b/core/src/test/kotlin/net/samyn/kapper/internal/ConverterTest.kt @@ -159,6 +159,46 @@ class ConverterTest { } } + @Nested + inner class CharConverter { + @Test + fun `convert valid String to Char`() { + val charString = "A" + val char = convertChar(charString) + char.shouldBe('A') + } + + @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 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 + } + } + } + private fun UUID.asBytes(): ByteArray { val b = ByteBuffer.wrap(ByteArray(16)) b.putLong(mostSignificantBits) 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 d64dc3e9..65c2b326 100644 --- a/core/src/test/kotlin/net/samyn/kapper/internal/SQLTypesConverterTest.kt +++ b/core/src/test/kotlin/net/samyn/kapper/internal/SQLTypesConverterTest.kt @@ -197,11 +197,11 @@ class SQLTypesConverterTest { } @Test - fun `char needs truncating`() { + fun `char needs converting`() { every { resultSet.getString(1) } returns "example" val result = SQLTypesConverter.convertSQLType(JDBCType.CHAR, "CHAR", resultSet, 1, DbFlavour.UNKNOWN) - result.shouldBe('e') + result.shouldBe("example".toCharArray()) } @Test From 5cb0c298b7c0a64d6cc802d328846da33b6095c2 Mon Sep 17 00:00:00 2001 From: Dries Samyn <5557551+driessamyn@users.noreply.github.com> Date: Wed, 23 Apr 2025 20:32:03 +0100 Subject: [PATCH 5/6] feat: Support for Oracle DB - Update integration tests to support Oracle - Various updates to converter/mapper functions to support Oracle types - Refactor converter tests to be more readable --- .run/Integration Tests (ALL).run.xml | 24 ++ .../Integration Tests (MS SQL Server).run.xml | 24 ++ .run/Integration Tests (Oracle).run.xml | 24 ++ .run/Integration Tests.run.xml | 24 ++ .../net/samyn/kapper/AbstractDbTests.kt | 90 +++----- .../net/samyn/kapper/DbTypeConverter.kt | 92 ++++++++ .../kotlin/net/samyn/kapper/ExecuteTests.kt | 91 +++++--- .../net/samyn/kapper/QuerySingleTests.kt | 6 +- .../kotlin/net/samyn/kapper/QueryTests.kt | 4 +- .../net/samyn/kapper/SqlInjectionTest.kt | 4 +- .../kotlin/net/samyn/kapper/TypesTest.kt | 14 +- .../samyn/kapper/internal/AutoConverter.kt | 5 + .../net/samyn/kapper/internal/Converters.kt | 168 +++++++++++--- .../kapper/internal/KotlinDataClassMapper.kt | 2 +- .../net/samyn/kapper/internal/Metadata.kt | 6 +- .../kapper/internal/SQLTypesConverter.kt | 57 +++-- .../samyn/kapper/internal/ConverterTest.kt | 208 ------------------ .../kapper/internal/KapperImplQueryTest.kt | 14 ++ .../net/samyn/kapper/internal/MetadataTest.kt | 15 ++ .../kapper/internal/SQLTypesConverterTest.kt | 98 ++++++++- .../converter/BooleanConverterTest.kt | 61 +++++ .../internal/converter/CharConverterTest.kt | 63 ++++++ .../internal/converter/DateConverterTest.kt | 33 +++ .../converter/InstantConverterTest.kt | 45 ++++ .../internal/converter/IntConverterTest.kt | 24 ++ .../converter/LocalDateConverterTest.kt | 48 ++++ .../converter/LocalDateTimeConverterTest.kt | 35 +++ .../converter/LocalTimeConverterTest.kt | 51 +++++ .../internal/converter/LongConverterTest.kt | 23 ++ .../internal/converter/UUIDConverterTest.kt | 57 +++++ 30 files changed, 1035 insertions(+), 375 deletions(-) create mode 100644 .run/Integration Tests (ALL).run.xml create mode 100644 .run/Integration Tests (MS SQL Server).run.xml create mode 100644 .run/Integration Tests (Oracle).run.xml create mode 100644 .run/Integration Tests.run.xml create mode 100644 core/src/integrationTest/kotlin/net/samyn/kapper/DbTypeConverter.kt delete mode 100644 core/src/test/kotlin/net/samyn/kapper/internal/ConverterTest.kt create mode 100644 core/src/test/kotlin/net/samyn/kapper/internal/converter/BooleanConverterTest.kt create mode 100644 core/src/test/kotlin/net/samyn/kapper/internal/converter/CharConverterTest.kt create mode 100644 core/src/test/kotlin/net/samyn/kapper/internal/converter/DateConverterTest.kt create mode 100644 core/src/test/kotlin/net/samyn/kapper/internal/converter/InstantConverterTest.kt create mode 100644 core/src/test/kotlin/net/samyn/kapper/internal/converter/IntConverterTest.kt create mode 100644 core/src/test/kotlin/net/samyn/kapper/internal/converter/LocalDateConverterTest.kt create mode 100644 core/src/test/kotlin/net/samyn/kapper/internal/converter/LocalDateTimeConverterTest.kt create mode 100644 core/src/test/kotlin/net/samyn/kapper/internal/converter/LocalTimeConverterTest.kt create mode 100644 core/src/test/kotlin/net/samyn/kapper/internal/converter/LongConverterTest.kt create mode 100644 core/src/test/kotlin/net/samyn/kapper/internal/converter/UUIDConverterTest.kt 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/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt index 3c87f689..75500481 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/AbstractDbTests.kt @@ -12,15 +12,16 @@ import org.testcontainers.containers.JdbcDatabaseContainer import org.testcontainers.containers.MSSQLServerContainer import org.testcontainers.containers.MySQLContainer import org.testcontainers.containers.PostgreSQLContainer +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 -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.toKotlinUuid @TestInstance(TestInstance.Lifecycle.PER_CLASS) abstract class AbstractDbTests { @@ -29,50 +30,6 @@ abstract class AbstractDbTests { Class.forName("org.sqlite.JDBC") } - val specialTypes = - mapOf( - "UUID" to - mapOf( - DbFlavour.MYSQL to "VARCHAR(36)", - DbFlavour.MSSQLSERVER to "UNIQUEIDENTIFIER", - ), - "CLOB" to - mapOf( - DbFlavour.MYSQL to "TEXT", - DbFlavour.POSTGRESQL to "TEXT", - DbFlavour.MSSQLSERVER to "NVARCHAR(MAX)", - ), - "BINARY" to - mapOf( - DbFlavour.POSTGRESQL to "BYTEA", - ), - "VARBINARY" to - mapOf( - DbFlavour.POSTGRESQL to "BYTEA", - ), - "BLOB" to - mapOf( - DbFlavour.POSTGRESQL to "BYTEA", - DbFlavour.MSSQLSERVER to "VARBINARY(MAX)", - ), - "FLOAT" to - mapOf( - DbFlavour.POSTGRESQL to "NUMERIC", - ), - "REAL" to - mapOf( - DbFlavour.MYSQL to "FLOAT", - ), - "BOOLEAN" to - mapOf( - DbFlavour.MSSQLSERVER to "BIT", - ), - "TIMESTAMP" to - mapOf( - DbFlavour.MSSQLSERVER to "DATETIME", - ), - ) - private val postgresql by lazy { PostgreSQLContainer("postgres:16").also { it.start() } } @@ -81,13 +38,18 @@ abstract class AbstractDbTests { MySQLContainer("mysql:8.4").also { it.start() } } -// @Container -// val oracle = OracleContainer("gvenzl/oracle-free:23.4-slim-faststart") + 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() @@ -106,10 +68,10 @@ abstract class AbstractDbTests { DbFlavour.POSTGRESQL to { getConnection(postgresql) }, DbFlavour.MYSQL to { getConnection(mysql) }, DbFlavour.SQLITE to { DriverManager.getConnection("jdbc:sqlite::memory:") }, -// "Oracle" to oracle, DbFlavour.MSSQLSERVER to { getConnection(msSqlServer) }, + DbFlavour.ORACLE to { getConnection(oracle) }, ).filter { - // by default run against SQLite only + // 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 @@ -133,14 +95,7 @@ abstract class AbstractDbTests { } } - protected fun convertDbColumnType( - name: String, - flavour: DbFlavour, - suffix: String = "", - ) = specialTypes[name]?.get(flavour) ?: (name + suffix) - - @OptIn(ExperimentalUuidApi::class) - val testId = UUID.randomUUID().toKotlinUuid().toHexString() + 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) @@ -169,18 +124,19 @@ abstract class AbstractDbTests { id ${convertDbColumnType("UUID", dbFlavour)} PRIMARY KEY, name VARCHAR(100), email VARCHAR(100), - age INT - ); + age ${convertDbColumnType("INT", dbFlavour)} + ) """.trimIndent().also { + println("------------ $dbFlavour --------------") println(it) }, ) statement.execute( """ INSERT INTO super_heroes_$testId (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}); + (${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}); """, ) } @@ -193,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 b1bf12c8..b12a9583 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/ExecuteTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/ExecuteTests.kt @@ -4,6 +4,7 @@ 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 java.sql.Connection @@ -18,7 +19,7 @@ class ExecuteTests : AbstractDbTests() { val results = connection.execute( """ - INSERT INTO super_heroes_$testId(id, name, email, age) VALUES(:id, :name, :email, :age); + INSERT INTO super_heroes_$testId(id, name, email, age) VALUES(:id, :name, :email, :age) """.trimIndent(), "id" to supermanClone.id, "name" to supermanClone.name, @@ -28,15 +29,19 @@ class ExecuteTests : AbstractDbTests() { results.shouldBe(1) - connection.prepareStatement("SELECT name, email, age FROM super_heroes_$testId WHERE id = '${supermanClone.id}'").use { stmt -> - val resultSet = stmt.executeQuery() - assertSoftly(resultSet) { - next().shouldBe(true) - getString(1).shouldBe(supermanClone.name) - getString(2).shouldBe(supermanClone.email) - getInt(3).shouldBe(supermanClone.age) + 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) { + getString(1).shouldBe(supermanClone.name) + getString(2).shouldBe(supermanClone.email) + getInt(3).shouldBe(supermanClone.age) + } } - } } @ParameterizedTest() @@ -44,12 +49,15 @@ class ExecuteTests : AbstractDbTests() { 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('${batmanClone.id}', 'foo');") + stmt.execute( + "INSERT INTO super_heroes_$testId(id, name) " + + "VALUES(${convertUUIDString(batmanClone.id, connection.getDbFlavour())}, 'foo')", + ) } val results = connection.execute( - "UPDATE super_heroes_$testId SET name = :name, email = :email, age = :age WHERE id = :id;", + "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, @@ -58,15 +66,19 @@ class ExecuteTests : AbstractDbTests() { results.shouldBe(1) - connection.prepareStatement("SELECT name, email, age FROM super_heroes_$testId WHERE id = '${batmanClone.id}'").use { stmt -> - val resultSet = stmt.executeQuery() - assertSoftly(resultSet) { - next().shouldBe(true) - getString(1).shouldBe(batmanClone.name) - getString(2).shouldBe(batmanClone.email) - getInt(3).shouldBe(batmanClone.age) + 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(batmanClone.name) + getString(2).shouldBe(batmanClone.email) + getInt(3).shouldBe(batmanClone.age) + } } - } } @ParameterizedTest() @@ -76,13 +88,16 @@ class ExecuteTests : AbstractDbTests() { val name = "foo-${UUID.randomUUID()}" connection.createStatement().use { stmt -> ids.forEach { - stmt.execute("INSERT INTO super_heroes_$testId(id, name) VALUES('$it', '$name');") + stmt.execute( + "INSERT INTO super_heroes_$testId(id, name) " + + "VALUES(${convertUUIDString(it, connection.getDbFlavour())}, '$name')", + ) } } val results = connection.execute( - "UPDATE super_heroes_$testId SET name = :newName WHERE name = :name;", + "UPDATE super_heroes_$testId SET name = :newName WHERE name = :name", "name" to name, "newName" to "bar", ) @@ -95,23 +110,30 @@ class ExecuteTests : AbstractDbTests() { 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('${spidermanClone.id}', 'foo');") + stmt.execute( + "INSERT INTO super_heroes_$testId(id, name) " + + "VALUES(${convertUUIDString(spidermanClone.id, connection.getDbFlavour())}, 'foo')", + ) } val results = connection.execute( - "DELETE FROM super_heroes_$testId WHERE id = :id;", + "DELETE FROM super_heroes_$testId WHERE id = :id", "id" to spidermanClone.id, ) results.shouldBe(1) - connection.prepareStatement("SELECT * FROM super_heroes_$testId WHERE id = '${spidermanClone.id}'").use { stmt -> - val resultSet = stmt.executeQuery() - assertSoftly(resultSet) { - next().shouldBe(false) + 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() @@ -121,13 +143,16 @@ class ExecuteTests : AbstractDbTests() { val name = "bar-${UUID.randomUUID()}" connection.createStatement().use { stmt -> ids.forEach { - stmt.execute("INSERT INTO super_heroes_$testId(id, name) VALUES('$it', '$name');") + stmt.execute( + "INSERT INTO super_heroes_$testId(id, name) " + + "VALUES(${convertUUIDString(it, connection.getDbFlavour())}, '$name')", + ) } } val results = connection.execute( - "DELETE FROM super_heroes_$testId WHERE name = :name;", + "DELETE FROM super_heroes_$testId WHERE name = :name", "name" to name, ) @@ -141,7 +166,7 @@ class ExecuteTests : AbstractDbTests() { val results = connection.execute( """ - INSERT INTO super_heroes_$testId(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", @@ -149,7 +174,7 @@ class ExecuteTests : AbstractDbTests() { ) + connection.execute( """ - INSERT INTO super_heroes_$testId(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", @@ -168,7 +193,7 @@ class ExecuteTests : AbstractDbTests() { repeat(2) { connection.execute( """ - INSERT INTO super_heroes_$testId(id, name, email) VALUES(:id, :name, :email); + INSERT INTO super_heroes_$testId(id, name, email) VALUES(:id, :name, :email) """.trimIndent(), "id" to id, "name" to "thor", diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/QuerySingleTests.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/QuerySingleTests.kt index 588aa4a4..6034d65c 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/QuerySingleTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/QuerySingleTests.kt @@ -19,6 +19,8 @@ class QuerySingleTests : AbstractDbTests() { 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" } @@ -89,7 +91,7 @@ class QuerySingleTests : AbstractDbTests() { "name" to superman.name, ) villain.shouldNotBeNull() - villain.id!!.lowercase().shouldBe(superman.id.toString().lowercase()) + villain.id!!.shouldBe(superman.id.toString().lowercase().replace("-", "")) villain.name.shouldBe(superman.name) } @@ -98,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 1a171fa7..d5a58dab 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt @@ -95,7 +95,7 @@ class QueryTests : AbstractDbTests() { ) villain.size.shouldBe(1) - villain.first().id!!.lowercase().shouldBe(superman.id.toString().lowercase()) + villain.first().id!!.shouldBe(superman.id.toString().lowercase().replace("-", "")) villain.first().name.shouldBe(superman.name) } @@ -104,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 d0d4cb05..69af678e 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/SqlInjectionTest.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/SqlInjectionTest.kt @@ -10,8 +10,8 @@ class SqlInjectionTest : AbstractDbTests() { @MethodSource("databaseContainers") 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;", + "UPDATE super_heroes_$testId SET name = 'foo' WHERE name = :name", + "name" to "bar; DROP TABLE super_heroes", ) connection.query("SELECT * FROM super_heroes_$testId").shouldNotBeEmpty() diff --git a/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt b/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt index 8f13f7ce..1daa0a2d 100644 --- a/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt +++ b/core/src/integrationTest/kotlin/net/samyn/kapper/TypesTest.kt @@ -23,22 +23,22 @@ class TypesTest : AbstractDbTests() { CREATE TABLE types_test_$testId ( t_uuid ${convertDbColumnType("UUID", dbFlavour)}, t_char CHAR, - t_varchar VARCHAR(120), + 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 SMALLINT, - t_int INT, - t_bigint BIGINT, + 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 DOUBLE PRECISION, + t_double ${convertDbColumnType("DOUBLE PRECISION", dbFlavour)}, t_date DATE, t_local_date DATE, - t_local_time TIME, + t_local_time ${convertDbColumnType("TIME", dbFlavour)}, t_timestamp ${convertDbColumnType("TIMESTAMP", dbFlavour)}, t_boolean ${convertDbColumnType("BOOLEAN", dbFlavour)} ) @@ -98,7 +98,7 @@ class TypesTest : AbstractDbTests() { :local_time, :timestamp, :boolean - ); + ) """.trimIndent(), "uuid" to testData.t_uuid, "char" to testData.t_char, 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 7298518c..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 @@ -21,6 +22,10 @@ internal class AutoConverter( 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 23a802bc..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,38 +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 if (value is String) { - LocalTime.parse(value) - } else { - throw KapperUnsupportedOperationException( - "Cannot auto-convert value '$value' 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 if (value is String) { - LocalDateTime.parse(value) - } 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 if (value is String) { - LocalDate.parse(value) - } 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 = @@ -102,16 +149,16 @@ internal fun convertUUID(value: Any): UUID = internal fun convertChar(value: Any): Char = if (value is String) { - if (value.length > 1) { + if (value.length != 1) { throw KapperParseException( - "Cannot parse $value to Char (length > 1)", + "Cannot parse $value to Char (length != 1)", ) } value[0] } else if (value is CharArray) { - if (value.size > 1) { + if (value.size != 1) { throw KapperParseException( - "Cannot parse $value to Char (size > 1)", + "Cannot parse $value to Char (size != 1)", ) } value[0] @@ -121,6 +168,65 @@ internal fun convertChar(value: Any): 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/KotlinDataClassMapper.kt b/core/src/main/kotlin/net/samyn/kapper/internal/KotlinDataClassMapper.kt index 9bf5b28c..69488e71 100644 --- a/core/src/main/kotlin/net/samyn/kapper/internal/KotlinDataClassMapper.kt +++ b/core/src/main/kotlin/net/samyn/kapper/internal/KotlinDataClassMapper.kt @@ -29,7 +29,7 @@ class KotlinDataClassMapper( "Constructor for ${clazz.name} only has: ${properties.keys}", ) } else if (columns.size < properties.size) { - val all = columns.map { it.name } + val all = columns.map { it.name.lowercase() } val missing = properties.filter { !it.value.isOptional && !all.contains(it.value.name) } if (missing.isNotEmpty()) { throw KapperMappingException("The following properties are non-optional and missing: ${missing.keys}") 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 f8664c0e..526522bc 100644 --- a/core/src/main/kotlin/net/samyn/kapper/internal/Metadata.kt +++ b/core/src/main/kotlin/net/samyn/kapper/internal/Metadata.kt @@ -11,8 +11,12 @@ fun ResultSet.extractFields(dbFlavour: DbFlavour): Map = 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 953493e7..63e42269 100644 --- a/core/src/main/kotlin/net/samyn/kapper/internal/SQLTypesConverter.kt +++ b/core/src/main/kotlin/net/samyn/kapper/internal/SQLTypesConverter.kt @@ -3,17 +3,20 @@ 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, @@ -21,7 +24,7 @@ internal object SQLTypesConverter { resultSet: ResultSet, fieldIndex: Int, dbFlavour: DbFlavour, - ): Any { + ): Any? { val result = when (sqlType) { JDBCType.ARRAY -> resultSet.getArray(fieldIndex) @@ -31,7 +34,7 @@ internal object SQLTypesConverter { in listOf(JDBCType.BIT, JDBCType.BOOLEAN) -> resultSet.getBoolean(fieldIndex) in listOf(JDBCType.CHAR), - -> resultSet.getString(fieldIndex).toCharArray() + -> resultSet.getString(fieldIndex)?.toCharArray() in listOf( JDBCType.CLOB, JDBCType.LONGNVARCHAR, JDBCType.LONGVARCHAR, @@ -53,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") } @@ -74,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?, @@ -90,20 +108,29 @@ 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"), @@ -131,10 +158,10 @@ fun convertDate( resultSet: ResultSet, fieldIndex: Int, dbFlavour: DbFlavour, -): Date { +): Date? { when (dbFlavour) { DbFlavour.SQLITE -> { - val date = resultSet.getObject(fieldIndex) + val date = resultSet.getObject(fieldIndex) ?: return null if (date is Long) { return Date(date) } @@ -172,6 +199,6 @@ fun convertDate( } throw KapperUnsupportedOperationException("Conversion from type ${date.javaClass} to Date is not supported") } - else -> return Date(resultSet.getDate(fieldIndex).time) + else -> return resultSet.getDate(fieldIndex)?.let { Date(it.time) } } } 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 f1793093..00000000 --- a/core/src/test/kotlin/net/samyn/kapper/internal/ConverterTest.kt +++ /dev/null @@ -1,208 +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 `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 - } - } - } - - @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 `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 - } - } - } - - @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 `convert valid String to LocalTime`() { - val timeString = "12:30:00" - val locaTime = convertLocalTime(timeString) - val expectedTime = LocalTime.parse(timeString) - locaTime.shouldBe(expectedTime) - } - - @Test - fun `throw exception when invalid type is converted to LocalTime`() { - shouldThrow { - convertLocalTime(Date()) // Invalid input type - } - } - } - - @Nested - inner class CharConverter { - @Test - fun `convert valid String to Char`() { - val charString = "A" - val char = convertChar(charString) - char.shouldBe('A') - } - - @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 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 - } - } - } - - 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/KapperImplQueryTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/KapperImplQueryTest.kt index 3f904ff3..0992aca3 100644 --- a/core/src/test/kotlin/net/samyn/kapper/internal/KapperImplQueryTest.kt +++ b/core/src/test/kotlin/net/samyn/kapper/internal/KapperImplQueryTest.kt @@ -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/MetadataTest.kt b/core/src/test/kotlin/net/samyn/kapper/internal/MetadataTest.kt index 62457cd9..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 @@ -36,4 +37,18 @@ class MetadataTest { ), ) } + + @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 65c2b326..0e493333 100644 --- a/core/src/test/kotlin/net/samyn/kapper/internal/SQLTypesConverterTest.kt +++ b/core/src/test/kotlin/net/samyn/kapper/internal/SQLTypesConverterTest.kt @@ -21,10 +21,13 @@ 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.TimeZone import java.util.UUID class SQLTypesConverterTest { @@ -56,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() @@ -70,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 @@ -204,6 +225,14 @@ class SQLTypesConverterTest { 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() @@ -213,6 +242,32 @@ class SQLTypesConverterTest { 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) @@ -222,6 +277,14 @@ class SQLTypesConverterTest { 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) @@ -232,21 +295,28 @@ class SQLTypesConverterTest { } @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, DbFlavour.UNKNOWN) + 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, DbFlavour.UNKNOWN) + 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(timestamp) + result.shouldBe(null) } @Test @@ -258,6 +328,14 @@ class SQLTypesConverterTest { 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(null) + } + @ParameterizedTest // https://sqlite.org/lang_datefunc.html#tmval @ValueSource( @@ -284,7 +362,7 @@ class SQLTypesConverterTest { ], ) fun `sqlite string date needs converting`(format: String) { - val df = SimpleDateFormat(format).also { it.timeZone = TimeZone.getTimeZone("CET") } + 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) 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() + } +} From a59197988443c21aa4eaf6deeb5c92c1463bdced Mon Sep 17 00:00:00 2001 From: Dries Samyn <5557551+driessamyn@users.noreply.github.com> Date: Wed, 23 Apr 2025 21:11:59 +0100 Subject: [PATCH 6/6] fix: fix case-insensitive property name comparison in auto-mapper. --- .../kapper/internal/KotlinDataClassMapper.kt | 2 +- .../kapper/internal/SQLTypesConverter.kt | 79 ++++++++++--------- 2 files changed, 42 insertions(+), 39 deletions(-) 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 69488e71..8a182fa3 100644 --- a/core/src/main/kotlin/net/samyn/kapper/internal/KotlinDataClassMapper.kt +++ b/core/src/main/kotlin/net/samyn/kapper/internal/KotlinDataClassMapper.kt @@ -30,7 +30,7 @@ class KotlinDataClassMapper( ) } else if (columns.size < properties.size) { val all = columns.map { it.name.lowercase() } - val missing = properties.filter { !it.value.isOptional && !all.contains(it.value.name) } + 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}") } 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 63e42269..efc016c2 100644 --- a/core/src/main/kotlin/net/samyn/kapper/internal/SQLTypesConverter.kt +++ b/core/src/main/kotlin/net/samyn/kapper/internal/SQLTypesConverter.kt @@ -158,47 +158,50 @@ fun convertDate( resultSet: ResultSet, fieldIndex: Int, dbFlavour: DbFlavour, -): Date? { +): Date? = when (dbFlavour) { DbFlavour.SQLITE -> { val date = resultSet.getObject(fieldIndex) ?: return null - if (date is Long) { - return Date(date) + when (date) { + is Long -> Date(date) + is String -> convertSQliteDate(date) + else -> throw KapperUnsupportedOperationException("Conversion from type ${date.javaClass} to Date is not supported") } - if (date is String) { - // https://sqlite.org/lang_datefunc.html#tmval - if (date[2] == ':') { - // time formats - when (date.length) { - 5 -> return formatters["HH:mm"]!!.parse(date) - 6 -> return formatters["HH:mm'Z'"]!!.parse(date) - 8 -> return formatters["HH:mm:ss"]!!.parse(date) - 9 -> return formatters["HH:mm:ss'Z'"]!!.parse(date) - 12 -> return formatters["HH:mm:ss.SSS"]!!.parse(date) - 13 -> return formatters["HH:mm:ss.SSS'Z'"]!!.parse(date) - } - } else if (date.length > 10 && date[10] == 'T') { - when (date.length) { - 16 -> return formatters["yyyy-MM-dd'T'HH:mm"]!!.parse(date) - 17 -> return formatters["yyyy-MM-dd'T'HH:mm'Z'"]!!.parse(date) - 19 -> return formatters["yyyy-MM-dd'T'HH:mm:ss"]!!.parse(date) - 20 -> return formatters["yyyy-MM-dd'T'HH:mm:ss'Z'"]!!.parse(date) - 23 -> return formatters["yyyy-MM-dd'T'HH:mm:ss.SSS"]!!.parse(date) - 24 -> return formatters["yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"]!!.parse(date) - } - } - when (date.length) { - 10 -> return formatters["yyyy-MM-dd"]!!.parse(date) - 16 -> return formatters["yyyy-MM-dd HH:mm"]!!.parse(date) - 17 -> return formatters["yyyy-MM-dd HH:mm'Z'"]!!.parse(date) - 19 -> return formatters["yyyy-MM-dd HH:mm:ss"]!!.parse(date) - 20 -> return formatters["yyyy-MM-dd HH:mm:ss'Z'"]!!.parse(date) - 23 -> return formatters["yyyy-MM-dd HH:mm:ss.SSS"]!!.parse(date) - 24 -> return formatters["yyyy-MM-dd HH:mm:ss.SSS'Z'"]!!.parse(date) - } - } - throw KapperUnsupportedOperationException("Conversion from type ${date.javaClass} to Date is not supported") } - else -> return resultSet.getDate(fieldIndex)?.let { Date(it.time) } + 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")