From bfca24aff5785bf73cb1a5abf05827812a8df70f Mon Sep 17 00:00:00 2001 From: Dries Samyn <5557551+driessamyn@users.noreply.github.com> Date: Tue, 19 Nov 2024 21:35:16 +0000 Subject: [PATCH] feat!: Support custom mapper. Enhance the query API to support specifying a custom mapper function. --- .../kotlin/net/samyn/kapper/QueryTests.kt | 34 +++++ .../main/kotlin/net/samyn/kapper/Kapper.kt | 75 ++++++++++- .../net/samyn/kapper/internal/KapperImpl.kt | 43 ++++--- .../net/samyn/kapper/internal/Mapper.kt | 20 ++- .../kotlin/net/samyn/kapper/MapperTest.kt | 120 ++++++++++++------ 5 files changed, 232 insertions(+), 60 deletions(-) diff --git a/lib/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt b/lib/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt index bcd81c4..4109bf2 100644 --- a/lib/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt +++ b/lib/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt @@ -3,6 +3,8 @@ package net.samyn.kapper import io.kotest.matchers.booleans.shouldBeTrue import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe +import net.samyn.kapper.internal.Mapper.Field import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Named.named import org.junit.jupiter.api.Order @@ -16,6 +18,7 @@ import org.testcontainers.containers.PostgreSQLContainer import org.testcontainers.junit.jupiter.Container import org.testcontainers.junit.jupiter.Testcontainers import java.sql.DriverManager +import java.sql.ResultSet import java.util.UUID @Testcontainers @@ -158,5 +161,36 @@ class QueryTests { } } + @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, + ) + + villain.size.shouldBe(1) + villain.first().id.shouldBe(superman.id.toString()) + villain.first().name.shouldBe(superman.name.toString()) + } + } + + private fun createVillain( + resultSet: ResultSet, + fields: Map, + ): Villain = + Villain().also { + it.id = resultSet.getString("id") + it.name = resultSet.getString("name") + } + data class SuperHero(val id: UUID, val name: String, val email: String? = null, val age: Int? = null) + + class Villain { + var id: String? = null + var name: String? = null + } } diff --git a/lib/src/main/kotlin/net/samyn/kapper/Kapper.kt b/lib/src/main/kotlin/net/samyn/kapper/Kapper.kt index efa6721..18d58b3 100644 --- a/lib/src/main/kotlin/net/samyn/kapper/Kapper.kt +++ b/lib/src/main/kotlin/net/samyn/kapper/Kapper.kt @@ -1,7 +1,10 @@ package net.samyn.kapper import net.samyn.kapper.internal.KapperImpl +import net.samyn.kapper.internal.Mapper.Field import java.sql.Connection +import java.sql.ResultSet +import java.util.HashMap import kotlin.reflect.KClass val impl: Kapper by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { @@ -22,6 +25,22 @@ inline fun Connection.query( return query(T::class, sql, *args) } +/** + * Execute a SQL query and map the results to a list of Kotlin data class instances. + * + * @param sql The SQL query to execute. + * @param args Optional parameters to be substituted in the SQL query. + * @param mapper Optional mapping function to map the [ResultSet] to the target class. + * @return A list of Kotlin data class instances. + */ +inline fun Connection.query( + sql: String, + noinline mapper: (ResultSet, Map) -> T, + vararg args: Pair, +): List { + return query(T::class, sql, args.toMap(), mapper) +} + /** * Execute a SQL query and map the results to a list of instances of the specified Kotlin data class. * @@ -36,6 +55,22 @@ fun Connection.query( vararg args: Pair, ): List = impl.query(clazz.java, this, sql, args.toMap()) +/** + * Execute a SQL query and map the results to a list of instances of the specified Kotlin data class. + * + * @param clazz The Kotlin data class to map the results to. + * @param sql The SQL query to execute. + * @param args Optional parameters to be substituted in the SQL query. + * @param mapper Optional mapping function to map the [ResultSet] to the target class. + * @return A list of Kotlin data class instances. + */ +fun Connection.query( + clazz: KClass, + sql: String, + args: Map, + mapper: (ResultSet, Map) -> T, +): List = impl.query(clazz.java, this, sql, args, mapper) + /** * Execute a SQL query and map the result to a single Kotlin data class instance. * @@ -112,13 +147,49 @@ interface Kapper { * @param connection The SQL connection to use. * @param sql The SQL query to execute. * @param args Optional parameters to be substituted in the SQL query. + * @param mapper Optional mapping function to map the [ResultSet] to the target class. * @return A list of Kotlin data class instances. */ fun query( clazz: Class, connection: Connection, sql: String, - args: java.util.HashMap, + args: Map, + mapper: (ResultSet, Map) -> T, + ): List + + /** + * Execute a SQL query and map the results to a list of instances of the specified class. + * + * @param clazz The class to map the results to. + * @param connection The SQL connection to use. + * @param sql The SQL query to execute. + * @param args Optional parameters to be substituted in the SQL query. + * @return A list of Kotlin data class instances. + */ + fun query( + clazz: Class, + connection: Connection, + sql: String, + args: HashMap, + ): List + + /** + * Execute a SQL query and map the results to a list of instances of the specified class. + * + * @param clazz The class to map the results to. + * @param connection The SQL connection to use. + * @param sql The SQL query to execute. + * @param args Optional parameters to be substituted in the SQL query. + * @param mapper Optional mapping function to map the [ResultSet] to the target class. + * @return A list of Kotlin data class instances. + */ + fun query( + clazz: Class, + connection: Connection, + sql: String, + args: HashMap, + mapper: (ResultSet, Map) -> T, ): List /** @@ -133,7 +204,7 @@ interface Kapper { clazz: Class, connection: Connection, sql: String, - args: java.util.HashMap, + args: HashMap, ): T /** diff --git a/lib/src/main/kotlin/net/samyn/kapper/internal/KapperImpl.kt b/lib/src/main/kotlin/net/samyn/kapper/internal/KapperImpl.kt index 820b048..e6ffe58 100644 --- a/lib/src/main/kotlin/net/samyn/kapper/internal/KapperImpl.kt +++ b/lib/src/main/kotlin/net/samyn/kapper/internal/KapperImpl.kt @@ -2,15 +2,14 @@ package net.samyn.kapper.internal import net.samyn.kapper.Kapper import net.samyn.kapper.KapperParseException +import net.samyn.kapper.internal.Mapper.Field import java.sql.Connection import java.sql.ResultSet import java.util.HashMap // TODO: this is only covered by the TestContainer integration tests. // Break this up further and ensure unit test coverage. -class KapperImpl( - val sqlTypesConverter: (Int, String, ResultSet, String) -> Any = SQLTypesConverter::convert, -) : Kapper { +class KapperImpl : Kapper { override fun query( clazz: Class, connection: Connection, @@ -18,17 +17,32 @@ class KapperImpl( args: HashMap, ): List = query(clazz, connection, sql, args.toMap()) + override fun query( + clazz: Class, + connection: Connection, + sql: String, + args: HashMap, + mapper: (ResultSet, Map) -> T, + ): List = query(clazz, connection, sql, args.toMap(), mapper) + + override fun query( + clazz: Class, + connection: Connection, + sql: String, + args: Map, + ): List = + // TODO: cash mapper + query(clazz, connection, sql, args.toMap(), Mapper(clazz)::createInstance) + override fun query( clazz: Class, connection: Connection, sql: String, args: Map, + mapper: (ResultSet, Map) -> T, ): List { // TODO: cache query val query = Query(sql) - // TODO: cash mapper/type reflection - // TODO: allow multiple constructors and non-data classes - val mapper = Mapper(clazz) val results = mutableListOf() connection.prepareStatement(query.sql).use { stmt -> args.forEach { a -> @@ -42,25 +56,18 @@ class KapperImpl( } // TODO: introduce SLF4J println(stmt) - // TODO: create overload that takes custom conversion function and defaults to this + // TODO: refactor stmt.executeQuery().use { rs -> // TODO: cache data - // TODO: structure nicer + // TODO: cash fields (persist in query?) val fields = (1..rs.metaData.columnCount).map { rs.metaData.getColumnName(it) to - Pair(rs.metaData.getColumnType(it), rs.metaData.getColumnTypeName(it)) - } + Field(rs.metaData.getColumnType(it), rs.metaData.getColumnTypeName(it)) + }.toMap() while (rs.next()) { results.add( - mapper.createInstance( - fields.map { field -> - Mapper.ColumnValue( - field.first, - sqlTypesConverter(field.second.first, field.second.second, rs, field.first), - ) - }, - ), + mapper(rs, fields), ) } } diff --git a/lib/src/main/kotlin/net/samyn/kapper/internal/Mapper.kt b/lib/src/main/kotlin/net/samyn/kapper/internal/Mapper.kt index 08f1270..d286d76 100644 --- a/lib/src/main/kotlin/net/samyn/kapper/internal/Mapper.kt +++ b/lib/src/main/kotlin/net/samyn/kapper/internal/Mapper.kt @@ -1,6 +1,7 @@ package net.samyn.kapper.internal import net.samyn.kapper.KapperMappingException +import java.sql.ResultSet import kotlin.reflect.KClass import kotlin.reflect.KFunction import kotlin.reflect.full.primaryConstructor @@ -8,6 +9,7 @@ import kotlin.reflect.full.primaryConstructor class Mapper( val clazz: Class, val autoConverter: (Any, KClass<*>) -> Any = AutoConverter::convert, + val sqlTypesConverter: (Int, String, ResultSet, String) -> Any = SQLTypesConverter::convert, ) { // TODO: relax case sensitivity of tokens names? private val constructor: KFunction = @@ -17,7 +19,7 @@ class Mapper( constructor.parameters .map { it.name to it }.toMap() - fun createInstance(columns: List): T { + private fun createInstance(columns: List): T { if (columns.size > properties.size) { throw KapperMappingException( "Too many tokens provided in the template: ${columns.map { it.name }}. " + @@ -48,5 +50,21 @@ class Mapper( return instance } + fun createInstance( + resultSet: ResultSet, + fields: Map, + ): T { + return createInstance( + fields.map { field -> + ColumnValue( + field.key, + sqlTypesConverter(field.value.type, field.value.typeName, resultSet, field.key), + ) + }, + ) + } + data class ColumnValue(val name: String, val value: Any?) + + data class Field(val type: Int, val typeName: String) } diff --git a/lib/src/test/kotlin/net/samyn/kapper/MapperTest.kt b/lib/src/test/kotlin/net/samyn/kapper/MapperTest.kt index 6310edc..6f1cb7f 100644 --- a/lib/src/test/kotlin/net/samyn/kapper/MapperTest.kt +++ b/lib/src/test/kotlin/net/samyn/kapper/MapperTest.kt @@ -7,27 +7,38 @@ import io.mockk.every import io.mockk.mockk import io.mockk.verify import net.samyn.kapper.internal.Mapper +import net.samyn.kapper.internal.Mapper.Field import org.junit.jupiter.api.Test +import java.sql.ResultSet import java.util.UUID import kotlin.reflect.KClass class MapperTest { private val autoMapperMock = mockk<(Any, KClass<*>) -> Any>() - private val mapper = Mapper(SuperHero::class.java, autoMapperMock) + private val resultSet = mockk() + val sqlTypeConverterMock = mockk<(Int, String, ResultSet, String) -> Any>() @Test fun `should map to super hero`() { val batman = SuperHero(UUID.randomUUID(), "Batman", "batman@dc.com", 85) - - val instance = - mapper.createInstance( - listOf( - Mapper.ColumnValue("id", batman.id), - Mapper.ColumnValue("name", batman.name), - Mapper.ColumnValue("email", batman.email), - Mapper.ColumnValue("age", batman.age), - ), + val fields = + mapOf( + "id" to Field(1, "SomeType"), + "name" to Field(1, "SomeType"), + "email" to Field(1, "SomeType"), + "age" to Field(1, "SomeType"), ) + every { sqlTypeConverterMock(any(), any(), any(), eq("id")) } returns + batman.id + every { sqlTypeConverterMock(any(), any(), any(), eq("name")) } returns + batman.name + every { sqlTypeConverterMock(any(), any(), any(), eq("email")) } returns + batman.email!! + every { sqlTypeConverterMock(any(), any(), any(), eq("age")) } returns + batman.age!! + + val mapper = Mapper(SuperHero::class.java, autoMapperMock, sqlTypeConverterMock) + val instance = mapper.createInstance(resultSet, fields) instance.shouldBe(batman) } @@ -35,46 +46,71 @@ class MapperTest { @Test fun `should map to super hero respecting defaults`() { val batman = SuperHero(UUID.randomUUID(), "Batman", "batman@dc.com", 85) - - val instance = - mapper.createInstance( - listOf( - Mapper.ColumnValue("id", batman.id), - Mapper.ColumnValue("name", batman.name), - ), + val fields = + mapOf( + "id" to Field(1, "SomeType"), + "name" to Field(1, "SomeType"), ) + every { sqlTypeConverterMock(any(), any(), any(), eq("id")) } returns + batman.id + every { sqlTypeConverterMock(any(), any(), any(), eq("name")) } returns + batman.name + + val mapper = Mapper(SuperHero::class.java, autoMapperMock, sqlTypeConverterMock) + val instance = mapper.createInstance(resultSet, fields) instance.shouldBe(SuperHero(batman.id, "Batman", null, null)) } @Test fun `should throw when too many columns`() { + val fields = + mapOf( + "id" to Field(1, "SomeType"), + "name" to Field(1, "SomeType"), + "email" to Field(1, "SomeType"), + "age" to Field(1, "SomeType"), + "foo" to Field(1, "SomeType"), + ) + every { sqlTypeConverterMock(any(), any(), any(), eq("id")) } returns + UUID.randomUUID() + every { sqlTypeConverterMock(any(), any(), any(), eq("name")) } returns + "joker" + every { sqlTypeConverterMock(any(), any(), any(), eq("email")) } returns + "joker@dc.com" + every { sqlTypeConverterMock(any(), any(), any(), eq("age")) } returns + 85 + every { sqlTypeConverterMock(any(), any(), any(), eq("foo")) } returns + "bar" + + val mapper = Mapper(SuperHero::class.java, autoMapperMock, sqlTypeConverterMock) val ex = shouldThrow { - mapper.createInstance( - listOf( - Mapper.ColumnValue("id", UUID.randomUUID()), - Mapper.ColumnValue("name", "joker"), - Mapper.ColumnValue("email", "joker@dc.com"), - Mapper.ColumnValue("age", 85), - Mapper.ColumnValue("foo", "bar"), - ), - ) + mapper.createInstance(resultSet, fields) } ex.message.shouldContain("foo") } @Test fun `should throw when non-optional are missing`() { + val batman = SuperHero(UUID.randomUUID(), "Batman", "batman@dc.com", 85) + val fields = + mapOf( + "name" to Field(1, "SomeType"), + "email" to Field(1, "SomeType"), + "age" to Field(1, "SomeType"), + ) + every { sqlTypeConverterMock(any(), any(), any(), eq("name")) } returns + batman.name + every { sqlTypeConverterMock(any(), any(), any(), eq("email")) } returns + batman.email!! + every { sqlTypeConverterMock(any(), any(), any(), eq("age")) } returns + batman.age!! + + val mapper = Mapper(SuperHero::class.java, autoMapperMock, sqlTypeConverterMock) val ex = shouldThrow { - mapper.createInstance( - listOf( - Mapper.ColumnValue("name", "joker"), - Mapper.ColumnValue("email", "joker@dc.com"), - Mapper.ColumnValue("age", 85), - ), - ) + mapper.createInstance(resultSet, fields) } ex.message.shouldContain("id") } @@ -82,12 +118,18 @@ class MapperTest { @Test fun `should convert when type not known`() { every { autoMapperMock(any(), any>()) } returns "Foo" - mapper.createInstance( - listOf( - Mapper.ColumnValue("id", UUID.randomUUID()), - Mapper.ColumnValue("name", 123), - ), - ) + val fields = + mapOf( + "id" to Field(1, "SomeType"), + "name" to Field(1, "SomeType"), + ) + every { sqlTypeConverterMock(any(), any(), any(), eq("id")) } returns + UUID.randomUUID() + every { sqlTypeConverterMock(any(), any(), any(), eq("name")) } returns + 123 + + val mapper = Mapper(SuperHero::class.java, autoMapperMock, sqlTypeConverterMock) + mapper.createInstance(resultSet, fields) verify { autoMapperMock(123, String::class) } }