diff --git a/lyngio/src/jvmMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformJvm.kt b/lyngio/src/jvmMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformJvm.kt index 0933042..e852d23 100644 --- a/lyngio/src/jvmMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformJvm.kt +++ b/lyngio/src/jvmMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformJvm.kt @@ -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) { 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 { 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 9961d94..81130ed 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 @@ -212,6 +212,30 @@ class LyngSqliteModuleTest { 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(0) + leakedRow = rowsOf(requireScope(), tx.invokeInstanceMethod(requireScope(), "select", ObjString("select 42 as answer")))[0] + ObjNull + } + ) + + val error = assertFailsWith { + leakedRow.getAt(scope, ObjString("answer")) + } + + assertEquals("SqlUsageException", error.errorObject.objClass.className) + assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage) + } + @Test fun testInvalidSqliteUrlFailsWithIllegalArgument() = runTest { 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 { + 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(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 { + readOnlyDb.invokeInstanceMethod( + scope, + "transaction", + ObjExternCallable.fromBridge { + val tx = requiredArg(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(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 { + dbWithFk.invokeInstanceMethod( + scope, + "transaction", + ObjExternCallable.fromBridge { + val tx = requiredArg(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 fun testCommitFailureBecomesPrimaryAfterNormalCompletion() = runTest { val scope = Script.newScope() @@ -658,6 +827,14 @@ class LyngSqliteModuleTest { return callee.invoke(this, ObjNull, *args) } + private suspend fun sqliteOptions(scope: Scope, vararg entries: Pair): ObjMap { + val result = ObjMap() + for ((key, value) in entries) { + result.putAt(scope, ObjString(key), value) + } + return result + } + private suspend fun openMemoryDb(scope: Scope): Obj { createSqliteModule(scope.importManager) val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite") 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 2365dda..accddaa 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 @@ -185,6 +185,30 @@ class LyngSqliteModuleNativeTest { 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(0) + leakedRow = rowsOf(requireScope(), tx.invokeInstanceMethod(requireScope(), "select", ObjString("select 42 as answer")))[0] + ObjNull + } + ) + + val error = assertFailsWith { + leakedRow.getAt(scope, ObjString("answer")) + } + + assertEquals("SqlUsageException", error.errorObject.objClass.className) + assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage) + } + @Test fun testExecuteRejectsReturningButSelectSupportsIt() = runTest { 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 { + 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(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 { + readOnlyDb.invokeInstanceMethod( + scope, + "transaction", + ObjExternCallable.fromBridge { + val tx = requiredArg(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(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 { + dbWithFk.invokeInstanceMethod( + scope, + "transaction", + ObjExternCallable.fromBridge { + val tx = requiredArg(0) + tx.invokeInstanceMethod( + requireScope(), + "execute", + ObjString("insert into child(parent_id) values(?)"), + ObjInt.of(2) + ) + } + ) + } + + assertEquals("SqlConstraintException", error.errorObject.objClass.className) + } + } + @Test fun testCommitFailureBecomesPrimaryAfterNormalCompletion() = runTest { val scope = Script.newScope() @@ -553,6 +716,14 @@ class LyngSqliteModuleNativeTest { return callee.invoke(this, ObjNull, *args) } + private suspend fun sqliteOptions(scope: Scope, vararg entries: Pair): ObjMap { + val result = ObjMap() + for ((key, value) in entries) { + result.putAt(scope, ObjString(key), value) + } + return result + } + private suspend fun openMemoryDb(scope: Scope): Obj { createSqliteModule(scope.importManager) val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite") diff --git a/lyngio/stdlib/lyng/io/db_sqlite.lyng b/lyngio/stdlib/lyng/io/db_sqlite.lyng index 63baaac..7ddd198 100644 --- a/lyngio/stdlib/lyng/io/db_sqlite.lyng +++ b/lyngio/stdlib/lyng/io/db_sqlite.lyng @@ -8,6 +8,22 @@ import lyng.io.db Importing this module registers the `sqlite:` URL scheme for `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: - `Bool` is written as `0` / `1` - `Decimal` is written as canonical text