Skip to content

Commit e38c9c8

Browse files
“owenstanfordSC”claude
andcommitted
feat!: add executeReturning for DML statements with RETURNING clause
Adds a first-class executeReturning API so callers can run INSERT/UPDATE/ UPSERT statements with a RETURNING clause and get back mapped results, rather than relying on the undocumented querySingle-for-RETURNING workaround. - Kapper interface: four overloads (map-args + object-args, each with auto-mapper and custom mapper variants) - KapperImpl: two abstract implementations — map-args reuses the existing executeQuery path; object-args combines object-based parameter binding from execute() with stmt.executeQuery() result processing - KapperKotlinExecuteReturningFun: six Kotlin extension functions mirroring the query/execute extension function style - KapperImplExecuteReturningTest: 14 unit tests covering both variants Rejected: route execute() through executeQuery() on RETURNING detection | breaking API change (return type changes from Int to List<T>) Rejected: document querySingle workaround only | fragile, breaks if DML routing ever changes Confidence: high Scope-risk: narrow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent dd05d0b commit e38c9c8

8 files changed

Lines changed: 869 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Instead of adding another abstraction layer, Kapper embraces three core principl
4848
- **📦 Java Records**: Native support for Java record classes alongside Kotlin data classes
4949
- **🔄 Transactions**: Simple transaction handling with automatic commit/rollback
5050
- **📊 Bulk Operations**: Efficient batch inserts, updates, and deletes with `executeAll`
51+
- **↩️ DML with Results**: Execute DML statements that return records with `executeReturning` (where supported by the database)
5152
- **🗄️ Database Support**: PostgreSQL, MySQL, SQLite, Oracle, MS SQL Server, DuckDB, and many others
5253
- **📏 Minimal Dependencies**: Lightweight library with zero external dependencies
5354
- **🔌 JDBC Extension**: Extends `java.sql.Connection` - works alongside existing JDBC code
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package net.samyn.kapper
2+
3+
import io.kotest.matchers.collections.shouldHaveSize
4+
import io.kotest.matchers.shouldBe
5+
import net.samyn.kapper.internal.getDbFlavour
6+
import org.junit.jupiter.api.Assumptions.assumeTrue
7+
import org.junit.jupiter.api.Test
8+
import java.sql.Connection
9+
import java.util.UUID
10+
11+
class ExecuteReturningTests : AbstractDbTests() {
12+
// RETURNING clause support varies by database.
13+
// MySQL and Oracle use different syntax; MSSQL uses OUTPUT.
14+
private val returningDbs = setOf(DbFlavour.POSTGRESQL, DbFlavour.DUCKDB, DbFlavour.SQLITE)
15+
16+
override fun setupDatabase(connection: Connection) {
17+
super.setupDatabase(connection)
18+
}
19+
20+
@Test
21+
fun `SQL Insert returning mapped record`() {
22+
assumeTrue(connection.getDbFlavour() in returningDbs, "RETURNING clause not supported on this database")
23+
val newHero = superman.copy(id = UUID.randomUUID())
24+
val results =
25+
connection.executeReturning<SuperHero>(
26+
"""
27+
INSERT INTO super_heroes_$testId(id, name, email, age)
28+
VALUES(:id, :name, :email, :age)
29+
RETURNING *
30+
""",
31+
"id" to newHero.id,
32+
"name" to newHero.name,
33+
"email" to newHero.email,
34+
"age" to newHero.age,
35+
)
36+
results shouldHaveSize 1
37+
results[0] shouldBe newHero
38+
}
39+
40+
@Test
41+
fun `SQL Update returning mapped record`() {
42+
assumeTrue(connection.getDbFlavour() in returningDbs, "RETURNING clause not supported on this database")
43+
val batmanClone = batman.copy(id = UUID.randomUUID())
44+
connection.createStatement().use { stmt ->
45+
stmt.execute(
46+
"INSERT INTO super_heroes_$testId(id, name) " +
47+
"VALUES(${convertUUIDString(batmanClone.id, connection.getDbFlavour())}, 'foo')",
48+
)
49+
}
50+
val updated = batmanClone.copy(name = "Batman Updated", email = "batman@wayne.com", age = 42)
51+
val results =
52+
connection.executeReturning<SuperHero>(
53+
"UPDATE super_heroes_$testId SET name = :name, email = :email, age = :age WHERE id = :id RETURNING *",
54+
"id" to updated.id,
55+
"name" to updated.name,
56+
"email" to updated.email,
57+
"age" to updated.age,
58+
)
59+
results shouldHaveSize 1
60+
results[0] shouldBe updated
61+
}
62+
63+
@Test
64+
fun `SQL Insert DTO returning mapped record`() {
65+
assumeTrue(connection.getDbFlavour() in returningDbs, "RETURNING clause not supported on this database")
66+
val newHero = spiderMan.copy(id = UUID.randomUUID())
67+
val results =
68+
connection.executeReturning(
69+
"""
70+
INSERT INTO super_heroes_$testId(id, name, email, age)
71+
VALUES(:id, :name, :email, :age)
72+
RETURNING *
73+
""",
74+
newHero,
75+
"id" to SuperHero::id,
76+
"name" to SuperHero::name,
77+
"email" to SuperHero::email,
78+
"age" to SuperHero::age,
79+
)
80+
results shouldHaveSize 1
81+
results[0] shouldBe newHero
82+
}
83+
84+
@Test
85+
fun `SQL Insert with custom mapper returning selected fields`() {
86+
assumeTrue(connection.getDbFlavour() in returningDbs, "RETURNING clause not supported on this database")
87+
val newHero = batman.copy(id = UUID.randomUUID())
88+
val results =
89+
connection.executeReturning(
90+
"""
91+
INSERT INTO super_heroes_$testId(id, name, email, age)
92+
VALUES(:id, :name, :email, :age)
93+
RETURNING *
94+
""",
95+
mapper = { rs, _ -> rs.getString("name") },
96+
"id" to newHero.id,
97+
"name" to newHero.name,
98+
"email" to newHero.email,
99+
"age" to newHero.age,
100+
)
101+
results shouldHaveSize 1
102+
results[0] shouldBe newHero.name
103+
}
104+
}

