Add JDBC database provider for JVM

This commit is contained in:
Sergey Chernov 2026-04-16 22:10:36 +03:00
parent ee392daa13
commit 64273ac60a
22 changed files with 1913 additions and 459 deletions

1
.gitignore vendored
View File

@ -29,3 +29,4 @@ debug.log
test_output*.txt
/site/src/version-template/lyng-version.js
/bugcontents.db
/bugs/

View File

@ -1,6 +1,6 @@
### lyng.io.db — SQL database access for Lyng scripts
This module provides the portable SQL database contract for Lyng. The first shipped provider is SQLite via `lyng.io.db.sqlite`.
This module provides the portable SQL database contract for Lyng. The current shipped providers are SQLite via `lyng.io.db.sqlite` and a JVM-only JDBC bridge via `lyng.io.db.jdbc`.
> **Note:** `lyngio` is a separate library module. It must be explicitly added as a dependency to your host application and initialized in your Lyng scopes.
@ -30,6 +30,28 @@ suspend fun bootstrapDb() {
`createSqliteModule(...)` also registers the `sqlite:` scheme for generic `openDatabase(...)`.
For JVM JDBC-backed access, install the JDBC provider as well:
```kotlin
import net.sergeych.lyng.EvalSession
import net.sergeych.lyng.Scope
import net.sergeych.lyng.io.db.createDbModule
import net.sergeych.lyng.io.db.jdbc.createJdbcModule
suspend fun bootstrapJdbc() {
val session = EvalSession()
val scope: Scope = session.getScope()
createDbModule(scope)
createJdbcModule(scope)
session.eval("""
import lyng.io.db
import lyng.io.db.jdbc
""".trimIndent())
}
```
`createJdbcModule(...)` registers `jdbc:`, `h2:`, `postgres:`, and `postgresql:` for `openDatabase(...)`.
---
#### Using from Lyng scripts
@ -66,6 +88,63 @@ val db = openDatabase(
)
```
JVM JDBC open with H2:
```lyng
import lyng.io.db.jdbc
val db = openH2("mem:demo;DB_CLOSE_DELAY=-1")
val names = db.transaction { tx ->
tx.execute("create table person(id bigint auto_increment primary key, name varchar(120) not null)")
tx.execute("insert into person(name) values(?)", "Ada")
tx.execute("insert into person(name) values(?)", "Linus")
tx.select("select name from person order by id").toList()
}
assertEquals("Ada", names[0]["name"])
assertEquals("Linus", names[1]["name"])
```
Generic JDBC open through `openDatabase(...)`:
```lyng
import lyng.io.db
import lyng.io.db.jdbc
val db = openDatabase(
"jdbc:h2:mem:demo2;DB_CLOSE_DELAY=-1",
Map()
)
val answer = db.transaction { tx ->
tx.select("select 42 as answer").toList()[0]["answer"]
}
assertEquals(42, answer)
```
PostgreSQL typed open:
```lyng
import lyng.io.db.jdbc
val db = openPostgres(
"jdbc:postgresql://127.0.0.1/appdb",
"appuser",
"secret"
)
val titles = db.transaction { tx ->
tx.execute("create table if not exists task(id bigserial primary key, title text not null)")
tx.execute("insert into task(title) values(?)", "Ship JDBC provider")
tx.execute("insert into task(title) values(?)", "Test PostgreSQL path")
tx.select("select title from task order by id").toList()
}
assertEquals("Ship JDBC provider", titles[0]["title"])
```
Nested transactions use real savepoint semantics:
```lyng
@ -217,6 +296,76 @@ Open-time validation failures:
- malformed URL or bad option shape -> `IllegalArgumentException`
- runtime open failure -> `DatabaseException`
#### JDBC provider
`lyng.io.db.jdbc` is currently implemented on the JVM target only. The `lyngio-jvm` artifact bundles and explicitly loads these JDBC drivers:
- SQLite
- H2
- PostgreSQL
Typed helpers:
```lyng
openJdbc(
connectionUrl: String,
user: String? = null,
password: String? = null,
driverClass: String? = null,
properties: Map<String, Object?>? = null
): Database
openH2(
connectionUrl: String,
user: String? = null,
password: String? = null,
properties: Map<String, Object?>? = null
): Database
openPostgres(
connectionUrl: String,
user: String? = null,
password: String? = null,
properties: Map<String, Object?>? = null
): Database
```
Accepted generic URL forms:
- `jdbc:h2:mem:test;DB_CLOSE_DELAY=-1`
- `h2:mem:test;DB_CLOSE_DELAY=-1`
- `jdbc:postgresql://localhost/app`
- `postgres://localhost/app`
- `postgresql://localhost/app`
Supported `openDatabase(..., extraParams)` keys for JDBC:
- `driverClass: String`
- `user: String`
- `password: String`
- `properties: Map<String, Object?>`
Behavior notes for the JDBC bridge:
- the portable `Database` / `SqlTransaction` API stays the same as for SQLite
- nested transactions use JDBC savepoints
- JDBC connection properties are built from `user`, `password`, and `properties`
- `properties` values are stringified before being passed to JDBC
- statements with row-returning clauses still must use `select(...)`, not `execute(...)`
Platform support for this provider:
- `lyng.io.db.jdbc` — JVM only
- `openH2(...)` — works out of the box with `lyngio-jvm`
- `openPostgres(...)` — driver included, but an actual PostgreSQL server is still required
PostgreSQL-specific notes:
- `openPostgres(...)` accepts either a full JDBC URL or shorthand forms such as `//localhost/app`
- local peer/trust setups may use an empty password string
- generated keys work with PostgreSQL `bigserial` / identity columns through `ExecutionResult.getGeneratedKeys()`
- for reproducible automated tests, prefer a disposable PostgreSQL instance such as Docker/Testcontainers instead of a long-lived shared server
---
#### Lifetime rules
@ -236,5 +385,6 @@ The same lifetime rule applies to generated keys returned by `ExecutionResult.ge
- `lyng.io.db` — generic contract, available when host code installs it
- `lyng.io.db.sqlite` — implemented on JVM and Linux Native in the current release tree
- `lyng.io.db.jdbc` — implemented on JVM in the current release tree
For the broader I/O overview, see [lyngio overview](lyngio.md).

View File

@ -12,7 +12,7 @@
#### Included Modules
- **[lyng.io.db](lyng.io.db.md):** Portable SQL database access. Provides `Database`, `SqlTransaction`, `ResultSet`, and SQLite support through `lyng.io.db.sqlite`.
- **[lyng.io.db](lyng.io.db.md):** Portable SQL database access. Provides `Database`, `SqlTransaction`, `ResultSet`, SQLite support through `lyng.io.db.sqlite`, and JVM JDBC support through `lyng.io.db.jdbc`.
- **[lyng.io.fs](lyng.io.fs.md):** Async filesystem access. Provides the `Path` class for file/directory operations, streaming, and globbing.
- **[lyng.io.process](lyng.io.process.md):** External process execution and shell commands. Provides `Process`, `RunningProcess`, and `Platform` information.
- **[lyng.io.console](lyng.io.console.md):** Rich console/TTY access. Provides `Console` capability detection, geometry, output, and iterable events.
@ -45,6 +45,7 @@ To use `lyngio` modules in your scripts, you must install them into your Lyng sc
```kotlin
import net.sergeych.lyng.EvalSession
import net.sergeych.lyng.io.db.createDbModule
import net.sergeych.lyng.io.db.jdbc.createJdbcModule
import net.sergeych.lyng.io.db.sqlite.createSqliteModule
import net.sergeych.lyng.io.fs.createFs
import net.sergeych.lyng.io.process.createProcessModule
@ -65,6 +66,7 @@ suspend fun runMyScript() {
// Install modules with policies
createDbModule(scope)
createJdbcModule(scope)
createSqliteModule(scope)
createFs(PermitAllAccessPolicy, scope)
createProcessModule(PermitAllProcessAccessPolicy, scope)
@ -76,6 +78,7 @@ suspend fun runMyScript() {
// Now scripts can import them
session.eval("""
import lyng.io.db
import lyng.io.db.jdbc
import lyng.io.db.sqlite
import lyng.io.fs
import lyng.io.process
@ -84,6 +87,7 @@ suspend fun runMyScript() {
import lyng.io.net
import lyng.io.ws
println("H2 JDBC available: " + (openH2("mem:demo;DB_CLOSE_DELAY=-1") != null))
println("SQLite available: " + (openSqlite(":memory:") != null))
println("Working dir: " + Path(".").readUtf8())
println("OS: " + Platform.details().name)
@ -102,7 +106,7 @@ suspend fun runMyScript() {
`lyngio` is built with a "Secure by Default" philosophy. Every I/O or process operation is checked against a policy.
- **Filesystem Security:** Implement `FsAccessPolicy` to restrict access to specific paths or operations (e.g., read-only access to a sandbox directory).
- **Database Installation:** Database access is still explicit-capability style. The host must install `lyng.io.db` and at least one provider such as `lyng.io.db.sqlite`; otherwise scripts cannot open databases.
- **Database Installation:** Database access is still explicit-capability style. The host must install `lyng.io.db` and at least one provider such as `lyng.io.db.sqlite` or `lyng.io.db.jdbc`; otherwise scripts cannot open databases.
- **Process Security:** Implement `ProcessAccessPolicy` to restrict which executables can be run or to disable shell execution entirely.
- **Console Security:** Implement `ConsoleAccessPolicy` to control output writes, event reads, and raw mode switching.
- **HTTP Security:** Implement `HttpAccessPolicy` to restrict which requests scripts may send.
@ -122,16 +126,16 @@ For more details, see the specific module documentation:
#### Platform Support Overview
| Platform | lyng.io.db/sqlite | lyng.io.fs | lyng.io.process | lyng.io.console | lyng.io.http | lyng.io.ws | lyng.io.net |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
| **JVM** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Linux Native** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Apple Native** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ |
| **Windows Native** | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ |
| **Android** | ⚠️ | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ |
| **JS / Node** | ❌ | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ |
| **JS / Browser** | ❌ | ✅ (in-memory) | ❌ | ⚠️ | ✅ | ✅ | ❌ |
| **Wasm** | ❌ | ✅ (in-memory) | ❌ | ⚠️ | ❌ | ❌ | ❌ |
| Platform | lyng.io.db/sqlite | lyng.io.db/jdbc | lyng.io.fs | lyng.io.process | lyng.io.console | lyng.io.http | lyng.io.ws | lyng.io.net |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
| **JVM** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Linux Native** | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Apple Native** | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ |
| **Windows Native** | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ |
| **Android** | ⚠️ | ❌ | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ |
| **JS / Node** | ❌ | ❌ | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ |
| **JS / Browser** | ❌ | ❌ | ✅ (in-memory) | ❌ | ⚠️ | ✅ | ✅ | ❌ |
| **Wasm** | ❌ | ❌ | ✅ (in-memory) | ❌ | ⚠️ | ❌ | ❌ | ❌ |
Legend:
- `✅` supported

43
examples/h2_basic.lyng Normal file
View File

@ -0,0 +1,43 @@
import lyng.io.db
import lyng.io.db.jdbc
println("H2 JDBC demo: typed open, generic open, generated keys")
val db = openH2("mem:lyng_h2_demo;DB_CLOSE_DELAY=-1")
db.transaction { tx ->
tx.execute("create table if not exists person(id bigint auto_increment primary key, name varchar(120) not null, active boolean not null)")
tx.execute("delete from person")
val firstInsert = tx.execute(
"insert into person(name, active) values(?, ?)",
"Ada",
true
)
val firstId = firstInsert.getGeneratedKeys().toList()[0][0]
assertEquals(1, firstId)
tx.execute(
"insert into person(name, active) values(?, ?)",
"Linus",
false
)
val rows = tx.select("select id, name, active from person order by id").toList()
assertEquals(2, rows.size)
println("#" + rows[0]["id"] + " " + rows[0]["name"] + " active=" + rows[0]["active"])
println("#" + rows[1]["id"] + " " + rows[1]["name"] + " active=" + rows[1]["active"])
}
val genericDb = openDatabase(
"jdbc:h2:mem:lyng_h2_generic;DB_CLOSE_DELAY=-1",
Map()
)
val answer = genericDb.transaction { tx ->
tx.select("select 42 as answer").toList()[0]["answer"]
}
assertEquals(42, answer)
println("Generic JDBC openDatabase(...) also works: answer=$answer")
println("OK")

View File

@ -0,0 +1,71 @@
import lyng.io.db.jdbc
/*
PostgreSQL JDBC demo.
Usage:
lyng examples/postgres_basic.lyng [jdbc-url] [user] [password]
Typical local URL:
jdbc:postgresql://127.0.0.1/postgres
*/
fun cliArgs(): List<String> {
val result: List<String> = []
for (raw in ARGV as List) {
result.add(raw as String)
}
return result
}
val argv = cliArgs()
val URL = if (argv.size > 0) argv[0] else "jdbc:postgresql://127.0.0.1/postgres"
val USER = if (argv.size > 1) argv[1] else ""
val PASSWORD = if (argv.size > 2) argv[2] else ""
println("PostgreSQL JDBC demo: typed open, generated keys, nested transaction")
val db = openPostgres(URL, USER, PASSWORD)
db.transaction { tx ->
tx.execute("create table if not exists lyng_pg_demo(id bigserial primary key, title text not null, done boolean not null)")
tx.execute("delete from lyng_pg_demo")
val firstInsert = tx.execute(
"insert into lyng_pg_demo(title, done) values(?, ?)",
"Verify PostgreSQL JDBC support",
false
)
val firstId = firstInsert.getGeneratedKeys().toList()[0][0]
println("First generated id=" + firstId)
tx.execute(
"insert into lyng_pg_demo(title, done) values(?, ?)",
"Review documentation",
true
)
try {
tx.transaction { inner ->
inner.execute(
"insert into lyng_pg_demo(title, done) values(?, ?)",
"This row is rolled back",
false
)
throw IllegalStateException("rollback nested")
}
} catch (_: IllegalStateException) {
println("Nested transaction rolled back as expected")
}
val rows = tx.select("select id, title, done from lyng_pg_demo order by id").toList()
for (row in rows) {
println("#" + row["id"] + " " + row["title"] + " done=" + row["done"])
}
val count = tx.select("select count(*) as count from lyng_pg_demo").toList()[0]["count"]
assertEquals(2, count)
println("Visible rows after nested rollback: " + count)
}
println("OK")

View File

@ -16,6 +16,9 @@ compiler = "3.2.0-alpha11"
ktor = "3.3.1"
slf4j = "2.0.17"
sqlite-jdbc = "3.50.3.0"
h2 = "2.4.240"
postgresql = "42.7.8"
testcontainers = "1.20.6"
[libraries]
clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" }
@ -45,6 +48,10 @@ ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.re
ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor" }
slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" }
sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite-jdbc" }
h2 = { module = "com.h2database:h2", version.ref = "h2" }
postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }
testcontainers = { module = "org.testcontainers:testcontainers", version.ref = "testcontainers" }
testcontainers-postgresql = { module = "org.testcontainers:postgresql", version.ref = "testcontainers" }
[plugins]
androidLibrary = { id = "com.android.library", version.ref = "agp" }

View File

@ -41,6 +41,7 @@ import net.sergeych.lyng.Source
import net.sergeych.lyng.asFacade
import net.sergeych.lyng.io.console.createConsoleModule
import net.sergeych.lyng.io.db.createDbModule
import net.sergeych.lyng.io.db.jdbc.createJdbcModule
import net.sergeych.lyng.io.db.sqlite.createSqliteModule
import net.sergeych.lyng.io.fs.createFs
import net.sergeych.lyng.io.http.createHttpModule
@ -140,6 +141,7 @@ private val baseCliImportManagerDefer = globalDefer {
private fun ImportManager.invalidateCliModuleCaches() {
invalidatePackageCache("lyng.io.fs")
invalidatePackageCache("lyng.io.console")
invalidatePackageCache("lyng.io.db.jdbc")
invalidatePackageCache("lyng.io.db.sqlite")
invalidatePackageCache("lyng.io.http")
invalidatePackageCache("lyng.io.ws")
@ -229,6 +231,7 @@ private fun installCliModules(manager: ImportManager) {
createFs(PermitAllAccessPolicy, manager)
createConsoleModule(PermitAllConsoleAccessPolicy, manager)
createDbModule(manager)
createJdbcModule(manager)
createSqliteModule(manager)
createHttpModule(PermitAllHttpAccessPolicy, manager)
createWsModule(PermitAllWsAccessPolicy, manager)

View File

@ -181,6 +181,12 @@ kotlin {
implementation(libs.kotlinx.coroutines.test)
}
}
val jvmTest by getting {
dependencies {
implementation(libs.testcontainers)
implementation(libs.testcontainers.postgresql)
}
}
val linuxTest by creating {
dependsOn(commonTest)
}
@ -217,6 +223,8 @@ kotlin {
implementation(libs.ktor.client.cio)
implementation(libs.ktor.network)
implementation(libs.sqlite.jdbc)
implementation(libs.h2)
implementation(libs.postgresql)
}
}
// // For Wasm we use in-memory VFS for now

View File

@ -0,0 +1,22 @@
package net.sergeych.lyng.io.db.jdbc
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.io.db.SqlCoreModule
import net.sergeych.lyng.io.db.SqlDatabaseBackend
import net.sergeych.lyng.obj.ObjException
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.requireScope
internal actual suspend fun openJdbcBackend(
scope: ScopeFacade,
core: SqlCoreModule,
options: JdbcOpenOptions,
): SqlDatabaseBackend {
scope.raiseError(
ObjException(
core.databaseException,
scope.requireScope(),
ObjString("lyng.io.db.jdbc is available only on the JVM target")
)
)
}

View File

@ -18,15 +18,17 @@
package net.sergeych.lyng.io.db.sqlite
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.io.db.SqlCoreModule
import net.sergeych.lyng.io.db.SqlDatabaseBackend
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,
core: SqlCoreModule,
options: SqliteOpenOptions,
): SqliteDatabaseBackend {
): SqlDatabaseBackend {
scope.raiseError(
ObjException(
core.databaseException,

View File

@ -0,0 +1,384 @@
/*
* 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.Arguments
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.asFacade
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjClass
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.ObjString
import net.sergeych.lyng.obj.thisAs
import net.sergeych.lyng.requireScope
internal data class SqlColumnMeta(
val name: String,
val sqlType: ObjEnumEntry,
val nullable: Boolean,
val nativeType: String,
)
internal data class SqlResultSetData(
val columns: List<SqlColumnMeta>,
val rows: List<List<Obj>>,
)
internal data class SqlExecutionResultData(
val affectedRowsCount: Int,
val generatedKeys: SqlResultSetData,
)
internal interface SqlDatabaseBackend {
suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T
}
internal interface SqlTransactionBackend {
suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqlResultSetData
suspend fun execute(scope: ScopeFacade, clause: String, params: List<Obj>): SqlExecutionResultData
suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T
}
internal class SqlCoreModule 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): SqlCoreModule = SqlCoreModule(
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")
}
}
}
internal class SqlRuntimeTypes private constructor(
val core: SqlCoreModule,
val databaseClass: ObjClass,
val transactionClass: ObjClass,
val resultSetClass: ObjClass,
val rowClass: ObjClass,
val columnClass: ObjClass,
val executionResultClass: ObjClass,
) {
companion object {
fun create(prefix: String, core: SqlCoreModule): SqlRuntimeTypes {
val databaseClass = object : ObjClass("${prefix}Database", core.databaseClass) {}
val transactionClass = object : ObjClass("${prefix}Transaction", core.transactionClass) {}
val resultSetClass = object : ObjClass("${prefix}ResultSet", core.resultSetClass) {}
val rowClass = object : ObjClass("${prefix}Row", core.rowClass) {}
val columnClass = object : ObjClass("${prefix}Column", core.columnClass) {}
val executionResultClass = object : ObjClass("${prefix}ExecutionResult", core.executionResultClass) {}
val runtime = SqlRuntimeTypes(
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<SqlDatabaseObj>()
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 = SqlTransactionLifetime(this@SqlRuntimeTypes.core)
try {
call(block, Arguments(SqlTransactionObj(this@SqlRuntimeTypes, backend, lifetime)), ObjNull)
} finally {
lifetime.close()
}
}
}
transactionClass.addFn("select") {
val self = thisAs<SqlTransactionObj>()
self.lifetime.ensureActive(this)
val clause = (args.list.getOrNull(0) as? ObjString)?.value
?: raiseClassCastError("query must be String")
val params = args.list.drop(1)
SqlResultSetObj(self.types, self.lifetime, self.backend.select(this, clause, params))
}
transactionClass.addFn("execute") {
val self = thisAs<SqlTransactionObj>()
self.lifetime.ensureActive(this)
val clause = (args.list.getOrNull(0) as? ObjString)?.value
?: raiseClassCastError("query must be String")
val params = args.list.drop(1)
SqlExecutionResultObj(self.types, self.lifetime, self.backend.execute(this, clause, params))
}
transactionClass.addFn("transaction") {
val self = thisAs<SqlTransactionObj>()
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 = SqlTransactionLifetime(this@SqlRuntimeTypes.core)
try {
call(block, Arguments(SqlTransactionObj(self.types, backend, lifetime)), ObjNull)
} finally {
lifetime.close()
}
}
}
resultSetClass.addProperty("columns", getter = {
val self = thisAs<SqlResultSetObj>()
self.lifetime.ensureActive(this)
ObjImmutableList(self.columns)
})
resultSetClass.addFn("size") {
val self = thisAs<SqlResultSetObj>()
self.lifetime.ensureActive(this)
ObjInt.of(self.rows.size.toLong())
}
resultSetClass.addFn("isEmpty") {
val self = thisAs<SqlResultSetObj>()
self.lifetime.ensureActive(this)
ObjBool(self.rows.isEmpty())
}
resultSetClass.addFn("iterator") {
val self = thisAs<SqlResultSetObj>()
self.lifetime.ensureActive(this)
ObjImmutableList(self.rows).invokeInstanceMethod(requireScope(), "iterator")
}
resultSetClass.addFn("toList") {
val self = thisAs<SqlResultSetObj>()
self.lifetime.ensureActive(this)
ObjImmutableList(self.rows)
}
rowClass.addProperty("size", getter = {
val self = thisAs<SqlRowObj>()
self.lifetime.ensureActive(this)
ObjInt.of(self.values.size.toLong())
})
rowClass.addProperty("values", getter = {
val self = thisAs<SqlRowObj>()
self.lifetime.ensureActive(this)
ObjImmutableList(self.values)
})
columnClass.addProperty("name", getter = { ObjString(thisAs<SqlColumnObj>().meta.name) })
columnClass.addProperty("sqlType", getter = { thisAs<SqlColumnObj>().meta.sqlType })
columnClass.addProperty("nullable", getter = { ObjBool(thisAs<SqlColumnObj>().meta.nullable) })
columnClass.addProperty("nativeType", getter = { ObjString(thisAs<SqlColumnObj>().meta.nativeType) })
executionResultClass.addProperty("affectedRowsCount", getter = {
val self = thisAs<SqlExecutionResultObj>()
self.lifetime.ensureActive(this)
ObjInt.of(self.result.affectedRowsCount.toLong())
})
executionResultClass.addFn("getGeneratedKeys") {
val self = thisAs<SqlExecutionResultObj>()
self.lifetime.ensureActive(this)
SqlResultSetObj(self.types, self.lifetime, self.result.generatedKeys)
}
}
}
internal class SqlTransactionLifetime(
private val core: SqlCoreModule,
) {
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"))
)
}
}
}
internal class SqlDatabaseObj(
val types: SqlRuntimeTypes,
val backend: SqlDatabaseBackend,
) : Obj() {
override val objClass: ObjClass
get() = types.databaseClass
}
internal class SqlTransactionObj(
val types: SqlRuntimeTypes,
val backend: SqlTransactionBackend,
val lifetime: SqlTransactionLifetime,
) : Obj() {
override val objClass: ObjClass
get() = types.transactionClass
}
internal class SqlResultSetObj(
val types: SqlRuntimeTypes,
val lifetime: SqlTransactionLifetime,
data: SqlResultSetData,
) : Obj() {
val columns: List<Obj> = data.columns.map { SqlColumnObj(types, it) }
val rows: List<Obj> = buildRows(types, lifetime, data)
override val objClass: ObjClass
get() = types.resultSetClass
private fun buildRows(
types: SqlRuntimeTypes,
lifetime: SqlTransactionLifetime,
data: SqlResultSetData,
): 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 ->
SqlRowObj(types, lifetime, rowValues, indexByName)
}
}
}
internal class SqlRowObj(
val types: SqlRuntimeTypes,
val lifetime: SqlTransactionLifetime,
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")
}
}
}
internal class SqlColumnObj(
val types: SqlRuntimeTypes,
val meta: SqlColumnMeta,
) : Obj() {
override val objClass: ObjClass
get() = types.columnClass
}
internal class SqlExecutionResultObj(
val types: SqlRuntimeTypes,
val lifetime: SqlTransactionLifetime,
val result: SqlExecutionResultData,
) : Obj() {
override val objClass: ObjClass
get() = types.executionResultClass
}

View File

@ -0,0 +1,278 @@
/*
* 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.jdbc
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.io.db.SqlCoreModule
import net.sergeych.lyng.io.db.SqlDatabaseBackend
import net.sergeych.lyng.io.db.SqlDatabaseObj
import net.sergeych.lyng.io.db.SqlRuntimeTypes
import net.sergeych.lyng.io.db.createDbModule
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjImmutableMap
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjMap
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.db_jdbcLyng
private const val JDBC_MODULE_NAME = "lyng.io.db.jdbc"
private const val DB_MODULE_NAME = "lyng.io.db"
private const val JDBC_SCHEME = "jdbc"
private const val H2_DRIVER = "org.h2.Driver"
private const val POSTGRES_DRIVER = "org.postgresql.Driver"
fun createJdbcModule(scope: Scope): Boolean = createJdbcModule(scope.importManager)
fun createJdbc(scope: Scope): Boolean = createJdbcModule(scope)
fun createJdbcModule(manager: ImportManager): Boolean {
createDbModule(manager)
if (manager.packageNames.contains(JDBC_MODULE_NAME)) return false
manager.addPackage(JDBC_MODULE_NAME) { module ->
buildJdbcModule(module)
}
return true
}
fun createJdbc(manager: ImportManager): Boolean = createJdbcModule(manager)
private suspend fun buildJdbcModule(module: ModuleScope) {
module.eval(Source(JDBC_MODULE_NAME, db_jdbcLyng))
val dbModule = module.importProvider.createModuleScope(Pos.builtIn, DB_MODULE_NAME)
val core = SqlCoreModule.resolve(dbModule)
val runtimeTypes = SqlRuntimeTypes.create("Jdbc", core)
module.addFn("openJdbc") {
val options = parseOpenJdbcArgs(this)
SqlDatabaseObj(runtimeTypes, openJdbcBackend(this, core, options))
}
module.addFn("openH2") {
val options = parseOpenShortcutArgs(this, H2_DRIVER, ::normalizeH2Url)
SqlDatabaseObj(runtimeTypes, openJdbcBackend(this, core, options))
}
module.addFn("openPostgres") {
val options = parseOpenShortcutArgs(this, POSTGRES_DRIVER, ::normalizePostgresUrl)
SqlDatabaseObj(runtimeTypes, openJdbcBackend(this, core, options))
}
registerProvider(dbModule, runtimeTypes, core, JDBC_SCHEME, null)
registerProvider(dbModule, runtimeTypes, core, "h2", H2_DRIVER)
registerProvider(dbModule, runtimeTypes, core, "postgres", POSTGRES_DRIVER)
registerProvider(dbModule, runtimeTypes, core, "postgresql", POSTGRES_DRIVER)
}
private suspend fun registerProvider(
dbModule: ModuleScope,
runtimeTypes: SqlRuntimeTypes,
core: SqlCoreModule,
scheme: String,
implicitDriverClass: String?,
) {
dbModule.callFn(
"registerDatabaseProvider",
ObjString(scheme),
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 = parseJdbcConnectionUrl(this, scheme, implicitDriverClass, connectionUrl, extraParams)
SqlDatabaseObj(runtimeTypes, openJdbcBackend(this, core, options))
}
)
}
private suspend fun parseOpenJdbcArgs(scope: ScopeFacade): JdbcOpenOptions {
val rawUrl = readArg(scope, "connectionUrl", 0) ?: scope.raiseError("argument 'connectionUrl' is required")
val connectionUrl = (rawUrl as? ObjString)?.value ?: scope.raiseClassCastError("connectionUrl must be String")
val user = readNullableStringArg(scope, "user", 1)
val password = readNullableStringArg(scope, "password", 2)
val driverClass = readNullableStringArg(scope, "driverClass", 3)
val properties = readPropertiesArg(scope, "properties", 4)
return JdbcOpenOptions(
connectionUrl = normalizeJdbcUrl(connectionUrl, scope),
user = user,
password = password,
driverClass = driverClass,
properties = properties,
)
}
private suspend fun parseOpenShortcutArgs(
scope: ScopeFacade,
defaultDriverClass: String,
normalize: (String, ScopeFacade) -> String,
): JdbcOpenOptions {
val rawUrl = readArg(scope, "connectionUrl", 0) ?: scope.raiseError("argument 'connectionUrl' is required")
val connectionUrl = (rawUrl as? ObjString)?.value ?: scope.raiseClassCastError("connectionUrl must be String")
val user = readNullableStringArg(scope, "user", 1)
val password = readNullableStringArg(scope, "password", 2)
val properties = readPropertiesArg(scope, "properties", 3)
return JdbcOpenOptions(
connectionUrl = normalize(connectionUrl, scope),
user = user,
password = password,
driverClass = defaultDriverClass,
properties = properties,
)
}
private suspend fun parseJdbcConnectionUrl(
scope: ScopeFacade,
scheme: String,
implicitDriverClass: String?,
connectionUrl: String,
extraParams: Obj,
): JdbcOpenOptions {
val driverClass = mapNullableString(extraParams, scope, "driverClass") ?: implicitDriverClass
val user = mapNullableString(extraParams, scope, "user")
val password = mapNullableString(extraParams, scope, "password")
val properties = mapProperties(extraParams, scope, "properties")
val normalizedUrl = when (scheme) {
JDBC_SCHEME -> normalizeJdbcUrl(connectionUrl, scope)
"h2" -> normalizeH2Url(connectionUrl, scope)
"postgres", "postgresql" -> normalizePostgresUrl(connectionUrl, scope)
else -> scope.raiseIllegalArgument("Unsupported JDBC provider scheme: $scheme")
}
return JdbcOpenOptions(
connectionUrl = normalizedUrl,
user = user,
password = password,
driverClass = driverClass,
properties = properties,
)
}
private fun normalizeJdbcUrl(rawUrl: String, scope: ScopeFacade): String {
val trimmed = rawUrl.trim()
if (!trimmed.startsWith("jdbc:", ignoreCase = true)) {
scope.raiseIllegalArgument("JDBC connection URL must start with jdbc:")
}
return trimmed
}
private fun normalizeH2Url(rawUrl: String, scope: ScopeFacade): String {
val trimmed = rawUrl.trim()
if (trimmed.isEmpty()) {
scope.raiseIllegalArgument("H2 connection URL must not be empty")
}
return when {
trimmed.startsWith("jdbc:h2:", ignoreCase = true) -> trimmed
trimmed.startsWith("h2:", ignoreCase = true) -> "jdbc:${trimmed}"
else -> "jdbc:h2:$trimmed"
}
}
private fun normalizePostgresUrl(rawUrl: String, scope: ScopeFacade): String {
val trimmed = rawUrl.trim()
if (trimmed.isEmpty()) {
scope.raiseIllegalArgument("PostgreSQL connection URL must not be empty")
}
return when {
trimmed.startsWith("jdbc:postgresql:", ignoreCase = true) -> trimmed
trimmed.startsWith("postgresql:", ignoreCase = true) -> "jdbc:$trimmed"
trimmed.startsWith("postgres:", ignoreCase = true) -> "jdbc:postgresql:${trimmed.substringAfter(':')}"
else -> "jdbc:postgresql:$trimmed"
}
}
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 readNullableStringArg(scope: ScopeFacade, name: String, position: Int): String? {
val value = readArg(scope, name, position) ?: return null
return when (value) {
ObjNull -> null
is ObjString -> value.value
else -> scope.raiseClassCastError("$name must be String?")
}
}
private suspend fun readPropertiesArg(scope: ScopeFacade, name: String, position: Int): Map<String, String> {
val value = readArg(scope, name, position) ?: return emptyMap()
return when (value) {
ObjNull -> emptyMap()
else -> objToStringMap(value, scope, name)
}
}
private suspend fun mapNullableString(map: Obj, scope: ScopeFacade, key: String): String? {
val value = map.getAt(scope.requireScope(), ObjString(key))
return when (value) {
ObjNull -> null
is ObjString -> value.value
else -> scope.raiseClassCastError("extraParams.$key must be String?")
}
}
private suspend fun mapProperties(map: Obj, scope: ScopeFacade, key: String): Map<String, String> {
val value = map.getAt(scope.requireScope(), ObjString(key))
return when (value) {
ObjNull -> emptyMap()
else -> objToStringMap(value, scope, "extraParams.$key")
}
}
private suspend fun objToStringMap(value: Obj, scope: ScopeFacade, label: String): Map<String, String> {
val rawEntries = when (value) {
is ObjMap -> value.map
is ObjImmutableMap -> value.map
else -> scope.raiseClassCastError("$label must be Map<String, Object?>")
}
val properties = linkedMapOf<String, String>()
for ((rawKey, rawValue) in rawEntries) {
val key = (rawKey as? ObjString)?.value ?: scope.raiseClassCastError("$label keys must be String")
if (rawValue == ObjNull) continue
properties[key] = scope.toStringOf(rawValue).value
}
return properties
}
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 JdbcOpenOptions(
val connectionUrl: String,
val user: String?,
val password: String?,
val driverClass: String?,
val properties: Map<String, String>,
)
internal expect suspend fun openJdbcBackend(
scope: ScopeFacade,
core: SqlCoreModule,
options: JdbcOpenOptions,
): SqlDatabaseBackend

View File

@ -24,22 +24,20 @@ 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.SqlCoreModule
import net.sergeych.lyng.io.db.SqlDatabaseBackend
import net.sergeych.lyng.io.db.SqlDatabaseObj
import net.sergeych.lyng.io.db.SqlRuntimeTypes
import net.sergeych.lyng.io.db.SqlTransactionBackend
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
@ -64,12 +62,12 @@ 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)
val core = SqlCoreModule.resolve(dbModule)
val runtimeTypes = SqlRuntimeTypes.create("Sqlite", core)
module.addFn("openSqlite") {
val options = parseOpenSqliteArgs(this)
SqliteDatabaseObj(runtimeTypes, openSqliteBackend(this, core, options))
SqlDatabaseObj(runtimeTypes, openSqliteBackend(this, core, options))
}
dbModule.callFn(
@ -80,7 +78,7 @@ private suspend fun buildSqliteModule(module: ModuleScope) {
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))
SqlDatabaseObj(runtimeTypes, openSqliteBackend(this, core, options))
}
)
}
@ -189,354 +187,8 @@ internal data class SqliteOpenOptions(
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,
core: SqlCoreModule,
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
}
): SqlDatabaseBackend

View File

@ -0,0 +1,22 @@
package net.sergeych.lyng.io.db.jdbc
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.io.db.SqlCoreModule
import net.sergeych.lyng.io.db.SqlDatabaseBackend
import net.sergeych.lyng.obj.ObjException
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.requireScope
internal actual suspend fun openJdbcBackend(
scope: ScopeFacade,
core: SqlCoreModule,
options: JdbcOpenOptions,
): SqlDatabaseBackend {
scope.raiseError(
ObjException(
core.databaseException,
scope.requireScope(),
ObjString("lyng.io.db.jdbc is available only on the JVM target")
)
)
}

View File

@ -18,15 +18,17 @@
package net.sergeych.lyng.io.db.sqlite
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.io.db.SqlCoreModule
import net.sergeych.lyng.io.db.SqlDatabaseBackend
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,
core: SqlCoreModule,
options: SqliteOpenOptions,
): SqliteDatabaseBackend {
): SqlDatabaseBackend {
scope.raiseError(
ObjException(
core.databaseException,

View File

@ -0,0 +1,456 @@
/*
* 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.jdbc
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.io.db.SqlColumnMeta
import net.sergeych.lyng.io.db.SqlCoreModule
import net.sergeych.lyng.io.db.SqlDatabaseBackend
import net.sergeych.lyng.io.db.SqlExecutionResultData
import net.sergeych.lyng.io.db.SqlResultSetData
import net.sergeych.lyng.io.db.SqlTransactionBackend
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjBuffer
import net.sergeych.lyng.obj.ObjDate
import net.sergeych.lyng.obj.ObjDateTime
import net.sergeych.lyng.obj.ObjEnumEntry
import net.sergeych.lyng.obj.ObjException
import net.sergeych.lyng.obj.ObjInstant
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.requireScope
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import java.math.BigDecimal
import java.sql.Connection
import java.sql.DriverManager
import java.sql.PreparedStatement
import java.sql.ResultSet
import java.sql.SQLException
import java.sql.SQLIntegrityConstraintViolationException
import java.sql.SQLNonTransientConnectionException
import java.sql.Statement
import java.util.Properties
import kotlin.time.Instant
private val knownJdbcDrivers = listOf(
"org.sqlite.JDBC",
"org.h2.Driver",
"org.postgresql.Driver",
)
internal actual suspend fun openJdbcBackend(
scope: ScopeFacade,
core: SqlCoreModule,
options: JdbcOpenOptions,
): SqlDatabaseBackend {
return JdbcDatabaseBackend(core, options)
}
private class JdbcDatabaseBackend(
private val core: SqlCoreModule,
private val options: JdbcOpenOptions,
) : SqlDatabaseBackend {
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T {
val connection = openConnection(scope)
try {
connection.autoCommit = false
val tx = JdbcTransactionBackend(core, connection)
val result = try {
block(tx)
} catch (e: Throwable) {
throw finishFailedTransaction(scope, core, e) {
rollbackOrThrow(scope, core, connection)
}
}
try {
connection.commit()
} catch (e: SQLException) {
throw mapSqlException(scope, core, e)
}
return result
} catch (e: SQLException) {
throw mapSqlException(scope, core, e)
} finally {
try {
connection.close()
} catch (_: SQLException) {
}
}
}
private fun openConnection(scope: ScopeFacade): Connection {
ensureJdbcDriversLoaded(scope, core, options.driverClass)
val properties = Properties()
options.user?.let { properties.setProperty("user", it) }
options.password?.let { properties.setProperty("password", it) }
options.properties.forEach { (key, value) -> properties.setProperty(key, value) }
return try {
DriverManager.getConnection(options.connectionUrl, properties)
} catch (e: SQLException) {
throw mapOpenException(scope, core, e)
}
}
}
private class JdbcTransactionBackend(
private val core: SqlCoreModule,
private val connection: Connection,
) : SqlTransactionBackend {
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqlResultSetData {
try {
connection.prepareStatement(clause).use { statement ->
bindParams(statement, params, scope, core)
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>): SqlExecutionResultData {
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, core)
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 generatedKeys = statement.generatedKeys.use { rs ->
if (rs == null) emptyResultSet() else readResultSet(scope, core, rs)
}
return SqlExecutionResultData(statement.updateCount, generatedKeys)
}
} catch (e: SQLException) {
throw mapSqlException(scope, core, e)
}
}
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T {
val savepoint = try {
connection.setSavepoint()
} catch (e: SQLException) {
throw mapSqlUsage(scope, core, "Nested transactions are not supported by this JDBC backend", e)
}
val nested = JdbcTransactionBackend(core, connection)
val result = try {
block(nested)
} catch (e: Throwable) {
throw finishFailedTransaction(scope, core, e) {
rollbackToSavepointOrThrow(scope, core, connection, savepoint)
releaseSavepointOrThrow(scope, core, connection, savepoint)
}
}
try {
connection.releaseSavepoint(savepoint)
} catch (e: SQLException) {
throw mapSqlException(scope, core, e)
}
return result
}
}
private suspend fun bindParams(statement: PreparedStatement, params: List<Obj>, scope: ScopeFacade, core: SqlCoreModule) {
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 ObjDate -> statement.setObject(jdbcIndex, java.time.LocalDate.parse(value.date.toString()))
is ObjDateTime -> statement.setObject(jdbcIndex, java.time.LocalDateTime.parse(value.localDateTime.toString()))
is ObjInstant -> statement.setObject(jdbcIndex, java.time.Instant.parse(value.instant.toString()))
else -> when (value.objClass.className) {
"Decimal" -> statement.setBigDecimal(jdbcIndex, BigDecimal(scope.toStringOf(value).value))
else -> scope.raiseError(
ObjException(
core.sqlUsageException,
scope.requireScope(),
ObjString("Unsupported JDBC parameter type: ${value.objClass.className}")
)
)
}
}
}
}
private suspend fun readResultSet(scope: ScopeFacade, core: SqlCoreModule, resultSet: ResultSet): SqlResultSetData {
val meta = resultSet.metaData
val columns = (1..meta.columnCount).map { index ->
val nativeType = meta.getColumnTypeName(index) ?: ""
SqlColumnMeta(
name = meta.getColumnLabel(index),
sqlType = mapSqlType(core, nativeType, meta.getColumnType(index)),
nullable = meta.isNullable(index) != java.sql.ResultSetMetaData.columnNoNulls,
nativeType = nativeType,
)
}
val rows = mutableListOf<List<Obj>>()
while (resultSet.next()) {
rows += columns.mapIndexed { index, column ->
readColumnValue(scope, core, resultSet, index + 1, column.nativeType)
}
}
return SqlResultSetData(columns, rows)
}
private suspend fun readColumnValue(
scope: ScopeFacade,
core: SqlCoreModule,
resultSet: ResultSet,
index: Int,
nativeType: String,
): Obj {
val value = resultSet.getObject(index) ?: return ObjNull
val normalizedNativeType = normalizeDeclaredTypeName(nativeType)
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 java.math.BigInteger -> ObjInt.of(value.longValueExact())
is BigDecimal -> decimalFromString(scope, value.toPlainString())
is ByteArray -> ObjBuffer(value.toUByteArray())
is java.sql.Date -> ObjDate(LocalDate.parse(value.toLocalDate().toString()))
is java.sql.Timestamp -> timestampValue(scope, normalizedNativeType, value.toInstant())
is java.time.LocalDate -> ObjDate(LocalDate.parse(value.toString()))
is java.time.LocalDateTime -> ObjDateTime(value.toString().let(LocalDateTime::parse).toInstant(TimeZone.UTC), TimeZone.UTC)
is java.time.OffsetDateTime -> ObjInstant(Instant.parse(value.toInstant().toString()))
is java.time.ZonedDateTime -> ObjInstant(Instant.parse(value.toInstant().toString()))
is java.time.Instant -> ObjInstant(Instant.parse(value.toString()))
is java.sql.Time -> ObjString(value.toLocalTime().toString())
is java.time.LocalTime -> ObjString(value.toString())
is java.time.OffsetTime -> ObjString(value.toString())
is String -> stringValue(scope, normalizedNativeType, value)
else -> ObjString(value.toString())
}
}
private suspend fun stringValue(scope: ScopeFacade, normalizedNativeType: String, value: String): Obj {
return when {
normalizedNativeType == "DATE" -> ObjDate(LocalDate.parse(value.trim()))
normalizedNativeType == "TIMESTAMP WITH TIME ZONE" || normalizedNativeType == "TIMESTAMPTZ" ->
ObjInstant(Instant.parse(value.trim()))
normalizedNativeType == "TIMESTAMP" || normalizedNativeType == "DATETIME" ->
ObjDateTime(LocalDateTime.parse(value.trim()).toInstant(TimeZone.UTC), TimeZone.UTC)
normalizedNativeType == "DECIMAL" || normalizedNativeType == "NUMERIC" -> decimalFromString(scope, value.trim())
else -> ObjString(value)
}
}
private fun timestampValue(scope: ScopeFacade, normalizedNativeType: String, value: java.time.Instant): Obj {
return if (normalizedNativeType == "TIMESTAMP WITH TIME ZONE" || normalizedNativeType == "TIMESTAMPTZ") {
ObjInstant(Instant.parse(value.toString()))
} else {
val local = value.atOffset(java.time.ZoneOffset.UTC).toLocalDateTime().toString()
ObjDateTime(LocalDateTime.parse(local).toInstant(TimeZone.UTC), TimeZone.UTC)
}
}
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 normalizeDeclaredTypeName(nativeTypeName: String): String {
val strippedSuffix = nativeTypeName.trim().replace(Regex("""\s*\(.*\)\s*$"""), "")
return strippedSuffix.uppercase().replace(Regex("""\s+"""), " ").trim()
}
private fun mapSqlType(core: SqlCoreModule, nativeTypeName: String, jdbcType: Int): ObjEnumEntry {
val normalized = normalizeDeclaredTypeName(nativeTypeName)
return when {
normalized == "BOOLEAN" || normalized == "BOOL" -> core.sqlTypes.require("Bool")
normalized == "DATE" -> core.sqlTypes.require("Date")
normalized == "TIMESTAMP" || normalized == "DATETIME" -> core.sqlTypes.require("DateTime")
normalized == "TIMESTAMP WITH TIME ZONE" || normalized == "TIMESTAMPTZ" -> core.sqlTypes.require("Instant")
normalized == "TIME" || normalized == "TIME WITHOUT TIME ZONE" || normalized == "TIME WITH TIME ZONE" -> core.sqlTypes.require("String")
normalized == "DECIMAL" || normalized == "NUMERIC" -> core.sqlTypes.require("Decimal")
normalized.contains("BLOB") || normalized.contains("BINARY") || normalized == "BYTEA" -> core.sqlTypes.require("Binary")
normalized.contains("INT") -> core.sqlTypes.require("Int")
normalized.contains("CHAR") || normalized.contains("TEXT") || normalized.contains("CLOB") || normalized == "VARCHAR" -> core.sqlTypes.require("String")
normalized.contains("REAL") || normalized.contains("FLOA") || normalized.contains("DOUB") -> core.sqlTypes.require("Double")
jdbcType == java.sql.Types.BOOLEAN || jdbcType == java.sql.Types.BIT -> core.sqlTypes.require("Bool")
jdbcType == java.sql.Types.DATE -> core.sqlTypes.require("Date")
jdbcType == java.sql.Types.TIMESTAMP -> core.sqlTypes.require("DateTime")
jdbcType == java.sql.Types.TIMESTAMP_WITH_TIMEZONE -> core.sqlTypes.require("Instant")
jdbcType == java.sql.Types.TIME || jdbcType == java.sql.Types.TIME_WITH_TIMEZONE -> core.sqlTypes.require("String")
jdbcType == java.sql.Types.BLOB || jdbcType == java.sql.Types.BINARY || jdbcType == java.sql.Types.VARBINARY || jdbcType == java.sql.Types.LONGVARBINARY -> core.sqlTypes.require("Binary")
jdbcType == java.sql.Types.INTEGER || jdbcType == java.sql.Types.BIGINT || jdbcType == java.sql.Types.SMALLINT || jdbcType == java.sql.Types.TINYINT -> 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(): SqlResultSetData = SqlResultSetData(emptyList(), emptyList())
private fun containsRowReturningClause(clause: String): Boolean =
Regex("""\b(returning|output)\b""", RegexOption.IGNORE_CASE).containsMatchIn(clause)
private fun ensureJdbcDriversLoaded(scope: ScopeFacade, core: SqlCoreModule, requestedDriverClass: String?) {
for (driverClass in knownJdbcDrivers) {
try {
Class.forName(driverClass)
} catch (_: Throwable) {
}
}
val explicit = requestedDriverClass ?: return
try {
Class.forName(explicit)
} catch (e: ClassNotFoundException) {
throw ExecutionError(
ObjException(core.databaseException, scope.requireScope(), ObjString("JDBC driver class not found: $explicit")),
scope.pos,
"JDBC driver class not found: $explicit",
e,
)
}
}
private fun rollbackOrThrow(scope: ScopeFacade, core: SqlCoreModule, connection: Connection) {
try {
connection.rollback()
} catch (e: SQLException) {
throw mapSqlException(scope, core, e)
}
}
private fun rollbackToSavepointOrThrow(
scope: ScopeFacade,
core: SqlCoreModule,
connection: Connection,
savepoint: java.sql.Savepoint,
) {
try {
connection.rollback(savepoint)
} catch (e: SQLException) {
throw mapSqlException(scope, core, e)
}
}
private fun releaseSavepointOrThrow(
scope: ScopeFacade,
core: SqlCoreModule,
connection: Connection,
savepoint: java.sql.Savepoint,
) {
try {
connection.releaseSavepoint(savepoint)
} catch (e: SQLException) {
throw mapSqlException(scope, core, e)
}
}
private inline fun finishFailedTransaction(
scope: ScopeFacade,
core: SqlCoreModule,
failure: Throwable,
rollback: () -> Unit,
): Throwable {
return try {
rollback()
failure
} catch (rollbackFailure: Throwable) {
if (isRollbackSignal(failure, core)) {
attachSecondaryFailure(rollbackFailure, failure)
rollbackFailure
} else {
attachSecondaryFailure(failure, rollbackFailure)
failure
}
}
}
private fun isRollbackSignal(failure: Throwable, core: SqlCoreModule): Boolean {
val errorObject = (failure as? ExecutionError)?.errorObject ?: return false
return errorObject.isInstanceOf(core.rollbackException)
}
private fun attachSecondaryFailure(primary: Throwable, secondary: Throwable) {
if (primary === secondary) return
primary.addSuppressed(secondary)
}
private fun mapOpenException(scope: ScopeFacade, core: SqlCoreModule, e: SQLException): Nothing {
val message = e.message ?: "JDBC open failed"
if (e is SQLNonTransientConnectionException) {
throw ExecutionError(
ObjException(core.databaseException, scope.requireScope(), ObjString(message)),
scope.pos,
message,
e,
)
}
throw ExecutionError(
ObjException(core.databaseException, scope.requireScope(), ObjString(message)),
scope.pos,
message,
e,
)
}
private fun mapSqlException(scope: ScopeFacade, core: SqlCoreModule, e: SQLException): ExecutionError {
val exceptionClass = when {
e is SQLIntegrityConstraintViolationException -> core.sqlConstraintException
e.sqlState?.startsWith("23") == true -> core.sqlConstraintException
else -> core.sqlExecutionException
}
return ExecutionError(
ObjException(exceptionClass, scope.requireScope(), ObjString(e.message ?: "JDBC error")),
scope.pos,
e.message ?: "JDBC error",
e,
)
}
private fun mapSqlUsage(scope: ScopeFacade, core: SqlCoreModule, message: String, cause: Throwable? = null): ExecutionError {
return ExecutionError(
ObjException(core.sqlUsageException, scope.requireScope(), ObjString(message)),
scope.pos,
message,
cause,
)
}

View File

@ -20,6 +20,12 @@ 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.io.db.SqlColumnMeta
import net.sergeych.lyng.io.db.SqlCoreModule
import net.sergeych.lyng.io.db.SqlDatabaseBackend
import net.sergeych.lyng.io.db.SqlExecutionResultData
import net.sergeych.lyng.io.db.SqlResultSetData
import net.sergeych.lyng.io.db.SqlTransactionBackend
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjBuffer
@ -50,24 +56,24 @@ import kotlin.time.Instant
internal actual suspend fun openSqliteBackend(
scope: ScopeFacade,
core: SqliteCoreModule,
core: SqlCoreModule,
options: SqliteOpenOptions,
): SqliteDatabaseBackend {
): SqlDatabaseBackend {
if (options.busyTimeoutMillis < 0) {
scope.raiseIllegalArgument("busyTimeoutMillis must be >= 0")
}
return JdbcSqliteDatabaseBackend(core, options)
return JdbcSqlDatabaseBackend(core, options)
}
private class JdbcSqliteDatabaseBackend(
private val core: SqliteCoreModule,
private class JdbcSqlDatabaseBackend(
private val core: SqlCoreModule,
private val options: SqliteOpenOptions,
) : SqliteDatabaseBackend {
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T {
) : SqlDatabaseBackend {
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T {
val connection = openConnection(scope)
try {
connection.autoCommit = false
val tx = JdbcSqliteTransactionBackend(core, connection)
val tx = JdbcSqlTransactionBackend(core, connection)
val result = try {
block(tx)
} catch (e: Throwable) {
@ -124,11 +130,11 @@ private class JdbcSqliteDatabaseBackend(
}
}
private class JdbcSqliteTransactionBackend(
private val core: SqliteCoreModule,
private class JdbcSqlTransactionBackend(
private val core: SqlCoreModule,
private val connection: Connection,
) : SqliteTransactionBackend {
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteResultSetData {
) : SqlTransactionBackend {
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqlResultSetData {
try {
connection.prepareStatement(clause).use { statement ->
bindParams(statement, params, scope, core)
@ -141,7 +147,7 @@ private class JdbcSqliteTransactionBackend(
}
}
override suspend fun execute(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteExecutionResultData {
override suspend fun execute(scope: ScopeFacade, clause: String, params: List<Obj>): SqlExecutionResultData {
if (containsRowReturningClause(clause)) {
scope.raiseError(
ObjException(
@ -172,20 +178,20 @@ private class JdbcSqliteTransactionBackend(
readResultSet(scope, core, rs)
}
}
return SqliteExecutionResultData(affected, generatedKeys)
return SqlExecutionResultData(affected, generatedKeys)
}
} catch (e: SQLException) {
throw mapSqlException(scope, core, e)
}
}
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T {
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T {
val savepoint = try {
connection.setSavepoint()
} catch (e: SQLException) {
throw mapSqlUsage(scope, core, "Nested transactions are not supported by this SQLite backend", e)
}
val nested = JdbcSqliteTransactionBackend(core, connection)
val nested = JdbcSqlTransactionBackend(core, connection)
val result = try {
block(nested)
} catch (e: Throwable) {
@ -203,7 +209,7 @@ private class JdbcSqliteTransactionBackend(
}
}
private suspend fun bindParams(statement: PreparedStatement, params: List<Obj>, scope: ScopeFacade, core: SqliteCoreModule) {
private suspend fun bindParams(statement: PreparedStatement, params: List<Obj>, scope: ScopeFacade, core: SqlCoreModule) {
params.forEachIndexed { index, value ->
val jdbcIndex = index + 1
when (value) {
@ -231,12 +237,12 @@ private suspend fun bindParams(statement: PreparedStatement, params: List<Obj>,
private suspend fun readResultSet(
scope: ScopeFacade,
core: SqliteCoreModule,
core: SqlCoreModule,
resultSet: ResultSet,
): SqliteResultSetData {
): SqlResultSetData {
val meta = resultSet.metaData
val columns = (1..meta.columnCount).map { index ->
SqliteColumnMeta(
SqlColumnMeta(
name = meta.getColumnLabel(index),
sqlType = mapSqlType(core, meta.getColumnTypeName(index), meta.getColumnType(index)),
nullable = meta.isNullable(index) != java.sql.ResultSetMetaData.columnNoNulls,
@ -249,12 +255,12 @@ private suspend fun readResultSet(
readColumnValue(scope, core, resultSet, index + 1, column.nativeType)
}
}
return SqliteResultSetData(columns, rows)
return SqlResultSetData(columns, rows)
}
private suspend fun readColumnValue(
scope: ScopeFacade,
core: SqliteCoreModule,
core: SqlCoreModule,
resultSet: ResultSet,
index: Int,
nativeType: String,
@ -278,7 +284,7 @@ private suspend fun readColumnValue(
private fun convertIntegerValue(
scope: ScopeFacade,
core: SqliteCoreModule,
core: SqlCoreModule,
normalizedNativeType: String,
value: Long,
): Obj {
@ -294,7 +300,7 @@ private fun convertIntegerValue(
private suspend fun convertStringValue(
scope: ScopeFacade,
core: SqliteCoreModule,
core: SqlCoreModule,
normalizedNativeType: String,
value: String,
): Obj {
@ -317,7 +323,7 @@ private fun isDecimalNativeType(normalizedNativeType: String): Boolean =
private fun isBooleanNativeType(normalizedNativeType: String): Boolean =
normalizedNativeType == "BOOLEAN" || normalizedNativeType == "BOOL"
private fun booleanFromString(scope: ScopeFacade, core: SqliteCoreModule, value: String): Obj {
private fun booleanFromString(scope: ScopeFacade, core: SqlCoreModule, value: String): Obj {
return when (value.trim().lowercase()) {
"true", "t" -> ObjBool(true)
"false", "f" -> ObjBool(false)
@ -331,7 +337,7 @@ private suspend fun decimalFromString(scope: ScopeFacade, value: String): Obj {
return decimalClass.invokeInstanceMethod(scope.requireScope(), "fromString", ObjString(value))
}
private fun dateTimeFromString(scope: ScopeFacade, core: SqliteCoreModule, value: String): ObjDateTime {
private fun dateTimeFromString(scope: ScopeFacade, core: SqlCoreModule, value: String): ObjDateTime {
val trimmed = value.trim()
if (hasExplicitTimeZone(trimmed)) {
sqlExecutionFailure(scope, core, "SQLite TIMESTAMP/DATETIME value must not contain a timezone offset: $value")
@ -358,7 +364,7 @@ private fun normalizeDeclaredTypeName(nativeTypeName: String): String {
return strippedSuffix.uppercase().replace(Regex("""\s+"""), " ").trim()
}
private fun mapSqlType(core: SqliteCoreModule, nativeTypeName: String, jdbcType: Int): ObjEnumEntry {
private fun mapSqlType(core: SqlCoreModule, nativeTypeName: String, jdbcType: Int): ObjEnumEntry {
val normalized = normalizeDeclaredTypeName(nativeTypeName)
return when {
normalized == "BOOLEAN" || normalized == "BOOL" -> core.sqlTypes.require("Bool")
@ -385,9 +391,9 @@ private fun mapSqlType(core: SqliteCoreModule, nativeTypeName: String, jdbcType:
}
}
private fun emptyResultSet(core: SqliteCoreModule): SqliteResultSetData = SqliteResultSetData(emptyList(), emptyList())
private fun emptyResultSet(core: SqlCoreModule): SqlResultSetData = SqlResultSetData(emptyList(), emptyList())
private fun sqlExecutionFailure(scope: ScopeFacade, core: SqliteCoreModule, message: String): Nothing {
private fun sqlExecutionFailure(scope: ScopeFacade, core: SqlCoreModule, message: String): Nothing {
throw ExecutionError(
ObjException(core.sqlExecutionException, scope.requireScope(), ObjString(message)),
scope.pos,
@ -395,7 +401,7 @@ private fun sqlExecutionFailure(scope: ScopeFacade, core: SqliteCoreModule, mess
)
}
private fun rollbackOrThrow(scope: ScopeFacade, core: SqliteCoreModule, connection: Connection) {
private fun rollbackOrThrow(scope: ScopeFacade, core: SqlCoreModule, connection: Connection) {
try {
connection.rollback()
} catch (e: SQLException) {
@ -405,7 +411,7 @@ private fun rollbackOrThrow(scope: ScopeFacade, core: SqliteCoreModule, connecti
private fun rollbackToSavepointOrThrow(
scope: ScopeFacade,
core: SqliteCoreModule,
core: SqlCoreModule,
connection: Connection,
savepoint: java.sql.Savepoint,
) {
@ -418,7 +424,7 @@ private fun rollbackToSavepointOrThrow(
private fun releaseSavepointOrThrow(
scope: ScopeFacade,
core: SqliteCoreModule,
core: SqlCoreModule,
connection: Connection,
savepoint: java.sql.Savepoint,
) {
@ -431,7 +437,7 @@ private fun releaseSavepointOrThrow(
private inline fun finishFailedTransaction(
scope: ScopeFacade,
core: SqliteCoreModule,
core: SqlCoreModule,
failure: Throwable,
rollback: () -> Unit,
): Throwable {
@ -449,7 +455,7 @@ private inline fun finishFailedTransaction(
}
}
private fun isRollbackSignal(failure: Throwable, core: SqliteCoreModule): Boolean {
private fun isRollbackSignal(failure: Throwable, core: SqlCoreModule): Boolean {
val errorObject = (failure as? ExecutionError)?.errorObject ?: return false
return errorObject.isInstanceOf(core.rollbackException)
}
@ -459,7 +465,7 @@ private fun attachSecondaryFailure(primary: Throwable, secondary: Throwable) {
primary.addSuppressed(secondary)
}
private fun mapOpenException(scope: ScopeFacade, core: SqliteCoreModule, e: SQLException): Nothing {
private fun mapOpenException(scope: ScopeFacade, core: SqlCoreModule, 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) {
@ -473,7 +479,7 @@ private fun mapOpenException(scope: ScopeFacade, core: SqliteCoreModule, e: SQLE
)
}
private fun mapSqlException(scope: ScopeFacade, core: SqliteCoreModule, e: SQLException): ExecutionError {
private fun mapSqlException(scope: ScopeFacade, core: SqlCoreModule, e: SQLException): ExecutionError {
val code = SQLiteErrorCode.getErrorCode(e.errorCode)
val exceptionClass = when (code) {
SQLiteErrorCode.SQLITE_CONSTRAINT,
@ -491,7 +497,7 @@ private fun mapSqlException(scope: ScopeFacade, core: SqliteCoreModule, e: SQLEx
)
}
private fun mapSqlUsage(scope: ScopeFacade, core: SqliteCoreModule, message: String, cause: Throwable? = null): ExecutionError {
private fun mapSqlUsage(scope: ScopeFacade, core: SqlCoreModule, message: String, cause: Throwable? = null): ExecutionError {
return ExecutionError(
ObjException(core.sqlUsageException, scope.requireScope(), ObjString(message)),
scope.pos,

View File

@ -0,0 +1,135 @@
/*
* 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.jdbc
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.Compiler
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.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 kotlin.test.Test
import kotlin.test.assertEquals
class LyngJdbcModuleTest {
@Test
fun testTypedOpenH2ExecutesQueriesAndGeneratedKeys() = runTest {
val scope = Script.newScope()
createJdbcModule(scope.importManager)
val jdbcModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.jdbc")
val db = jdbcModule.callFn("openH2", ObjString("mem:typed_h2_${System.nanoTime()};DB_CLOSE_DELAY=-1"))
val insertedId = db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("create table person(id bigint auto_increment primary key, name varchar(120) not null)")
)
val result = tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("insert into person(name) values(?)"),
ObjString("Ada")
)
val rows = result.invokeInstanceMethod(requireScope(), "getGeneratedKeys")
.invokeInstanceMethod(requireScope(), "toList")
rows.getAt(requireScope(), ObjInt.Zero).getAt(requireScope(), ObjInt.Zero)
}
) as ObjInt
assertEquals(1L, insertedId.value)
}
@Test
fun testGenericOpenDatabaseUsesJdbcAndH2AliasProviders() = runTest {
val scope = Script.newScope()
createJdbcModule(scope.importManager)
scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.jdbc")
val dbModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db")
val genericJdbcDb = dbModule.callFn(
"openDatabase",
ObjString("jdbc:h2:mem:generic_jdbc_${System.nanoTime()};DB_CLOSE_DELAY=-1"),
emptyMapObj()
)
val h2AliasDb = dbModule.callFn(
"openDatabase",
ObjString("h2:mem:generic_alias_${System.nanoTime()};DB_CLOSE_DELAY=-1"),
emptyMapObj()
)
assertEquals(42L, scalarSelect(scope, genericJdbcDb, "select 42 as answer"))
assertEquals(7L, scalarSelect(scope, h2AliasDb, "select 7 as answer"))
}
@Test
fun testImportedJdbcOpenersPreserveDeclaredReturnTypesForInference() = runTest {
val scope = Script.newScope()
createJdbcModule(scope.importManager)
val code = """
import lyng.io.db
import lyng.io.db.jdbc
val h2db = openH2("mem:inference_demo;DB_CLOSE_DELAY=-1")
h2db.transaction { 1 }
val jdbcDb = openJdbc("jdbc:h2:mem:inference_demo_2;DB_CLOSE_DELAY=-1")
jdbcDb.transaction { 2 }
val genericDb = openDatabase("jdbc:h2:mem:inference_demo_3;DB_CLOSE_DELAY=-1", Map())
genericDb.transaction { 3 }
""".trimIndent()
val result = Compiler.compile(Source("<jdbc-inference>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(3L, result.value)
}
private suspend fun scalarSelect(scope: net.sergeych.lyng.Scope, db: Obj, sql: String): Long {
val result = db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
val rows = tx.invokeInstanceMethod(requireScope(), "select", ObjString(sql))
.invokeInstanceMethod(requireScope(), "toList")
rows.getAt(requireScope(), ObjInt.Zero).getAt(requireScope(), ObjString("answer"))
}
) as ObjInt
return result.value
}
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 emptyMapObj(): Obj = ObjMap()
}

View File

@ -0,0 +1,123 @@
/*
* 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.jdbc
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Script
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjExternCallable
import net.sergeych.lyng.obj.ObjInt
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 org.testcontainers.containers.PostgreSQLContainer
import kotlin.test.Test
import kotlin.test.assertEquals
class LyngJdbcPostgresContainerTest {
@Test
fun testOpenPostgresAgainstContainer() = runTest {
PostgreSQLContainer("postgres:16-alpine").use { postgres ->
postgres.start()
val scope = Script.newScope()
createJdbcModule(scope.importManager)
val jdbcModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.jdbc")
val db = jdbcModule.callFn(
"openPostgres",
ObjString(postgres.jdbcUrl),
ObjString(postgres.username),
ObjString(postgres.password)
)
val count = rowCount(scope, db, "people", "Ada", "Linus")
assertEquals(2L, count)
}
}
@Test
fun testGenericPostgresAliasAgainstContainer() = runTest {
PostgreSQLContainer("postgres:16-alpine").use { postgres ->
postgres.start()
val scope = Script.newScope()
createJdbcModule(scope.importManager)
scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.jdbc")
val dbModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db")
val aliasUrl = postgres.jdbcUrl.removePrefix("jdbc:postgresql:")
val db = dbModule.callFn(
"openDatabase",
ObjString("postgres:$aliasUrl"),
mapOfStrings(
"user" to postgres.username,
"password" to postgres.password
)
)
val count = rowCount(scope, db, "pets", "Milo", "Otis", "Pixel")
assertEquals(3L, count)
}
}
private suspend fun rowCount(scope: net.sergeych.lyng.Scope, db: Obj, table: String, vararg names: String): Long {
val result = db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("create table $table(id bigserial primary key, name text not null)")
)
for (name in names) {
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("insert into $table(name) values(?)"),
ObjString(name)
)
}
val rows = tx.invokeInstanceMethod(
requireScope(),
"select",
ObjString("select count(*) as count from $table")
).invokeInstanceMethod(requireScope(), "toList")
rows.getAt(requireScope(), ObjInt.Zero).getAt(requireScope(), ObjString("count"))
}
) as ObjInt
return result.value
}
private suspend fun mapOfStrings(vararg entries: Pair<String, String>): Obj {
val map = ObjMap()
for ((key, value) in entries) {
map.map[ObjString(key)] = ObjString(value)
}
return map
}
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)
}
}

View File

@ -0,0 +1,22 @@
package net.sergeych.lyng.io.db.jdbc
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.io.db.SqlCoreModule
import net.sergeych.lyng.io.db.SqlDatabaseBackend
import net.sergeych.lyng.obj.ObjException
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.requireScope
internal actual suspend fun openJdbcBackend(
scope: ScopeFacade,
core: SqlCoreModule,
options: JdbcOpenOptions,
): SqlDatabaseBackend {
scope.raiseError(
ObjException(
core.databaseException,
scope.requireScope(),
ObjString("lyng.io.db.jdbc is available only on the JVM target")
)
)
}

View File

@ -41,6 +41,12 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.io.db.SqlColumnMeta
import net.sergeych.lyng.io.db.SqlCoreModule
import net.sergeych.lyng.io.db.SqlDatabaseBackend
import net.sergeych.lyng.io.db.SqlExecutionResultData
import net.sergeych.lyng.io.db.SqlResultSetData
import net.sergeych.lyng.io.db.SqlTransactionBackend
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
@ -100,25 +106,25 @@ import kotlin.time.Instant
internal actual suspend fun openSqliteBackend(
scope: ScopeFacade,
core: SqliteCoreModule,
core: SqlCoreModule,
options: SqliteOpenOptions,
): SqliteDatabaseBackend {
): SqlDatabaseBackend {
if (options.busyTimeoutMillis < 0) {
scope.raiseIllegalArgument("busyTimeoutMillis must be >= 0")
}
return NativeSqliteDatabaseBackend(core, options)
return NativeSqlDatabaseBackend(core, options)
}
private class NativeSqliteDatabaseBackend(
private val core: SqliteCoreModule,
private class NativeSqlDatabaseBackend(
private val core: SqlCoreModule,
private val options: SqliteOpenOptions,
) : SqliteDatabaseBackend {
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T {
) : SqlDatabaseBackend {
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T {
val handle = openHandle(scope, core, options)
val savepoints = SavepointCounter()
try {
handle.execUnit(scope, core, "begin")
val tx = NativeSqliteTransactionBackend(core, handle, savepoints)
val tx = NativeSqlTransactionBackend(core, handle, savepoints)
val result = try {
block(tx)
} catch (e: Throwable) {
@ -134,23 +140,23 @@ private class NativeSqliteDatabaseBackend(
}
}
private class NativeSqliteTransactionBackend(
private val core: SqliteCoreModule,
private class NativeSqlTransactionBackend(
private val core: SqlCoreModule,
private val handle: NativeSqliteHandle,
private val savepoints: SavepointCounter,
) : SqliteTransactionBackend {
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteResultSetData {
) : SqlTransactionBackend {
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqlResultSetData {
return handle.select(scope, core, clause, params)
}
override suspend fun execute(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteExecutionResultData {
override suspend fun execute(scope: ScopeFacade, clause: String, params: List<Obj>): SqlExecutionResultData {
return handle.execute(scope, core, clause, params)
}
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T {
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T {
val savepoint = "lyng_sp_${savepoints.next()}"
handle.execUnit(scope, core, "savepoint $savepoint")
val nested = NativeSqliteTransactionBackend(core, handle, savepoints)
val nested = NativeSqlTransactionBackend(core, handle, savepoints)
val result = try {
block(nested)
} catch (e: Throwable) {
@ -178,10 +184,10 @@ private class NativeSqliteHandle(
) {
suspend fun select(
scope: ScopeFacade,
core: SqliteCoreModule,
core: SqlCoreModule,
clause: String,
params: List<Obj>,
): SqliteResultSetData = memScoped {
): SqlResultSetData = memScoped {
val stmt = prepare(scope, core, clause)
try {
bindParams(scope, core, stmt, params, this)
@ -193,10 +199,10 @@ private class NativeSqliteHandle(
suspend fun execute(
scope: ScopeFacade,
core: SqliteCoreModule,
core: SqlCoreModule,
clause: String,
params: List<Obj>,
): SqliteExecutionResultData = memScoped {
): SqlExecutionResultData = memScoped {
if (containsRowReturningClause(clause)) {
raiseExecuteReturningUsage(scope, core)
}
@ -207,7 +213,7 @@ private class NativeSqliteHandle(
SQLITE_DONE -> {
val affectedRows = sqlite3_changes(db)
val generatedKeys = readGeneratedKeys(core, clause, affectedRows)
SqliteExecutionResultData(affectedRows, generatedKeys)
SqlExecutionResultData(affectedRows, generatedKeys)
}
SQLITE_ROW -> raiseExecuteReturningUsage(scope, core)
else -> throw sqlError(scope, core, rc)
@ -218,7 +224,7 @@ private class NativeSqliteHandle(
}
}
fun execUnit(scope: ScopeFacade, core: SqliteCoreModule, sql: String) {
fun execUnit(scope: ScopeFacade, core: SqlCoreModule, sql: String) {
memScoped {
val stmt = prepare(scope, core, sql)
try {
@ -236,13 +242,13 @@ private class NativeSqliteHandle(
sqlite3_close_v2(db)
}
private fun MemScope.prepare(scope: ScopeFacade, core: SqliteCoreModule, sql: String): CPointer<sqlite3_stmt> {
private fun MemScope.prepare(scope: ScopeFacade, core: SqlCoreModule, 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,
core: SqlCoreModule,
stmt: CPointer<sqlite3_stmt>,
params: List<Obj>,
memScope: MemScope,
@ -308,13 +314,13 @@ private class NativeSqliteHandle(
private suspend fun readResultSet(
scope: ScopeFacade,
core: SqliteCoreModule,
core: SqlCoreModule,
stmt: CPointer<sqlite3_stmt>,
): SqliteResultSetData {
): SqlResultSetData {
val columnCount = sqlite3_column_count(stmt)
val columns = (0 until columnCount).map { index ->
val nativeType = sqlite3_column_decltype(stmt, index)?.toKString().orEmpty()
SqliteColumnMeta(
SqlColumnMeta(
name = sqlite3_column_name(stmt, index)?.toKString().orEmpty(),
sqlType = mapSqlType(core, nativeType, SQLITE_NULL),
nullable = true,
@ -338,7 +344,7 @@ private class NativeSqliteHandle(
}
rows += row
}
SQLITE_DONE -> return SqliteResultSetData(columns, rows)
SQLITE_DONE -> return SqlResultSetData(columns, rows)
else -> throw sqlError(scope, core, rc)
}
}
@ -346,7 +352,7 @@ private class NativeSqliteHandle(
private suspend fun readColumnValue(
scope: ScopeFacade,
core: SqliteCoreModule,
core: SqlCoreModule,
stmt: CPointer<sqlite3_stmt>,
index: Int,
nativeType: String,
@ -383,7 +389,7 @@ private class NativeSqliteHandle(
private suspend fun convertStringValue(
scope: ScopeFacade,
core: SqliteCoreModule,
core: SqlCoreModule,
normalizedNativeType: String,
value: String,
): Obj {
@ -401,16 +407,16 @@ private class NativeSqliteHandle(
}
private fun readGeneratedKeys(
core: SqliteCoreModule,
core: SqlCoreModule,
clause: String,
affectedRows: Int,
): SqliteResultSetData {
): SqlResultSetData {
if (affectedRows <= 0 || !looksLikeInsert(clause)) {
return emptyResultSet()
}
return SqliteResultSetData(
return SqlResultSetData(
columns = listOf(
SqliteColumnMeta(
SqlColumnMeta(
name = "generated_key",
sqlType = core.sqlTypes.require("Int"),
nullable = false,
@ -425,7 +431,7 @@ private class NativeSqliteHandle(
return sqlite3_column_text(stmt, index)?.reinterpret<ByteVar>()?.toKString().orEmpty()
}
private fun sqlError(scope: ScopeFacade, core: SqliteCoreModule, rc: Int): ExecutionError {
private fun sqlError(scope: ScopeFacade, core: SqlCoreModule, 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
@ -439,7 +445,7 @@ private class NativeSqliteHandle(
private fun openHandle(
scope: ScopeFacade,
core: SqliteCoreModule,
core: SqlCoreModule,
options: SqliteOpenOptions,
): NativeSqliteHandle = memScoped {
val flags = buildOpenFlags(options)
@ -488,9 +494,9 @@ private fun looksLikeInsert(clause: String): Boolean = clause.trimStart().starts
private val SQLITE_TRANSIENT = (-1L).toCPointer<CFunction<(COpaquePointer?) -> Unit>>()
private fun emptyResultSet(): SqliteResultSetData = SqliteResultSetData(emptyList(), emptyList())
private fun emptyResultSet(): SqlResultSetData = SqlResultSetData(emptyList(), emptyList())
private fun mapSqlType(core: SqliteCoreModule, nativeType: String, sqliteType: Int): ObjEnumEntry = when (val normalized = normalizeDeclaredTypeName(nativeType)) {
private fun mapSqlType(core: SqlCoreModule, nativeType: String, sqliteType: Int): ObjEnumEntry = when (val normalized = normalizeDeclaredTypeName(nativeType)) {
"BOOLEAN", "BOOL" -> core.sqlTypes.require("Bool")
"DATE" -> core.sqlTypes.require("Date")
"DATETIME", "TIMESTAMP" -> core.sqlTypes.require("DateTime")
@ -521,7 +527,7 @@ private suspend fun decimalFromString(scope: ScopeFacade, value: String): Obj {
return decimalClass.invokeInstanceMethod(scope.requireScope(), "fromString", ObjString(value))
}
private fun dateTimeFromString(scope: ScopeFacade, core: SqliteCoreModule, value: String): ObjDateTime {
private fun dateTimeFromString(scope: ScopeFacade, core: SqlCoreModule, value: String): ObjDateTime {
val trimmed = value.trim()
if (hasExplicitTimeZone(trimmed)) {
throw sqlExecutionError(scope, core, "SQLite TIMESTAMP/DATETIME value must not contain a timezone offset: $value")
@ -540,7 +546,7 @@ private fun hasExplicitTimeZone(value: String): Boolean {
return offsetStart > tIndex
}
private fun raiseExecuteReturningUsage(scope: ScopeFacade, core: SqliteCoreModule): Nothing {
private fun raiseExecuteReturningUsage(scope: ScopeFacade, core: SqlCoreModule): Nothing {
scope.raiseError(
ObjException(
core.sqlUsageException,
@ -550,7 +556,7 @@ private fun raiseExecuteReturningUsage(scope: ScopeFacade, core: SqliteCoreModul
)
}
private fun usageError(scope: ScopeFacade, core: SqliteCoreModule, message: String): ExecutionError {
private fun usageError(scope: ScopeFacade, core: SqlCoreModule, message: String): ExecutionError {
return ExecutionError(
ObjException(core.sqlUsageException, scope.requireScope(), ObjString(message)),
scope.pos,
@ -558,7 +564,7 @@ private fun usageError(scope: ScopeFacade, core: SqliteCoreModule, message: Stri
)
}
private fun databaseError(scope: ScopeFacade, core: SqliteCoreModule, message: String): ExecutionError {
private fun databaseError(scope: ScopeFacade, core: SqlCoreModule, message: String): ExecutionError {
return ExecutionError(
ObjException(core.databaseException, scope.requireScope(), ObjString(message)),
scope.pos,
@ -566,14 +572,14 @@ private fun databaseError(scope: ScopeFacade, core: SqliteCoreModule, message: S
)
}
private fun integerToBool(scope: ScopeFacade, core: SqliteCoreModule, value: Long): Obj =
private fun integerToBool(scope: ScopeFacade, core: SqlCoreModule, value: Long): Obj =
when (value) {
0L -> ObjBool(false)
1L -> ObjBool(true)
else -> throw sqlExecutionError(scope, core, "Invalid SQLite boolean value: $value")
}
private fun stringToBool(scope: ScopeFacade, core: SqliteCoreModule, value: String): Obj =
private fun stringToBool(scope: ScopeFacade, core: SqlCoreModule, value: String): Obj =
when (value.trim().lowercase()) {
"true", "t" -> ObjBool(true)
"false", "f" -> ObjBool(false)
@ -585,7 +591,7 @@ private fun normalizeDeclaredTypeName(nativeTypeName: String): String {
return strippedSuffix.uppercase().replace(Regex("""\s+"""), " ").trim()
}
private fun sqlExecutionError(scope: ScopeFacade, core: SqliteCoreModule, message: String): ExecutionError {
private fun sqlExecutionError(scope: ScopeFacade, core: SqlCoreModule, message: String): ExecutionError {
return ExecutionError(
ObjException(core.sqlExecutionException, scope.requireScope(), ObjString(message)),
scope.pos,
@ -595,7 +601,7 @@ private fun sqlExecutionError(scope: ScopeFacade, core: SqliteCoreModule, messag
private inline fun finishFailedTransaction(
scope: ScopeFacade,
core: SqliteCoreModule,
core: SqlCoreModule,
failure: Throwable,
rollback: () -> Unit,
): Throwable {
@ -613,7 +619,7 @@ private inline fun finishFailedTransaction(
}
}
private fun isRollbackSignal(failure: Throwable, core: SqliteCoreModule): Boolean {
private fun isRollbackSignal(failure: Throwable, core: SqlCoreModule): Boolean {
val errorObject = (failure as? ExecutionError)?.errorObject ?: return false
return errorObject.isInstanceOf(core.rollbackException)
}

View File

@ -0,0 +1,57 @@
package lyng.io.db.jdbc
import lyng.io.db
/*
Generic JDBC provider for `lyng.io.db` on the JVM target.
Importing this module registers:
- `jdbc:` for raw JDBC URLs such as `jdbc:h2:mem:test`
- `h2:` as a shorthand alias for `jdbc:h2:...`
- `postgres:` / `postgresql:` as shorthand aliases for `jdbc:postgresql:...`
The JVM artifact bundles and explicitly loads the SQLite, H2, and
PostgreSQL JDBC drivers, so `openJdbc(...)`, `openH2(...)`, and
`openPostgres(...)` work under the standard `jlyng` launcher without extra
classpath setup.
Supported `openDatabase(..., extraParams)` keys for JDBC:
- `driverClass: String`
- `user: String`
- `password: String`
- `properties: Map<String, Object?>`
The optional `properties` map is converted to JDBC connection properties by
stringifying each non-null value.
*/
extern fun openJdbc(
connectionUrl: String,
user: String? = null,
password: String? = null,
driverClass: String? = null,
properties: Map<String, Object?>? = null
): Database
/*
Open an H2 database. `connectionUrl` may be a full JDBC URL such as
`jdbc:h2:mem:test`, or the shorter H2-specific tail such as
`mem:test;DB_CLOSE_DELAY=-1`.
*/
extern fun openH2(
connectionUrl: String,
user: String? = null,
password: String? = null,
properties: Map<String, Object?>? = null
): Database
/*
Open a PostgreSQL database. `connectionUrl` may be a full JDBC URL such as
`jdbc:postgresql://localhost/app`, or the shorter provider tail such as
`//localhost/app`.
*/
extern fun openPostgres(
connectionUrl: String,
user: String? = null,
password: String? = null,
properties: Map<String, Object?>? = null
): Database