Detach materialized SQL rows from transaction lifetime
This commit is contained in:
parent
07cb5a519c
commit
49fc700233
@ -205,7 +205,8 @@ assertThrows(RollbackException) {
|
||||
- `columns` — positional `SqlColumn` metadata, available before iteration.
|
||||
- `size()` — result row count.
|
||||
- `isEmpty()` — fast emptiness check where possible.
|
||||
- `iterator()` / `toList()` — normal row iteration.
|
||||
- `iterator()` — normal row iteration while the transaction is active.
|
||||
- `toList()` — materialize detached `SqlRow` snapshots that may be used after the transaction ends.
|
||||
|
||||
##### `SqlRow`
|
||||
|
||||
@ -370,14 +371,24 @@ PostgreSQL-specific notes:
|
||||
|
||||
#### Lifetime rules
|
||||
|
||||
Result sets and rows are valid only while their owning transaction is active.
|
||||
`ResultSet` is valid only while its owning transaction is active.
|
||||
|
||||
`SqlRow` values are detached snapshots once materialized, so this pattern is valid:
|
||||
|
||||
```lyng
|
||||
val rows = db.transaction { tx ->
|
||||
tx.select("select name from person order by id").toList()
|
||||
}
|
||||
|
||||
assertEquals("Ada", rows[0]["name"])
|
||||
```
|
||||
|
||||
This means:
|
||||
|
||||
- do not keep `ResultSet` or `SqlRow` objects after the transaction block returns
|
||||
- copy the values you need into ordinary Lyng objects inside the transaction
|
||||
- do not keep `ResultSet` objects after the transaction block returns
|
||||
- materialize rows with `toList()` inside the transaction when they must outlive it
|
||||
|
||||
The same lifetime rule applies to generated keys returned by `ExecutionResult.getGeneratedKeys()`.
|
||||
The same rule applies to generated keys from `ExecutionResult.getGeneratedKeys()`: the `ResultSet` is transaction-scoped, but rows returned by `toList()` are detached.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -65,7 +65,7 @@ db.transaction { tx ->
|
||||
println(" #" + row["ID"] + " " + row["title"] + " done=" + row["done"] + " due=" + row["due_date"])
|
||||
}
|
||||
|
||||
// If values need to survive after the transaction closes, copy them now.
|
||||
// toList() materializes detached rows that stay usable after transaction close.
|
||||
val snapshot = tx.select("select title, due_date from task order by id").toList()
|
||||
assertEquals("Write a SQLite example", snapshot[0]["title"])
|
||||
assertEquals(Date(2026, 4, 16), snapshot[1]["due_date"])
|
||||
|
||||
@ -21,7 +21,6 @@ import net.sergeych.lyng.Arguments
|
||||
import net.sergeych.lyng.ModuleScope
|
||||
import net.sergeych.lyng.Scope
|
||||
import net.sergeych.lyng.ScopeFacade
|
||||
import net.sergeych.lyng.asFacade
|
||||
import net.sergeych.lyng.obj.Obj
|
||||
import net.sergeych.lyng.obj.ObjBool
|
||||
import net.sergeych.lyng.obj.ObjClass
|
||||
@ -234,12 +233,10 @@ internal class SqlRuntimeTypes private constructor(
|
||||
|
||||
rowClass.addProperty("size", getter = {
|
||||
val self = thisAs<SqlRowObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjInt.of(self.values.size.toLong())
|
||||
})
|
||||
rowClass.addProperty("values", getter = {
|
||||
val self = thisAs<SqlRowObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjImmutableList(self.values)
|
||||
})
|
||||
|
||||
@ -302,14 +299,13 @@ internal class SqlResultSetObj(
|
||||
data: SqlResultSetData,
|
||||
) : Obj() {
|
||||
val columns: List<Obj> = data.columns.map { SqlColumnObj(types, it) }
|
||||
val rows: List<Obj> = buildRows(types, lifetime, data)
|
||||
val rows: List<Obj> = buildRows(types, data)
|
||||
|
||||
override val objClass: ObjClass
|
||||
get() = types.resultSetClass
|
||||
|
||||
private fun buildRows(
|
||||
types: SqlRuntimeTypes,
|
||||
lifetime: SqlTransactionLifetime,
|
||||
data: SqlResultSetData,
|
||||
): List<Obj> {
|
||||
val indexByName = linkedMapOf<String, MutableList<Int>>()
|
||||
@ -317,14 +313,13 @@ internal class SqlResultSetObj(
|
||||
indexByName.getOrPut(column.name.lowercase()) { mutableListOf() }.add(index)
|
||||
}
|
||||
return data.rows.map { rowValues ->
|
||||
SqlRowObj(types, lifetime, rowValues, indexByName)
|
||||
SqlRowObj(types, rowValues, indexByName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class SqlRowObj(
|
||||
val types: SqlRuntimeTypes,
|
||||
val lifetime: SqlTransactionLifetime,
|
||||
val values: List<Obj>,
|
||||
private val indexByName: Map<String, List<Int>>,
|
||||
) : Obj() {
|
||||
@ -332,7 +327,6 @@ internal class SqlRowObj(
|
||||
get() = types.rowClass
|
||||
|
||||
override suspend fun getAt(scope: Scope, index: Obj): Obj {
|
||||
lifetime.ensureActive(scope.asFacade())
|
||||
return when (index) {
|
||||
is ObjInt -> {
|
||||
val idx = index.value.toInt()
|
||||
|
||||
@ -112,6 +112,34 @@ class LyngJdbcModuleTest {
|
||||
assertEquals(3L, result.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMaterializedRowsListCanBeReturnedFromTransaction() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createJdbcModule(scope.importManager)
|
||||
val jdbcModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.jdbc")
|
||||
val db = jdbcModule.callFn("openH2", ObjString("mem:rows_return_${System.nanoTime()};DB_CLOSE_DELAY=-1"))
|
||||
|
||||
val rows = db.invokeInstanceMethod(
|
||||
scope,
|
||||
"transaction",
|
||||
ObjExternCallable.fromBridge {
|
||||
val tx = requiredArg<Obj>(0)
|
||||
tx.invokeInstanceMethod(
|
||||
requireScope(),
|
||||
"execute",
|
||||
ObjString("create table person(id bigint auto_increment primary key, name varchar(120) not null)")
|
||||
)
|
||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into person(name) values(?)"), ObjString("Ada"))
|
||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into person(name) values(?)"), ObjString("Linus"))
|
||||
tx.invokeInstanceMethod(requireScope(), "select", ObjString("select name from person order by id"))
|
||||
.invokeInstanceMethod(requireScope(), "toList")
|
||||
}
|
||||
)
|
||||
|
||||
assertEquals("Ada", (rows.getAt(scope, ObjInt.Zero).getAt(scope, ObjString("name")) as ObjString).value)
|
||||
assertEquals("Linus", (rows.getAt(scope, ObjInt.of(1)).getAt(scope, ObjString("name")) as ObjString).value)
|
||||
}
|
||||
|
||||
private suspend fun scalarSelect(scope: net.sergeych.lyng.Scope, db: Obj, sql: String): Long {
|
||||
val result = db.invokeInstanceMethod(
|
||||
scope,
|
||||
|
||||
@ -250,7 +250,7 @@ class LyngSqliteModuleTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRowFailsAfterTransactionEnds() = runTest {
|
||||
fun testMaterializedRowSurvivesAfterTransactionEnds() = runTest {
|
||||
val scope = Script.newScope()
|
||||
val db = openMemoryDb(scope)
|
||||
var leakedRow: Obj = ObjNull
|
||||
@ -265,12 +265,34 @@ class LyngSqliteModuleTest {
|
||||
}
|
||||
)
|
||||
|
||||
val error = assertFailsWith<ExecutionError> {
|
||||
leakedRow.getAt(scope, ObjString("answer"))
|
||||
}
|
||||
val answer = leakedRow.getAt(scope, ObjString("answer")) as ObjInt
|
||||
assertEquals(42L, answer.value)
|
||||
}
|
||||
|
||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
||||
assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage)
|
||||
@Test
|
||||
fun testMaterializedRowsListCanBeReturnedFromTransaction() = runTest {
|
||||
val scope = Script.newScope()
|
||||
val db = openMemoryDb(scope)
|
||||
|
||||
val rows = db.invokeInstanceMethod(
|
||||
scope,
|
||||
"transaction",
|
||||
ObjExternCallable.fromBridge {
|
||||
val tx = requiredArg<Obj>(0)
|
||||
tx.invokeInstanceMethod(
|
||||
requireScope(),
|
||||
"execute",
|
||||
ObjString("create table items(id integer primary key autoincrement, name text not null)")
|
||||
)
|
||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into items(name) values(?)"), ObjString("alpha"))
|
||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into items(name) values(?)"), ObjString("beta"))
|
||||
tx.invokeInstanceMethod(requireScope(), "select", ObjString("select name from items order by id"))
|
||||
.invokeInstanceMethod(requireScope(), "toList")
|
||||
}
|
||||
)
|
||||
|
||||
assertEquals("alpha", stringValue(scope, rows.getAt(scope, ObjInt.Zero).getAt(scope, ObjString("name"))))
|
||||
assertEquals("beta", stringValue(scope, rows.getAt(scope, ObjInt.of(1)).getAt(scope, ObjString("name"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -186,7 +186,7 @@ class LyngSqliteModuleNativeTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRowFailsAfterTransactionEnds() = runTest {
|
||||
fun testMaterializedRowSurvivesAfterTransactionEnds() = runTest {
|
||||
val scope = Script.newScope()
|
||||
val db = openMemoryDb(scope)
|
||||
var leakedRow: Obj = ObjNull
|
||||
@ -201,12 +201,34 @@ class LyngSqliteModuleNativeTest {
|
||||
}
|
||||
)
|
||||
|
||||
val error = assertFailsWith<ExecutionError> {
|
||||
leakedRow.getAt(scope, ObjString("answer"))
|
||||
}
|
||||
val answer = leakedRow.getAt(scope, ObjString("answer")) as ObjInt
|
||||
assertEquals(42L, answer.value)
|
||||
}
|
||||
|
||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
||||
assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage)
|
||||
@Test
|
||||
fun testMaterializedRowsListCanBeReturnedFromTransaction() = runTest {
|
||||
val scope = Script.newScope()
|
||||
val db = openMemoryDb(scope)
|
||||
|
||||
val rows = db.invokeInstanceMethod(
|
||||
scope,
|
||||
"transaction",
|
||||
ObjExternCallable.fromBridge {
|
||||
val tx = requiredArg<Obj>(0)
|
||||
tx.invokeInstanceMethod(
|
||||
requireScope(),
|
||||
"execute",
|
||||
ObjString("create table items(id integer primary key autoincrement, name text not null)")
|
||||
)
|
||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into items(name) values(?)"), ObjString("alpha"))
|
||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into items(name) values(?)"), ObjString("beta"))
|
||||
tx.invokeInstanceMethod(requireScope(), "select", ObjString("select name from items order by id"))
|
||||
.invokeInstanceMethod(requireScope(), "toList")
|
||||
}
|
||||
)
|
||||
|
||||
assertEquals("alpha", stringValue(scope, rows.getAt(scope, ObjInt.Zero).getAt(scope, ObjString("name"))))
|
||||
assertEquals("beta", stringValue(scope, rows.getAt(scope, ObjInt.of(1)).getAt(scope, ObjString("name"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -44,11 +44,11 @@ extern class SqlRow {
|
||||
- iteration to the end or canceled iteration should close the underlying
|
||||
resources automatically
|
||||
- using the result set after its transaction ends is invalid
|
||||
- rows obtained from the result set are also invalid after the owning
|
||||
transaction ends, even if the implementation had already buffered them
|
||||
- rows obtained from the result set stay usable after the owning
|
||||
transaction ends once they have been materialized
|
||||
|
||||
If user code wants row data to survive independently, it should copy the
|
||||
values it needs into ordinary Lyng objects while the transaction is active.
|
||||
Calling `toList()` while the transaction is active is the normal way to
|
||||
detach rows for use after the transaction block returns.
|
||||
*/
|
||||
extern class ResultSet : Iterable<SqlRow> {
|
||||
/*
|
||||
@ -84,7 +84,8 @@ extern class ExecutionResult {
|
||||
other result set.
|
||||
|
||||
If the statement produced no generated values, the returned result set
|
||||
is empty.
|
||||
is empty. Call `toList()` if generated-key rows must outlive the
|
||||
transaction.
|
||||
*/
|
||||
fun getGeneratedKeys(): ResultSet
|
||||
}
|
||||
|
||||
@ -61,10 +61,10 @@ Notes:
|
||||
rather than silently degrading to some other visible type.
|
||||
- `ResultSet` should stay iterable, but also expose `isEmpty()` for cheap
|
||||
emptiness checks where possible and `size()` as a separate operation.
|
||||
- `ResultSet` and all `SqlRow` instances obtained from it are valid only while
|
||||
the owning transaction is active. After transaction end, any further row or
|
||||
result-set access should fail with `SqlUsageException`, even if the provider
|
||||
had buffered data internally.
|
||||
- `ResultSet` is valid only while the owning transaction is active.
|
||||
- Materialized `SqlRow` values should be detached snapshots, so
|
||||
`transaction { tx.select(...).toList() }` is a valid pattern and the rows
|
||||
remain usable after transaction end.
|
||||
- Portable SQL parameter values should match the row conversion set: `null`,
|
||||
`Bool`, `Int`, `Double`, `Decimal`, `String`, `Buffer`,
|
||||
`Date`, `DateTime`, and `Instant`.
|
||||
|
||||
@ -42,11 +42,11 @@ class SqlRow(
|
||||
- iteration to the end or canceled iteration should close the underlying
|
||||
resources automatically
|
||||
- using the result set after its transaction ends is invalid
|
||||
- rows obtained from the result set are also invalid after the owning
|
||||
transaction ends, even if the implementation had already buffered them
|
||||
- rows obtained from the result set stay usable after the owning
|
||||
transaction ends once they have been materialized
|
||||
|
||||
If user code wants row data to survive independently, it should copy the
|
||||
values it needs into ordinary Lyng objects while the transaction is active.
|
||||
Calling [toList] while the transaction is active is the normal way to
|
||||
detach rows for use after the transaction block returns.
|
||||
*/
|
||||
interface ResultSet : Iterable<SqlRow> {
|
||||
/*
|
||||
|
||||
@ -96,8 +96,8 @@ row-id behavior are all connection-local.
|
||||
The provider may stream rows or buffer them, but it must preserve the core
|
||||
contract:
|
||||
- result sets are valid only while the owning transaction is active
|
||||
- rows obtained from a result set are also invalid after the owning
|
||||
transaction ends, even if they were already buffered
|
||||
- rows obtained from a result set should stay usable after the owning
|
||||
transaction ends once they were materialized, e.g. with `toList()`
|
||||
- iteration closes underlying resources when finished or canceled
|
||||
- `isEmpty()` should be cheap where possible
|
||||
- `size()` may consume or buffer the full result
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user