Harden SQLite provider release behavior

This commit is contained in:
Sergey Chernov 2026-04-15 23:11:50 +03:00
parent f9bbdd56bf
commit 14dc73db3e
4 changed files with 370 additions and 1 deletions

View File

@ -465,7 +465,12 @@ private fun mapOpenException(scope: ScopeFacade, core: SqliteCoreModule, e: SQLE
if ("malformed" in lower || "no such access mode" in lower || "invalid uri" in lower) { if ("malformed" in lower || "no such access mode" in lower || "invalid uri" in lower) {
scope.raiseIllegalArgument(message) scope.raiseIllegalArgument(message)
} }
throw mapSqlException(scope, core, e) throw ExecutionError(
ObjException(core.databaseException, scope.requireScope(), ObjString(message)),
scope.pos,
message,
e,
)
} }
private fun mapSqlException(scope: ScopeFacade, core: SqliteCoreModule, e: SQLException): ExecutionError { private fun mapSqlException(scope: ScopeFacade, core: SqliteCoreModule, e: SQLException): ExecutionError {

View File

@ -212,6 +212,30 @@ class LyngSqliteModuleTest {
assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage) assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage)
} }
@Test
fun testRowFailsAfterTransactionEnds() = runTest {
val scope = Script.newScope()
val db = openMemoryDb(scope)
var leakedRow: Obj = ObjNull
db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
leakedRow = rowsOf(requireScope(), tx.invokeInstanceMethod(requireScope(), "select", ObjString("select 42 as answer")))[0]
ObjNull
}
)
val error = assertFailsWith<ExecutionError> {
leakedRow.getAt(scope, ObjString("answer"))
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage)
}
@Test @Test
fun testInvalidSqliteUrlFailsWithIllegalArgument() = runTest { fun testInvalidSqliteUrlFailsWithIllegalArgument() = runTest {
val scope = Script.newScope() val scope = Script.newScope()
@ -594,6 +618,151 @@ class LyngSqliteModuleTest {
} }
} }
@Test
fun testMissingFileWithCreateIfMissingFalseFailsWithDatabaseException() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
val missingDir = Files.createTempDirectory("lyng-sqlite-missing-dir-")
missingDir.deleteIfExists()
val missingFile = missingDir.resolve("missing.db")
try {
val db = sqliteModule.callFn(
"openSqlite",
ObjString(missingFile.toString()),
net.sergeych.lyng.obj.ObjFalse,
net.sergeych.lyng.obj.ObjFalse
)
val error = assertFailsWith<ExecutionError> {
db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge { ObjNull }
)
}
assertEquals("DatabaseException", error.errorObject.objClass.className)
} finally {
missingFile.deleteIfExists()
missingDir.deleteIfExists()
}
}
@Test
fun testGenericOpenDatabaseReadOnlyOptionMatchesTypedHelper() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
val dbModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db")
val tempFile = Files.createTempFile("lyng-sqlite-generic-", ".db")
try {
val writableDb = dbModule.callFn("openDatabase", ObjString("sqlite:${tempFile}"), sqliteOptions(scope))
writableDb.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("create table item(id integer primary key autoincrement, name text not null)")
)
}
)
val readOnlyDb = dbModule.callFn(
"openDatabase",
ObjString("sqlite:${tempFile}"),
sqliteOptions(
scope,
"readOnly" to net.sergeych.lyng.obj.ObjTrue,
"createIfMissing" to net.sergeych.lyng.obj.ObjFalse
)
)
val error = assertFailsWith<ExecutionError> {
readOnlyDb.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("insert into item(name) values(?)"),
ObjString("blocked")
)
}
)
}
assertEquals("SqlExecutionException", error.errorObject.objClass.className)
} finally {
tempFile.deleteIfExists()
}
}
@Test
fun testForeignKeysOptionControlsConstraintEnforcement() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
val tempFile = Files.createTempFile("lyng-sqlite-fk-", ".db")
try {
val dbNoFk = sqliteModule.callFn(
"openSqlite",
ObjString(tempFile.toString()),
net.sergeych.lyng.obj.ObjFalse,
net.sergeych.lyng.obj.ObjTrue,
net.sergeych.lyng.obj.ObjFalse
)
dbNoFk.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("create table parent(id integer primary key)"))
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("create table child(parent_id integer not null references parent(id))")
)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("insert into child(parent_id) values(?)"),
ObjInt.One
)
}
)
val dbWithFk = sqliteModule.callFn("openSqlite", ObjString(tempFile.toString()))
val error = assertFailsWith<ExecutionError> {
dbWithFk.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("insert into child(parent_id) values(?)"),
ObjInt.of(2)
)
}
)
}
assertEquals("SqlConstraintException", error.errorObject.objClass.className)
} finally {
tempFile.deleteIfExists()
}
}
@Test @Test
fun testCommitFailureBecomesPrimaryAfterNormalCompletion() = runTest { fun testCommitFailureBecomesPrimaryAfterNormalCompletion() = runTest {
val scope = Script.newScope() val scope = Script.newScope()
@ -658,6 +827,14 @@ class LyngSqliteModuleTest {
return callee.invoke(this, ObjNull, *args) return callee.invoke(this, ObjNull, *args)
} }
private suspend fun sqliteOptions(scope: Scope, vararg entries: Pair<String, Obj>): ObjMap {
val result = ObjMap()
for ((key, value) in entries) {
result.putAt(scope, ObjString(key), value)
}
return result
}
private suspend fun openMemoryDb(scope: Scope): Obj { private suspend fun openMemoryDb(scope: Scope): Obj {
createSqliteModule(scope.importManager) createSqliteModule(scope.importManager)
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite") val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")

View File

@ -185,6 +185,30 @@ class LyngSqliteModuleNativeTest {
assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage) assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage)
} }
@Test
fun testRowFailsAfterTransactionEnds() = runTest {
val scope = Script.newScope()
val db = openMemoryDb(scope)
var leakedRow: Obj = ObjNull
db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
leakedRow = rowsOf(requireScope(), tx.invokeInstanceMethod(requireScope(), "select", ObjString("select 42 as answer")))[0]
ObjNull
}
)
val error = assertFailsWith<ExecutionError> {
leakedRow.getAt(scope, ObjString("answer"))
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage)
}
@Test @Test
fun testExecuteRejectsReturningButSelectSupportsIt() = runTest { fun testExecuteRejectsReturningButSelectSupportsIt() = runTest {
val scope = Script.newScope() val scope = Script.newScope()
@ -489,6 +513,145 @@ class LyngSqliteModuleNativeTest {
} }
} }
@Test
fun testMissingFileWithCreateIfMissingFalseFailsWithDatabaseException() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
val missingDir = "/tmp/lyng-sqlite-missing-${Random.nextInt(Int.MAX_VALUE)}".toPath()
val missingFile = (missingDir.toString() + "/missing.db").toPath()
try {
FileSystem.SYSTEM.delete(missingFile, mustExist = false)
FileSystem.SYSTEM.delete(missingDir, mustExist = false)
val db = sqliteModule.callFn(
"openSqlite",
ObjString(missingFile.toString()),
net.sergeych.lyng.obj.ObjFalse,
net.sergeych.lyng.obj.ObjFalse
)
val error = assertFailsWith<ExecutionError> {
db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge { ObjNull }
)
}
assertEquals("DatabaseException", error.errorObject.objClass.className)
} finally {
FileSystem.SYSTEM.delete(missingFile, mustExist = false)
FileSystem.SYSTEM.delete(missingDir, mustExist = false)
}
}
@Test
fun testGenericOpenDatabaseReadOnlyOptionMatchesTypedHelper() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
val dbModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db")
withTempPath { tempPath ->
val writableDb = dbModule.callFn("openDatabase", ObjString("sqlite:${tempPath}"), sqliteOptions(scope))
writableDb.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("create table item(id integer primary key autoincrement, name text not null)")
)
}
)
val readOnlyDb = dbModule.callFn(
"openDatabase",
ObjString("sqlite:${tempPath}"),
sqliteOptions(
scope,
"readOnly" to net.sergeych.lyng.obj.ObjTrue,
"createIfMissing" to net.sergeych.lyng.obj.ObjFalse
)
)
val error = assertFailsWith<ExecutionError> {
readOnlyDb.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("insert into item(name) values(?)"),
ObjString("blocked")
)
}
)
}
assertEquals("SqlExecutionException", error.errorObject.objClass.className)
}
}
@Test
fun testForeignKeysOptionControlsConstraintEnforcement() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
withTempPath { tempPath ->
val dbNoFk = sqliteModule.callFn(
"openSqlite",
ObjString(tempPath.toString()),
net.sergeych.lyng.obj.ObjFalse,
net.sergeych.lyng.obj.ObjTrue,
net.sergeych.lyng.obj.ObjFalse
)
dbNoFk.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("create table parent(id integer primary key)"))
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("create table child(parent_id integer not null references parent(id))")
)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("insert into child(parent_id) values(?)"),
ObjInt.One
)
}
)
val dbWithFk = sqliteModule.callFn("openSqlite", ObjString(tempPath.toString()))
val error = assertFailsWith<ExecutionError> {
dbWithFk.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("insert into child(parent_id) values(?)"),
ObjInt.of(2)
)
}
)
}
assertEquals("SqlConstraintException", error.errorObject.objClass.className)
}
}
@Test @Test
fun testCommitFailureBecomesPrimaryAfterNormalCompletion() = runTest { fun testCommitFailureBecomesPrimaryAfterNormalCompletion() = runTest {
val scope = Script.newScope() val scope = Script.newScope()
@ -553,6 +716,14 @@ class LyngSqliteModuleNativeTest {
return callee.invoke(this, ObjNull, *args) return callee.invoke(this, ObjNull, *args)
} }
private suspend fun sqliteOptions(scope: Scope, vararg entries: Pair<String, Obj>): ObjMap {
val result = ObjMap()
for ((key, value) in entries) {
result.putAt(scope, ObjString(key), value)
}
return result
}
private suspend fun openMemoryDb(scope: Scope): Obj { private suspend fun openMemoryDb(scope: Scope): Obj {
createSqliteModule(scope.importManager) createSqliteModule(scope.importManager)
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite") val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")

View File

@ -8,6 +8,22 @@ import lyng.io.db
Importing this module registers the `sqlite:` URL scheme for Importing this module registers the `sqlite:` URL scheme for
`openDatabase(...)`. `openDatabase(...)`.
Accepted generic URL forms:
- `sqlite::memory:`
- `sqlite:relative/path.db`
- `sqlite:/absolute/path.db`
Provider-specific `openDatabase(..., extraParams)` options:
- `readOnly: Bool`
- `createIfMissing: Bool`
- `foreignKeys: Bool`
- `busyTimeoutMillis: Int`
Open-time URL/config validation failures should surface as
`IllegalArgumentException`.
Runtime SQLite open failures such as opening a missing file with
`createIfMissing = false` should surface as `DatabaseException`.
SQLite provider defaults: SQLite provider defaults:
- `Bool` is written as `0` / `1` - `Bool` is written as `0` / `1`
- `Decimal` is written as canonical text - `Decimal` is written as canonical text