core/src/main/kotlin/net/samyn/kapper/Kapper.kt

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,4 +173,106 @@ interface Kapper {
173173
objects: Iterable<T>,
174174
args: Map<String, (T) -> Any?>,
175175
): IntArray
176+
177+
/**
178+
* Execute a SQL statement with a RETURNING clause and map the results to a list of instances of the specified class.
179+
*
180+
* @param clazz The class to map the results to.
181+
* @param connection The SQL connection to use.
182+
* @param sql The SQL statement to execute, including a RETURNING clause.
183+
* @param args Optional parameters to be substituted in the SQL statement. Parameter substitution is based on the Map keys.
184+
* @return The rows returned by the RETURNING clause as a list of [T] instances.
185+
*/
186+
fun <T : Any> executeReturning(
187+
clazz: Class<T>,
188+
connection: Connection,
189+
sql: String,
190+
args: Args,
191+
): List<T> {
192+
val mapper =
193+
try {
194+
mapperRegistry.get(clazz)
195+
} catch (e: Exception) {
196+
logger.error("Error creating instance of $clazz", e)
197+
throw KapperMappingException("Error creating mapper for $clazz", e)
198+
}
199+
return executeReturning(clazz, connection, sql, mapper::createInstance, args)
200+
}
201+
202+
/**
203+
* Execute a SQL statement with a RETURNING clause and map the results to a list of instances of the specified class.
204+
*
205+
* @param clazz The class to map the results to.
206+
* @param connection The SQL connection to use.
207+
* @param sql The SQL statement to execute, including a RETURNING clause.
208+
* @param mapper Mapping function to map the [ResultSet] to the target class.
209+
* @param args Optional parameters to be substituted in the SQL statement. Parameter substitution is based on the Map keys.
210+
* @return The rows returned by the RETURNING clause as a list of [T] instances.
211+
*/
212+
fun <T : Any> executeReturning(
213+
clazz: Class<T>,
214+
connection: Connection,
215+
sql: String,
216+
mapper: (ResultSet, Map<String, Field>) -> T,
217+
args: Args,
218+
): List<T>
219+
220+
/**
221+
* Execute a SQL statement with a RETURNING clause using an object and argument mapper functions,
222+
* and map the results to a list of instances of the specified class.
223+
*
224+
* The argument object type [A] and the returned row type [R] are intentionally separate,
225+
* allowing patterns such as passing a `CreateFoo` request and receiving a `Foo` result.
226+
*
227+
* @param R The type to map the results to.
228+
* @param A The type of the object used to provide parameter values.
229+
* @param clazz The class to map the results to.
230+
* @param connection The SQL connection to use.
231+
* @param sql The SQL statement to execute, including a RETURNING clause.
232+
* @param obj The object containing the values to be used in the SQL statement.
233+
* @param args A map where the keys are the names of the parameters in the SQL statement, and the values are functions that extract the corresponding values from the object.
234+
* @return The rows returned by the RETURNING clause as a list of [R] instances.
235+
*/
236+
fun <R : Any, A : Any> executeReturning(
237+
clazz: Class<R>,
238+
connection: Connection,
239+
sql: String,
240+
obj: A,
241+
args: Map<String, (A) -> Any?>,
242+
): List<R> {
243+
val mapper =
244+
try {
245+
mapperRegistry.get(clazz)
246+
} catch (e: Exception) {
247+
logger.error("Error creating instance of $clazz", e)
248+
throw KapperMappingException("Error creating auto-mapper for $clazz", e)
249+
}
250+
return executeReturning(clazz, connection, sql, mapper::createInstance, obj, args)
251+
}
252+
253+
/**
254+
* Execute a SQL statement with a RETURNING clause using an object and argument mapper functions,
255+
* and map the results to a list of instances of the specified class.
256+
*
257+
* The argument object type [A] and the returned row type [R] are intentionally separate,
258+
* allowing patterns such as passing a `CreateFoo` request and receiving a `Foo` result.
259+
*
260+
* @param R The type to map the results to.
261+
* @param A The type of the object used to provide parameter values.
262+
* @param clazz The class to map the results to.
263+
* @param connection The SQL connection to use.
264+
* @param sql The SQL statement to execute, including a RETURNING clause.
265+
* @param mapper Mapping function to map the [ResultSet] to the target class.
266+
* @param obj The object containing the values to be used in the SQL statement.
267+
* @param args A map where the keys are the names of the parameters in the SQL statement, and the values are functions that extract the corresponding values from the object.
268+
* @return The rows returned by the RETURNING clause as a list of [R] instances.
269+
*/
270+
fun <R : Any, A : Any> executeReturning(
271+
clazz: Class<R>,
272+
connection: Connection,
273+
sql: String,
274+
mapper: (ResultSet, Map<String, Field>) -> R,
275+
obj: A,
276+
args: Map<String, (A) -> Any?>,
277+
): List<R>
176278
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package net.samyn.kapper
2+
3+
import java.sql.Connection
4+
import java.sql.ResultSet
5+
import kotlin.reflect.KClass
6+
7+
/**
8+
* Execute a SQL statement with a RETURNING clause and map the results to a list of instances of the specified class.
9+
*
10+
* This function uses reflection to automatically map the result set columns to the properties of the specified class.
11+
* For advanced mappings, use the overloaded version with a custom `mapper` function.
12+
*
13+
* **Example**:
14+
* ```kotlin
15+
* data class Hero(val id: UUID, val name: String, val updatedAt: Instant)
16+
*
17+
* val heroes: List<Hero> = connection.executeReturning(
18+
* sql = """
19+
* INSERT INTO heroes (id, name) VALUES (:id, :name)
20+
* ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name
21+
* RETURNING *
22+
* """,
23+
* "id" to UUID.randomUUID(),
24+
* "name" to "Superman"
25+
* )
26+
* ```
27+
*
28+
* @param sql The SQL statement to execute, including a RETURNING clause.
29+
* @param args Optional key-value pairs representing named parameters to substitute into the statement.
30+
* @return The rows returned by the RETURNING clause as a list of [T] instances.
31+
* @throws java.sql.SQLException If there's a database error.
32+
*/
33+
inline fun <reified T : Any> Connection.executeReturning(
34+
sql: String,
35+
vararg args: Pair<String, Any?>,
36+
): List<T> = executeReturning(T::class, sql, *args)
37+
38+
/**
39+
* Execute a SQL statement with a RETURNING clause and map the results to a list of instances of the specified class
40+
* with a custom mapper.
41+
*
42+
* **Example**:
43+
* ```kotlin
44+
* val heroes: List<Hero> = connection.executeReturning(
45+
* sql = "INSERT INTO heroes (id, name) VALUES (:id, :name) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name RETURNING *",
46+
* mapper = { resultSet, _ ->
47+
* Hero(
48+
* id = resultSet.getObject("id", UUID::class.java),
49+
* name = resultSet.getString("name")
50+
* )
51+
* },
52+
* "id" to UUID.randomUUID(),
53+
* "name" to "Superman"
54+
* )
55+
* ```
56+
*
57+
* @param sql The SQL statement to execute, including a RETURNING clause.
58+
* @param mapper Custom mapping function to transform the [ResultSet] into the target class.
59+
* @param args Optional parameters to be substituted in the SQL statement during execution.
60+
* @return The rows returned by the RETURNING clause as a list of [T] instances.
61+
*/
62+
inline fun <reified T : Any> Connection.executeReturning(
63+
sql: String,
64+
noinline mapper: (ResultSet, Map<String, Field>) -> T,
65+
vararg args: Pair<String, Any?>,
66+
): List<T> = executeReturning(T::class, sql, mapper, args.toMap())
67+
68+
/**
69+
* Execute a SQL statement with a RETURNING clause and map the results to a list of instances of the specified class.
70+
*
71+
* @param clazz The class to map the results to.
72+
* @param sql The SQL statement to execute, including a RETURNING clause.
73+
* @param args Optional parameters to be substituted in the SQL statement during execution.
74+
* @return The rows returned by the RETURNING clause as a list of [T] instances.
75+
*/
76+
fun <T : Any> Connection.executeReturning(
77+
clazz: KClass<T>,
78+
sql: String,
79+
vararg args: Pair<String, Any?>,
80+
): List<T> = Kapper.instance.executeReturning(clazz.java, this, sql, args.toMap())
81+
82+
/**
83+
* Execute a SQL statement with a RETURNING clause and map the results to a list of instances of the specified class
84+
* with a custom mapper.
85+
*
86+
* @param clazz The class to map the results to.
87+
* @param sql The SQL statement to execute, including a RETURNING clause.
88+
* @param mapper Custom mapping function to transform the [ResultSet] into the target class.
89+
* @param args Optional parameters to be substituted in the SQL statement during execution.
90+
* @return The rows returned by the RETURNING clause as a list of [T] instances.
91+
*/
92+
fun <T : Any> Connection.executeReturning(
93+
clazz: KClass<T>,
94+
sql: String,
95+
mapper: (ResultSet, Map<String, Field>) -> T,
96+
args: Map<String, Any?>,
97+
): List<T> = Kapper.instance.executeReturning(clazz.java, this, sql, mapper, args)
98+
99+
/**
100+
* Execute a SQL statement with a RETURNING clause using an object and argument mapper functions,
101+
* and map the results to a list of instances of the specified class.
102+
*
103+
* The argument object type [A] and the returned row type [R] are intentionally separate,
104+
* allowing patterns such as passing a `CreateFoo` request and receiving a `Foo` result.
105+
*
106+
* **Example**:
107+
* ```kotlin
108+
* data class Hero(val id: UUID, val name: String)
109+
*
110+
* val hero = Hero(id = UUID.randomUUID(), name = "Superman")
111+
* val result: List<Hero> = connection.executeReturning(
112+
* sql = """
113+
* INSERT INTO heroes (id, name) VALUES (:id, :name)
114+
* ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name
115+
* RETURNING *
116+
* """,
117+
* obj = hero,
118+
* "id" to Hero::id,
119+
* "name" to Hero::name
120+
* )
121+
* ```
122+
*
123+
* @param sql The SQL statement to execute, including a RETURNING clause.
124+
* @param obj The object containing the values to be used in the SQL statement.
125+
* @param args Argument mappers that extract values from the object for parameter substitution.
126+
* @return The rows returned by the RETURNING clause as a list of [R] instances.
127+
* @throws java.sql.SQLException If there's a database error.
128+
*/
129+
inline fun <reified R : Any, A : Any> Connection.executeReturning(
130+
sql: String,
131+
obj: A,
132+
vararg args: ArgMapper<A>,
133+
): List<R> = Kapper.instance.executeReturning(R::class.java, this, sql, obj, args.toMap())
134+
135+
/**
136+
* Execute a SQL statement with a RETURNING clause using an object, argument mapper functions, and a custom result mapper.
137+
*
138+
* The argument object type [A] and the returned row type [R] are intentionally separate,
139+
* allowing patterns such as passing a `CreateFoo` request and receiving a `Foo` result.
140+
*
141+
* @param sql The SQL statement to execute, including a RETURNING clause.
142+
* @param mapper Custom mapping function to transform the [ResultSet] into the target class.
143+
* @param obj The object containing the values to be used in the SQL statement.
144+
* @param args Argument mappers that extract values from the object for parameter substitution.
145+
* @return The rows returned by the RETURNING clause as a list of [R] instances.
146+
* @throws java.sql.SQLException If there's a database error.
147+
*/
148+
inline fun <reified R : Any, A : Any> Connection.executeReturning(
149+
sql: String,
150+
noinline mapper: (ResultSet, Map<String, Field>) -> R,
151+
obj: A,
152+
vararg args: ArgMapper<A>,
153+
): List<R> = Kapper.instance.executeReturning(R::class.java, this, sql, mapper, obj, args.toMap())

0 commit comments

Comments
 (0)