Add Lyng DB contract and SQLite provider skeleton
This commit is contained in:
1 parent
b42ceec686
commit
55ba6113e7
21 files changed
+3860
No files matched your search
@@ -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()
|
||||
}
|
||||
+353
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user