Add Lyng DB contract and SQLite provider skeleton

This commit is contained in:
sergeych committed 2026-04-15 20:39:38 +03:00
1 parent b42ceec686
commit 55ba6113e7
21 files changed
+3860

No files matched your search

+22
View File
@@ -58,6 +58,27 @@ kotlin {
// nodejs()
// }
targets.withType(org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget::class.java).configureEach {
compilations.getByName("main").cinterops.create("sqlite3") {
defFile(project.file("src/nativeInterop/cinterop/sqlite/sqlite3.def"))
packageName("net.sergeych.lyng.io.db.sqlite.cinterop")
includeDirs(project.file("src/nativeInterop/cinterop/sqlite"))
}
binaries.all {
when (konanTarget.name) {
"linux_x64" -> linkerOpts(
"-L/lib/x86_64-linux-gnu",
"-l:libsqlite3.so.0",
"-ldl",
"-lpthread",
"-lm",
"-Wl,--allow-shlib-undefined"
)
else -> linkerOpts("-lsqlite3")
}
}
}
// Keep expect/actual warning suppressed consistently with other modules
targets.configureEach {
compilations.configureEach {
@@ -155,6 +176,7 @@ kotlin {
implementation("org.jline:jline-terminal:3.29.0")
implementation(libs.ktor.client.cio)
implementation(libs.ktor.network)
implementation(libs.sqlite.jdbc)
}
}
// // For Wasm we use in-memory VFS for now
@@ -0,0 +1,37 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.io.db.sqlite
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.requireScope
import net.sergeych.lyng.obj.ObjException
import net.sergeych.lyng.obj.ObjString
internal actual suspend fun openSqliteBackend(
scope: ScopeFacade,
core: SqliteCoreModule,
options: SqliteOpenOptions,
): SqliteDatabaseBackend {
scope.raiseError(
ObjException(
core.databaseException,
scope.requireScope(),
ObjString("SQLite provider is not implemented on this platform yet")
)
)
}
@@ -0,0 +1,142 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.io.db
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.Source
import net.sergeych.lyng.Arguments
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjException
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.ObjVoid
import net.sergeych.lyng.obj.requiredArg
import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyng.requireScope
import net.sergeych.lyngio.stdlib_included.dbLyng
private const val DB_MODULE_NAME = "lyng.io.db"
fun createDbModule(scope: Scope): Boolean = createDbModule(scope.importManager)
fun createDb(scope: Scope): Boolean = createDbModule(scope)
fun createDbModule(manager: ImportManager): Boolean {
if (manager.packageNames.contains(DB_MODULE_NAME)) return false
manager.addPackage(DB_MODULE_NAME) { module ->
buildDbModule(module)
}
return true
}
fun createDb(manager: ImportManager): Boolean = createDbModule(manager)
private suspend fun buildDbModule(module: ModuleScope) {
module.eval(Source(DB_MODULE_NAME, dbLyng))
val exceptions = installDbExceptionClasses(module)
val registry = DbProviderRegistry()
module.addFn("registerDatabaseProvider") {
val scheme = requiredArg<ObjString>(0).value
val opener = args.list.getOrNull(1)
?: raiseError("Expected exactly 2 arguments, got ${args.list.size}")
registry.register(this, scheme, opener)
ObjVoid
}
module.addFn("openDatabase") {
val connectionUrl = requiredArg<ObjString>(0).value
val extraParams = args.list.getOrNull(1)
?: raiseError("Expected exactly 2 arguments, got ${args.list.size}")
if (!extraParams.isInstanceOf("Map")) {
raiseIllegalArgument("extraParams must be Map")
}
val scheme = parseConnectionScheme(connectionUrl)
?: raiseIllegalArgument("Malformed database connection URL: $connectionUrl")
val opener = registry.providers[scheme]
?: raiseDatabaseException(exceptions.database, "No database provider registered for scheme '$scheme'")
call(opener, Arguments(listOf(ObjString(connectionUrl), extraParams)), newThisObj = ObjNull)
}
}
private data class DbExceptionClasses(
val database: ObjException.Companion.ExceptionClass,
val sqlExecution: ObjException.Companion.ExceptionClass,
val sqlConstraint: ObjException.Companion.ExceptionClass,
val sqlUsage: ObjException.Companion.ExceptionClass,
val rollback: ObjException.Companion.ExceptionClass,
)
private fun installDbExceptionClasses(module: ModuleScope): DbExceptionClasses {
val database = ObjException.Companion.ExceptionClass("DatabaseException", ObjException.Root)
val sqlExecution = ObjException.Companion.ExceptionClass("SqlExecutionException", database)
val sqlConstraint = ObjException.Companion.ExceptionClass("SqlConstraintException", sqlExecution)
val sqlUsage = ObjException.Companion.ExceptionClass("SqlUsageException", database)
val rollback = ObjException.Companion.ExceptionClass("RollbackException", ObjException.Root)
module.addConst("DatabaseException", database)
module.addConst("SqlExecutionException", sqlExecution)
module.addConst("SqlConstraintException", sqlConstraint)
module.addConst("SqlUsageException", sqlUsage)
module.addConst("RollbackException", rollback)
return DbExceptionClasses(
database = database,
sqlExecution = sqlExecution,
sqlConstraint = sqlConstraint,
sqlUsage = sqlUsage,
rollback = rollback,
)
}
private class DbProviderRegistry {
val providers: MutableMap<String, Obj> = linkedMapOf()
fun register(scope: ScopeFacade, rawScheme: String, opener: Obj) {
val scheme = normalizeScheme(rawScheme)
?: scope.raiseIllegalArgument("Database provider scheme must not be empty")
if (!opener.isInstanceOf("Callable")) {
scope.raiseIllegalArgument("Database provider opener must be callable")
}
if (providers.containsKey(scheme)) {
scope.raiseIllegalState("Database provider already registered for scheme '$scheme'")
}
providers[scheme] = opener
}
}
private fun normalizeScheme(rawScheme: String): String? {
val trimmed = rawScheme.trim()
if (trimmed.isEmpty()) return null
if (':' in trimmed) return null
return trimmed.lowercase()
}
private fun parseConnectionScheme(connectionUrl: String): String? {
val colonIndex = connectionUrl.indexOf(':')
if (colonIndex <= 0) return null
return normalizeScheme(connectionUrl.substring(0, colonIndex))
}
private fun ScopeFacade.raiseDatabaseException(
exceptionClass: ObjException.Companion.ExceptionClass,
message: String,
): Nothing = raiseError(ObjException(exceptionClass, requireScope(), ObjString(message)))
@@ -0,0 +1,542 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.io.db.sqlite
import net.sergeych.lyng.Arguments
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.Source
import net.sergeych.lyng.asFacade
import net.sergeych.lyng.io.db.createDbModule
import net.sergeych.lyng.requireScope
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjClass
import net.sergeych.lyng.obj.ObjEnumClass
import net.sergeych.lyng.obj.ObjEnumEntry
import net.sergeych.lyng.obj.ObjException
import net.sergeych.lyng.obj.ObjImmutableList
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjReal
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.ObjVoid
import net.sergeych.lyng.obj.requiredArg
import net.sergeych.lyng.obj.thisAs
import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyngio.stdlib_included.db_sqliteLyng
private const val SQLITE_MODULE_NAME = "lyng.io.db.sqlite"
private const val DB_MODULE_NAME = "lyng.io.db"
fun createSqliteModule(scope: Scope): Boolean = createSqliteModule(scope.importManager)
fun createSqlite(scope: Scope): Boolean = createSqliteModule(scope)
fun createSqliteModule(manager: ImportManager): Boolean {
createDbModule(manager)
if (manager.packageNames.contains(SQLITE_MODULE_NAME)) return false
manager.addPackage(SQLITE_MODULE_NAME) { module ->
buildSqliteModule(module)
}
return true
}
fun createSqlite(manager: ImportManager): Boolean = createSqliteModule(manager)
private suspend fun buildSqliteModule(module: ModuleScope) {
module.eval(Source(SQLITE_MODULE_NAME, db_sqliteLyng))
val dbModule = module.importProvider.createModuleScope(Pos.builtIn, DB_MODULE_NAME)
val core = SqliteCoreModule.resolve(dbModule)
val runtimeTypes = SqliteRuntimeTypes.create(core)
module.addFn("openSqlite") {
val options = parseOpenSqliteArgs(this)
SqliteDatabaseObj(runtimeTypes, openSqliteBackend(this, core, options))
}
dbModule.callFn(
"registerDatabaseProvider",
ObjString("sqlite"),
net.sergeych.lyng.obj.ObjExternCallable.fromBridge {
val connectionUrl = requiredArg<ObjString>(0).value
val extraParams = args.list.getOrNull(1)
?: raiseError("Expected exactly 2 arguments, got ${args.list.size}")
val options = parseSqliteConnectionUrl(this, connectionUrl, extraParams)
SqliteDatabaseObj(runtimeTypes, openSqliteBackend(this, core, options))
}
)
}
private suspend fun parseOpenSqliteArgs(scope: ScopeFacade): SqliteOpenOptions {
val pathValue = readArg(scope, "path", 0) ?: scope.raiseError("argument 'path' is required")
val path = (pathValue as? ObjString)?.value ?: scope.raiseClassCastError("path must be String")
val readOnly = readBoolArg(scope, "readOnly", 1, false)
val createIfMissing = readBoolArg(scope, "createIfMissing", 2, true)
val foreignKeys = readBoolArg(scope, "foreignKeys", 3, true)
val busyTimeoutMillis = readIntArg(scope, "busyTimeoutMillis", 4, 5000)
return SqliteOpenOptions(
path = normalizeSqlitePath(path, scope),
readOnly = readOnly,
createIfMissing = createIfMissing,
foreignKeys = foreignKeys,
busyTimeoutMillis = busyTimeoutMillis,
)
}
private suspend fun parseSqliteConnectionUrl(
scope: ScopeFacade,
connectionUrl: String,
extraParams: Obj,
): SqliteOpenOptions {
val prefix = "sqlite:"
if (!connectionUrl.startsWith(prefix, ignoreCase = true)) {
scope.raiseIllegalArgument("Malformed SQLite connection URL: $connectionUrl")
}
val rawPath = connectionUrl.substring(prefix.length)
val path = normalizeSqlitePath(rawPath, scope)
val readOnly = mapBool(extraParams, scope, "readOnly") ?: false
val createIfMissing = mapBool(extraParams, scope, "createIfMissing") ?: true
val foreignKeys = mapBool(extraParams, scope, "foreignKeys") ?: true
val busyTimeoutMillis = mapInt(extraParams, scope, "busyTimeoutMillis") ?: 5000
return SqliteOpenOptions(
path = path,
readOnly = readOnly,
createIfMissing = createIfMissing,
foreignKeys = foreignKeys,
busyTimeoutMillis = busyTimeoutMillis,
)
}
private fun normalizeSqlitePath(rawPath: String, scope: ScopeFacade): String {
val path = rawPath.trim()
if (path.isEmpty()) {
scope.raiseIllegalArgument("SQLite path must not be empty")
}
if (path.startsWith("//")) {
scope.raiseIllegalArgument("Unsupported SQLite URL form: sqlite:$path")
}
return path
}
private suspend fun readArg(scope: ScopeFacade, name: String, position: Int): Obj? {
val named = scope.args.named[name]
val positional = scope.args.list.getOrNull(position)
if (named != null && positional != null) {
scope.raiseIllegalArgument("argument '$name' is already set")
}
return named ?: positional
}
private suspend fun readBoolArg(scope: ScopeFacade, name: String, position: Int, default: Boolean): Boolean {
val value = readArg(scope, name, position) ?: return default
return (value as? ObjBool)?.value ?: scope.raiseClassCastError("$name must be Bool")
}
private suspend fun readIntArg(scope: ScopeFacade, name: String, position: Int, default: Int): Int {
val value = readArg(scope, name, position) ?: return default
return when (value) {
is ObjInt -> value.value.toInt()
else -> scope.raiseClassCastError("$name must be Int")
}
}
private suspend fun mapBool(map: Obj, scope: ScopeFacade, key: String): Boolean? {
val value = map.getAt(scope.requireScope(), ObjString(key))
return when (value) {
ObjNull -> null
is ObjBool -> value.value
else -> scope.raiseClassCastError("extraParams.$key must be Bool")
}
}
private suspend fun mapInt(map: Obj, scope: ScopeFacade, key: String): Int? {
val value = map.getAt(scope.requireScope(), ObjString(key))
return when (value) {
ObjNull -> null
is ObjInt -> value.value.toInt()
else -> scope.raiseClassCastError("extraParams.$key must be Int")
}
}
private suspend fun ModuleScope.callFn(name: String, vararg args: Obj): Obj {
val callee = get(name)?.value ?: error("Missing $name in module")
return callee.invoke(this, ObjNull, *args)
}
internal data class SqliteOpenOptions(
val path: String,
val readOnly: Boolean,
val createIfMissing: Boolean,
val foreignKeys: Boolean,
val busyTimeoutMillis: Int,
)
internal data class SqliteColumnMeta(
val name: String,
val sqlType: ObjEnumEntry,
val nullable: Boolean,
val nativeType: String,
)
internal data class SqliteResultSetData(
val columns: List<SqliteColumnMeta>,
val rows: List<List<Obj>>,
)
internal data class SqliteExecutionResultData(
val affectedRowsCount: Int,
val generatedKeys: SqliteResultSetData,
)
internal interface SqliteDatabaseBackend {
suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T
}
internal interface SqliteTransactionBackend {
suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteResultSetData
suspend fun execute(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteExecutionResultData
suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T
}
internal expect suspend fun openSqliteBackend(
scope: ScopeFacade,
core: SqliteCoreModule,
options: SqliteOpenOptions,
): SqliteDatabaseBackend
internal class SqliteCoreModule private constructor(
val module: ModuleScope,
val databaseClass: ObjClass,
val transactionClass: ObjClass,
val resultSetClass: ObjClass,
val rowClass: ObjClass,
val columnClass: ObjClass,
val executionResultClass: ObjClass,
val databaseException: ObjException.Companion.ExceptionClass,
val sqlExecutionException: ObjException.Companion.ExceptionClass,
val sqlConstraintException: ObjException.Companion.ExceptionClass,
val sqlUsageException: ObjException.Companion.ExceptionClass,
val rollbackException: ObjException.Companion.ExceptionClass,
val sqlTypes: SqlTypeEntries,
) {
companion object {
fun resolve(module: ModuleScope): SqliteCoreModule = SqliteCoreModule(
module = module,
databaseClass = module.requireClass("Database"),
transactionClass = module.requireClass("SqlTransaction"),
resultSetClass = module.requireClass("ResultSet"),
rowClass = module.requireClass("SqlRow"),
columnClass = module.requireClass("SqlColumn"),
executionResultClass = module.requireClass("ExecutionResult"),
databaseException = module.requireClass("DatabaseException") as ObjException.Companion.ExceptionClass,
sqlExecutionException = module.requireClass("SqlExecutionException") as ObjException.Companion.ExceptionClass,
sqlConstraintException = module.requireClass("SqlConstraintException") as ObjException.Companion.ExceptionClass,
sqlUsageException = module.requireClass("SqlUsageException") as ObjException.Companion.ExceptionClass,
rollbackException = module.requireClass("RollbackException") as ObjException.Companion.ExceptionClass,
sqlTypes = SqlTypeEntries.resolve(module),
)
}
}
internal class SqlTypeEntries private constructor(
private val entries: Map<String, ObjEnumEntry>,
) {
fun require(name: String): ObjEnumEntry = entries[name]
?: error("lyng.io.db.SqlType entry is missing: $name")
companion object {
fun resolve(module: ModuleScope): SqlTypeEntries {
val enumClass = resolveEnum(module, "SqlType")
return SqlTypeEntries(
listOf(
"Binary", "String", "Int", "Double", "Decimal",
"Bool", "Instant", "Date", "DateTime"
).associateWith { name ->
enumClass.byName[ObjString(name)] as? ObjEnumEntry
?: error("lyng.io.db.SqlType.$name is missing")
}
)
}
private fun resolveEnum(module: ModuleScope, enumName: String): ObjEnumClass {
val local = module.get(enumName)?.value as? ObjEnumClass
if (local != null) return local
val root = module.importProvider.rootScope.get(enumName)?.value as? ObjEnumClass
return root ?: error("lyng.io.db declaration enum is missing: $enumName")
}
}
}
private class SqliteRuntimeTypes private constructor(
val core: SqliteCoreModule,
val databaseClass: ObjClass,
val transactionClass: ObjClass,
val resultSetClass: ObjClass,
val rowClass: ObjClass,
val columnClass: ObjClass,
val executionResultClass: ObjClass,
) {
companion object {
fun create(core: SqliteCoreModule): SqliteRuntimeTypes {
val databaseClass = object : ObjClass("SqliteDatabase", core.databaseClass) {}
val transactionClass = object : ObjClass("SqliteTransaction", core.transactionClass) {}
val resultSetClass = object : ObjClass("SqliteResultSet", core.resultSetClass) {}
val rowClass = object : ObjClass("SqliteRow", core.rowClass) {}
val columnClass = object : ObjClass("SqliteColumn", core.columnClass) {}
val executionResultClass = object : ObjClass("SqliteExecutionResult", core.executionResultClass) {}
val runtime = SqliteRuntimeTypes(
core = core,
databaseClass = databaseClass,
transactionClass = transactionClass,
resultSetClass = resultSetClass,
rowClass = rowClass,
columnClass = columnClass,
executionResultClass = executionResultClass,
)
runtime.bind()
return runtime
}
}
private fun bind() {
databaseClass.addFn("transaction") {
val self = thisAs<SqliteDatabaseObj>()
val block = args.list.getOrNull(0) ?: raiseError("Expected exactly 1 argument, got ${args.list.size}")
if (!block.isInstanceOf("Callable")) {
raiseClassCastError("transaction block must be callable")
}
self.backend.transaction(this) { backend ->
val lifetime = TransactionLifetime(this@SqliteRuntimeTypes.core)
try {
call(block, Arguments(SqliteTransactionObj(this@SqliteRuntimeTypes, backend, lifetime)), ObjNull)
} finally {
lifetime.close()
}
}
}
transactionClass.addFn("select") {
val self = thisAs<SqliteTransactionObj>()
self.lifetime.ensureActive(this)
val clause = requiredArg<ObjString>(0).value
val params = args.list.drop(1)
SqliteResultSetObj(thisAs<SqliteTransactionObj>().types, self.lifetime, self.backend.select(this, clause, params))
}
transactionClass.addFn("execute") {
val self = thisAs<SqliteTransactionObj>()
self.lifetime.ensureActive(this)
val clause = requiredArg<ObjString>(0).value
val params = args.list.drop(1)
SqliteExecutionResultObj(self.types, self.lifetime, self.backend.execute(this, clause, params))
}
transactionClass.addFn("transaction") {
val self = thisAs<SqliteTransactionObj>()
self.lifetime.ensureActive(this)
val block = args.list.getOrNull(0) ?: raiseError("Expected exactly 1 argument, got ${args.list.size}")
if (!block.isInstanceOf("Callable")) {
raiseClassCastError("transaction block must be callable")
}
self.backend.transaction(this) { backend ->
val lifetime = TransactionLifetime(this@SqliteRuntimeTypes.core)
try {
call(block, Arguments(SqliteTransactionObj(self.types, backend, lifetime)), ObjNull)
} finally {
lifetime.close()
}
}
}
resultSetClass.addProperty("columns", getter = {
val self = thisAs<SqliteResultSetObj>()
self.lifetime.ensureActive(this)
ObjImmutableList(self.columns)
})
resultSetClass.addFn("size") {
val self = thisAs<SqliteResultSetObj>()
self.lifetime.ensureActive(this)
ObjInt.of(self.rows.size.toLong())
}
resultSetClass.addFn("isEmpty") {
val self = thisAs<SqliteResultSetObj>()
self.lifetime.ensureActive(this)
ObjBool(self.rows.isEmpty())
}
resultSetClass.addFn("iterator") {
val self = thisAs<SqliteResultSetObj>()
self.lifetime.ensureActive(this)
ObjImmutableList(self.rows).invokeInstanceMethod(requireScope(), "iterator")
}
resultSetClass.addFn("toList") {
val self = thisAs<SqliteResultSetObj>()
self.lifetime.ensureActive(this)
ObjImmutableList(self.rows)
}
rowClass.addProperty("size", getter = {
val self = thisAs<SqliteRowObj>()
self.lifetime.ensureActive(this)
ObjInt.of(self.values.size.toLong())
})
rowClass.addProperty("values", getter = {
val self = thisAs<SqliteRowObj>()
self.lifetime.ensureActive(this)
ObjImmutableList(self.values)
})
columnClass.addProperty("name", getter = { ObjString(thisAs<SqliteColumnObj>().meta.name) })
columnClass.addProperty("sqlType", getter = { thisAs<SqliteColumnObj>().meta.sqlType })
columnClass.addProperty("nullable", getter = { ObjBool(thisAs<SqliteColumnObj>().meta.nullable) })
columnClass.addProperty("nativeType", getter = { ObjString(thisAs<SqliteColumnObj>().meta.nativeType) })
executionResultClass.addProperty("affectedRowsCount", getter = {
val self = thisAs<SqliteExecutionResultObj>()
self.lifetime.ensureActive(this)
ObjInt.of(self.result.affectedRowsCount.toLong())
})
executionResultClass.addFn("getGeneratedKeys") {
val self = thisAs<SqliteExecutionResultObj>()
self.lifetime.ensureActive(this)
SqliteResultSetObj(self.types, self.lifetime, self.result.generatedKeys)
}
}
}
private class TransactionLifetime(
private val core: SqliteCoreModule,
) {
private var active = true
fun close() {
active = false
}
fun ensureActive(scope: ScopeFacade) {
if (!active) {
scope.raiseError(
ObjException(core.sqlUsageException, scope.requireScope(), ObjString("SQL result can be used only while its transaction is active"))
)
}
}
}
private class SqliteDatabaseObj(
val types: SqliteRuntimeTypes,
val backend: SqliteDatabaseBackend,
) : Obj() {
override val objClass: ObjClass
get() = types.databaseClass
}
private class SqliteTransactionObj(
val types: SqliteRuntimeTypes,
val backend: SqliteTransactionBackend,
val lifetime: TransactionLifetime,
) : Obj() {
override val objClass: ObjClass
get() = types.transactionClass
}
private class SqliteResultSetObj(
val types: SqliteRuntimeTypes,
val lifetime: TransactionLifetime,
data: SqliteResultSetData,
) : Obj() {
val columns: List<Obj> = data.columns.map { SqliteColumnObj(types, it) }
val rows: List<Obj> = buildRows(types, lifetime, data)
override val objClass: ObjClass
get() = types.resultSetClass
private fun buildRows(
types: SqliteRuntimeTypes,
lifetime: TransactionLifetime,
data: SqliteResultSetData,
): List<Obj> {
val indexByName = linkedMapOf<String, MutableList<Int>>()
data.columns.forEachIndexed { index, column ->
indexByName.getOrPut(column.name.lowercase()) { mutableListOf() }.add(index)
}
return data.rows.map { rowValues ->
SqliteRowObj(types, lifetime, rowValues, indexByName)
}
}
}
private class SqliteRowObj(
val types: SqliteRuntimeTypes,
val lifetime: TransactionLifetime,
val values: List<Obj>,
private val indexByName: Map<String, List<Int>>,
) : Obj() {
override val objClass: ObjClass
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()
if (idx !in values.indices) {
scope.raiseIndexOutOfBounds("SQL row index $idx is out of bounds")
}
values[idx]
}
is ObjString -> {
val matches = indexByName[index.value.lowercase()]
?: scope.raiseError(
ObjException(
types.core.sqlUsageException,
scope,
ObjString("No such SQL result column: ${index.value}")
)
)
if (matches.size != 1) {
scope.raiseError(
ObjException(
types.core.sqlUsageException,
scope,
ObjString("Ambiguous SQL result column: ${index.value}")
)
)
}
values[matches.first()]
}
else -> scope.raiseClassCastError("SQL row index must be Int or String")
}
}
}
private class SqliteColumnObj(
val types: SqliteRuntimeTypes,
val meta: SqliteColumnMeta,
) : Obj() {
override val objClass: ObjClass
get() = types.columnClass
}
private class SqliteExecutionResultObj(
val types: SqliteRuntimeTypes,
val lifetime: TransactionLifetime,
val result: SqliteExecutionResultData,
) : Obj() {
override val objClass: ObjClass
get() = types.executionResultClass
}
@@ -0,0 +1,134 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.io.db
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Script
import net.sergeych.lyng.Source
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjExternCallable
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.requiredArg
import net.sergeych.lyng.requireScope
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class LyngDbModuleTest {
@Test
fun testModuleRegistrationIsIdempotent() = runTest {
val importManager = ImportManager()
assertTrue(createDbModule(importManager))
assertFalse(createDbModule(importManager))
}
@Test
fun testOpenDatabaseDispatchesByNormalizedScheme() = runTest {
val scope = Script.newScope()
createDbModule(scope.importManager)
val module = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db")
module.callFn(
"registerDatabaseProvider",
ObjString("TeSt"),
ObjExternCallable.fromBridge {
val url = requiredArg<ObjString>(0).value
val params = requiredArg<Obj>(1)
val size = (params.invokeInstanceMethod(requireScope(), "size") as ObjInt).value
ObjString("$url|$size")
}
)
val code = """
import lyng.io.db
openDatabase("TEST:demo", Map("a" => 1, "b" => 2))
""".trimIndent()
val result = Compiler.compile(Source("<db-test>", code), scope.importManager).execute(scope) as ObjString
assertEquals("TEST:demo|2", result.value)
}
@Test
fun testDuplicateSchemeRegistrationFailsCaseInsensitively() = runTest {
val importManager = Script.defaultImportManager.copy()
createDbModule(importManager)
val module = importManager.createModuleScope(Pos.builtIn, "lyng.io.db")
module.callFn("registerDatabaseProvider", ObjString("sqlite"), trivialOpener())
val error = try {
module.callFn("registerDatabaseProvider", ObjString("SQLITE"), trivialOpener())
kotlin.test.fail("expected duplicate registration to fail")
} catch (e: ExecutionError) {
e
}
assertTrue(error.errorMessage.contains("already registered"), error.errorMessage)
}
@Test
fun testMalformedUrlFailsWithIllegalArgument() = runTest {
val scope = Script.newScope()
createDbModule(scope.importManager)
val code = """
import lyng.io.db
openDatabase("not-a-url", Map())
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(Source("<db-test>", code), scope.importManager).execute(scope)
}
assertEquals("IllegalArgumentException", error.errorObject.objClass.className)
assertTrue(error.errorMessage.contains("Malformed database connection URL"), error.errorMessage)
}
@Test
fun testUnknownSchemeFailsWithDatabaseException() = runTest {
val scope = Script.newScope()
createDbModule(scope.importManager)
val code = """
import lyng.io.db
openDatabase("unknown:demo", Map())
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(Source("<db-test>", code), scope.importManager).execute(scope)
}
assertEquals("DatabaseException", error.errorObject.objClass.className)
assertTrue(error.errorMessage.contains("No database provider registered"), error.errorMessage)
}
private suspend fun net.sergeych.lyng.ModuleScope.callFn(name: String, vararg args: Obj): Obj {
val callee = get(name)?.value ?: error("Missing $name in module")
return callee.invoke(this, ObjNull, *args)
}
private fun trivialOpener(): Obj = ObjExternCallable.fromBridge { ObjInt.One }
}
@@ -0,0 +1,37 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.io.db.sqlite
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.requireScope
import net.sergeych.lyng.obj.ObjException
import net.sergeych.lyng.obj.ObjString
internal actual suspend fun openSqliteBackend(
scope: ScopeFacade,
core: SqliteCoreModule,
options: SqliteOpenOptions,
): SqliteDatabaseBackend {
scope.raiseError(
ObjException(
core.databaseException,
scope.requireScope(),
ObjString("SQLite provider is not implemented on this platform yet")
)
)
}
@@ -0,0 +1,389 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.io.db.sqlite
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.Arguments
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjBuffer
import net.sergeych.lyng.obj.ObjDateTime
import net.sergeych.lyng.obj.ObjEnumEntry
import net.sergeych.lyng.obj.ObjException
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjInstant
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjReal
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.requireScope
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import org.sqlite.SQLiteConfig
import org.sqlite.SQLiteErrorCode
import org.sqlite.SQLiteOpenMode
import java.sql.Connection
import java.sql.DriverManager
import java.sql.PreparedStatement
import java.sql.ResultSet
import java.sql.SQLException
import java.sql.Statement
import kotlin.time.Instant
internal actual suspend fun openSqliteBackend(
scope: ScopeFacade,
core: SqliteCoreModule,
options: SqliteOpenOptions,
): SqliteDatabaseBackend {
if (options.busyTimeoutMillis < 0) {
scope.raiseIllegalArgument("busyTimeoutMillis must be >= 0")
}
return JdbcSqliteDatabaseBackend(core, options)
}
private class JdbcSqliteDatabaseBackend(
private val core: SqliteCoreModule,
private val options: SqliteOpenOptions,
) : SqliteDatabaseBackend {
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T {
val connection = openConnection(scope)
try {
connection.autoCommit = false
val tx = JdbcSqliteTransactionBackend(core, connection)
return try {
val result = block(tx)
connection.commit()
result
} catch (e: Throwable) {
rollbackQuietly(connection)
throw e
}
} catch (e: SQLException) {
throw mapSqlException(scope, core, e)
} finally {
try {
connection.close()
} catch (_: SQLException) {
}
}
}
private fun openConnection(scope: ScopeFacade): Connection {
try {
val config = SQLiteConfig().apply {
setOpenMode(SQLiteOpenMode.OPEN_URI)
if (options.readOnly) {
setReadOnly(true)
setOpenMode(SQLiteOpenMode.READONLY)
} else {
setReadOnly(false)
setOpenMode(SQLiteOpenMode.READWRITE)
if (options.createIfMissing) {
setOpenMode(SQLiteOpenMode.CREATE)
}
}
enforceForeignKeys(options.foreignKeys)
busyTimeout = options.busyTimeoutMillis
}
return DriverManager.getConnection(jdbcUrl(options.path), config.toProperties())
} catch (e: SQLException) {
throw mapOpenException(scope, core, e)
} catch (e: IllegalArgumentException) {
scope.raiseIllegalArgument(e.message ?: "Invalid SQLite configuration")
}
}
private fun jdbcUrl(path: String): String {
return when (path) {
":memory:" -> "jdbc:sqlite::memory:"
else -> if (path.startsWith("/")) "jdbc:sqlite:$path" else "jdbc:sqlite:$path"
}
}
}
private class JdbcSqliteTransactionBackend(
private val core: SqliteCoreModule,
private val connection: Connection,
) : SqliteTransactionBackend {
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteResultSetData {
try {
connection.prepareStatement(clause).use { statement ->
bindParams(statement, params, scope)
statement.executeQuery().use { rs ->
return readResultSet(scope, core, rs)
}
}
} catch (e: SQLException) {
throw mapSqlException(scope, core, e)
}
}
override suspend fun execute(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteExecutionResultData {
if (containsRowReturningClause(clause)) {
scope.raiseError(
ObjException(
core.sqlUsageException,
scope.requireScope(),
ObjString("execute(...) cannot be used with statements that return rows; use select(...)")
)
)
}
try {
connection.prepareStatement(clause, Statement.RETURN_GENERATED_KEYS).use { statement ->
bindParams(statement, params, scope)
val hasResultSet = statement.execute()
if (hasResultSet) {
scope.raiseError(
ObjException(
core.sqlUsageException,
scope.requireScope(),
ObjString("execute(...) cannot be used with statements that return rows; use select(...)")
)
)
}
val affected = statement.updateCount
val generatedKeys = statement.generatedKeys.use { rs ->
if (rs == null) {
emptyResultSet(core)
} else {
readResultSet(scope, core, rs)
}
}
return SqliteExecutionResultData(affected, generatedKeys)
}
} catch (e: SQLException) {
throw mapSqlException(scope, core, e)
}
}
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T {
val savepoint = try {
connection.setSavepoint()
} catch (e: SQLException) {
throw mapSqlUsage(scope, core, "Nested transactions are not supported by this SQLite backend", e)
}
return try {
val result = block(JdbcSqliteTransactionBackend(core, connection))
connection.releaseSavepoint(savepoint)
result
} catch (e: Throwable) {
rollbackQuietly(connection, savepoint)
throw e
}
}
}
private suspend fun bindParams(statement: PreparedStatement, params: List<Obj>, scope: ScopeFacade) {
params.forEachIndexed { index, value ->
val jdbcIndex = index + 1
when (value) {
ObjNull -> statement.setObject(jdbcIndex, null)
is ObjBool -> statement.setBoolean(jdbcIndex, value.value)
is ObjInt -> statement.setLong(jdbcIndex, value.value)
is ObjReal -> statement.setDouble(jdbcIndex, value.value)
is ObjString -> statement.setString(jdbcIndex, value.value)
is ObjBuffer -> statement.setBytes(jdbcIndex, value.byteArray.toByteArray())
is ObjInstant -> statement.setString(jdbcIndex, value.instant.toString())
is ObjDateTime -> statement.setString(jdbcIndex, value.localDateTime.toString())
else -> when (value.objClass.className) {
"Date", "Decimal" -> statement.setString(jdbcIndex, scope.toStringOf(value).value)
else -> scope.raiseClassCastError("Unsupported SQLite parameter type: ${value.objClass.className}")
}
}
}
}
private suspend fun readResultSet(
scope: ScopeFacade,
core: SqliteCoreModule,
resultSet: ResultSet,
): SqliteResultSetData {
val meta = resultSet.metaData
val columns = (1..meta.columnCount).map { index ->
SqliteColumnMeta(
name = meta.getColumnLabel(index),
sqlType = mapSqlType(core, meta.getColumnTypeName(index), meta.getColumnType(index)),
nullable = meta.isNullable(index) != java.sql.ResultSetMetaData.columnNoNulls,
nativeType = meta.getColumnTypeName(index) ?: "",
)
}
val rows = mutableListOf<List<Obj>>()
while (resultSet.next()) {
rows += columns.mapIndexed { index, column ->
readColumnValue(scope, core, resultSet, index + 1, column.nativeType)
}
}
return SqliteResultSetData(columns, rows)
}
private suspend fun readColumnValue(
scope: ScopeFacade,
core: SqliteCoreModule,
resultSet: ResultSet,
index: Int,
nativeType: String,
): Obj {
val value = resultSet.getObject(index) ?: return ObjNull
if (isDecimalNativeType(nativeType) && value is Number) {
return decimalFromString(scope, value.toString())
}
return when (value) {
is Boolean -> ObjBool(value)
is Byte, is Short, is Int -> ObjInt.of((value as Number).toLong())
is Long -> ObjInt.of(value)
is Float, is Double -> ObjReal.of((value as Number).toDouble())
is ByteArray -> ObjBuffer(value.toUByteArray())
is String -> convertStringValue(scope, core, nativeType, value)
is java.math.BigDecimal -> decimalFromString(scope, value.toPlainString())
else -> ObjString(value.toString())
}
}
private suspend fun convertStringValue(
scope: ScopeFacade,
core: SqliteCoreModule,
nativeType: String,
value: String,
): Obj {
val normalized = nativeType.trim().uppercase()
return when {
normalized == "DECIMAL" || normalized == "NUMERIC" -> decimalFromString(scope, value)
normalized == "DATETIME" || normalized == "TIMESTAMP" -> dateTimeFromString(value)
normalized == "TIMESTAMP WITH TIME ZONE" || normalized == "TIMESTAMPTZ" ->
ObjInstant(Instant.parse(value))
else -> ObjString(value)
}
}
private fun isDecimalNativeType(nativeType: String): Boolean {
val normalized = nativeType.trim().uppercase()
return normalized == "DECIMAL" || normalized == "NUMERIC"
}
private suspend fun decimalFromString(scope: ScopeFacade, value: String): Obj {
val decimalModule = scope.requireScope().currentImportProvider.createModuleScope(scope.pos, "lyng.decimal")
val decimalClass = decimalModule.requireClass("Decimal")
return decimalClass.invokeInstanceMethod(scope.requireScope(), "fromString", ObjString(value))
}
private fun dateTimeFromString(value: String): ObjDateTime {
val trimmed = value.trim()
return if (hasExplicitTimeZone(trimmed)) {
val instant = Instant.parse(trimmed)
ObjDateTime(instant, parseTimeZoneOrUtc(trimmed))
} else {
val local = LocalDateTime.parse(trimmed)
ObjDateTime(local.toInstant(TimeZone.UTC), TimeZone.UTC)
}
}
private fun hasExplicitTimeZone(value: String): Boolean {
if (value.endsWith("Z", ignoreCase = true)) return true
val tIndex = value.indexOf('T')
if (tIndex < 0) return false
val plus = value.lastIndexOf('+')
val minus = value.lastIndexOf('-')
val offsetStart = maxOf(plus, minus)
return offsetStart > tIndex
}
private fun containsRowReturningClause(clause: String): Boolean =
Regex("""\breturning\b""", RegexOption.IGNORE_CASE).containsMatchIn(clause)
private fun parseTimeZoneOrUtc(value: String): TimeZone {
if (value.endsWith("Z", ignoreCase = true)) return TimeZone.UTC
val tIndex = value.indexOf('T')
if (tIndex < 0) return TimeZone.UTC
val plus = value.lastIndexOf('+')
val minus = value.lastIndexOf('-')
val offsetStart = maxOf(plus, minus)
if (offsetStart <= tIndex) return TimeZone.UTC
return try {
TimeZone.of(value.substring(offsetStart))
} catch (_: IllegalArgumentException) {
TimeZone.UTC
}
}
private fun mapSqlType(core: SqliteCoreModule, nativeTypeName: String, jdbcType: Int): ObjEnumEntry {
val normalized = nativeTypeName.trim().uppercase()
return when {
normalized == "BOOLEAN" -> core.sqlTypes.require("Bool")
normalized == "DATE" -> core.sqlTypes.require("Date")
normalized == "DATETIME" || normalized == "TIMESTAMP" -> core.sqlTypes.require("DateTime")
normalized == "TIMESTAMP WITH TIME ZONE" || normalized == "TIMESTAMPTZ" -> core.sqlTypes.require("Instant")
normalized == "DECIMAL" || normalized == "NUMERIC" -> core.sqlTypes.require("Decimal")
normalized.contains("BLOB") -> core.sqlTypes.require("Binary")
normalized.contains("INT") -> core.sqlTypes.require("Int")
normalized.contains("CHAR") || normalized.contains("TEXT") || normalized.contains("CLOB") -> core.sqlTypes.require("String")
normalized.contains("REAL") || normalized.contains("FLOA") || normalized.contains("DOUB") -> core.sqlTypes.require("Double")
jdbcType == java.sql.Types.BOOLEAN -> core.sqlTypes.require("Bool")
jdbcType == java.sql.Types.BLOB || jdbcType == java.sql.Types.BINARY || jdbcType == java.sql.Types.VARBINARY -> core.sqlTypes.require("Binary")
jdbcType == java.sql.Types.INTEGER -> core.sqlTypes.require("Int")
jdbcType == java.sql.Types.BIGINT -> core.sqlTypes.require("Int")
jdbcType == java.sql.Types.DECIMAL || jdbcType == java.sql.Types.NUMERIC -> core.sqlTypes.require("Decimal")
jdbcType == java.sql.Types.FLOAT || jdbcType == java.sql.Types.REAL || jdbcType == java.sql.Types.DOUBLE -> core.sqlTypes.require("Double")
else -> core.sqlTypes.require("String")
}
}
private fun emptyResultSet(core: SqliteCoreModule): SqliteResultSetData = SqliteResultSetData(emptyList(), emptyList())
private fun rollbackQuietly(connection: Connection, savepoint: java.sql.Savepoint? = null) {
try {
if (savepoint == null) connection.rollback() else connection.rollback(savepoint)
} catch (_: SQLException) {
}
}
private fun mapOpenException(scope: ScopeFacade, core: SqliteCoreModule, e: SQLException): Nothing {
val message = e.message ?: "SQLite open failed"
val lower = message.lowercase()
if ("malformed" in lower || "no such access mode" in lower || "invalid uri" in lower) {
scope.raiseIllegalArgument(message)
}
throw mapSqlException(scope, core, e)
}
private fun mapSqlException(scope: ScopeFacade, core: SqliteCoreModule, e: SQLException): ExecutionError {
val code = SQLiteErrorCode.getErrorCode(e.errorCode)
val exceptionClass = when (code) {
SQLiteErrorCode.SQLITE_CONSTRAINT,
SQLiteErrorCode.SQLITE_CONSTRAINT_PRIMARYKEY,
SQLiteErrorCode.SQLITE_CONSTRAINT_UNIQUE,
SQLiteErrorCode.SQLITE_CONSTRAINT_FOREIGNKEY,
SQLiteErrorCode.SQLITE_CONSTRAINT_NOTNULL -> core.sqlConstraintException
else -> core.sqlExecutionException
}
return ExecutionError(
ObjException(exceptionClass, scope.requireScope(), ObjString(e.message ?: "SQLite error")),
scope.pos,
e.message ?: "SQLite error",
e,
)
}
private fun mapSqlUsage(scope: ScopeFacade, core: SqliteCoreModule, message: String, cause: Throwable? = null): ExecutionError {
return ExecutionError(
ObjException(core.sqlUsageException, scope.requireScope(), ObjString(message)),
scope.pos,
message,
cause,
)
}
@@ -0,0 +1,452 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.io.db.sqlite
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Scope
import net.sergeych.lyng.Script
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBuffer
import net.sergeych.lyng.obj.ObjDateTime
import net.sergeych.lyng.obj.ObjExternCallable
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjInstant
import net.sergeych.lyng.obj.ObjMap
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.requiredArg
import net.sergeych.lyng.requireScope
import kotlinx.datetime.TimeZone
import java.nio.file.Files
import kotlin.io.path.deleteIfExists
import kotlin.time.Instant
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class LyngSqliteModuleTest {
@Test
fun testTypedOpenSqliteExecutesQueriesAndGeneratedKeys() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
val db = sqliteModule.callFn("openSqlite", ObjString(":memory:"))
val insertedId = db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("create table person(id integer primary key autoincrement, name text not null)"))
val result = tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("insert into person(name) values(?)"),
ObjString("John Doe")
)
val generatedKeys = result.invokeInstanceMethod(requireScope(), "getGeneratedKeys")
val rows = generatedKeys.invokeInstanceMethod(requireScope(), "toList")
rows.getAt(requireScope(), ObjInt.Zero).getAt(requireScope(), ObjInt.Zero)
}
) as ObjInt
assertEquals(1L, insertedId.value)
}
@Test
fun testGenericOpenDatabaseUsesRegisteredSqliteProvider() = 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 db = dbModule.callFn("openDatabase", ObjString("sqlite::memory:"), emptyMapObj())
val count = 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"))
val resultSet = tx.invokeInstanceMethod(requireScope(), "select", ObjString("select count(*) as count from items"))
val rows = resultSet.invokeInstanceMethod(requireScope(), "toList")
rows.getAt(requireScope(), ObjInt.Zero).getAt(requireScope(), ObjString("count"))
}
) as ObjInt
assertEquals(2L, count.value)
}
@Test
fun testNestedTransactionRollbackUsesSavepoint() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
val db = sqliteModule.callFn("openSqlite", ObjString(":memory:"))
val count = 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("outer"))
try {
tx.invokeInstanceMethod(
requireScope(),
"transaction",
ObjExternCallable.fromBridge {
val inner = requiredArg<Obj>(0)
inner.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into items(name) values(?)"), ObjString("inner"))
throw IllegalStateException("rollback nested")
}
)
} catch (_: IllegalStateException) {
}
val resultSet = tx.invokeInstanceMethod(requireScope(), "select", ObjString("select count(*) as count from items"))
val rows = resultSet.invokeInstanceMethod(requireScope(), "toList")
rows.getAt(requireScope(), ObjInt.Zero).getAt(requireScope(), ObjString("count"))
}
) as ObjInt
assertEquals(1L, count.value)
}
@Test
fun testResultSetFailsAfterTransactionEnds() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
val db = sqliteModule.callFn("openSqlite", ObjString(":memory:"))
var leakedResultSet: Obj = ObjNull
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)"))
leakedResultSet = tx.invokeInstanceMethod(requireScope(), "select", ObjString("select 42 as answer"))
ObjNull
}
)
val error = assertFailsWith<ExecutionError> {
leakedResultSet.invokeInstanceMethod(scope, "size")
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage)
}
@Test
fun testInvalidSqliteUrlFailsWithIllegalArgument() = 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 error = assertFailsWith<ExecutionError> {
dbModule.callFn("openDatabase", ObjString("sqlite://bad"), emptyMapObj())
}
assertEquals("IllegalArgumentException", error.errorObject.objClass.className)
}
@Test
fun testConstraintViolationIsMappedToSqlConstraintException() = runTest {
val scope = Script.newScope()
val db = openMemoryDb(scope)
val error = assertFailsWith<ExecutionError> {
db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("create table person(id integer primary key autoincrement, email text unique not null)")
)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("insert into person(email) values(?)"),
ObjString("a@example.com")
)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("insert into person(email) values(?)"),
ObjString("a@example.com")
)
}
)
}
assertEquals("SqlConstraintException", error.errorObject.objClass.className)
}
@Test
fun testAmbiguousColumnNameAccessFailsWithSqlUsageException() = runTest {
val scope = Script.newScope()
val db = openMemoryDb(scope)
val error = assertFailsWith<ExecutionError> {
db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
val resultSet = tx.invokeInstanceMethod(
requireScope(),
"select",
ObjString("select 1 as value, 2 as value")
)
val row = rowsOf(requireScope(), resultSet)[0]
row.getAt(requireScope(), ObjString("value"))
}
)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
assertTrue(error.errorMessage.contains("Ambiguous SQL result column"), error.errorMessage)
}
@Test
fun testExecuteRejectsReturningButSelectSupportsIt() = runTest {
val scope = Script.newScope()
withTempDb(scope) { db ->
db.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 error = assertFailsWith<ExecutionError> {
db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("insert into item(name) values(?) returning id"),
ObjString("bad")
)
}
)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
val insertedId = db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
val resultSet = tx.invokeInstanceMethod(
requireScope(),
"select",
ObjString("insert into item(name) values(?) returning id"),
ObjString("good")
)
val row = rowsOf(requireScope(), resultSet)[0]
row.getAt(requireScope(), ObjString("id"))
}
) as ObjInt
assertEquals(1L, insertedId.value)
}
}
@Test
fun testColumnMetadataAndTypedValueConversion() = runTest {
val scope = Script.newScope()
val db = openMemoryDb(scope)
val summary = db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString(
"create table events(" +
"amount NUMERIC not null, " +
"happened TIMESTAMPTZ not null, " +
"scheduled TIMESTAMP not null, " +
"note TEXT not null, " +
"payload BLOB not null)"
)
)
val decimal = decimalOf(requireScope(), "12.50")
val happened = ObjInstant(Instant.parse("2024-05-06T07:08:09Z"))
val scheduled = ObjDateTime(Instant.parse("2024-05-06T10:11:12Z"), TimeZone.UTC)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("insert into events(amount, happened, scheduled, note, payload) values(?, ?, ?, ?, ?)"),
decimal,
happened,
scheduled,
ObjString("hello"),
ObjBuffer(byteArrayOf(1, 2, 3).toUByteArray())
)
val resultSet = tx.invokeInstanceMethod(
requireScope(),
"select",
ObjString("select amount, happened, scheduled, note, payload from events")
)
val columns = field(requireScope(), resultSet, "columns")
val firstColumn = columns.getAt(requireScope(), ObjInt.Zero)
val row = rowsOf(requireScope(), resultSet)[0]
ObjString(
listOf(
stringValue(requireScope(), field(requireScope(), firstColumn, "name")),
enumName(requireScope(), field(requireScope(), firstColumn, "sqlType")),
stringValue(requireScope(), field(requireScope(), firstColumn, "nativeType")),
row.getAt(requireScope(), ObjString("amount")).objClass.className,
row.getAt(requireScope(), ObjString("happened")).objClass.className,
row.getAt(requireScope(), ObjString("scheduled")).objClass.className,
stringValue(requireScope(), row.getAt(requireScope(), ObjString("note"))),
row.getAt(requireScope(), ObjString("payload")).objClass.className,
).joinToString("|")
)
}
) as ObjString
assertEquals(
"amount|Decimal|NUMERIC|Decimal|Instant|DateTime|hello|Buffer",
summary.value
)
}
@Test
fun testReadOnlyOpenPreventsWrites() = 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-", ".db")
try {
val writableDb = sqliteModule.callFn("openSqlite", ObjString(tempFile.toString()))
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 = sqliteModule.callFn(
"openSqlite",
ObjString(tempFile.toString()),
net.sergeych.lyng.obj.ObjTrue,
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()
}
}
private suspend fun ModuleScope.callFn(name: String, vararg args: Obj): Obj {
val callee = get(name)?.value ?: error("Missing $name in module")
return callee.invoke(this, ObjNull, *args)
}
private suspend fun openMemoryDb(scope: Scope): Obj {
createSqliteModule(scope.importManager)
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
return sqliteModule.callFn("openSqlite", ObjString(":memory:"))
}
private suspend fun withTempDb(scope: Scope, block: suspend (Obj) -> Unit) {
createSqliteModule(scope.importManager)
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
val tempFile = Files.createTempFile("lyng-sqlite-", ".db")
try {
val db = sqliteModule.callFn("openSqlite", ObjString(tempFile.toString()))
block(db)
} finally {
tempFile.deleteIfExists()
}
}
private suspend fun field(scope: Scope, obj: Obj, name: String): Obj =
obj.readField(scope, name).value
private suspend fun rowsOf(scope: Scope, resultSet: Obj): List<Obj> {
val rows = resultSet.invokeInstanceMethod(scope, "toList")
val size = (field(scope, rows, "size") as ObjInt).value.toInt()
return (0 until size).map { index -> rows.getAt(scope, ObjInt.of(index.toLong())) }
}
private suspend fun stringValue(scope: Scope, obj: Obj): String =
(obj as? ObjString ?: obj.toString(scope)) .value
private suspend fun enumName(scope: Scope, obj: Obj): String =
stringValue(scope, field(scope, obj, "name"))
private suspend fun decimalOf(scope: Scope, value: String): Obj {
val decimalModule = scope.currentImportProvider.createModuleScope(scope.pos, "lyng.decimal")
val decimalClass = decimalModule.requireClass("Decimal")
return decimalClass.invokeInstanceMethod(scope, "fromString", ObjString(value))
}
private fun emptyMapObj(): Obj = ObjMap()
}
@@ -0,0 +1,353 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.io.db.sqlite
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.TimeZone
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Scope
import net.sergeych.lyng.Script
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBuffer
import net.sergeych.lyng.obj.ObjDateTime
import net.sergeych.lyng.obj.ObjExternCallable
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjInstant
import net.sergeych.lyng.obj.ObjMap
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.requiredArg
import net.sergeych.lyng.requireScope
import okio.FileSystem
import okio.Path
import okio.Path.Companion.toPath
import kotlin.random.Random
import kotlin.time.Instant
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class LyngSqliteModuleNativeTest {
@Test
fun testTypedOpenSqliteExecutesQueriesAndGeneratedKeys() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
val db = sqliteModule.callFn("openSqlite", ObjString(":memory:"))
val insertedId = db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("create table person(id integer primary key autoincrement, name text not null)"))
val result = tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("insert into person(name) values(?)"),
ObjString("John Doe")
)
val generatedKeys = result.invokeInstanceMethod(requireScope(), "getGeneratedKeys")
val rows = generatedKeys.invokeInstanceMethod(requireScope(), "toList")
rows.getAt(requireScope(), ObjInt.Zero).getAt(requireScope(), ObjInt.Zero)
}
) as ObjInt
assertEquals(1L, insertedId.value)
}
@Test
fun testNestedTransactionRollbackUsesSavepoint() = runTest {
val scope = Script.newScope()
val db = openMemoryDb(scope)
val count = 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("outer"))
try {
tx.invokeInstanceMethod(
requireScope(),
"transaction",
ObjExternCallable.fromBridge {
val inner = requiredArg<Obj>(0)
inner.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into items(name) values(?)"), ObjString("inner"))
throw IllegalStateException("rollback nested")
}
)
} catch (_: IllegalStateException) {
}
val resultSet = tx.invokeInstanceMethod(requireScope(), "select", ObjString("select count(*) as count from items"))
val rows = resultSet.invokeInstanceMethod(requireScope(), "toList")
rows.getAt(requireScope(), ObjInt.Zero).getAt(requireScope(), ObjString("count"))
}
) as ObjInt
assertEquals(1L, count.value)
}
@Test
fun testResultSetFailsAfterTransactionEnds() = runTest {
val scope = Script.newScope()
val db = openMemoryDb(scope)
var leakedResultSet: Obj = ObjNull
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)"))
leakedResultSet = tx.invokeInstanceMethod(requireScope(), "select", ObjString("select 42 as answer"))
ObjNull
}
)
val error = assertFailsWith<ExecutionError> {
leakedResultSet.invokeInstanceMethod(scope, "size")
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage)
}
@Test
fun testExecuteRejectsReturningButSelectSupportsIt() = runTest {
val scope = Script.newScope()
withTempDb(scope) { db ->
db.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 error = assertFailsWith<ExecutionError> {
db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("insert into item(name) values(?) returning id"),
ObjString("bad")
)
}
)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
val insertedId = db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
val resultSet = tx.invokeInstanceMethod(
requireScope(),
"select",
ObjString("insert into item(name) values(?) returning id"),
ObjString("good")
)
val row = rowsOf(requireScope(), resultSet)[0]
row.getAt(requireScope(), ObjString("id"))
}
) as ObjInt
assertEquals(1L, insertedId.value)
}
}
@Test
fun testColumnMetadataAndTypedValueConversion() = runTest {
val scope = Script.newScope()
val db = openMemoryDb(scope)
val summary = db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString(
"create table events(" +
"amount NUMERIC not null, " +
"happened TIMESTAMPTZ not null, " +
"scheduled TIMESTAMP not null, " +
"note TEXT not null, " +
"payload BLOB not null)"
)
)
val decimal = decimalOf(requireScope(), "12.50")
val happened = ObjInstant(Instant.parse("2024-05-06T07:08:09Z"))
val scheduled = ObjDateTime(Instant.parse("2024-05-06T10:11:12Z"), TimeZone.UTC)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("insert into events(amount, happened, scheduled, note, payload) values(?, ?, ?, ?, ?)"),
decimal,
happened,
scheduled,
ObjString("hello"),
ObjBuffer(byteArrayOf(1, 2, 3).toUByteArray())
)
val resultSet = tx.invokeInstanceMethod(
requireScope(),
"select",
ObjString("select amount, happened, scheduled, note, payload from events")
)
val columns = field(requireScope(), resultSet, "columns")
val firstColumn = columns.getAt(requireScope(), ObjInt.Zero)
val row = rowsOf(requireScope(), resultSet)[0]
ObjString(
listOf(
stringValue(requireScope(), field(requireScope(), firstColumn, "name")),
enumName(requireScope(), field(requireScope(), firstColumn, "sqlType")),
stringValue(requireScope(), field(requireScope(), firstColumn, "nativeType")),
row.getAt(requireScope(), ObjString("amount")).objClass.className,
row.getAt(requireScope(), ObjString("happened")).objClass.className,
row.getAt(requireScope(), ObjString("scheduled")).objClass.className,
stringValue(requireScope(), row.getAt(requireScope(), ObjString("note"))),
row.getAt(requireScope(), ObjString("payload")).objClass.className,
).joinToString("|")
)
}
) as ObjString
assertEquals(
"amount|Decimal|NUMERIC|Decimal|Instant|DateTime|hello|Buffer",
summary.value
)
}
@Test
fun testReadOnlyOpenPreventsWrites() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
withTempPath { tempPath ->
val writableDb = sqliteModule.callFn("openSqlite", ObjString(tempPath.toString()))
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 = sqliteModule.callFn(
"openSqlite",
ObjString(tempPath.toString()),
net.sergeych.lyng.obj.ObjTrue,
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)
}
}
private suspend fun ModuleScope.callFn(name: String, vararg args: Obj): Obj {
val callee = get(name)?.value ?: error("Missing $name in module")
return callee.invoke(this, ObjNull, *args)
}
private suspend fun openMemoryDb(scope: Scope): Obj {
createSqliteModule(scope.importManager)
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
return sqliteModule.callFn("openSqlite", ObjString(":memory:"))
}
private suspend fun withTempDb(scope: Scope, block: suspend (Obj) -> Unit) {
createSqliteModule(scope.importManager)
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
withTempPath { tempPath ->
val db = sqliteModule.callFn("openSqlite", ObjString(tempPath.toString()))
block(db)
}
}
private suspend fun withTempPath(block: suspend (Path) -> Unit) {
val path = "/tmp/lyng-sqlite-${Random.nextInt(Int.MAX_VALUE)}.db".toPath()
try {
block(path)
} finally {
FileSystem.SYSTEM.delete(path, mustExist = false)
}
}
private suspend fun field(scope: Scope, obj: Obj, name: String): Obj =
obj.readField(scope, name).value
private suspend fun rowsOf(scope: Scope, resultSet: Obj): List<Obj> {
val rows = resultSet.invokeInstanceMethod(scope, "toList")
val size = (field(scope, rows, "size") as ObjInt).value.toInt()
return (0 until size).map { index -> rows.getAt(scope, ObjInt.of(index.toLong())) }
}
private suspend fun stringValue(scope: Scope, obj: Obj): String =
(obj as? ObjString ?: obj.toString(scope)).value
private suspend fun enumName(scope: Scope, obj: Obj): String =
stringValue(scope, field(scope, obj, "name"))
private suspend fun decimalOf(scope: Scope, value: String): Obj {
val decimalModule = scope.currentImportProvider.createModuleScope(scope.pos, "lyng.decimal")
val decimalClass = decimalModule.requireClass("Decimal")
return decimalClass.invokeInstanceMethod(scope, "fromString", ObjString(value))
}
private fun emptyMapObj(): Obj = ObjMap()
}
@@ -0,0 +1,2 @@
headers = sqlite3_lyng.h
package = net.sergeych.lyng.io.db.sqlite.cinterop
@@ -0,0 +1,102 @@
#ifndef LYNG_SQLITE3_LYNG_H
#define LYNG_SQLITE3_LYNG_H
#ifdef __cplusplus
extern "C" {
#endif
typedef struct sqlite3 sqlite3;
typedef struct sqlite3_stmt sqlite3_stmt;
typedef long long sqlite3_int64;
typedef void (*sqlite3_destructor_type)(void*);
#define SQLITE_OK 0
#define SQLITE_ERROR 1
#define SQLITE_INTERNAL 2
#define SQLITE_PERM 3
#define SQLITE_ABORT 4
#define SQLITE_BUSY 5
#define SQLITE_LOCKED 6
#define SQLITE_NOMEM 7
#define SQLITE_READONLY 8
#define SQLITE_INTERRUPT 9
#define SQLITE_IOERR 10
#define SQLITE_CORRUPT 11
#define SQLITE_NOTFOUND 12
#define SQLITE_FULL 13
#define SQLITE_CANTOPEN 14
#define SQLITE_PROTOCOL 15
#define SQLITE_EMPTY 16
#define SQLITE_SCHEMA 17
#define SQLITE_TOOBIG 18
#define SQLITE_CONSTRAINT 19
#define SQLITE_MISMATCH 20
#define SQLITE_MISUSE 21
#define SQLITE_NOLFS 22
#define SQLITE_AUTH 23
#define SQLITE_FORMAT 24
#define SQLITE_RANGE 25
#define SQLITE_NOTADB 26
#define SQLITE_NOTICE 27
#define SQLITE_WARNING 28
#define SQLITE_ROW 100
#define SQLITE_DONE 101
#define SQLITE_INTEGER 1
#define SQLITE_FLOAT 2
#define SQLITE_TEXT 3
#define SQLITE_BLOB 4
#define SQLITE_NULL 5
#define SQLITE_OPEN_READONLY 0x00000001
#define SQLITE_OPEN_READWRITE 0x00000002
#define SQLITE_OPEN_CREATE 0x00000004
#define SQLITE_OPEN_URI 0x00000040
int sqlite3_open_v2(const char* filename, sqlite3** ppDb, int flags, const char* zVfs);
int sqlite3_close_v2(sqlite3*);
int sqlite3_extended_result_codes(sqlite3*, int onoff);
int sqlite3_busy_timeout(sqlite3*, int ms);
int sqlite3_prepare_v2(sqlite3* db, const char* zSql, int nByte, sqlite3_stmt** ppStmt, const char** pzTail);
int sqlite3_finalize(sqlite3_stmt* pStmt);
int sqlite3_step(sqlite3_stmt*);
int sqlite3_reset(sqlite3_stmt* pStmt);
int sqlite3_clear_bindings(sqlite3_stmt*);
int sqlite3_bind_parameter_count(sqlite3_stmt*);
int sqlite3_bind_null(sqlite3_stmt*, int);
int sqlite3_bind_int64(sqlite3_stmt*, int, sqlite3_int64);
int sqlite3_bind_double(sqlite3_stmt*, int, double);
int sqlite3_bind_text(sqlite3_stmt*, int, const char*, int n, sqlite3_destructor_type);
int sqlite3_bind_blob(sqlite3_stmt*, int, const void*, int n, sqlite3_destructor_type);
int sqlite3_column_count(sqlite3_stmt* pStmt);
const char* sqlite3_column_name(sqlite3_stmt*, int N);
const char* sqlite3_column_decltype(sqlite3_stmt*, int);
int sqlite3_column_type(sqlite3_stmt*, int iCol);
sqlite3_int64 sqlite3_column_int64(sqlite3_stmt*, int iCol);
double sqlite3_column_double(sqlite3_stmt*, int iCol);
const unsigned char* sqlite3_column_text(sqlite3_stmt*, int iCol);
const void* sqlite3_column_blob(sqlite3_stmt*, int iCol);
int sqlite3_column_bytes(sqlite3_stmt*, int iCol);
const char* sqlite3_errmsg(sqlite3*);
int sqlite3_extended_errcode(sqlite3*);
int sqlite3_changes(sqlite3*);
sqlite3_int64 sqlite3_last_insert_rowid(sqlite3*);
int sqlite3_db_readonly(sqlite3*, const char* zDbName);
static inline sqlite3* lyng_sqlite3_open(const char* filename, int flags) {
sqlite3* db = 0;
sqlite3_open_v2(filename, &db, flags, 0);
return db;
}
static inline sqlite3_stmt* lyng_sqlite3_prepare(sqlite3* db, const char* sql) {
sqlite3_stmt* stmt = 0;
sqlite3_prepare_v2(db, sql, -1, &stmt, 0);
return stmt;
}
#ifdef __cplusplus
}
#endif
#endif
@@ -0,0 +1,573 @@
@file:OptIn(ExperimentalForeignApi::class)
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.io.db.sqlite
import cnames.structs.sqlite3
import cnames.structs.sqlite3_stmt
import kotlinx.cinterop.ByteVar
import kotlinx.cinterop.CFunction
import kotlinx.cinterop.CPointer
import kotlinx.cinterop.COpaquePointer
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.MemScope
import kotlinx.cinterop.allocArray
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.readBytes
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.toCPointer
import kotlinx.cinterop.toKString
import kotlinx.cinterop.usePinned
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_BLOB
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_CONSTRAINT
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_DONE
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_FLOAT
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_INTEGER
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_NULL
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_OK
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_OPEN_CREATE
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_OPEN_READONLY
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_OPEN_READWRITE
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_OPEN_URI
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_ROW
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_TEXT
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_bind_blob
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_bind_double
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_bind_int64
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_bind_null
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_bind_parameter_count
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_bind_text
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_busy_timeout
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_changes
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_close_v2
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_column_blob
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_column_bytes
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_column_count
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_column_decltype
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_column_double
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_column_int64
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_column_name
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_column_text
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_column_type
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_errmsg
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_extended_errcode
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_extended_result_codes
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_finalize
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_last_insert_rowid
import net.sergeych.lyng.io.db.sqlite.cinterop.lyng_sqlite3_open
import net.sergeych.lyng.io.db.sqlite.cinterop.lyng_sqlite3_prepare
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_open_v2
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_reset
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_step
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjBuffer
import net.sergeych.lyng.obj.ObjDateTime
import net.sergeych.lyng.obj.ObjEnumEntry
import net.sergeych.lyng.obj.ObjException
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjInstant
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjReal
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.requireScope
import platform.posix.memcpy
import kotlin.time.Instant
internal actual suspend fun openSqliteBackend(
scope: ScopeFacade,
core: SqliteCoreModule,
options: SqliteOpenOptions,
): SqliteDatabaseBackend {
if (options.busyTimeoutMillis < 0) {
scope.raiseIllegalArgument("busyTimeoutMillis must be >= 0")
}
return NativeSqliteDatabaseBackend(core, options)
}
private class NativeSqliteDatabaseBackend(
private val core: SqliteCoreModule,
private val options: SqliteOpenOptions,
) : SqliteDatabaseBackend {
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T {
val handle = openHandle(scope, core, options)
val savepoints = SavepointCounter()
try {
handle.execUnit(scope, core, "begin")
val tx = NativeSqliteTransactionBackend(core, handle, savepoints)
return try {
val result = block(tx)
handle.execUnit(scope, core, "commit")
result
} catch (e: Throwable) {
handle.execUnitQuietly("rollback")
throw e
}
} finally {
handle.close()
}
}
}
private class NativeSqliteTransactionBackend(
private val core: SqliteCoreModule,
private val handle: NativeSqliteHandle,
private val savepoints: SavepointCounter,
) : SqliteTransactionBackend {
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteResultSetData {
return handle.select(scope, core, clause, params)
}
override suspend fun execute(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteExecutionResultData {
return handle.execute(scope, core, clause, params)
}
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T {
val savepoint = "lyng_sp_${savepoints.next()}"
handle.execUnit(scope, core, "savepoint $savepoint")
return try {
val result = block(NativeSqliteTransactionBackend(core, handle, savepoints))
handle.execUnit(scope, core, "release savepoint $savepoint")
result
} catch (e: Throwable) {
handle.execUnitQuietly("rollback to savepoint $savepoint")
handle.execUnitQuietly("release savepoint $savepoint")
throw e
}
}
}
private class SavepointCounter {
private var nextValue = 0
fun next(): Int {
nextValue += 1
return nextValue
}
}
private class NativeSqliteHandle(
private val db: CPointer<sqlite3>,
) {
suspend fun select(
scope: ScopeFacade,
core: SqliteCoreModule,
clause: String,
params: List<Obj>,
): SqliteResultSetData = memScoped {
val stmt = prepare(scope, core, clause)
try {
bindParams(scope, core, stmt, params, this)
readResultSet(scope, core, stmt)
} finally {
sqlite3_finalize(stmt)
}
}
suspend fun execute(
scope: ScopeFacade,
core: SqliteCoreModule,
clause: String,
params: List<Obj>,
): SqliteExecutionResultData = memScoped {
if (containsRowReturningClause(clause)) {
raiseExecuteReturningUsage(scope, core)
}
val stmt = prepare(scope, core, clause)
try {
bindParams(scope, core, stmt, params, this)
when (val rc = sqlite3_step(stmt)) {
SQLITE_DONE -> {
val affectedRows = sqlite3_changes(db)
val generatedKeys = readGeneratedKeys(core, clause, affectedRows)
SqliteExecutionResultData(affectedRows, generatedKeys)
}
SQLITE_ROW -> raiseExecuteReturningUsage(scope, core)
else -> throw sqlError(scope, core, rc)
}
} finally {
sqlite3_reset(stmt)
sqlite3_finalize(stmt)
}
}
fun execUnit(scope: ScopeFacade, core: SqliteCoreModule, sql: String) {
memScoped {
val stmt = prepare(scope, core, sql)
try {
when (val rc = sqlite3_step(stmt)) {
SQLITE_DONE, SQLITE_ROW -> Unit
else -> throw sqlError(scope, core, rc)
}
} finally {
sqlite3_finalize(stmt)
}
}
}
fun execUnitQuietly(sql: String) {
memScoped {
val stmt = lyng_sqlite3_prepare(db, sql) ?: return@memScoped
try {
sqlite3_step(stmt)
} finally {
sqlite3_finalize(stmt)
}
}
}
fun close() {
sqlite3_close_v2(db)
}
private fun MemScope.prepare(scope: ScopeFacade, core: SqliteCoreModule, sql: String): CPointer<sqlite3_stmt> {
return lyng_sqlite3_prepare(db, sql) ?: throw sqlError(scope, core, sqlite3_extended_errcode(db))
}
private suspend fun bindParams(
scope: ScopeFacade,
core: SqliteCoreModule,
stmt: CPointer<sqlite3_stmt>,
params: List<Obj>,
memScope: MemScope,
) {
val expectedCount = sqlite3_bind_parameter_count(stmt)
if (expectedCount != params.size) {
throw usageError(
scope,
core,
"SQL parameter count mismatch: statement expects $expectedCount value(s), got ${params.size}"
)
}
params.forEachIndexed { index, value ->
val parameterIndex = index + 1
val rc = when (value) {
ObjNull -> sqlite3_bind_null(stmt, parameterIndex)
is ObjBool -> sqlite3_bind_int64(stmt, parameterIndex, if (value.value) 1L else 0L)
is ObjInt -> sqlite3_bind_int64(stmt, parameterIndex, value.value)
is ObjReal -> sqlite3_bind_double(stmt, parameterIndex, value.value)
is ObjString -> bindText(stmt, parameterIndex, value.value, memScope)
is ObjBuffer -> bindBlob(stmt, parameterIndex, value.byteArray.toByteArray(), memScope)
is ObjInstant -> bindText(stmt, parameterIndex, value.instant.toString(), memScope)
is ObjDateTime -> bindText(stmt, parameterIndex, value.localDateTime.toString(), memScope)
else -> when (value.objClass.className) {
"Date", "Decimal" -> bindText(stmt, parameterIndex, scope.toStringOf(value).value, memScope)
else -> scope.raiseClassCastError("Unsupported SQLite parameter type: ${value.objClass.className}")
}
}
if (rc != SQLITE_OK) {
throw sqlError(scope, core, rc)
}
}
}
private fun bindText(
stmt: CPointer<sqlite3_stmt>,
parameterIndex: Int,
value: String,
memScope: MemScope,
): Int {
return sqlite3_bind_text(stmt, parameterIndex, value, -1, SQLITE_TRANSIENT)
}
private fun bindBlob(
stmt: CPointer<sqlite3_stmt>,
parameterIndex: Int,
value: ByteArray,
memScope: MemScope,
): Int {
if (value.isEmpty()) {
return sqlite3_bind_blob(stmt, parameterIndex, null, 0, null)
}
val target = memScope.allocArray<ByteVar>(value.size)
value.usePinned { pinned ->
memcpy(target, pinned.addressOf(0), value.size.toULong())
}
return sqlite3_bind_blob(stmt, parameterIndex, target, value.size, SQLITE_TRANSIENT)
}
private suspend fun readResultSet(
scope: ScopeFacade,
core: SqliteCoreModule,
stmt: CPointer<sqlite3_stmt>,
): SqliteResultSetData {
val columnCount = sqlite3_column_count(stmt)
val columns = (0 until columnCount).map { index ->
val nativeType = sqlite3_column_decltype(stmt, index)?.toKString().orEmpty()
SqliteColumnMeta(
name = sqlite3_column_name(stmt, index)?.toKString().orEmpty(),
sqlType = mapSqlType(core, nativeType, SQLITE_NULL),
nullable = true,
nativeType = nativeType,
)
}.toMutableList()
if (columnCount == 0) {
return emptyResultSet()
}
val rows = mutableListOf<List<Obj>>()
while (true) {
when (val rc = sqlite3_step(stmt)) {
SQLITE_ROW -> {
val row = (0 until columnCount).map { index ->
val dynamicType = sqlite3_column_type(stmt, index)
if (columns[index].nativeType.isBlank()) {
columns[index] = columns[index].copy(sqlType = mapSqlType(core, columns[index].nativeType, dynamicType))
}
readColumnValue(scope, core, stmt, index, columns[index].nativeType)
}
rows += row
}
SQLITE_DONE -> return SqliteResultSetData(columns, rows)
else -> throw sqlError(scope, core, rc)
}
}
}
private suspend fun readColumnValue(
scope: ScopeFacade,
core: SqliteCoreModule,
stmt: CPointer<sqlite3_stmt>,
index: Int,
nativeType: String,
): Obj {
val normalizedNativeType = nativeType.trim().uppercase()
return when (val type = sqlite3_column_type(stmt, index)) {
SQLITE_NULL -> ObjNull
SQLITE_INTEGER -> {
val value = sqlite3_column_int64(stmt, index)
when {
normalizedNativeType == "BOOLEAN" -> ObjBool(value != 0L)
isDecimalNativeType(nativeType) -> decimalFromString(scope, value.toString())
else -> ObjInt.of(value)
}
}
SQLITE_FLOAT -> {
val value = sqlite3_column_double(stmt, index)
if (isDecimalNativeType(nativeType)) decimalFromString(scope, value.toString()) else ObjReal.of(value)
}
SQLITE_TEXT -> {
val textPtr = sqlite3_column_text(stmt, index)?.reinterpret<ByteVar>()
val value = textPtr?.toKString() ?: ""
convertStringValue(scope, nativeType, value)
}
SQLITE_BLOB -> {
val size = sqlite3_column_bytes(stmt, index)
val blob = sqlite3_column_blob(stmt, index)
val bytes = if (blob == null || size <= 0) byteArrayOf() else blob.reinterpret<ByteVar>().readBytes(size)
ObjBuffer(bytes.toUByteArray())
}
else -> ObjString(columnText(stmt, index))
}
}
private suspend fun convertStringValue(scope: ScopeFacade, nativeType: String, value: String): Obj {
val normalized = nativeType.trim().uppercase()
return when {
normalized == "DECIMAL" || normalized == "NUMERIC" -> decimalFromString(scope, value)
normalized == "DATETIME" || normalized == "TIMESTAMP" -> dateTimeFromString(value)
normalized == "TIMESTAMP WITH TIME ZONE" || normalized == "TIMESTAMPTZ" -> ObjInstant(Instant.parse(value))
else -> ObjString(value)
}
}
private fun readGeneratedKeys(
core: SqliteCoreModule,
clause: String,
affectedRows: Int,
): SqliteResultSetData {
if (affectedRows <= 0 || !looksLikeInsert(clause)) {
return emptyResultSet()
}
return SqliteResultSetData(
columns = listOf(
SqliteColumnMeta(
name = "generated_key",
sqlType = core.sqlTypes.require("Int"),
nullable = false,
nativeType = "INTEGER",
)
),
rows = listOf(listOf(ObjInt.of(sqlite3_last_insert_rowid(db))))
)
}
private fun columnText(stmt: CPointer<sqlite3_stmt>, index: Int): String {
return sqlite3_column_text(stmt, index)?.reinterpret<ByteVar>()?.toKString().orEmpty()
}
private fun sqlError(scope: ScopeFacade, core: SqliteCoreModule, rc: Int): ExecutionError {
val code = sqlite3_extended_errcode(db)
val message = sqlite3_errmsg(db)?.toKString() ?: "SQLite error ($rc)"
val exceptionClass = if ((code and 0xff) == SQLITE_CONSTRAINT) core.sqlConstraintException else core.sqlExecutionException
return ExecutionError(
ObjException(exceptionClass, scope.requireScope(), ObjString(message)),
scope.pos,
message,
)
}
}
private fun openHandle(
scope: ScopeFacade,
core: SqliteCoreModule,
options: SqliteOpenOptions,
): NativeSqliteHandle = memScoped {
val flags = buildOpenFlags(options)
val db = lyng_sqlite3_open(options.path, flags)
val rc = db?.let { sqlite3_extended_errcode(it) } ?: SQLITE_OK
if (db == null || rc != SQLITE_OK) {
val message = db?.let { sqlite3_errmsg(it)?.toKString() } ?: "SQLite open failed"
if (db != null) {
sqlite3_close_v2(db)
}
throw databaseError(scope, core, message)
}
sqlite3_extended_result_codes(db, 1)
if (sqlite3_busy_timeout(db, options.busyTimeoutMillis) != SQLITE_OK) {
val message = sqlite3_errmsg(db)?.toKString() ?: "Failed to configure SQLite busy timeout"
sqlite3_close_v2(db)
throw databaseError(scope, core, message)
}
val handle = NativeSqliteHandle(db)
try {
handle.execUnit(scope, core, if (options.foreignKeys) "pragma foreign_keys = on" else "pragma foreign_keys = off")
} catch (e: Throwable) {
handle.close()
throw e
}
handle
}
private fun buildOpenFlags(options: SqliteOpenOptions): Int {
var flags = SQLITE_OPEN_URI
if (options.readOnly) {
flags = flags or SQLITE_OPEN_READONLY
} else {
flags = flags or SQLITE_OPEN_READWRITE
if (options.createIfMissing) {
flags = flags or SQLITE_OPEN_CREATE
}
}
return flags
}
private fun containsRowReturningClause(clause: String): Boolean =
Regex("""\breturning\b""", RegexOption.IGNORE_CASE).containsMatchIn(clause)
private fun looksLikeInsert(clause: String): Boolean = clause.trimStart().startsWith("insert", ignoreCase = true)
private val SQLITE_TRANSIENT = (-1L).toCPointer<CFunction<(COpaquePointer?) -> Unit>>()
private fun emptyResultSet(): SqliteResultSetData = SqliteResultSetData(emptyList(), emptyList())
private fun mapSqlType(core: SqliteCoreModule, nativeType: String, sqliteType: Int): ObjEnumEntry = when {
nativeType.trim().equals("BOOLEAN", ignoreCase = true) -> core.sqlTypes.require("Bool")
nativeType.trim().equals("DATE", ignoreCase = true) -> core.sqlTypes.require("Date")
nativeType.trim().equals("DATETIME", ignoreCase = true) || nativeType.trim().equals("TIMESTAMP", ignoreCase = true) -> core.sqlTypes.require("DateTime")
nativeType.trim().equals("TIMESTAMP WITH TIME ZONE", ignoreCase = true) || nativeType.trim().equals("TIMESTAMPTZ", ignoreCase = true) -> core.sqlTypes.require("Instant")
nativeType.trim().equals("DECIMAL", ignoreCase = true) || nativeType.trim().equals("NUMERIC", ignoreCase = true) -> core.sqlTypes.require("Decimal")
nativeType.contains("BLOB", ignoreCase = true) -> core.sqlTypes.require("Binary")
nativeType.contains("INT", ignoreCase = true) -> core.sqlTypes.require("Int")
nativeType.contains("CHAR", ignoreCase = true) || nativeType.contains("TEXT", ignoreCase = true) || nativeType.contains("CLOB", ignoreCase = true) -> core.sqlTypes.require("String")
nativeType.contains("REAL", ignoreCase = true) || nativeType.contains("FLOA", ignoreCase = true) || nativeType.contains("DOUB", ignoreCase = true) -> core.sqlTypes.require("Double")
sqliteType == SQLITE_INTEGER -> core.sqlTypes.require("Int")
sqliteType == SQLITE_FLOAT -> core.sqlTypes.require("Double")
sqliteType == SQLITE_BLOB -> core.sqlTypes.require("Binary")
else -> core.sqlTypes.require("String")
}
private fun isDecimalNativeType(nativeType: String): Boolean {
val normalized = nativeType.trim().uppercase()
return normalized == "DECIMAL" || normalized == "NUMERIC"
}
private suspend fun decimalFromString(scope: ScopeFacade, value: String): Obj {
val decimalModule = scope.requireScope().currentImportProvider.createModuleScope(scope.pos, "lyng.decimal")
val decimalClass = decimalModule.requireClass("Decimal")
return decimalClass.invokeInstanceMethod(scope.requireScope(), "fromString", ObjString(value))
}
private fun dateTimeFromString(value: String): ObjDateTime {
val trimmed = value.trim()
return if (hasExplicitTimeZone(trimmed)) {
val instant = Instant.parse(trimmed)
ObjDateTime(instant, parseTimeZoneOrUtc(trimmed))
} else {
val local = LocalDateTime.parse(trimmed)
ObjDateTime(local.toInstant(TimeZone.UTC), TimeZone.UTC)
}
}
private fun hasExplicitTimeZone(value: String): Boolean {
if (value.endsWith("Z", ignoreCase = true)) return true
val tIndex = value.indexOf('T')
if (tIndex < 0) return false
val plus = value.lastIndexOf('+')
val minus = value.lastIndexOf('-')
val offsetStart = maxOf(plus, minus)
return offsetStart > tIndex
}
private fun parseTimeZoneOrUtc(value: String): TimeZone {
if (value.endsWith("Z", ignoreCase = true)) return TimeZone.UTC
val tIndex = value.indexOf('T')
if (tIndex < 0) return TimeZone.UTC
val plus = value.lastIndexOf('+')
val minus = value.lastIndexOf('-')
val offsetStart = maxOf(plus, minus)
if (offsetStart <= tIndex) return TimeZone.UTC
return try {
TimeZone.of(value.substring(offsetStart))
} catch (_: IllegalArgumentException) {
TimeZone.UTC
}
}
private fun raiseExecuteReturningUsage(scope: ScopeFacade, core: SqliteCoreModule): Nothing {
scope.raiseError(
ObjException(
core.sqlUsageException,
scope.requireScope(),
ObjString("execute(...) cannot be used with statements that return rows; use select(...)")
)
)
}
private fun usageError(scope: ScopeFacade, core: SqliteCoreModule, message: String): ExecutionError {
return ExecutionError(
ObjException(core.sqlUsageException, scope.requireScope(), ObjString(message)),
scope.pos,
message,
)
}
private fun databaseError(scope: ScopeFacade, core: SqliteCoreModule, message: String): ExecutionError {
return ExecutionError(
ObjException(core.databaseException, scope.requireScope(), ObjString(message)),
scope.pos,
message,
)
}
+209
View File
@@ -0,0 +1,209 @@
package lyng.io.db
/*
Portable value categories exposed by the Lyng SQL layer and used in
`SqlColumn`.
*/
enum SqlType {
Binary, String, Int, Double, Decimal,
Bool, Instant, Date, DateTime
}
extern class SqlColumn {
val name: String
val sqlType: SqlType
val nullable: Bool
/*
Original database type name as reported by the backend, such as
VARCHAR, TEXT, INT8, TIMESTAMPTZ, or BYTEA.
*/
val nativeType: String
}
extern class SqlRow {
/* Number of columns in the row */
val size: Int
val values: List<Object?>
/*
Return the already converted Lyng value for a column addressed by
index or output column label. SQL NULL is returned as null.
Name lookup uses result-column labels. If several columns share the same
label, name-based access is ambiguous and should fail. Missing column
names and invalid indexes should also fail.
*/
override fun getAt(indexOrName: String | Int): Object?
}
/*
A result set is valid only while its owning transaction is active.
Implementations may stream rows or buffer them internally, but:
- rows must be exposed through normal iteration
- 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
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.
*/
extern class ResultSet : Iterable<SqlRow> {
/*
Column metadata for the result rows, in positional order.
*/
val columns: List<SqlColumn>
/*
Number of rows if the implementation can determine it. Implementations
may need to consume or buffer the whole result in order to answer, but
this must not change visible later iteration behavior.
*/
fun size(): Int
/*
Fast emptiness check when the implementation can provide it without
consuming the result. Implementations may still peek or buffer
internally, but this must not change visible later iteration behavior.
*/
override fun isEmpty(): Bool
}
extern class ExecutionResult {
val affectedRowsCount: Int
/*
Return implementation-supported auto-generated values produced by
`execute`. This is intentionally stricter than arbitrary SQL result
sets: statements such as INSERT/UPDATE/DELETE ... RETURNING should be
executed with `select`, not exposed here.
The returned result set has the same transaction-scoped lifetime as any
other result set.
If the statement produced no generated values, the returned result set
is empty.
*/
fun getGeneratedKeys(): ResultSet
}
extern class DatabaseException: Exception
extern class SqlExecutionException: DatabaseException
extern class SqlConstraintException: SqlExecutionException
extern class SqlUsageException: DatabaseException
/*
Special exception to be thrown from `SqlTransaction.transaction` when an
intentional rollback is requested without treating it as a backend failure.
It causes rollback and is propagated to the caller, but should not be
treated as a backend/driver failure.
If rollback itself fails, that rollback failure becomes the primary backend
error instead.
*/
extern class RollbackException: Exception
/*
Transaction represents a database transaction.
Important: a transaction has no explicit commit; instead it commits when
leaving the transaction block normally.
If the transaction block throws any exception not caught inside the calling
code, it will be rolled back.
*/
extern class SqlTransaction {
/*
Execute a SQL statement that returns rows. This includes plain SELECT
queries and database-specific DML statements with row-returning clauses
such as RETURNING or OUTPUT.
Portable SQL uses positional `?` placeholders only.
Portable bindable values are:
- null
- Bool
- Int, Double, Decimal
- String
- Buffer
- Date, DateTime, Instant
Unsupported parameter values should fail with `SqlUsageException`.
*/
fun select(clause: String, params...): ResultSet
/*
Execute a SQL statement for side effects. Use `select` for any statement
whose primary result is a row set.
Parameters follow the same binding rules as `select`.
*/
fun execute(clause: String, params...): ExecutionResult
/*
Create a nested transaction with real nested semantics, typically using
database savepoints.
If the backend cannot provide real nested transaction semantics, this
call should fail with `SqlUsageException` rather than flattening into
the outer transaction.
Failure inside the nested transaction rolls back only the nested scope;
the outer transaction remains active unless the exception is allowed to
propagate further.
*/
fun transaction<T>(block: (SqlTransaction) -> T): T
}
extern class Database {
/*
Open a transaction. Any pooling, physical connection lifecycle, and
implementation-specific configuration are owned by the database
implementation and hidden from the user.
The transaction commits when the block finishes normally,
and rolls back if the block exits with an uncaught exception.
Failure precedence is:
- user exception + successful rollback -> original exception escapes
- user exception + rollback failure -> original exception stays primary
- RollbackException + rollback failure -> rollback failure is primary
- commit failure after normal completion -> commit failure is primary
*/
fun transaction<T>(block: (SqlTransaction) -> T): T
}
/*
Register a database provider for a URL scheme.
Provider modules should call this during module initialization when first
imported. Scheme matching is case-insensitive and normalized to lowercase.
Registering the same scheme more than once should fail.
*/
extern fun registerDatabaseProvider(
scheme: String,
opener: (String, Map<String, Object?>) -> Database
)
/*
The mandatory generic entry point for all providers. It opens a database
handle from a provider-specific connection URL plus extra parameters.
Providers may expose additional typed constructors such as `openSqlite(...)`
or `openPostgres(...)`, but `openDatabase(...)` should remain available for
configuration-driven usage.
It should throw IllegalArgumentException for malformed connection URLs or
invalid extra parameter shapes detected before opening the backend.
Runtime opening failures such as authentication, connectivity, or provider
initialization errors should be reported as DatabaseException.
The matching provider must already be registered, normally because its
module was imported and executed. Unknown schemes or missing providers
should fail with DatabaseException.
*/
extern fun openDatabase(connectionUrl: String, extraParams: Map<String, Object?>): Database
+24
View File
@@ -0,0 +1,24 @@
package lyng.io.db.sqlite
import lyng.io.db
/*
SQLite provider for `lyng.io.db`.
Importing this module registers the `sqlite:` URL scheme for
`openDatabase(...)`.
SQLite provider defaults:
- `Bool` is written as `0` / `1`
- `Decimal` is written as canonical text
- `Date` is written as `YYYY-MM-DD`
- `DateTime` is written as an ISO local timestamp without timezone
- `Instant` is written as an ISO UTC timestamp with explicit timezone marker
*/
extern fun openSqlite(
path: String,
readOnly: Bool = false,
createIfMissing: Bool = true,
foreignKeys: Bool = true,
busyTimeoutMillis: Int = 5000
): Database