From 64273ac60a59c7bc8485134fc056cbbd0b35d163 Mon Sep 17 00:00:00 2001 From: sergeych Date: Thu, 16 Apr 2026 22:10:36 +0300 Subject: [PATCH] Add JDBC database provider for JVM --- .gitignore | 1 + docs/lyng.io.db.md | 152 +++++- docs/lyngio.md | 28 +- examples/h2_basic.lyng | 43 ++ examples/postgres_basic.lyng | 71 +++ gradle/libs.versions.toml | 7 + lyng/src/commonMain/kotlin/Common.kt | 3 + lyngio/build.gradle.kts | 8 + .../lyng/io/db/jdbc/PlatformAndroid.kt | 22 + .../lyng/io/db/sqlite/PlatformAndroid.kt | 6 +- .../sergeych/lyng/io/db/SqlRuntimeSupport.kt | 384 +++++++++++++++ .../lyng/io/db/jdbc/LyngJdbcModule.kt | 278 +++++++++++ .../lyng/io/db/sqlite/LyngSqliteModule.kt | 370 +------------- .../sergeych/lyng/io/db/jdbc/PlatformJs.kt | 22 + .../sergeych/lyng/io/db/sqlite/PlatformJs.kt | 6 +- .../sergeych/lyng/io/db/jdbc/PlatformJvm.kt | 456 ++++++++++++++++++ .../sergeych/lyng/io/db/sqlite/PlatformJvm.kt | 80 +-- .../lyng/io/db/jdbc/LyngJdbcModuleTest.kt | 135 ++++++ .../db/jdbc/LyngJdbcPostgresContainerTest.kt | 123 +++++ .../lyng/io/db/jdbc/PlatformNative.kt | 22 + .../lyng/io/db/sqlite/PlatformNative.kt | 98 ++-- lyngio/stdlib/lyng/io/db_jdbc.lyng | 57 +++ 22 files changed, 1913 insertions(+), 459 deletions(-) create mode 100644 examples/h2_basic.lyng create mode 100644 examples/postgres_basic.lyng create mode 100644 lyngio/src/androidMain/kotlin/net/sergeych/lyng/io/db/jdbc/PlatformAndroid.kt create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/SqlRuntimeSupport.kt create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/jdbc/LyngJdbcModule.kt create mode 100644 lyngio/src/jsMain/kotlin/net/sergeych/lyng/io/db/jdbc/PlatformJs.kt create mode 100644 lyngio/src/jvmMain/kotlin/net/sergeych/lyng/io/db/jdbc/PlatformJvm.kt create mode 100644 lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/jdbc/LyngJdbcModuleTest.kt create mode 100644 lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/jdbc/LyngJdbcPostgresContainerTest.kt create mode 100644 lyngio/src/nativeMain/kotlin/net/sergeych/lyng/io/db/jdbc/PlatformNative.kt create mode 100644 lyngio/stdlib/lyng/io/db_jdbc.lyng diff --git a/.gitignore b/.gitignore index 74a7f78..3108ff1 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ debug.log test_output*.txt /site/src/version-template/lyng-version.js /bugcontents.db +/bugs/ diff --git a/docs/lyng.io.db.md b/docs/lyng.io.db.md index 8a6778d..18095cd 100644 --- a/docs/lyng.io.db.md +++ b/docs/lyng.io.db.md @@ -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? = null +): Database + +openH2( + connectionUrl: String, + user: String? = null, + password: String? = null, + properties: Map? = null +): Database + +openPostgres( + connectionUrl: String, + user: String? = null, + password: String? = null, + properties: Map? = 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` + +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). diff --git a/docs/lyngio.md b/docs/lyngio.md index dc35936..fd3171e 100644 --- a/docs/lyngio.md +++ b/docs/lyngio.md @@ -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 diff --git a/examples/h2_basic.lyng b/examples/h2_basic.lyng new file mode 100644 index 0000000..c077778 --- /dev/null +++ b/examples/h2_basic.lyng @@ -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") diff --git a/examples/postgres_basic.lyng b/examples/postgres_basic.lyng new file mode 100644 index 0000000..bb04b25 --- /dev/null +++ b/examples/postgres_basic.lyng @@ -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 { + val result: List = [] + 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") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7b88428..a25b160 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/lyng/src/commonMain/kotlin/Common.kt b/lyng/src/commonMain/kotlin/Common.kt index 61ecf51..0031099 100644 --- a/lyng/src/commonMain/kotlin/Common.kt +++ b/lyng/src/commonMain/kotlin/Common.kt @@ -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) diff --git a/lyngio/build.gradle.kts b/lyngio/build.gradle.kts index e57216a..6ceb22b 100644 --- a/lyngio/build.gradle.kts +++ b/lyngio/build.gradle.kts @@ -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 diff --git a/lyngio/src/androidMain/kotlin/net/sergeych/lyng/io/db/jdbc/PlatformAndroid.kt b/lyngio/src/androidMain/kotlin/net/sergeych/lyng/io/db/jdbc/PlatformAndroid.kt new file mode 100644 index 0000000..51962a2 --- /dev/null +++ b/lyngio/src/androidMain/kotlin/net/sergeych/lyng/io/db/jdbc/PlatformAndroid.kt @@ -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") + ) + ) +} diff --git a/lyngio/src/androidMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformAndroid.kt b/lyngio/src/androidMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformAndroid.kt index 146cac3..5b01ba3 100644 --- a/lyngio/src/androidMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformAndroid.kt +++ b/lyngio/src/androidMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformAndroid.kt @@ -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, diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/SqlRuntimeSupport.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/SqlRuntimeSupport.kt new file mode 100644 index 0000000..40766b7 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/SqlRuntimeSupport.kt @@ -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, + val rows: List>, +) + +internal data class SqlExecutionResultData( + val affectedRowsCount: Int, + val generatedKeys: SqlResultSetData, +) + +internal interface SqlDatabaseBackend { + suspend fun transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T +} + +internal interface SqlTransactionBackend { + suspend fun select(scope: ScopeFacade, clause: String, params: List): SqlResultSetData + suspend fun execute(scope: ScopeFacade, clause: String, params: List): SqlExecutionResultData + suspend fun 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, +) { + 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() + 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() + 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() + 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() + 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() + self.lifetime.ensureActive(this) + ObjImmutableList(self.columns) + }) + resultSetClass.addFn("size") { + val self = thisAs() + self.lifetime.ensureActive(this) + ObjInt.of(self.rows.size.toLong()) + } + resultSetClass.addFn("isEmpty") { + val self = thisAs() + self.lifetime.ensureActive(this) + ObjBool(self.rows.isEmpty()) + } + resultSetClass.addFn("iterator") { + val self = thisAs() + self.lifetime.ensureActive(this) + ObjImmutableList(self.rows).invokeInstanceMethod(requireScope(), "iterator") + } + resultSetClass.addFn("toList") { + val self = thisAs() + self.lifetime.ensureActive(this) + ObjImmutableList(self.rows) + } + + rowClass.addProperty("size", getter = { + val self = thisAs() + self.lifetime.ensureActive(this) + ObjInt.of(self.values.size.toLong()) + }) + rowClass.addProperty("values", getter = { + val self = thisAs() + self.lifetime.ensureActive(this) + ObjImmutableList(self.values) + }) + + columnClass.addProperty("name", getter = { ObjString(thisAs().meta.name) }) + columnClass.addProperty("sqlType", getter = { thisAs().meta.sqlType }) + columnClass.addProperty("nullable", getter = { ObjBool(thisAs().meta.nullable) }) + columnClass.addProperty("nativeType", getter = { ObjString(thisAs().meta.nativeType) }) + + executionResultClass.addProperty("affectedRowsCount", getter = { + val self = thisAs() + self.lifetime.ensureActive(this) + ObjInt.of(self.result.affectedRowsCount.toLong()) + }) + executionResultClass.addFn("getGeneratedKeys") { + val self = thisAs() + 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 = data.columns.map { SqlColumnObj(types, it) } + val rows: List = buildRows(types, lifetime, data) + + override val objClass: ObjClass + get() = types.resultSetClass + + private fun buildRows( + types: SqlRuntimeTypes, + lifetime: SqlTransactionLifetime, + data: SqlResultSetData, + ): List { + val indexByName = linkedMapOf>() + 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, + private val indexByName: Map>, +) : 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 +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/jdbc/LyngJdbcModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/jdbc/LyngJdbcModule.kt new file mode 100644 index 0000000..85eb867 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/jdbc/LyngJdbcModule.kt @@ -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(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 { + 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 { + 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 { + val rawEntries = when (value) { + is ObjMap -> value.map + is ObjImmutableMap -> value.map + else -> scope.raiseClassCastError("$label must be Map") + } + val properties = linkedMapOf() + 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, +) + +internal expect suspend fun openJdbcBackend( + scope: ScopeFacade, + core: SqlCoreModule, + options: JdbcOpenOptions, +): SqlDatabaseBackend diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModule.kt index 78d7064..1fb479e 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModule.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModule.kt @@ -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, - val rows: List>, -) - -internal data class SqliteExecutionResultData( - val affectedRowsCount: Int, - val generatedKeys: SqliteResultSetData, -) - -internal interface SqliteDatabaseBackend { - suspend fun transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T -} - -internal interface SqliteTransactionBackend { - suspend fun select(scope: ScopeFacade, clause: String, params: List): SqliteResultSetData - suspend fun execute(scope: ScopeFacade, clause: String, params: List): SqliteExecutionResultData - suspend fun 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, -) { - 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() - 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() - self.lifetime.ensureActive(this) - val clause = requiredArg(0).value - val params = args.list.drop(1) - SqliteResultSetObj(thisAs().types, self.lifetime, self.backend.select(this, clause, params)) - } - transactionClass.addFn("execute") { - val self = thisAs() - self.lifetime.ensureActive(this) - val clause = requiredArg(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() - 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() - self.lifetime.ensureActive(this) - ObjImmutableList(self.columns) - }) - resultSetClass.addFn("size") { - val self = thisAs() - self.lifetime.ensureActive(this) - ObjInt.of(self.rows.size.toLong()) - } - resultSetClass.addFn("isEmpty") { - val self = thisAs() - self.lifetime.ensureActive(this) - ObjBool(self.rows.isEmpty()) - } - resultSetClass.addFn("iterator") { - val self = thisAs() - self.lifetime.ensureActive(this) - ObjImmutableList(self.rows).invokeInstanceMethod(requireScope(), "iterator") - } - resultSetClass.addFn("toList") { - val self = thisAs() - self.lifetime.ensureActive(this) - ObjImmutableList(self.rows) - } - - rowClass.addProperty("size", getter = { - val self = thisAs() - self.lifetime.ensureActive(this) - ObjInt.of(self.values.size.toLong()) - }) - rowClass.addProperty("values", getter = { - val self = thisAs() - self.lifetime.ensureActive(this) - ObjImmutableList(self.values) - }) - - columnClass.addProperty("name", getter = { ObjString(thisAs().meta.name) }) - columnClass.addProperty("sqlType", getter = { thisAs().meta.sqlType }) - columnClass.addProperty("nullable", getter = { ObjBool(thisAs().meta.nullable) }) - columnClass.addProperty("nativeType", getter = { ObjString(thisAs().meta.nativeType) }) - - executionResultClass.addProperty("affectedRowsCount", getter = { - val self = thisAs() - self.lifetime.ensureActive(this) - ObjInt.of(self.result.affectedRowsCount.toLong()) - }) - executionResultClass.addFn("getGeneratedKeys") { - val self = thisAs() - 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 = data.columns.map { SqliteColumnObj(types, it) } - val rows: List = buildRows(types, lifetime, data) - - override val objClass: ObjClass - get() = types.resultSetClass - - private fun buildRows( - types: SqliteRuntimeTypes, - lifetime: TransactionLifetime, - data: SqliteResultSetData, - ): List { - val indexByName = linkedMapOf>() - 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, - private val indexByName: Map>, -) : 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 diff --git a/lyngio/src/jsMain/kotlin/net/sergeych/lyng/io/db/jdbc/PlatformJs.kt b/lyngio/src/jsMain/kotlin/net/sergeych/lyng/io/db/jdbc/PlatformJs.kt new file mode 100644 index 0000000..51962a2 --- /dev/null +++ b/lyngio/src/jsMain/kotlin/net/sergeych/lyng/io/db/jdbc/PlatformJs.kt @@ -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") + ) + ) +} diff --git a/lyngio/src/jsMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformJs.kt b/lyngio/src/jsMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformJs.kt index 146cac3..5b01ba3 100644 --- a/lyngio/src/jsMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformJs.kt +++ b/lyngio/src/jsMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformJs.kt @@ -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, diff --git a/lyngio/src/jvmMain/kotlin/net/sergeych/lyng/io/db/jdbc/PlatformJvm.kt b/lyngio/src/jvmMain/kotlin/net/sergeych/lyng/io/db/jdbc/PlatformJvm.kt new file mode 100644 index 0000000..630714a --- /dev/null +++ b/lyngio/src/jvmMain/kotlin/net/sergeych/lyng/io/db/jdbc/PlatformJvm.kt @@ -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 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): 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): 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 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, 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>() + 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, + ) +} diff --git a/lyngio/src/jvmMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformJvm.kt b/lyngio/src/jvmMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformJvm.kt index e852d23..06197b9 100644 --- a/lyngio/src/jvmMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformJvm.kt +++ b/lyngio/src/jvmMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformJvm.kt @@ -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 transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T { +) : SqlDatabaseBackend { + override suspend fun 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): SqliteResultSetData { +) : SqlTransactionBackend { + override suspend fun select(scope: ScopeFacade, clause: String, params: List): 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): SqliteExecutionResultData { + override suspend fun execute(scope: ScopeFacade, clause: String, params: List): 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 transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T { + override suspend fun 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, scope: ScopeFacade, core: SqliteCoreModule) { +private suspend fun bindParams(statement: PreparedStatement, params: List, 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, 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, diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/jdbc/LyngJdbcModuleTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/jdbc/LyngJdbcModuleTest.kt new file mode 100644 index 0000000..301266d --- /dev/null +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/jdbc/LyngJdbcModuleTest.kt @@ -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(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("", 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(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() +} diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/jdbc/LyngJdbcPostgresContainerTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/jdbc/LyngJdbcPostgresContainerTest.kt new file mode 100644 index 0000000..af43c60 --- /dev/null +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/jdbc/LyngJdbcPostgresContainerTest.kt @@ -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(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): 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) + } +} diff --git a/lyngio/src/nativeMain/kotlin/net/sergeych/lyng/io/db/jdbc/PlatformNative.kt b/lyngio/src/nativeMain/kotlin/net/sergeych/lyng/io/db/jdbc/PlatformNative.kt new file mode 100644 index 0000000..51962a2 --- /dev/null +++ b/lyngio/src/nativeMain/kotlin/net/sergeych/lyng/io/db/jdbc/PlatformNative.kt @@ -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") + ) + ) +} diff --git a/lyngio/src/nativeMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformNative.kt b/lyngio/src/nativeMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformNative.kt index 1316c77..24f836f 100644 --- a/lyngio/src/nativeMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformNative.kt +++ b/lyngio/src/nativeMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformNative.kt @@ -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 transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T { +) : SqlDatabaseBackend { + override suspend fun 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): SqliteResultSetData { +) : SqlTransactionBackend { + override suspend fun select(scope: ScopeFacade, clause: String, params: List): SqlResultSetData { return handle.select(scope, core, clause, params) } - override suspend fun execute(scope: ScopeFacade, clause: String, params: List): SqliteExecutionResultData { + override suspend fun execute(scope: ScopeFacade, clause: String, params: List): SqlExecutionResultData { return handle.execute(scope, core, clause, params) } - override suspend fun transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T { + override suspend fun 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, - ): 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, - ): 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 { + private fun MemScope.prepare(scope: ScopeFacade, core: SqlCoreModule, sql: String): CPointer { 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, params: List, memScope: MemScope, @@ -308,13 +314,13 @@ private class NativeSqliteHandle( private suspend fun readResultSet( scope: ScopeFacade, - core: SqliteCoreModule, + core: SqlCoreModule, stmt: CPointer, - ): 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, 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()?.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 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) } diff --git a/lyngio/stdlib/lyng/io/db_jdbc.lyng b/lyngio/stdlib/lyng/io/db_jdbc.lyng new file mode 100644 index 0000000..2a7acd5 --- /dev/null +++ b/lyngio/stdlib/lyng/io/db_jdbc.lyng @@ -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` + + 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? = 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? = 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? = null +): Database