Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: Support custom mapper. #12

Merged
merged 1 commit into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions lib/src/integrationTest/kotlin/net/samyn/kapper/QueryTests.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -158,5 +161,36 @@ class QueryTests {
}
}

@ParameterizedTest
@MethodSource("databaseContainers")
fun `can use custom mapper`(container: JdbcDatabaseContainer<*>) {
createConnection(container).use { connection ->
val villain =
connection.query<Villain>(
"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<String, Field>,
): 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
}
}
75 changes: 73 additions & 2 deletions lib/src/main/kotlin/net/samyn/kapper/Kapper.kt
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -22,6 +25,22 @@ inline fun <reified T : Any> 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 <reified T : Any> Connection.query(
sql: String,
noinline mapper: (ResultSet, Map<String, Field>) -> T,
vararg args: Pair<String, Any?>,
): List<T> {
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.
*
Expand All @@ -36,6 +55,22 @@ fun <T : Any> Connection.query(
vararg args: Pair<String, Any?>,
): List<T> = 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 <T : Any> Connection.query(
clazz: KClass<T>,
sql: String,
args: Map<String, Any?>,
mapper: (ResultSet, Map<String, Field>) -> T,
): List<T> = impl.query(clazz.java, this, sql, args, mapper)

/**
* Execute a SQL query and map the result to a single Kotlin data class instance.
*
Expand Down Expand Up @@ -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 <T : Any> query(
clazz: Class<T>,
connection: Connection,
sql: String,
args: java.util.HashMap<String, Any?>,
args: Map<String, Any?>,
mapper: (ResultSet, Map<String, Field>) -> T,
): List<T>

/**
* 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 <T : Any> query(
clazz: Class<T>,
connection: Connection,
sql: String,
args: HashMap<String, Any?>,
): List<T>

/**
* 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 <T : Any> query(
clazz: Class<T>,
connection: Connection,
sql: String,
args: HashMap<String, Any?>,
mapper: (ResultSet, Map<String, Field>) -> T,
): List<T>

/**
Expand All @@ -133,7 +204,7 @@ interface Kapper {
clazz: Class<T>,
connection: Connection,
sql: String,
args: java.util.HashMap<String, Any?>,
args: HashMap<String, Any?>,
): T

/**
Expand Down
43 changes: 25 additions & 18 deletions lib/src/main/kotlin/net/samyn/kapper/internal/KapperImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,47 @@ 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 <T : Any> query(
clazz: Class<T>,
connection: Connection,
sql: String,
args: HashMap<String, Any?>,
): List<T> = query(clazz, connection, sql, args.toMap())

override fun <T : Any> query(
clazz: Class<T>,
connection: Connection,
sql: String,
args: HashMap<String, Any?>,
mapper: (ResultSet, Map<String, Field>) -> T,
): List<T> = query(clazz, connection, sql, args.toMap(), mapper)

override fun <T : Any> query(
clazz: Class<T>,
connection: Connection,
sql: String,
args: Map<String, Any?>,
): List<T> =
// TODO: cash mapper
query(clazz, connection, sql, args.toMap(), Mapper(clazz)::createInstance)

override fun <T : Any> query(
clazz: Class<T>,
connection: Connection,
sql: String,
args: Map<String, Any?>,
mapper: (ResultSet, Map<String, Field>) -> T,
): List<T> {
// 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<T>()
connection.prepareStatement(query.sql).use { stmt ->
args.forEach { a ->
Expand All @@ -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),
)
}
}
Expand Down
20 changes: 19 additions & 1 deletion lib/src/main/kotlin/net/samyn/kapper/internal/Mapper.kt
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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

class Mapper<T : Any>(
val clazz: Class<T>,
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<T> =
Expand All @@ -17,7 +19,7 @@ class Mapper<T : Any>(
constructor.parameters
.map { it.name to it }.toMap()

fun createInstance(columns: List<ColumnValue>): T {
private fun createInstance(columns: List<ColumnValue>): T {
if (columns.size > properties.size) {
throw KapperMappingException(
"Too many tokens provided in the template: ${columns.map { it.name }}. " +
Expand Down Expand Up @@ -48,5 +50,21 @@ class Mapper<T : Any>(
return instance
}

fun createInstance(
resultSet: ResultSet,
fields: Map<String, Field>,
): 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)
}
Loading