From 49fc7002333b5db79a4764286d637bcb9ef77aff Mon Sep 17 00:00:00 2001 From: sergeych Date: Fri, 17 Apr 2026 20:38:40 +0300 Subject: [PATCH] Detach materialized SQL rows from transaction lifetime --- docs/lyng.io.db.md | 21 +++++++++--- examples/sqlite_basic.lyng | 2 +- .../sergeych/lyng/io/db/SqlRuntimeSupport.kt | 10 ++---- .../lyng/io/db/jdbc/LyngJdbcModuleTest.kt | 28 +++++++++++++++ .../lyng/io/db/sqlite/LyngSqliteModuleTest.kt | 34 +++++++++++++++---- .../db/sqlite/LyngSqliteModuleNativeTest.kt | 34 +++++++++++++++---- lyngio/stdlib/lyng/io/db.lyng | 11 +++--- notes/db/db_interface.md | 8 ++--- notes/db/lyngdb.lyng | 8 ++--- notes/db/sqlite_provider.md | 4 +-- 10 files changed, 119 insertions(+), 41 deletions(-) diff --git a/docs/lyng.io.db.md b/docs/lyng.io.db.md index 18095cd..7236a7e 100644 --- a/docs/lyng.io.db.md +++ b/docs/lyng.io.db.md @@ -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. --- diff --git a/examples/sqlite_basic.lyng b/examples/sqlite_basic.lyng index 98f24e6..a8eb705 100644 --- a/examples/sqlite_basic.lyng +++ b/examples/sqlite_basic.lyng @@ -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"]) diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/SqlRuntimeSupport.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/SqlRuntimeSupport.kt index 40766b7..d0d558a 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/SqlRuntimeSupport.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/SqlRuntimeSupport.kt @@ -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() - self.lifetime.ensureActive(this) ObjInt.of(self.values.size.toLong()) }) rowClass.addProperty("values", getter = { val self = thisAs() - self.lifetime.ensureActive(this) ObjImmutableList(self.values) }) @@ -302,14 +299,13 @@ internal class SqlResultSetObj( data: SqlResultSetData, ) : Obj() { val columns: List = data.columns.map { SqlColumnObj(types, it) } - val rows: List = buildRows(types, lifetime, data) + val rows: List = buildRows(types, data) override val objClass: ObjClass get() = types.resultSetClass private fun buildRows( types: SqlRuntimeTypes, - lifetime: SqlTransactionLifetime, data: SqlResultSetData, ): List { val indexByName = linkedMapOf>() @@ -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, private val indexByName: Map>, ) : 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() diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/jdbc/LyngJdbcModuleTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/jdbc/LyngJdbcModuleTest.kt index 301266d..a871e59 100644 --- a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/jdbc/LyngJdbcModuleTest.kt +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/jdbc/LyngJdbcModuleTest.kt @@ -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(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, diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleTest.kt index 8f249d0..89f924c 100644 --- a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleTest.kt +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleTest.kt @@ -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 { - 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(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 diff --git a/lyngio/src/linuxTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleNativeTest.kt b/lyngio/src/linuxTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleNativeTest.kt index accddaa..ac4ac41 100644 --- a/lyngio/src/linuxTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleNativeTest.kt +++ b/lyngio/src/linuxTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleNativeTest.kt @@ -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 { - 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(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 diff --git a/lyngio/stdlib/lyng/io/db.lyng b/lyngio/stdlib/lyng/io/db.lyng index 205dc9c..65f5410 100644 --- a/lyngio/stdlib/lyng/io/db.lyng +++ b/lyngio/stdlib/lyng/io/db.lyng @@ -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 { /* @@ -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 } diff --git a/notes/db/db_interface.md b/notes/db/db_interface.md index 0ae0700..942600b 100644 --- a/notes/db/db_interface.md +++ b/notes/db/db_interface.md @@ -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`. diff --git a/notes/db/lyngdb.lyng b/notes/db/lyngdb.lyng index acf7a26..9438330 100644 --- a/notes/db/lyngdb.lyng +++ b/notes/db/lyngdb.lyng @@ -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 { /* diff --git a/notes/db/sqlite_provider.md b/notes/db/sqlite_provider.md index ea0b27a..7c4db36 100644 --- a/notes/db/sqlite_provider.md +++ b/notes/db/sqlite_provider.md @@ -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