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