Add JDBC database provider for JVM
This commit is contained in:
parent
ee392daa13
commit
64273ac60a
1
.gitignore
vendored
1
.gitignore
vendored
@ -29,3 +29,4 @@ debug.log
|
||||
test_output*.txt
|
||||
/site/src/version-template/lyng-version.js
|
||||
/bugcontents.db
|
||||
/bugs/
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
### lyng.io.db — SQL database access for Lyng scripts
|
||||
|
||||
This module provides the portable SQL database contract for Lyng. The first shipped provider is SQLite via `lyng.io.db.sqlite`.
|
||||
This module provides the portable SQL database contract for Lyng. The current shipped providers are SQLite via `lyng.io.db.sqlite` and a JVM-only JDBC bridge via `lyng.io.db.jdbc`.
|
||||
|
||||
> **Note:** `lyngio` is a separate library module. It must be explicitly added as a dependency to your host application and initialized in your Lyng scopes.
|
||||
|
||||
@ -30,6 +30,28 @@ suspend fun bootstrapDb() {
|
||||
|
||||
`createSqliteModule(...)` also registers the `sqlite:` scheme for generic `openDatabase(...)`.
|
||||
|
||||
For JVM JDBC-backed access, install the JDBC provider as well:
|
||||
|
||||
```kotlin
|
||||
import net.sergeych.lyng.EvalSession
|
||||
import net.sergeych.lyng.Scope
|
||||
import net.sergeych.lyng.io.db.createDbModule
|
||||
import net.sergeych.lyng.io.db.jdbc.createJdbcModule
|
||||
|
||||
suspend fun bootstrapJdbc() {
|
||||
val session = EvalSession()
|
||||
val scope: Scope = session.getScope()
|
||||
createDbModule(scope)
|
||||
createJdbcModule(scope)
|
||||
session.eval("""
|
||||
import lyng.io.db
|
||||
import lyng.io.db.jdbc
|
||||
""".trimIndent())
|
||||
}
|
||||
```
|
||||
|
||||
`createJdbcModule(...)` registers `jdbc:`, `h2:`, `postgres:`, and `postgresql:` for `openDatabase(...)`.
|
||||
|
||||
---
|
||||
|
||||
#### Using from Lyng scripts
|
||||
@ -66,6 +88,63 @@ val db = openDatabase(
|
||||
)
|
||||
```
|
||||
|
||||
JVM JDBC open with H2:
|
||||
|
||||
```lyng
|
||||
import lyng.io.db.jdbc
|
||||
|
||||
val db = openH2("mem:demo;DB_CLOSE_DELAY=-1")
|
||||
|
||||
val names = db.transaction { tx ->
|
||||
tx.execute("create table person(id bigint auto_increment primary key, name varchar(120) not null)")
|
||||
tx.execute("insert into person(name) values(?)", "Ada")
|
||||
tx.execute("insert into person(name) values(?)", "Linus")
|
||||
tx.select("select name from person order by id").toList()
|
||||
}
|
||||
|
||||
assertEquals("Ada", names[0]["name"])
|
||||
assertEquals("Linus", names[1]["name"])
|
||||
```
|
||||
|
||||
Generic JDBC open through `openDatabase(...)`:
|
||||
|
||||
```lyng
|
||||
import lyng.io.db
|
||||
import lyng.io.db.jdbc
|
||||
|
||||
val db = openDatabase(
|
||||
"jdbc:h2:mem:demo2;DB_CLOSE_DELAY=-1",
|
||||
Map()
|
||||
)
|
||||
|
||||
val answer = db.transaction { tx ->
|
||||
tx.select("select 42 as answer").toList()[0]["answer"]
|
||||
}
|
||||
|
||||
assertEquals(42, answer)
|
||||
```
|
||||
|
||||
PostgreSQL typed open:
|
||||
|
||||
```lyng
|
||||
import lyng.io.db.jdbc
|
||||
|
||||
val db = openPostgres(
|
||||
"jdbc:postgresql://127.0.0.1/appdb",
|
||||
"appuser",
|
||||
"secret"
|
||||
)
|
||||
|
||||
val titles = db.transaction { tx ->
|
||||
tx.execute("create table if not exists task(id bigserial primary key, title text not null)")
|
||||
tx.execute("insert into task(title) values(?)", "Ship JDBC provider")
|
||||
tx.execute("insert into task(title) values(?)", "Test PostgreSQL path")
|
||||
tx.select("select title from task order by id").toList()
|
||||
}
|
||||
|
||||
assertEquals("Ship JDBC provider", titles[0]["title"])
|
||||
```
|
||||
|
||||
Nested transactions use real savepoint semantics:
|
||||
|
||||
```lyng
|
||||
@ -217,6 +296,76 @@ Open-time validation failures:
|
||||
- malformed URL or bad option shape -> `IllegalArgumentException`
|
||||
- runtime open failure -> `DatabaseException`
|
||||
|
||||
#### JDBC provider
|
||||
|
||||
`lyng.io.db.jdbc` is currently implemented on the JVM target only. The `lyngio-jvm` artifact bundles and explicitly loads these JDBC drivers:
|
||||
|
||||
- SQLite
|
||||
- H2
|
||||
- PostgreSQL
|
||||
|
||||
Typed helpers:
|
||||
|
||||
```lyng
|
||||
openJdbc(
|
||||
connectionUrl: String,
|
||||
user: String? = null,
|
||||
password: String? = null,
|
||||
driverClass: String? = null,
|
||||
properties: Map<String, Object?>? = null
|
||||
): Database
|
||||
|
||||
openH2(
|
||||
connectionUrl: String,
|
||||
user: String? = null,
|
||||
password: String? = null,
|
||||
properties: Map<String, Object?>? = null
|
||||
): Database
|
||||
|
||||
openPostgres(
|
||||
connectionUrl: String,
|
||||
user: String? = null,
|
||||
password: String? = null,
|
||||
properties: Map<String, Object?>? = null
|
||||
): Database
|
||||
```
|
||||
|
||||
Accepted generic URL forms:
|
||||
|
||||
- `jdbc:h2:mem:test;DB_CLOSE_DELAY=-1`
|
||||
- `h2:mem:test;DB_CLOSE_DELAY=-1`
|
||||
- `jdbc:postgresql://localhost/app`
|
||||
- `postgres://localhost/app`
|
||||
- `postgresql://localhost/app`
|
||||
|
||||
Supported `openDatabase(..., extraParams)` keys for JDBC:
|
||||
|
||||
- `driverClass: String`
|
||||
- `user: String`
|
||||
- `password: String`
|
||||
- `properties: Map<String, Object?>`
|
||||
|
||||
Behavior notes for the JDBC bridge:
|
||||
|
||||
- the portable `Database` / `SqlTransaction` API stays the same as for SQLite
|
||||
- nested transactions use JDBC savepoints
|
||||
- JDBC connection properties are built from `user`, `password`, and `properties`
|
||||
- `properties` values are stringified before being passed to JDBC
|
||||
- statements with row-returning clauses still must use `select(...)`, not `execute(...)`
|
||||
|
||||
Platform support for this provider:
|
||||
|
||||
- `lyng.io.db.jdbc` — JVM only
|
||||
- `openH2(...)` — works out of the box with `lyngio-jvm`
|
||||
- `openPostgres(...)` — driver included, but an actual PostgreSQL server is still required
|
||||
|
||||
PostgreSQL-specific notes:
|
||||
|
||||
- `openPostgres(...)` accepts either a full JDBC URL or shorthand forms such as `//localhost/app`
|
||||
- local peer/trust setups may use an empty password string
|
||||
- generated keys work with PostgreSQL `bigserial` / identity columns through `ExecutionResult.getGeneratedKeys()`
|
||||
- for reproducible automated tests, prefer a disposable PostgreSQL instance such as Docker/Testcontainers instead of a long-lived shared server
|
||||
|
||||
---
|
||||
|
||||
#### Lifetime rules
|
||||
@ -236,5 +385,6 @@ The same lifetime rule applies to generated keys returned by `ExecutionResult.ge
|
||||
|
||||
- `lyng.io.db` — generic contract, available when host code installs it
|
||||
- `lyng.io.db.sqlite` — implemented on JVM and Linux Native in the current release tree
|
||||
- `lyng.io.db.jdbc` — implemented on JVM in the current release tree
|
||||
|
||||
For the broader I/O overview, see [lyngio overview](lyngio.md).
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
#### Included Modules
|
||||
|
||||
- **[lyng.io.db](lyng.io.db.md):** Portable SQL database access. Provides `Database`, `SqlTransaction`, `ResultSet`, and SQLite support through `lyng.io.db.sqlite`.
|
||||
- **[lyng.io.db](lyng.io.db.md):** Portable SQL database access. Provides `Database`, `SqlTransaction`, `ResultSet`, SQLite support through `lyng.io.db.sqlite`, and JVM JDBC support through `lyng.io.db.jdbc`.
|
||||
- **[lyng.io.fs](lyng.io.fs.md):** Async filesystem access. Provides the `Path` class for file/directory operations, streaming, and globbing.
|
||||
- **[lyng.io.process](lyng.io.process.md):** External process execution and shell commands. Provides `Process`, `RunningProcess`, and `Platform` information.
|
||||
- **[lyng.io.console](lyng.io.console.md):** Rich console/TTY access. Provides `Console` capability detection, geometry, output, and iterable events.
|
||||
@ -45,6 +45,7 @@ To use `lyngio` modules in your scripts, you must install them into your Lyng sc
|
||||
```kotlin
|
||||
import net.sergeych.lyng.EvalSession
|
||||
import net.sergeych.lyng.io.db.createDbModule
|
||||
import net.sergeych.lyng.io.db.jdbc.createJdbcModule
|
||||
import net.sergeych.lyng.io.db.sqlite.createSqliteModule
|
||||
import net.sergeych.lyng.io.fs.createFs
|
||||
import net.sergeych.lyng.io.process.createProcessModule
|
||||
@ -65,6 +66,7 @@ suspend fun runMyScript() {
|
||||
|
||||
// Install modules with policies
|
||||
createDbModule(scope)
|
||||
createJdbcModule(scope)
|
||||
createSqliteModule(scope)
|
||||
createFs(PermitAllAccessPolicy, scope)
|
||||
createProcessModule(PermitAllProcessAccessPolicy, scope)
|
||||
@ -76,6 +78,7 @@ suspend fun runMyScript() {
|
||||
// Now scripts can import them
|
||||
session.eval("""
|
||||
import lyng.io.db
|
||||
import lyng.io.db.jdbc
|
||||
import lyng.io.db.sqlite
|
||||
import lyng.io.fs
|
||||
import lyng.io.process
|
||||
@ -84,6 +87,7 @@ suspend fun runMyScript() {
|
||||
import lyng.io.net
|
||||
import lyng.io.ws
|
||||
|
||||
println("H2 JDBC available: " + (openH2("mem:demo;DB_CLOSE_DELAY=-1") != null))
|
||||
println("SQLite available: " + (openSqlite(":memory:") != null))
|
||||
println("Working dir: " + Path(".").readUtf8())
|
||||
println("OS: " + Platform.details().name)
|
||||
@ -102,7 +106,7 @@ suspend fun runMyScript() {
|
||||
`lyngio` is built with a "Secure by Default" philosophy. Every I/O or process operation is checked against a policy.
|
||||
|
||||
- **Filesystem Security:** Implement `FsAccessPolicy` to restrict access to specific paths or operations (e.g., read-only access to a sandbox directory).
|
||||
- **Database Installation:** Database access is still explicit-capability style. The host must install `lyng.io.db` and at least one provider such as `lyng.io.db.sqlite`; otherwise scripts cannot open databases.
|
||||
- **Database Installation:** Database access is still explicit-capability style. The host must install `lyng.io.db` and at least one provider such as `lyng.io.db.sqlite` or `lyng.io.db.jdbc`; otherwise scripts cannot open databases.
|
||||
- **Process Security:** Implement `ProcessAccessPolicy` to restrict which executables can be run or to disable shell execution entirely.
|
||||
- **Console Security:** Implement `ConsoleAccessPolicy` to control output writes, event reads, and raw mode switching.
|
||||
- **HTTP Security:** Implement `HttpAccessPolicy` to restrict which requests scripts may send.
|
||||
@ -122,16 +126,16 @@ For more details, see the specific module documentation:
|
||||
|
||||
#### Platform Support Overview
|
||||
|
||||
| Platform | lyng.io.db/sqlite | lyng.io.fs | lyng.io.process | lyng.io.console | lyng.io.http | lyng.io.ws | lyng.io.net |
|
||||
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
|
||||
| **JVM** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Linux Native** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Apple Native** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ |
|
||||
| **Windows Native** | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **Android** | ⚠️ | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ |
|
||||
| **JS / Node** | ❌ | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ |
|
||||
| **JS / Browser** | ❌ | ✅ (in-memory) | ❌ | ⚠️ | ✅ | ✅ | ❌ |
|
||||
| **Wasm** | ❌ | ✅ (in-memory) | ❌ | ⚠️ | ❌ | ❌ | ❌ |
|
||||
| Platform | lyng.io.db/sqlite | lyng.io.db/jdbc | lyng.io.fs | lyng.io.process | lyng.io.console | lyng.io.http | lyng.io.ws | lyng.io.net |
|
||||
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
|
||||
| **JVM** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Linux Native** | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Apple Native** | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ |
|
||||
| **Windows Native** | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **Android** | ⚠️ | ❌ | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ |
|
||||
| **JS / Node** | ❌ | ❌ | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ |
|
||||
| **JS / Browser** | ❌ | ❌ | ✅ (in-memory) | ❌ | ⚠️ | ✅ | ✅ | ❌ |
|
||||
| **Wasm** | ❌ | ❌ | ✅ (in-memory) | ❌ | ⚠️ | ❌ | ❌ | ❌ |
|
||||
|
||||
Legend:
|
||||
- `✅` supported
|
||||
|
||||
43
examples/h2_basic.lyng
Normal file
43
examples/h2_basic.lyng
Normal file
@ -0,0 +1,43 @@
|
||||
import lyng.io.db
|
||||
import lyng.io.db.jdbc
|
||||
|
||||
println("H2 JDBC demo: typed open, generic open, generated keys")
|
||||
|
||||
val db = openH2("mem:lyng_h2_demo;DB_CLOSE_DELAY=-1")
|
||||
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table if not exists person(id bigint auto_increment primary key, name varchar(120) not null, active boolean not null)")
|
||||
tx.execute("delete from person")
|
||||
|
||||
val firstInsert = tx.execute(
|
||||
"insert into person(name, active) values(?, ?)",
|
||||
"Ada",
|
||||
true
|
||||
)
|
||||
val firstId = firstInsert.getGeneratedKeys().toList()[0][0]
|
||||
assertEquals(1, firstId)
|
||||
|
||||
tx.execute(
|
||||
"insert into person(name, active) values(?, ?)",
|
||||
"Linus",
|
||||
false
|
||||
)
|
||||
|
||||
val rows = tx.select("select id, name, active from person order by id").toList()
|
||||
assertEquals(2, rows.size)
|
||||
println("#" + rows[0]["id"] + " " + rows[0]["name"] + " active=" + rows[0]["active"])
|
||||
println("#" + rows[1]["id"] + " " + rows[1]["name"] + " active=" + rows[1]["active"])
|
||||
}
|
||||
|
||||
val genericDb = openDatabase(
|
||||
"jdbc:h2:mem:lyng_h2_generic;DB_CLOSE_DELAY=-1",
|
||||
Map()
|
||||
)
|
||||
|
||||
val answer = genericDb.transaction { tx ->
|
||||
tx.select("select 42 as answer").toList()[0]["answer"]
|
||||
}
|
||||
|
||||
assertEquals(42, answer)
|
||||
println("Generic JDBC openDatabase(...) also works: answer=$answer")
|
||||
println("OK")
|
||||
71
examples/postgres_basic.lyng
Normal file
71
examples/postgres_basic.lyng
Normal file
@ -0,0 +1,71 @@
|
||||
import lyng.io.db.jdbc
|
||||
|
||||
/*
|
||||
PostgreSQL JDBC demo.
|
||||
|
||||
Usage:
|
||||
lyng examples/postgres_basic.lyng [jdbc-url] [user] [password]
|
||||
|
||||
Typical local URL:
|
||||
jdbc:postgresql://127.0.0.1/postgres
|
||||
*/
|
||||
|
||||
fun cliArgs(): List<String> {
|
||||
val result: List<String> = []
|
||||
for (raw in ARGV as List) {
|
||||
result.add(raw as String)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
val argv = cliArgs()
|
||||
val URL = if (argv.size > 0) argv[0] else "jdbc:postgresql://127.0.0.1/postgres"
|
||||
val USER = if (argv.size > 1) argv[1] else ""
|
||||
val PASSWORD = if (argv.size > 2) argv[2] else ""
|
||||
|
||||
println("PostgreSQL JDBC demo: typed open, generated keys, nested transaction")
|
||||
|
||||
val db = openPostgres(URL, USER, PASSWORD)
|
||||
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table if not exists lyng_pg_demo(id bigserial primary key, title text not null, done boolean not null)")
|
||||
tx.execute("delete from lyng_pg_demo")
|
||||
|
||||
val firstInsert = tx.execute(
|
||||
"insert into lyng_pg_demo(title, done) values(?, ?)",
|
||||
"Verify PostgreSQL JDBC support",
|
||||
false
|
||||
)
|
||||
val firstId = firstInsert.getGeneratedKeys().toList()[0][0]
|
||||
println("First generated id=" + firstId)
|
||||
|
||||
tx.execute(
|
||||
"insert into lyng_pg_demo(title, done) values(?, ?)",
|
||||
"Review documentation",
|
||||
true
|
||||
)
|
||||
|
||||
try {
|
||||
tx.transaction { inner ->
|
||||
inner.execute(
|
||||
"insert into lyng_pg_demo(title, done) values(?, ?)",
|
||||
"This row is rolled back",
|
||||
false
|
||||
)
|
||||
throw IllegalStateException("rollback nested")
|
||||
}
|
||||
} catch (_: IllegalStateException) {
|
||||
println("Nested transaction rolled back as expected")
|
||||
}
|
||||
|
||||
val rows = tx.select("select id, title, done from lyng_pg_demo order by id").toList()
|
||||
for (row in rows) {
|
||||
println("#" + row["id"] + " " + row["title"] + " done=" + row["done"])
|
||||
}
|
||||
|
||||
val count = tx.select("select count(*) as count from lyng_pg_demo").toList()[0]["count"]
|
||||
assertEquals(2, count)
|
||||
println("Visible rows after nested rollback: " + count)
|
||||
}
|
||||
|
||||
println("OK")
|
||||
@ -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" }
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -0,0 +1,384 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng.io.db
|
||||
|
||||
import net.sergeych.lyng.Arguments
|
||||
import net.sergeych.lyng.ModuleScope
|
||||
import net.sergeych.lyng.Scope
|
||||
import net.sergeych.lyng.ScopeFacade
|
||||
import net.sergeych.lyng.asFacade
|
||||
import net.sergeych.lyng.obj.Obj
|
||||
import net.sergeych.lyng.obj.ObjBool
|
||||
import net.sergeych.lyng.obj.ObjClass
|
||||
import net.sergeych.lyng.obj.ObjEnumClass
|
||||
import net.sergeych.lyng.obj.ObjEnumEntry
|
||||
import net.sergeych.lyng.obj.ObjException
|
||||
import net.sergeych.lyng.obj.ObjImmutableList
|
||||
import net.sergeych.lyng.obj.ObjInt
|
||||
import net.sergeych.lyng.obj.ObjNull
|
||||
import net.sergeych.lyng.obj.ObjString
|
||||
import net.sergeych.lyng.obj.thisAs
|
||||
import net.sergeych.lyng.requireScope
|
||||
|
||||
internal data class SqlColumnMeta(
|
||||
val name: String,
|
||||
val sqlType: ObjEnumEntry,
|
||||
val nullable: Boolean,
|
||||
val nativeType: String,
|
||||
)
|
||||
|
||||
internal data class SqlResultSetData(
|
||||
val columns: List<SqlColumnMeta>,
|
||||
val rows: List<List<Obj>>,
|
||||
)
|
||||
|
||||
internal data class SqlExecutionResultData(
|
||||
val affectedRowsCount: Int,
|
||||
val generatedKeys: SqlResultSetData,
|
||||
)
|
||||
|
||||
internal interface SqlDatabaseBackend {
|
||||
suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T
|
||||
}
|
||||
|
||||
internal interface SqlTransactionBackend {
|
||||
suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqlResultSetData
|
||||
suspend fun execute(scope: ScopeFacade, clause: String, params: List<Obj>): SqlExecutionResultData
|
||||
suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T
|
||||
}
|
||||
|
||||
internal class SqlCoreModule private constructor(
|
||||
val module: ModuleScope,
|
||||
val databaseClass: ObjClass,
|
||||
val transactionClass: ObjClass,
|
||||
val resultSetClass: ObjClass,
|
||||
val rowClass: ObjClass,
|
||||
val columnClass: ObjClass,
|
||||
val executionResultClass: ObjClass,
|
||||
val databaseException: ObjException.Companion.ExceptionClass,
|
||||
val sqlExecutionException: ObjException.Companion.ExceptionClass,
|
||||
val sqlConstraintException: ObjException.Companion.ExceptionClass,
|
||||
val sqlUsageException: ObjException.Companion.ExceptionClass,
|
||||
val rollbackException: ObjException.Companion.ExceptionClass,
|
||||
val sqlTypes: SqlTypeEntries,
|
||||
) {
|
||||
companion object {
|
||||
fun resolve(module: ModuleScope): SqlCoreModule = SqlCoreModule(
|
||||
module = module,
|
||||
databaseClass = module.requireClass("Database"),
|
||||
transactionClass = module.requireClass("SqlTransaction"),
|
||||
resultSetClass = module.requireClass("ResultSet"),
|
||||
rowClass = module.requireClass("SqlRow"),
|
||||
columnClass = module.requireClass("SqlColumn"),
|
||||
executionResultClass = module.requireClass("ExecutionResult"),
|
||||
databaseException = module.requireClass("DatabaseException") as ObjException.Companion.ExceptionClass,
|
||||
sqlExecutionException = module.requireClass("SqlExecutionException") as ObjException.Companion.ExceptionClass,
|
||||
sqlConstraintException = module.requireClass("SqlConstraintException") as ObjException.Companion.ExceptionClass,
|
||||
sqlUsageException = module.requireClass("SqlUsageException") as ObjException.Companion.ExceptionClass,
|
||||
rollbackException = module.requireClass("RollbackException") as ObjException.Companion.ExceptionClass,
|
||||
sqlTypes = SqlTypeEntries.resolve(module),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal class SqlTypeEntries private constructor(
|
||||
private val entries: Map<String, ObjEnumEntry>,
|
||||
) {
|
||||
fun require(name: String): ObjEnumEntry = entries[name]
|
||||
?: error("lyng.io.db.SqlType entry is missing: $name")
|
||||
|
||||
companion object {
|
||||
fun resolve(module: ModuleScope): SqlTypeEntries {
|
||||
val enumClass = resolveEnum(module, "SqlType")
|
||||
return SqlTypeEntries(
|
||||
listOf(
|
||||
"Binary", "String", "Int", "Double", "Decimal",
|
||||
"Bool", "Instant", "Date", "DateTime"
|
||||
).associateWith { name ->
|
||||
enumClass.byName[ObjString(name)] as? ObjEnumEntry
|
||||
?: error("lyng.io.db.SqlType.$name is missing")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveEnum(module: ModuleScope, enumName: String): ObjEnumClass {
|
||||
val local = module.get(enumName)?.value as? ObjEnumClass
|
||||
if (local != null) return local
|
||||
val root = module.importProvider.rootScope.get(enumName)?.value as? ObjEnumClass
|
||||
return root ?: error("lyng.io.db declaration enum is missing: $enumName")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class SqlRuntimeTypes private constructor(
|
||||
val core: SqlCoreModule,
|
||||
val databaseClass: ObjClass,
|
||||
val transactionClass: ObjClass,
|
||||
val resultSetClass: ObjClass,
|
||||
val rowClass: ObjClass,
|
||||
val columnClass: ObjClass,
|
||||
val executionResultClass: ObjClass,
|
||||
) {
|
||||
companion object {
|
||||
fun create(prefix: String, core: SqlCoreModule): SqlRuntimeTypes {
|
||||
val databaseClass = object : ObjClass("${prefix}Database", core.databaseClass) {}
|
||||
val transactionClass = object : ObjClass("${prefix}Transaction", core.transactionClass) {}
|
||||
val resultSetClass = object : ObjClass("${prefix}ResultSet", core.resultSetClass) {}
|
||||
val rowClass = object : ObjClass("${prefix}Row", core.rowClass) {}
|
||||
val columnClass = object : ObjClass("${prefix}Column", core.columnClass) {}
|
||||
val executionResultClass = object : ObjClass("${prefix}ExecutionResult", core.executionResultClass) {}
|
||||
val runtime = SqlRuntimeTypes(
|
||||
core = core,
|
||||
databaseClass = databaseClass,
|
||||
transactionClass = transactionClass,
|
||||
resultSetClass = resultSetClass,
|
||||
rowClass = rowClass,
|
||||
columnClass = columnClass,
|
||||
executionResultClass = executionResultClass,
|
||||
)
|
||||
runtime.bind()
|
||||
return runtime
|
||||
}
|
||||
}
|
||||
|
||||
private fun bind() {
|
||||
databaseClass.addFn("transaction") {
|
||||
val self = thisAs<SqlDatabaseObj>()
|
||||
val block = args.list.getOrNull(0) ?: raiseError("Expected exactly 1 argument, got ${args.list.size}")
|
||||
if (!block.isInstanceOf("Callable")) {
|
||||
raiseClassCastError("transaction block must be callable")
|
||||
}
|
||||
self.backend.transaction(this) { backend ->
|
||||
val lifetime = SqlTransactionLifetime(this@SqlRuntimeTypes.core)
|
||||
try {
|
||||
call(block, Arguments(SqlTransactionObj(this@SqlRuntimeTypes, backend, lifetime)), ObjNull)
|
||||
} finally {
|
||||
lifetime.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transactionClass.addFn("select") {
|
||||
val self = thisAs<SqlTransactionObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
val clause = (args.list.getOrNull(0) as? ObjString)?.value
|
||||
?: raiseClassCastError("query must be String")
|
||||
val params = args.list.drop(1)
|
||||
SqlResultSetObj(self.types, self.lifetime, self.backend.select(this, clause, params))
|
||||
}
|
||||
transactionClass.addFn("execute") {
|
||||
val self = thisAs<SqlTransactionObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
val clause = (args.list.getOrNull(0) as? ObjString)?.value
|
||||
?: raiseClassCastError("query must be String")
|
||||
val params = args.list.drop(1)
|
||||
SqlExecutionResultObj(self.types, self.lifetime, self.backend.execute(this, clause, params))
|
||||
}
|
||||
transactionClass.addFn("transaction") {
|
||||
val self = thisAs<SqlTransactionObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
val block = args.list.getOrNull(0) ?: raiseError("Expected exactly 1 argument, got ${args.list.size}")
|
||||
if (!block.isInstanceOf("Callable")) {
|
||||
raiseClassCastError("transaction block must be callable")
|
||||
}
|
||||
self.backend.transaction(this) { backend ->
|
||||
val lifetime = SqlTransactionLifetime(this@SqlRuntimeTypes.core)
|
||||
try {
|
||||
call(block, Arguments(SqlTransactionObj(self.types, backend, lifetime)), ObjNull)
|
||||
} finally {
|
||||
lifetime.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resultSetClass.addProperty("columns", getter = {
|
||||
val self = thisAs<SqlResultSetObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjImmutableList(self.columns)
|
||||
})
|
||||
resultSetClass.addFn("size") {
|
||||
val self = thisAs<SqlResultSetObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjInt.of(self.rows.size.toLong())
|
||||
}
|
||||
resultSetClass.addFn("isEmpty") {
|
||||
val self = thisAs<SqlResultSetObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjBool(self.rows.isEmpty())
|
||||
}
|
||||
resultSetClass.addFn("iterator") {
|
||||
val self = thisAs<SqlResultSetObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjImmutableList(self.rows).invokeInstanceMethod(requireScope(), "iterator")
|
||||
}
|
||||
resultSetClass.addFn("toList") {
|
||||
val self = thisAs<SqlResultSetObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjImmutableList(self.rows)
|
||||
}
|
||||
|
||||
rowClass.addProperty("size", getter = {
|
||||
val self = thisAs<SqlRowObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjInt.of(self.values.size.toLong())
|
||||
})
|
||||
rowClass.addProperty("values", getter = {
|
||||
val self = thisAs<SqlRowObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjImmutableList(self.values)
|
||||
})
|
||||
|
||||
columnClass.addProperty("name", getter = { ObjString(thisAs<SqlColumnObj>().meta.name) })
|
||||
columnClass.addProperty("sqlType", getter = { thisAs<SqlColumnObj>().meta.sqlType })
|
||||
columnClass.addProperty("nullable", getter = { ObjBool(thisAs<SqlColumnObj>().meta.nullable) })
|
||||
columnClass.addProperty("nativeType", getter = { ObjString(thisAs<SqlColumnObj>().meta.nativeType) })
|
||||
|
||||
executionResultClass.addProperty("affectedRowsCount", getter = {
|
||||
val self = thisAs<SqlExecutionResultObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjInt.of(self.result.affectedRowsCount.toLong())
|
||||
})
|
||||
executionResultClass.addFn("getGeneratedKeys") {
|
||||
val self = thisAs<SqlExecutionResultObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
SqlResultSetObj(self.types, self.lifetime, self.result.generatedKeys)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class SqlTransactionLifetime(
|
||||
private val core: SqlCoreModule,
|
||||
) {
|
||||
private var active = true
|
||||
|
||||
fun close() {
|
||||
active = false
|
||||
}
|
||||
|
||||
fun ensureActive(scope: ScopeFacade) {
|
||||
if (!active) {
|
||||
scope.raiseError(
|
||||
ObjException(core.sqlUsageException, scope.requireScope(), ObjString("SQL result can be used only while its transaction is active"))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class SqlDatabaseObj(
|
||||
val types: SqlRuntimeTypes,
|
||||
val backend: SqlDatabaseBackend,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = types.databaseClass
|
||||
}
|
||||
|
||||
internal class SqlTransactionObj(
|
||||
val types: SqlRuntimeTypes,
|
||||
val backend: SqlTransactionBackend,
|
||||
val lifetime: SqlTransactionLifetime,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = types.transactionClass
|
||||
}
|
||||
|
||||
internal class SqlResultSetObj(
|
||||
val types: SqlRuntimeTypes,
|
||||
val lifetime: SqlTransactionLifetime,
|
||||
data: SqlResultSetData,
|
||||
) : Obj() {
|
||||
val columns: List<Obj> = data.columns.map { SqlColumnObj(types, it) }
|
||||
val rows: List<Obj> = buildRows(types, lifetime, data)
|
||||
|
||||
override val objClass: ObjClass
|
||||
get() = types.resultSetClass
|
||||
|
||||
private fun buildRows(
|
||||
types: SqlRuntimeTypes,
|
||||
lifetime: SqlTransactionLifetime,
|
||||
data: SqlResultSetData,
|
||||
): List<Obj> {
|
||||
val indexByName = linkedMapOf<String, MutableList<Int>>()
|
||||
data.columns.forEachIndexed { index, column ->
|
||||
indexByName.getOrPut(column.name.lowercase()) { mutableListOf() }.add(index)
|
||||
}
|
||||
return data.rows.map { rowValues ->
|
||||
SqlRowObj(types, lifetime, rowValues, indexByName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class SqlRowObj(
|
||||
val types: SqlRuntimeTypes,
|
||||
val lifetime: SqlTransactionLifetime,
|
||||
val values: List<Obj>,
|
||||
private val indexByName: Map<String, List<Int>>,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = types.rowClass
|
||||
|
||||
override suspend fun getAt(scope: Scope, index: Obj): Obj {
|
||||
lifetime.ensureActive(scope.asFacade())
|
||||
return when (index) {
|
||||
is ObjInt -> {
|
||||
val idx = index.value.toInt()
|
||||
if (idx !in values.indices) {
|
||||
scope.raiseIndexOutOfBounds("SQL row index $idx is out of bounds")
|
||||
}
|
||||
values[idx]
|
||||
}
|
||||
is ObjString -> {
|
||||
val matches = indexByName[index.value.lowercase()]
|
||||
?: scope.raiseError(
|
||||
ObjException(
|
||||
types.core.sqlUsageException,
|
||||
scope,
|
||||
ObjString("No such SQL result column: ${index.value}")
|
||||
)
|
||||
)
|
||||
if (matches.size != 1) {
|
||||
scope.raiseError(
|
||||
ObjException(
|
||||
types.core.sqlUsageException,
|
||||
scope,
|
||||
ObjString("Ambiguous SQL result column: ${index.value}")
|
||||
)
|
||||
)
|
||||
}
|
||||
values[matches.first()]
|
||||
}
|
||||
else -> scope.raiseClassCastError("SQL row index must be Int or String")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class SqlColumnObj(
|
||||
val types: SqlRuntimeTypes,
|
||||
val meta: SqlColumnMeta,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = types.columnClass
|
||||
}
|
||||
|
||||
internal class SqlExecutionResultObj(
|
||||
val types: SqlRuntimeTypes,
|
||||
val lifetime: SqlTransactionLifetime,
|
||||
val result: SqlExecutionResultData,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = types.executionResultClass
|
||||
}
|
||||
@ -0,0 +1,278 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng.io.db.jdbc
|
||||
|
||||
import net.sergeych.lyng.ModuleScope
|
||||
import net.sergeych.lyng.Pos
|
||||
import net.sergeych.lyng.Scope
|
||||
import net.sergeych.lyng.ScopeFacade
|
||||
import net.sergeych.lyng.Source
|
||||
import net.sergeych.lyng.io.db.SqlCoreModule
|
||||
import net.sergeych.lyng.io.db.SqlDatabaseBackend
|
||||
import net.sergeych.lyng.io.db.SqlDatabaseObj
|
||||
import net.sergeych.lyng.io.db.SqlRuntimeTypes
|
||||
import net.sergeych.lyng.io.db.createDbModule
|
||||
import net.sergeych.lyng.obj.Obj
|
||||
import net.sergeych.lyng.obj.ObjBool
|
||||
import net.sergeych.lyng.obj.ObjImmutableMap
|
||||
import net.sergeych.lyng.obj.ObjInt
|
||||
import net.sergeych.lyng.obj.ObjMap
|
||||
import net.sergeych.lyng.obj.ObjNull
|
||||
import net.sergeych.lyng.obj.ObjString
|
||||
import net.sergeych.lyng.obj.ObjVoid
|
||||
import net.sergeych.lyng.obj.requiredArg
|
||||
import net.sergeych.lyng.pacman.ImportManager
|
||||
import net.sergeych.lyng.requireScope
|
||||
import net.sergeych.lyngio.stdlib_included.db_jdbcLyng
|
||||
|
||||
private const val JDBC_MODULE_NAME = "lyng.io.db.jdbc"
|
||||
private const val DB_MODULE_NAME = "lyng.io.db"
|
||||
private const val JDBC_SCHEME = "jdbc"
|
||||
private const val H2_DRIVER = "org.h2.Driver"
|
||||
private const val POSTGRES_DRIVER = "org.postgresql.Driver"
|
||||
|
||||
fun createJdbcModule(scope: Scope): Boolean = createJdbcModule(scope.importManager)
|
||||
|
||||
fun createJdbc(scope: Scope): Boolean = createJdbcModule(scope)
|
||||
|
||||
fun createJdbcModule(manager: ImportManager): Boolean {
|
||||
createDbModule(manager)
|
||||
if (manager.packageNames.contains(JDBC_MODULE_NAME)) return false
|
||||
manager.addPackage(JDBC_MODULE_NAME) { module ->
|
||||
buildJdbcModule(module)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun createJdbc(manager: ImportManager): Boolean = createJdbcModule(manager)
|
||||
|
||||
private suspend fun buildJdbcModule(module: ModuleScope) {
|
||||
module.eval(Source(JDBC_MODULE_NAME, db_jdbcLyng))
|
||||
val dbModule = module.importProvider.createModuleScope(Pos.builtIn, DB_MODULE_NAME)
|
||||
val core = SqlCoreModule.resolve(dbModule)
|
||||
val runtimeTypes = SqlRuntimeTypes.create("Jdbc", core)
|
||||
|
||||
module.addFn("openJdbc") {
|
||||
val options = parseOpenJdbcArgs(this)
|
||||
SqlDatabaseObj(runtimeTypes, openJdbcBackend(this, core, options))
|
||||
}
|
||||
module.addFn("openH2") {
|
||||
val options = parseOpenShortcutArgs(this, H2_DRIVER, ::normalizeH2Url)
|
||||
SqlDatabaseObj(runtimeTypes, openJdbcBackend(this, core, options))
|
||||
}
|
||||
module.addFn("openPostgres") {
|
||||
val options = parseOpenShortcutArgs(this, POSTGRES_DRIVER, ::normalizePostgresUrl)
|
||||
SqlDatabaseObj(runtimeTypes, openJdbcBackend(this, core, options))
|
||||
}
|
||||
|
||||
registerProvider(dbModule, runtimeTypes, core, JDBC_SCHEME, null)
|
||||
registerProvider(dbModule, runtimeTypes, core, "h2", H2_DRIVER)
|
||||
registerProvider(dbModule, runtimeTypes, core, "postgres", POSTGRES_DRIVER)
|
||||
registerProvider(dbModule, runtimeTypes, core, "postgresql", POSTGRES_DRIVER)
|
||||
}
|
||||
|
||||
private suspend fun registerProvider(
|
||||
dbModule: ModuleScope,
|
||||
runtimeTypes: SqlRuntimeTypes,
|
||||
core: SqlCoreModule,
|
||||
scheme: String,
|
||||
implicitDriverClass: String?,
|
||||
) {
|
||||
dbModule.callFn(
|
||||
"registerDatabaseProvider",
|
||||
ObjString(scheme),
|
||||
net.sergeych.lyng.obj.ObjExternCallable.fromBridge {
|
||||
val connectionUrl = requiredArg<ObjString>(0).value
|
||||
val extraParams = args.list.getOrNull(1)
|
||||
?: raiseError("Expected exactly 2 arguments, got ${args.list.size}")
|
||||
val options = parseJdbcConnectionUrl(this, scheme, implicitDriverClass, connectionUrl, extraParams)
|
||||
SqlDatabaseObj(runtimeTypes, openJdbcBackend(this, core, options))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun parseOpenJdbcArgs(scope: ScopeFacade): JdbcOpenOptions {
|
||||
val rawUrl = readArg(scope, "connectionUrl", 0) ?: scope.raiseError("argument 'connectionUrl' is required")
|
||||
val connectionUrl = (rawUrl as? ObjString)?.value ?: scope.raiseClassCastError("connectionUrl must be String")
|
||||
val user = readNullableStringArg(scope, "user", 1)
|
||||
val password = readNullableStringArg(scope, "password", 2)
|
||||
val driverClass = readNullableStringArg(scope, "driverClass", 3)
|
||||
val properties = readPropertiesArg(scope, "properties", 4)
|
||||
return JdbcOpenOptions(
|
||||
connectionUrl = normalizeJdbcUrl(connectionUrl, scope),
|
||||
user = user,
|
||||
password = password,
|
||||
driverClass = driverClass,
|
||||
properties = properties,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun parseOpenShortcutArgs(
|
||||
scope: ScopeFacade,
|
||||
defaultDriverClass: String,
|
||||
normalize: (String, ScopeFacade) -> String,
|
||||
): JdbcOpenOptions {
|
||||
val rawUrl = readArg(scope, "connectionUrl", 0) ?: scope.raiseError("argument 'connectionUrl' is required")
|
||||
val connectionUrl = (rawUrl as? ObjString)?.value ?: scope.raiseClassCastError("connectionUrl must be String")
|
||||
val user = readNullableStringArg(scope, "user", 1)
|
||||
val password = readNullableStringArg(scope, "password", 2)
|
||||
val properties = readPropertiesArg(scope, "properties", 3)
|
||||
return JdbcOpenOptions(
|
||||
connectionUrl = normalize(connectionUrl, scope),
|
||||
user = user,
|
||||
password = password,
|
||||
driverClass = defaultDriverClass,
|
||||
properties = properties,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun parseJdbcConnectionUrl(
|
||||
scope: ScopeFacade,
|
||||
scheme: String,
|
||||
implicitDriverClass: String?,
|
||||
connectionUrl: String,
|
||||
extraParams: Obj,
|
||||
): JdbcOpenOptions {
|
||||
val driverClass = mapNullableString(extraParams, scope, "driverClass") ?: implicitDriverClass
|
||||
val user = mapNullableString(extraParams, scope, "user")
|
||||
val password = mapNullableString(extraParams, scope, "password")
|
||||
val properties = mapProperties(extraParams, scope, "properties")
|
||||
val normalizedUrl = when (scheme) {
|
||||
JDBC_SCHEME -> normalizeJdbcUrl(connectionUrl, scope)
|
||||
"h2" -> normalizeH2Url(connectionUrl, scope)
|
||||
"postgres", "postgresql" -> normalizePostgresUrl(connectionUrl, scope)
|
||||
else -> scope.raiseIllegalArgument("Unsupported JDBC provider scheme: $scheme")
|
||||
}
|
||||
return JdbcOpenOptions(
|
||||
connectionUrl = normalizedUrl,
|
||||
user = user,
|
||||
password = password,
|
||||
driverClass = driverClass,
|
||||
properties = properties,
|
||||
)
|
||||
}
|
||||
|
||||
private fun normalizeJdbcUrl(rawUrl: String, scope: ScopeFacade): String {
|
||||
val trimmed = rawUrl.trim()
|
||||
if (!trimmed.startsWith("jdbc:", ignoreCase = true)) {
|
||||
scope.raiseIllegalArgument("JDBC connection URL must start with jdbc:")
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private fun normalizeH2Url(rawUrl: String, scope: ScopeFacade): String {
|
||||
val trimmed = rawUrl.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
scope.raiseIllegalArgument("H2 connection URL must not be empty")
|
||||
}
|
||||
return when {
|
||||
trimmed.startsWith("jdbc:h2:", ignoreCase = true) -> trimmed
|
||||
trimmed.startsWith("h2:", ignoreCase = true) -> "jdbc:${trimmed}"
|
||||
else -> "jdbc:h2:$trimmed"
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizePostgresUrl(rawUrl: String, scope: ScopeFacade): String {
|
||||
val trimmed = rawUrl.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
scope.raiseIllegalArgument("PostgreSQL connection URL must not be empty")
|
||||
}
|
||||
return when {
|
||||
trimmed.startsWith("jdbc:postgresql:", ignoreCase = true) -> trimmed
|
||||
trimmed.startsWith("postgresql:", ignoreCase = true) -> "jdbc:$trimmed"
|
||||
trimmed.startsWith("postgres:", ignoreCase = true) -> "jdbc:postgresql:${trimmed.substringAfter(':')}"
|
||||
else -> "jdbc:postgresql:$trimmed"
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readArg(scope: ScopeFacade, name: String, position: Int): Obj? {
|
||||
val named = scope.args.named[name]
|
||||
val positional = scope.args.list.getOrNull(position)
|
||||
if (named != null && positional != null) {
|
||||
scope.raiseIllegalArgument("argument '$name' is already set")
|
||||
}
|
||||
return named ?: positional
|
||||
}
|
||||
|
||||
private suspend fun readNullableStringArg(scope: ScopeFacade, name: String, position: Int): String? {
|
||||
val value = readArg(scope, name, position) ?: return null
|
||||
return when (value) {
|
||||
ObjNull -> null
|
||||
is ObjString -> value.value
|
||||
else -> scope.raiseClassCastError("$name must be String?")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readPropertiesArg(scope: ScopeFacade, name: String, position: Int): Map<String, String> {
|
||||
val value = readArg(scope, name, position) ?: return emptyMap()
|
||||
return when (value) {
|
||||
ObjNull -> emptyMap()
|
||||
else -> objToStringMap(value, scope, name)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun mapNullableString(map: Obj, scope: ScopeFacade, key: String): String? {
|
||||
val value = map.getAt(scope.requireScope(), ObjString(key))
|
||||
return when (value) {
|
||||
ObjNull -> null
|
||||
is ObjString -> value.value
|
||||
else -> scope.raiseClassCastError("extraParams.$key must be String?")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun mapProperties(map: Obj, scope: ScopeFacade, key: String): Map<String, String> {
|
||||
val value = map.getAt(scope.requireScope(), ObjString(key))
|
||||
return when (value) {
|
||||
ObjNull -> emptyMap()
|
||||
else -> objToStringMap(value, scope, "extraParams.$key")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun objToStringMap(value: Obj, scope: ScopeFacade, label: String): Map<String, String> {
|
||||
val rawEntries = when (value) {
|
||||
is ObjMap -> value.map
|
||||
is ObjImmutableMap -> value.map
|
||||
else -> scope.raiseClassCastError("$label must be Map<String, Object?>")
|
||||
}
|
||||
val properties = linkedMapOf<String, String>()
|
||||
for ((rawKey, rawValue) in rawEntries) {
|
||||
val key = (rawKey as? ObjString)?.value ?: scope.raiseClassCastError("$label keys must be String")
|
||||
if (rawValue == ObjNull) continue
|
||||
properties[key] = scope.toStringOf(rawValue).value
|
||||
}
|
||||
return properties
|
||||
}
|
||||
|
||||
private suspend fun ModuleScope.callFn(name: String, vararg args: Obj): Obj {
|
||||
val callee = get(name)?.value ?: error("Missing $name in module")
|
||||
return callee.invoke(this, ObjNull, *args)
|
||||
}
|
||||
|
||||
internal data class JdbcOpenOptions(
|
||||
val connectionUrl: String,
|
||||
val user: String?,
|
||||
val password: String?,
|
||||
val driverClass: String?,
|
||||
val properties: Map<String, String>,
|
||||
)
|
||||
|
||||
internal expect suspend fun openJdbcBackend(
|
||||
scope: ScopeFacade,
|
||||
core: SqlCoreModule,
|
||||
options: JdbcOpenOptions,
|
||||
): SqlDatabaseBackend
|
||||
@ -24,22 +24,20 @@ import net.sergeych.lyng.Scope
|
||||
import net.sergeych.lyng.ScopeFacade
|
||||
import net.sergeych.lyng.Source
|
||||
import net.sergeych.lyng.asFacade
|
||||
import net.sergeych.lyng.io.db.SqlCoreModule
|
||||
import net.sergeych.lyng.io.db.SqlDatabaseBackend
|
||||
import net.sergeych.lyng.io.db.SqlDatabaseObj
|
||||
import net.sergeych.lyng.io.db.SqlRuntimeTypes
|
||||
import net.sergeych.lyng.io.db.SqlTransactionBackend
|
||||
import net.sergeych.lyng.io.db.createDbModule
|
||||
import net.sergeych.lyng.requireScope
|
||||
import net.sergeych.lyng.obj.Obj
|
||||
import net.sergeych.lyng.obj.ObjBool
|
||||
import net.sergeych.lyng.obj.ObjClass
|
||||
import net.sergeych.lyng.obj.ObjEnumClass
|
||||
import net.sergeych.lyng.obj.ObjEnumEntry
|
||||
import net.sergeych.lyng.obj.ObjException
|
||||
import net.sergeych.lyng.obj.ObjImmutableList
|
||||
import net.sergeych.lyng.obj.ObjInt
|
||||
import net.sergeych.lyng.obj.ObjNull
|
||||
import net.sergeych.lyng.obj.ObjReal
|
||||
import net.sergeych.lyng.obj.ObjString
|
||||
import net.sergeych.lyng.obj.ObjVoid
|
||||
import net.sergeych.lyng.obj.requiredArg
|
||||
import net.sergeych.lyng.obj.thisAs
|
||||
import net.sergeych.lyng.pacman.ImportManager
|
||||
import net.sergeych.lyngio.stdlib_included.db_sqliteLyng
|
||||
|
||||
@ -64,12 +62,12 @@ fun createSqlite(manager: ImportManager): Boolean = createSqliteModule(manager)
|
||||
private suspend fun buildSqliteModule(module: ModuleScope) {
|
||||
module.eval(Source(SQLITE_MODULE_NAME, db_sqliteLyng))
|
||||
val dbModule = module.importProvider.createModuleScope(Pos.builtIn, DB_MODULE_NAME)
|
||||
val core = SqliteCoreModule.resolve(dbModule)
|
||||
val runtimeTypes = SqliteRuntimeTypes.create(core)
|
||||
val core = SqlCoreModule.resolve(dbModule)
|
||||
val runtimeTypes = SqlRuntimeTypes.create("Sqlite", core)
|
||||
|
||||
module.addFn("openSqlite") {
|
||||
val options = parseOpenSqliteArgs(this)
|
||||
SqliteDatabaseObj(runtimeTypes, openSqliteBackend(this, core, options))
|
||||
SqlDatabaseObj(runtimeTypes, openSqliteBackend(this, core, options))
|
||||
}
|
||||
|
||||
dbModule.callFn(
|
||||
@ -80,7 +78,7 @@ private suspend fun buildSqliteModule(module: ModuleScope) {
|
||||
val extraParams = args.list.getOrNull(1)
|
||||
?: raiseError("Expected exactly 2 arguments, got ${args.list.size}")
|
||||
val options = parseSqliteConnectionUrl(this, connectionUrl, extraParams)
|
||||
SqliteDatabaseObj(runtimeTypes, openSqliteBackend(this, core, options))
|
||||
SqlDatabaseObj(runtimeTypes, openSqliteBackend(this, core, options))
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -189,354 +187,8 @@ internal data class SqliteOpenOptions(
|
||||
val busyTimeoutMillis: Int,
|
||||
)
|
||||
|
||||
internal data class SqliteColumnMeta(
|
||||
val name: String,
|
||||
val sqlType: ObjEnumEntry,
|
||||
val nullable: Boolean,
|
||||
val nativeType: String,
|
||||
)
|
||||
|
||||
internal data class SqliteResultSetData(
|
||||
val columns: List<SqliteColumnMeta>,
|
||||
val rows: List<List<Obj>>,
|
||||
)
|
||||
|
||||
internal data class SqliteExecutionResultData(
|
||||
val affectedRowsCount: Int,
|
||||
val generatedKeys: SqliteResultSetData,
|
||||
)
|
||||
|
||||
internal interface SqliteDatabaseBackend {
|
||||
suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T
|
||||
}
|
||||
|
||||
internal interface SqliteTransactionBackend {
|
||||
suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteResultSetData
|
||||
suspend fun execute(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteExecutionResultData
|
||||
suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T
|
||||
}
|
||||
|
||||
internal expect suspend fun openSqliteBackend(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
options: SqliteOpenOptions,
|
||||
): SqliteDatabaseBackend
|
||||
|
||||
internal class SqliteCoreModule private constructor(
|
||||
val module: ModuleScope,
|
||||
val databaseClass: ObjClass,
|
||||
val transactionClass: ObjClass,
|
||||
val resultSetClass: ObjClass,
|
||||
val rowClass: ObjClass,
|
||||
val columnClass: ObjClass,
|
||||
val executionResultClass: ObjClass,
|
||||
val databaseException: ObjException.Companion.ExceptionClass,
|
||||
val sqlExecutionException: ObjException.Companion.ExceptionClass,
|
||||
val sqlConstraintException: ObjException.Companion.ExceptionClass,
|
||||
val sqlUsageException: ObjException.Companion.ExceptionClass,
|
||||
val rollbackException: ObjException.Companion.ExceptionClass,
|
||||
val sqlTypes: SqlTypeEntries,
|
||||
) {
|
||||
companion object {
|
||||
fun resolve(module: ModuleScope): SqliteCoreModule = SqliteCoreModule(
|
||||
module = module,
|
||||
databaseClass = module.requireClass("Database"),
|
||||
transactionClass = module.requireClass("SqlTransaction"),
|
||||
resultSetClass = module.requireClass("ResultSet"),
|
||||
rowClass = module.requireClass("SqlRow"),
|
||||
columnClass = module.requireClass("SqlColumn"),
|
||||
executionResultClass = module.requireClass("ExecutionResult"),
|
||||
databaseException = module.requireClass("DatabaseException") as ObjException.Companion.ExceptionClass,
|
||||
sqlExecutionException = module.requireClass("SqlExecutionException") as ObjException.Companion.ExceptionClass,
|
||||
sqlConstraintException = module.requireClass("SqlConstraintException") as ObjException.Companion.ExceptionClass,
|
||||
sqlUsageException = module.requireClass("SqlUsageException") as ObjException.Companion.ExceptionClass,
|
||||
rollbackException = module.requireClass("RollbackException") as ObjException.Companion.ExceptionClass,
|
||||
sqlTypes = SqlTypeEntries.resolve(module),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal class SqlTypeEntries private constructor(
|
||||
private val entries: Map<String, ObjEnumEntry>,
|
||||
) {
|
||||
fun require(name: String): ObjEnumEntry = entries[name]
|
||||
?: error("lyng.io.db.SqlType entry is missing: $name")
|
||||
|
||||
companion object {
|
||||
fun resolve(module: ModuleScope): SqlTypeEntries {
|
||||
val enumClass = resolveEnum(module, "SqlType")
|
||||
return SqlTypeEntries(
|
||||
listOf(
|
||||
"Binary", "String", "Int", "Double", "Decimal",
|
||||
"Bool", "Instant", "Date", "DateTime"
|
||||
).associateWith { name ->
|
||||
enumClass.byName[ObjString(name)] as? ObjEnumEntry
|
||||
?: error("lyng.io.db.SqlType.$name is missing")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveEnum(module: ModuleScope, enumName: String): ObjEnumClass {
|
||||
val local = module.get(enumName)?.value as? ObjEnumClass
|
||||
if (local != null) return local
|
||||
val root = module.importProvider.rootScope.get(enumName)?.value as? ObjEnumClass
|
||||
return root ?: error("lyng.io.db declaration enum is missing: $enumName")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SqliteRuntimeTypes private constructor(
|
||||
val core: SqliteCoreModule,
|
||||
val databaseClass: ObjClass,
|
||||
val transactionClass: ObjClass,
|
||||
val resultSetClass: ObjClass,
|
||||
val rowClass: ObjClass,
|
||||
val columnClass: ObjClass,
|
||||
val executionResultClass: ObjClass,
|
||||
) {
|
||||
companion object {
|
||||
fun create(core: SqliteCoreModule): SqliteRuntimeTypes {
|
||||
val databaseClass = object : ObjClass("SqliteDatabase", core.databaseClass) {}
|
||||
val transactionClass = object : ObjClass("SqliteTransaction", core.transactionClass) {}
|
||||
val resultSetClass = object : ObjClass("SqliteResultSet", core.resultSetClass) {}
|
||||
val rowClass = object : ObjClass("SqliteRow", core.rowClass) {}
|
||||
val columnClass = object : ObjClass("SqliteColumn", core.columnClass) {}
|
||||
val executionResultClass = object : ObjClass("SqliteExecutionResult", core.executionResultClass) {}
|
||||
val runtime = SqliteRuntimeTypes(
|
||||
core = core,
|
||||
databaseClass = databaseClass,
|
||||
transactionClass = transactionClass,
|
||||
resultSetClass = resultSetClass,
|
||||
rowClass = rowClass,
|
||||
columnClass = columnClass,
|
||||
executionResultClass = executionResultClass,
|
||||
)
|
||||
runtime.bind()
|
||||
return runtime
|
||||
}
|
||||
}
|
||||
|
||||
private fun bind() {
|
||||
databaseClass.addFn("transaction") {
|
||||
val self = thisAs<SqliteDatabaseObj>()
|
||||
val block = args.list.getOrNull(0) ?: raiseError("Expected exactly 1 argument, got ${args.list.size}")
|
||||
if (!block.isInstanceOf("Callable")) {
|
||||
raiseClassCastError("transaction block must be callable")
|
||||
}
|
||||
self.backend.transaction(this) { backend ->
|
||||
val lifetime = TransactionLifetime(this@SqliteRuntimeTypes.core)
|
||||
try {
|
||||
call(block, Arguments(SqliteTransactionObj(this@SqliteRuntimeTypes, backend, lifetime)), ObjNull)
|
||||
} finally {
|
||||
lifetime.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transactionClass.addFn("select") {
|
||||
val self = thisAs<SqliteTransactionObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
val clause = requiredArg<ObjString>(0).value
|
||||
val params = args.list.drop(1)
|
||||
SqliteResultSetObj(thisAs<SqliteTransactionObj>().types, self.lifetime, self.backend.select(this, clause, params))
|
||||
}
|
||||
transactionClass.addFn("execute") {
|
||||
val self = thisAs<SqliteTransactionObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
val clause = requiredArg<ObjString>(0).value
|
||||
val params = args.list.drop(1)
|
||||
SqliteExecutionResultObj(self.types, self.lifetime, self.backend.execute(this, clause, params))
|
||||
}
|
||||
transactionClass.addFn("transaction") {
|
||||
val self = thisAs<SqliteTransactionObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
val block = args.list.getOrNull(0) ?: raiseError("Expected exactly 1 argument, got ${args.list.size}")
|
||||
if (!block.isInstanceOf("Callable")) {
|
||||
raiseClassCastError("transaction block must be callable")
|
||||
}
|
||||
self.backend.transaction(this) { backend ->
|
||||
val lifetime = TransactionLifetime(this@SqliteRuntimeTypes.core)
|
||||
try {
|
||||
call(block, Arguments(SqliteTransactionObj(self.types, backend, lifetime)), ObjNull)
|
||||
} finally {
|
||||
lifetime.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resultSetClass.addProperty("columns", getter = {
|
||||
val self = thisAs<SqliteResultSetObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjImmutableList(self.columns)
|
||||
})
|
||||
resultSetClass.addFn("size") {
|
||||
val self = thisAs<SqliteResultSetObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjInt.of(self.rows.size.toLong())
|
||||
}
|
||||
resultSetClass.addFn("isEmpty") {
|
||||
val self = thisAs<SqliteResultSetObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjBool(self.rows.isEmpty())
|
||||
}
|
||||
resultSetClass.addFn("iterator") {
|
||||
val self = thisAs<SqliteResultSetObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjImmutableList(self.rows).invokeInstanceMethod(requireScope(), "iterator")
|
||||
}
|
||||
resultSetClass.addFn("toList") {
|
||||
val self = thisAs<SqliteResultSetObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjImmutableList(self.rows)
|
||||
}
|
||||
|
||||
rowClass.addProperty("size", getter = {
|
||||
val self = thisAs<SqliteRowObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjInt.of(self.values.size.toLong())
|
||||
})
|
||||
rowClass.addProperty("values", getter = {
|
||||
val self = thisAs<SqliteRowObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjImmutableList(self.values)
|
||||
})
|
||||
|
||||
columnClass.addProperty("name", getter = { ObjString(thisAs<SqliteColumnObj>().meta.name) })
|
||||
columnClass.addProperty("sqlType", getter = { thisAs<SqliteColumnObj>().meta.sqlType })
|
||||
columnClass.addProperty("nullable", getter = { ObjBool(thisAs<SqliteColumnObj>().meta.nullable) })
|
||||
columnClass.addProperty("nativeType", getter = { ObjString(thisAs<SqliteColumnObj>().meta.nativeType) })
|
||||
|
||||
executionResultClass.addProperty("affectedRowsCount", getter = {
|
||||
val self = thisAs<SqliteExecutionResultObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjInt.of(self.result.affectedRowsCount.toLong())
|
||||
})
|
||||
executionResultClass.addFn("getGeneratedKeys") {
|
||||
val self = thisAs<SqliteExecutionResultObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
SqliteResultSetObj(self.types, self.lifetime, self.result.generatedKeys)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class TransactionLifetime(
|
||||
private val core: SqliteCoreModule,
|
||||
) {
|
||||
private var active = true
|
||||
|
||||
fun close() {
|
||||
active = false
|
||||
}
|
||||
|
||||
fun ensureActive(scope: ScopeFacade) {
|
||||
if (!active) {
|
||||
scope.raiseError(
|
||||
ObjException(core.sqlUsageException, scope.requireScope(), ObjString("SQL result can be used only while its transaction is active"))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SqliteDatabaseObj(
|
||||
val types: SqliteRuntimeTypes,
|
||||
val backend: SqliteDatabaseBackend,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = types.databaseClass
|
||||
}
|
||||
|
||||
private class SqliteTransactionObj(
|
||||
val types: SqliteRuntimeTypes,
|
||||
val backend: SqliteTransactionBackend,
|
||||
val lifetime: TransactionLifetime,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = types.transactionClass
|
||||
}
|
||||
|
||||
private class SqliteResultSetObj(
|
||||
val types: SqliteRuntimeTypes,
|
||||
val lifetime: TransactionLifetime,
|
||||
data: SqliteResultSetData,
|
||||
) : Obj() {
|
||||
val columns: List<Obj> = data.columns.map { SqliteColumnObj(types, it) }
|
||||
val rows: List<Obj> = buildRows(types, lifetime, data)
|
||||
|
||||
override val objClass: ObjClass
|
||||
get() = types.resultSetClass
|
||||
|
||||
private fun buildRows(
|
||||
types: SqliteRuntimeTypes,
|
||||
lifetime: TransactionLifetime,
|
||||
data: SqliteResultSetData,
|
||||
): List<Obj> {
|
||||
val indexByName = linkedMapOf<String, MutableList<Int>>()
|
||||
data.columns.forEachIndexed { index, column ->
|
||||
indexByName.getOrPut(column.name.lowercase()) { mutableListOf() }.add(index)
|
||||
}
|
||||
return data.rows.map { rowValues ->
|
||||
SqliteRowObj(types, lifetime, rowValues, indexByName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SqliteRowObj(
|
||||
val types: SqliteRuntimeTypes,
|
||||
val lifetime: TransactionLifetime,
|
||||
val values: List<Obj>,
|
||||
private val indexByName: Map<String, List<Int>>,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = types.rowClass
|
||||
|
||||
override suspend fun getAt(scope: Scope, index: Obj): Obj {
|
||||
lifetime.ensureActive(scope.asFacade())
|
||||
return when (index) {
|
||||
is ObjInt -> {
|
||||
val idx = index.value.toInt()
|
||||
if (idx !in values.indices) {
|
||||
scope.raiseIndexOutOfBounds("SQL row index $idx is out of bounds")
|
||||
}
|
||||
values[idx]
|
||||
}
|
||||
is ObjString -> {
|
||||
val matches = indexByName[index.value.lowercase()]
|
||||
?: scope.raiseError(
|
||||
ObjException(
|
||||
types.core.sqlUsageException,
|
||||
scope,
|
||||
ObjString("No such SQL result column: ${index.value}")
|
||||
)
|
||||
)
|
||||
if (matches.size != 1) {
|
||||
scope.raiseError(
|
||||
ObjException(
|
||||
types.core.sqlUsageException,
|
||||
scope,
|
||||
ObjString("Ambiguous SQL result column: ${index.value}")
|
||||
)
|
||||
)
|
||||
}
|
||||
values[matches.first()]
|
||||
}
|
||||
else -> scope.raiseClassCastError("SQL row index must be Int or String")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SqliteColumnObj(
|
||||
val types: SqliteRuntimeTypes,
|
||||
val meta: SqliteColumnMeta,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = types.columnClass
|
||||
}
|
||||
|
||||
private class SqliteExecutionResultObj(
|
||||
val types: SqliteRuntimeTypes,
|
||||
val lifetime: TransactionLifetime,
|
||||
val result: SqliteExecutionResultData,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = types.executionResultClass
|
||||
}
|
||||
): SqlDatabaseBackend
|
||||
|
||||
@ -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")
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -0,0 +1,456 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng.io.db.jdbc
|
||||
|
||||
import net.sergeych.lyng.ExecutionError
|
||||
import net.sergeych.lyng.ScopeFacade
|
||||
import net.sergeych.lyng.io.db.SqlColumnMeta
|
||||
import net.sergeych.lyng.io.db.SqlCoreModule
|
||||
import net.sergeych.lyng.io.db.SqlDatabaseBackend
|
||||
import net.sergeych.lyng.io.db.SqlExecutionResultData
|
||||
import net.sergeych.lyng.io.db.SqlResultSetData
|
||||
import net.sergeych.lyng.io.db.SqlTransactionBackend
|
||||
import net.sergeych.lyng.obj.Obj
|
||||
import net.sergeych.lyng.obj.ObjBool
|
||||
import net.sergeych.lyng.obj.ObjBuffer
|
||||
import net.sergeych.lyng.obj.ObjDate
|
||||
import net.sergeych.lyng.obj.ObjDateTime
|
||||
import net.sergeych.lyng.obj.ObjEnumEntry
|
||||
import net.sergeych.lyng.obj.ObjException
|
||||
import net.sergeych.lyng.obj.ObjInstant
|
||||
import net.sergeych.lyng.obj.ObjInt
|
||||
import net.sergeych.lyng.obj.ObjNull
|
||||
import net.sergeych.lyng.obj.ObjReal
|
||||
import net.sergeych.lyng.obj.ObjString
|
||||
import net.sergeych.lyng.requireScope
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import java.math.BigDecimal
|
||||
import java.sql.Connection
|
||||
import java.sql.DriverManager
|
||||
import java.sql.PreparedStatement
|
||||
import java.sql.ResultSet
|
||||
import java.sql.SQLException
|
||||
import java.sql.SQLIntegrityConstraintViolationException
|
||||
import java.sql.SQLNonTransientConnectionException
|
||||
import java.sql.Statement
|
||||
import java.util.Properties
|
||||
import kotlin.time.Instant
|
||||
|
||||
private val knownJdbcDrivers = listOf(
|
||||
"org.sqlite.JDBC",
|
||||
"org.h2.Driver",
|
||||
"org.postgresql.Driver",
|
||||
)
|
||||
|
||||
internal actual suspend fun openJdbcBackend(
|
||||
scope: ScopeFacade,
|
||||
core: SqlCoreModule,
|
||||
options: JdbcOpenOptions,
|
||||
): SqlDatabaseBackend {
|
||||
return JdbcDatabaseBackend(core, options)
|
||||
}
|
||||
|
||||
private class JdbcDatabaseBackend(
|
||||
private val core: SqlCoreModule,
|
||||
private val options: JdbcOpenOptions,
|
||||
) : SqlDatabaseBackend {
|
||||
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T {
|
||||
val connection = openConnection(scope)
|
||||
try {
|
||||
connection.autoCommit = false
|
||||
val tx = JdbcTransactionBackend(core, connection)
|
||||
val result = try {
|
||||
block(tx)
|
||||
} catch (e: Throwable) {
|
||||
throw finishFailedTransaction(scope, core, e) {
|
||||
rollbackOrThrow(scope, core, connection)
|
||||
}
|
||||
}
|
||||
try {
|
||||
connection.commit()
|
||||
} catch (e: SQLException) {
|
||||
throw mapSqlException(scope, core, e)
|
||||
}
|
||||
return result
|
||||
} catch (e: SQLException) {
|
||||
throw mapSqlException(scope, core, e)
|
||||
} finally {
|
||||
try {
|
||||
connection.close()
|
||||
} catch (_: SQLException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openConnection(scope: ScopeFacade): Connection {
|
||||
ensureJdbcDriversLoaded(scope, core, options.driverClass)
|
||||
val properties = Properties()
|
||||
options.user?.let { properties.setProperty("user", it) }
|
||||
options.password?.let { properties.setProperty("password", it) }
|
||||
options.properties.forEach { (key, value) -> properties.setProperty(key, value) }
|
||||
return try {
|
||||
DriverManager.getConnection(options.connectionUrl, properties)
|
||||
} catch (e: SQLException) {
|
||||
throw mapOpenException(scope, core, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class JdbcTransactionBackend(
|
||||
private val core: SqlCoreModule,
|
||||
private val connection: Connection,
|
||||
) : SqlTransactionBackend {
|
||||
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqlResultSetData {
|
||||
try {
|
||||
connection.prepareStatement(clause).use { statement ->
|
||||
bindParams(statement, params, scope, core)
|
||||
statement.executeQuery().use { rs ->
|
||||
return readResultSet(scope, core, rs)
|
||||
}
|
||||
}
|
||||
} catch (e: SQLException) {
|
||||
throw mapSqlException(scope, core, e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun execute(scope: ScopeFacade, clause: String, params: List<Obj>): SqlExecutionResultData {
|
||||
if (containsRowReturningClause(clause)) {
|
||||
scope.raiseError(
|
||||
ObjException(
|
||||
core.sqlUsageException,
|
||||
scope.requireScope(),
|
||||
ObjString("execute(...) cannot be used with statements that return rows; use select(...)")
|
||||
)
|
||||
)
|
||||
}
|
||||
try {
|
||||
connection.prepareStatement(clause, Statement.RETURN_GENERATED_KEYS).use { statement ->
|
||||
bindParams(statement, params, scope, core)
|
||||
val hasResultSet = statement.execute()
|
||||
if (hasResultSet) {
|
||||
scope.raiseError(
|
||||
ObjException(
|
||||
core.sqlUsageException,
|
||||
scope.requireScope(),
|
||||
ObjString("execute(...) cannot be used with statements that return rows; use select(...)")
|
||||
)
|
||||
)
|
||||
}
|
||||
val generatedKeys = statement.generatedKeys.use { rs ->
|
||||
if (rs == null) emptyResultSet() else readResultSet(scope, core, rs)
|
||||
}
|
||||
return SqlExecutionResultData(statement.updateCount, generatedKeys)
|
||||
}
|
||||
} catch (e: SQLException) {
|
||||
throw mapSqlException(scope, core, e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T {
|
||||
val savepoint = try {
|
||||
connection.setSavepoint()
|
||||
} catch (e: SQLException) {
|
||||
throw mapSqlUsage(scope, core, "Nested transactions are not supported by this JDBC backend", e)
|
||||
}
|
||||
val nested = JdbcTransactionBackend(core, connection)
|
||||
val result = try {
|
||||
block(nested)
|
||||
} catch (e: Throwable) {
|
||||
throw finishFailedTransaction(scope, core, e) {
|
||||
rollbackToSavepointOrThrow(scope, core, connection, savepoint)
|
||||
releaseSavepointOrThrow(scope, core, connection, savepoint)
|
||||
}
|
||||
}
|
||||
try {
|
||||
connection.releaseSavepoint(savepoint)
|
||||
} catch (e: SQLException) {
|
||||
throw mapSqlException(scope, core, e)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun bindParams(statement: PreparedStatement, params: List<Obj>, scope: ScopeFacade, core: SqlCoreModule) {
|
||||
params.forEachIndexed { index, value ->
|
||||
val jdbcIndex = index + 1
|
||||
when (value) {
|
||||
ObjNull -> statement.setObject(jdbcIndex, null)
|
||||
is ObjBool -> statement.setBoolean(jdbcIndex, value.value)
|
||||
is ObjInt -> statement.setLong(jdbcIndex, value.value)
|
||||
is ObjReal -> statement.setDouble(jdbcIndex, value.value)
|
||||
is ObjString -> statement.setString(jdbcIndex, value.value)
|
||||
is ObjBuffer -> statement.setBytes(jdbcIndex, value.byteArray.toByteArray())
|
||||
is ObjDate -> statement.setObject(jdbcIndex, java.time.LocalDate.parse(value.date.toString()))
|
||||
is ObjDateTime -> statement.setObject(jdbcIndex, java.time.LocalDateTime.parse(value.localDateTime.toString()))
|
||||
is ObjInstant -> statement.setObject(jdbcIndex, java.time.Instant.parse(value.instant.toString()))
|
||||
else -> when (value.objClass.className) {
|
||||
"Decimal" -> statement.setBigDecimal(jdbcIndex, BigDecimal(scope.toStringOf(value).value))
|
||||
else -> scope.raiseError(
|
||||
ObjException(
|
||||
core.sqlUsageException,
|
||||
scope.requireScope(),
|
||||
ObjString("Unsupported JDBC parameter type: ${value.objClass.className}")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readResultSet(scope: ScopeFacade, core: SqlCoreModule, resultSet: ResultSet): SqlResultSetData {
|
||||
val meta = resultSet.metaData
|
||||
val columns = (1..meta.columnCount).map { index ->
|
||||
val nativeType = meta.getColumnTypeName(index) ?: ""
|
||||
SqlColumnMeta(
|
||||
name = meta.getColumnLabel(index),
|
||||
sqlType = mapSqlType(core, nativeType, meta.getColumnType(index)),
|
||||
nullable = meta.isNullable(index) != java.sql.ResultSetMetaData.columnNoNulls,
|
||||
nativeType = nativeType,
|
||||
)
|
||||
}
|
||||
val rows = mutableListOf<List<Obj>>()
|
||||
while (resultSet.next()) {
|
||||
rows += columns.mapIndexed { index, column ->
|
||||
readColumnValue(scope, core, resultSet, index + 1, column.nativeType)
|
||||
}
|
||||
}
|
||||
return SqlResultSetData(columns, rows)
|
||||
}
|
||||
|
||||
private suspend fun readColumnValue(
|
||||
scope: ScopeFacade,
|
||||
core: SqlCoreModule,
|
||||
resultSet: ResultSet,
|
||||
index: Int,
|
||||
nativeType: String,
|
||||
): Obj {
|
||||
val value = resultSet.getObject(index) ?: return ObjNull
|
||||
val normalizedNativeType = normalizeDeclaredTypeName(nativeType)
|
||||
return when (value) {
|
||||
is Boolean -> ObjBool(value)
|
||||
is Byte, is Short, is Int -> ObjInt.of((value as Number).toLong())
|
||||
is Long -> ObjInt.of(value)
|
||||
is Float, is Double -> ObjReal.of((value as Number).toDouble())
|
||||
is java.math.BigInteger -> ObjInt.of(value.longValueExact())
|
||||
is BigDecimal -> decimalFromString(scope, value.toPlainString())
|
||||
is ByteArray -> ObjBuffer(value.toUByteArray())
|
||||
is java.sql.Date -> ObjDate(LocalDate.parse(value.toLocalDate().toString()))
|
||||
is java.sql.Timestamp -> timestampValue(scope, normalizedNativeType, value.toInstant())
|
||||
is java.time.LocalDate -> ObjDate(LocalDate.parse(value.toString()))
|
||||
is java.time.LocalDateTime -> ObjDateTime(value.toString().let(LocalDateTime::parse).toInstant(TimeZone.UTC), TimeZone.UTC)
|
||||
is java.time.OffsetDateTime -> ObjInstant(Instant.parse(value.toInstant().toString()))
|
||||
is java.time.ZonedDateTime -> ObjInstant(Instant.parse(value.toInstant().toString()))
|
||||
is java.time.Instant -> ObjInstant(Instant.parse(value.toString()))
|
||||
is java.sql.Time -> ObjString(value.toLocalTime().toString())
|
||||
is java.time.LocalTime -> ObjString(value.toString())
|
||||
is java.time.OffsetTime -> ObjString(value.toString())
|
||||
is String -> stringValue(scope, normalizedNativeType, value)
|
||||
else -> ObjString(value.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun stringValue(scope: ScopeFacade, normalizedNativeType: String, value: String): Obj {
|
||||
return when {
|
||||
normalizedNativeType == "DATE" -> ObjDate(LocalDate.parse(value.trim()))
|
||||
normalizedNativeType == "TIMESTAMP WITH TIME ZONE" || normalizedNativeType == "TIMESTAMPTZ" ->
|
||||
ObjInstant(Instant.parse(value.trim()))
|
||||
normalizedNativeType == "TIMESTAMP" || normalizedNativeType == "DATETIME" ->
|
||||
ObjDateTime(LocalDateTime.parse(value.trim()).toInstant(TimeZone.UTC), TimeZone.UTC)
|
||||
normalizedNativeType == "DECIMAL" || normalizedNativeType == "NUMERIC" -> decimalFromString(scope, value.trim())
|
||||
else -> ObjString(value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun timestampValue(scope: ScopeFacade, normalizedNativeType: String, value: java.time.Instant): Obj {
|
||||
return if (normalizedNativeType == "TIMESTAMP WITH TIME ZONE" || normalizedNativeType == "TIMESTAMPTZ") {
|
||||
ObjInstant(Instant.parse(value.toString()))
|
||||
} else {
|
||||
val local = value.atOffset(java.time.ZoneOffset.UTC).toLocalDateTime().toString()
|
||||
ObjDateTime(LocalDateTime.parse(local).toInstant(TimeZone.UTC), TimeZone.UTC)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun decimalFromString(scope: ScopeFacade, value: String): Obj {
|
||||
val decimalModule = scope.requireScope().currentImportProvider.createModuleScope(scope.pos, "lyng.decimal")
|
||||
val decimalClass = decimalModule.requireClass("Decimal")
|
||||
return decimalClass.invokeInstanceMethod(scope.requireScope(), "fromString", ObjString(value))
|
||||
}
|
||||
|
||||
private fun normalizeDeclaredTypeName(nativeTypeName: String): String {
|
||||
val strippedSuffix = nativeTypeName.trim().replace(Regex("""\s*\(.*\)\s*$"""), "")
|
||||
return strippedSuffix.uppercase().replace(Regex("""\s+"""), " ").trim()
|
||||
}
|
||||
|
||||
private fun mapSqlType(core: SqlCoreModule, nativeTypeName: String, jdbcType: Int): ObjEnumEntry {
|
||||
val normalized = normalizeDeclaredTypeName(nativeTypeName)
|
||||
return when {
|
||||
normalized == "BOOLEAN" || normalized == "BOOL" -> core.sqlTypes.require("Bool")
|
||||
normalized == "DATE" -> core.sqlTypes.require("Date")
|
||||
normalized == "TIMESTAMP" || normalized == "DATETIME" -> core.sqlTypes.require("DateTime")
|
||||
normalized == "TIMESTAMP WITH TIME ZONE" || normalized == "TIMESTAMPTZ" -> core.sqlTypes.require("Instant")
|
||||
normalized == "TIME" || normalized == "TIME WITHOUT TIME ZONE" || normalized == "TIME WITH TIME ZONE" -> core.sqlTypes.require("String")
|
||||
normalized == "DECIMAL" || normalized == "NUMERIC" -> core.sqlTypes.require("Decimal")
|
||||
normalized.contains("BLOB") || normalized.contains("BINARY") || normalized == "BYTEA" -> core.sqlTypes.require("Binary")
|
||||
normalized.contains("INT") -> core.sqlTypes.require("Int")
|
||||
normalized.contains("CHAR") || normalized.contains("TEXT") || normalized.contains("CLOB") || normalized == "VARCHAR" -> core.sqlTypes.require("String")
|
||||
normalized.contains("REAL") || normalized.contains("FLOA") || normalized.contains("DOUB") -> core.sqlTypes.require("Double")
|
||||
jdbcType == java.sql.Types.BOOLEAN || jdbcType == java.sql.Types.BIT -> core.sqlTypes.require("Bool")
|
||||
jdbcType == java.sql.Types.DATE -> core.sqlTypes.require("Date")
|
||||
jdbcType == java.sql.Types.TIMESTAMP -> core.sqlTypes.require("DateTime")
|
||||
jdbcType == java.sql.Types.TIMESTAMP_WITH_TIMEZONE -> core.sqlTypes.require("Instant")
|
||||
jdbcType == java.sql.Types.TIME || jdbcType == java.sql.Types.TIME_WITH_TIMEZONE -> core.sqlTypes.require("String")
|
||||
jdbcType == java.sql.Types.BLOB || jdbcType == java.sql.Types.BINARY || jdbcType == java.sql.Types.VARBINARY || jdbcType == java.sql.Types.LONGVARBINARY -> core.sqlTypes.require("Binary")
|
||||
jdbcType == java.sql.Types.INTEGER || jdbcType == java.sql.Types.BIGINT || jdbcType == java.sql.Types.SMALLINT || jdbcType == java.sql.Types.TINYINT -> core.sqlTypes.require("Int")
|
||||
jdbcType == java.sql.Types.DECIMAL || jdbcType == java.sql.Types.NUMERIC -> core.sqlTypes.require("Decimal")
|
||||
jdbcType == java.sql.Types.FLOAT || jdbcType == java.sql.Types.REAL || jdbcType == java.sql.Types.DOUBLE -> core.sqlTypes.require("Double")
|
||||
else -> core.sqlTypes.require("String")
|
||||
}
|
||||
}
|
||||
|
||||
private fun emptyResultSet(): SqlResultSetData = SqlResultSetData(emptyList(), emptyList())
|
||||
|
||||
private fun containsRowReturningClause(clause: String): Boolean =
|
||||
Regex("""\b(returning|output)\b""", RegexOption.IGNORE_CASE).containsMatchIn(clause)
|
||||
|
||||
private fun ensureJdbcDriversLoaded(scope: ScopeFacade, core: SqlCoreModule, requestedDriverClass: String?) {
|
||||
for (driverClass in knownJdbcDrivers) {
|
||||
try {
|
||||
Class.forName(driverClass)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
val explicit = requestedDriverClass ?: return
|
||||
try {
|
||||
Class.forName(explicit)
|
||||
} catch (e: ClassNotFoundException) {
|
||||
throw ExecutionError(
|
||||
ObjException(core.databaseException, scope.requireScope(), ObjString("JDBC driver class not found: $explicit")),
|
||||
scope.pos,
|
||||
"JDBC driver class not found: $explicit",
|
||||
e,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rollbackOrThrow(scope: ScopeFacade, core: SqlCoreModule, connection: Connection) {
|
||||
try {
|
||||
connection.rollback()
|
||||
} catch (e: SQLException) {
|
||||
throw mapSqlException(scope, core, e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rollbackToSavepointOrThrow(
|
||||
scope: ScopeFacade,
|
||||
core: SqlCoreModule,
|
||||
connection: Connection,
|
||||
savepoint: java.sql.Savepoint,
|
||||
) {
|
||||
try {
|
||||
connection.rollback(savepoint)
|
||||
} catch (e: SQLException) {
|
||||
throw mapSqlException(scope, core, e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseSavepointOrThrow(
|
||||
scope: ScopeFacade,
|
||||
core: SqlCoreModule,
|
||||
connection: Connection,
|
||||
savepoint: java.sql.Savepoint,
|
||||
) {
|
||||
try {
|
||||
connection.releaseSavepoint(savepoint)
|
||||
} catch (e: SQLException) {
|
||||
throw mapSqlException(scope, core, e)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun finishFailedTransaction(
|
||||
scope: ScopeFacade,
|
||||
core: SqlCoreModule,
|
||||
failure: Throwable,
|
||||
rollback: () -> Unit,
|
||||
): Throwable {
|
||||
return try {
|
||||
rollback()
|
||||
failure
|
||||
} catch (rollbackFailure: Throwable) {
|
||||
if (isRollbackSignal(failure, core)) {
|
||||
attachSecondaryFailure(rollbackFailure, failure)
|
||||
rollbackFailure
|
||||
} else {
|
||||
attachSecondaryFailure(failure, rollbackFailure)
|
||||
failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isRollbackSignal(failure: Throwable, core: SqlCoreModule): Boolean {
|
||||
val errorObject = (failure as? ExecutionError)?.errorObject ?: return false
|
||||
return errorObject.isInstanceOf(core.rollbackException)
|
||||
}
|
||||
|
||||
private fun attachSecondaryFailure(primary: Throwable, secondary: Throwable) {
|
||||
if (primary === secondary) return
|
||||
primary.addSuppressed(secondary)
|
||||
}
|
||||
|
||||
private fun mapOpenException(scope: ScopeFacade, core: SqlCoreModule, e: SQLException): Nothing {
|
||||
val message = e.message ?: "JDBC open failed"
|
||||
if (e is SQLNonTransientConnectionException) {
|
||||
throw ExecutionError(
|
||||
ObjException(core.databaseException, scope.requireScope(), ObjString(message)),
|
||||
scope.pos,
|
||||
message,
|
||||
e,
|
||||
)
|
||||
}
|
||||
throw ExecutionError(
|
||||
ObjException(core.databaseException, scope.requireScope(), ObjString(message)),
|
||||
scope.pos,
|
||||
message,
|
||||
e,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapSqlException(scope: ScopeFacade, core: SqlCoreModule, e: SQLException): ExecutionError {
|
||||
val exceptionClass = when {
|
||||
e is SQLIntegrityConstraintViolationException -> core.sqlConstraintException
|
||||
e.sqlState?.startsWith("23") == true -> core.sqlConstraintException
|
||||
else -> core.sqlExecutionException
|
||||
}
|
||||
return ExecutionError(
|
||||
ObjException(exceptionClass, scope.requireScope(), ObjString(e.message ?: "JDBC error")),
|
||||
scope.pos,
|
||||
e.message ?: "JDBC error",
|
||||
e,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapSqlUsage(scope: ScopeFacade, core: SqlCoreModule, message: String, cause: Throwable? = null): ExecutionError {
|
||||
return ExecutionError(
|
||||
ObjException(core.sqlUsageException, scope.requireScope(), ObjString(message)),
|
||||
scope.pos,
|
||||
message,
|
||||
cause,
|
||||
)
|
||||
}
|
||||
@ -20,6 +20,12 @@ package net.sergeych.lyng.io.db.sqlite
|
||||
import net.sergeych.lyng.ExecutionError
|
||||
import net.sergeych.lyng.Arguments
|
||||
import net.sergeych.lyng.ScopeFacade
|
||||
import net.sergeych.lyng.io.db.SqlColumnMeta
|
||||
import net.sergeych.lyng.io.db.SqlCoreModule
|
||||
import net.sergeych.lyng.io.db.SqlDatabaseBackend
|
||||
import net.sergeych.lyng.io.db.SqlExecutionResultData
|
||||
import net.sergeych.lyng.io.db.SqlResultSetData
|
||||
import net.sergeych.lyng.io.db.SqlTransactionBackend
|
||||
import net.sergeych.lyng.obj.Obj
|
||||
import net.sergeych.lyng.obj.ObjBool
|
||||
import net.sergeych.lyng.obj.ObjBuffer
|
||||
@ -50,24 +56,24 @@ import kotlin.time.Instant
|
||||
|
||||
internal actual suspend fun openSqliteBackend(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
options: SqliteOpenOptions,
|
||||
): SqliteDatabaseBackend {
|
||||
): SqlDatabaseBackend {
|
||||
if (options.busyTimeoutMillis < 0) {
|
||||
scope.raiseIllegalArgument("busyTimeoutMillis must be >= 0")
|
||||
}
|
||||
return JdbcSqliteDatabaseBackend(core, options)
|
||||
return JdbcSqlDatabaseBackend(core, options)
|
||||
}
|
||||
|
||||
private class JdbcSqliteDatabaseBackend(
|
||||
private val core: SqliteCoreModule,
|
||||
private class JdbcSqlDatabaseBackend(
|
||||
private val core: SqlCoreModule,
|
||||
private val options: SqliteOpenOptions,
|
||||
) : SqliteDatabaseBackend {
|
||||
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T {
|
||||
) : SqlDatabaseBackend {
|
||||
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T {
|
||||
val connection = openConnection(scope)
|
||||
try {
|
||||
connection.autoCommit = false
|
||||
val tx = JdbcSqliteTransactionBackend(core, connection)
|
||||
val tx = JdbcSqlTransactionBackend(core, connection)
|
||||
val result = try {
|
||||
block(tx)
|
||||
} catch (e: Throwable) {
|
||||
@ -124,11 +130,11 @@ private class JdbcSqliteDatabaseBackend(
|
||||
}
|
||||
}
|
||||
|
||||
private class JdbcSqliteTransactionBackend(
|
||||
private val core: SqliteCoreModule,
|
||||
private class JdbcSqlTransactionBackend(
|
||||
private val core: SqlCoreModule,
|
||||
private val connection: Connection,
|
||||
) : SqliteTransactionBackend {
|
||||
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteResultSetData {
|
||||
) : SqlTransactionBackend {
|
||||
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqlResultSetData {
|
||||
try {
|
||||
connection.prepareStatement(clause).use { statement ->
|
||||
bindParams(statement, params, scope, core)
|
||||
@ -141,7 +147,7 @@ private class JdbcSqliteTransactionBackend(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun execute(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteExecutionResultData {
|
||||
override suspend fun execute(scope: ScopeFacade, clause: String, params: List<Obj>): SqlExecutionResultData {
|
||||
if (containsRowReturningClause(clause)) {
|
||||
scope.raiseError(
|
||||
ObjException(
|
||||
@ -172,20 +178,20 @@ private class JdbcSqliteTransactionBackend(
|
||||
readResultSet(scope, core, rs)
|
||||
}
|
||||
}
|
||||
return SqliteExecutionResultData(affected, generatedKeys)
|
||||
return SqlExecutionResultData(affected, generatedKeys)
|
||||
}
|
||||
} catch (e: SQLException) {
|
||||
throw mapSqlException(scope, core, e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T {
|
||||
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T {
|
||||
val savepoint = try {
|
||||
connection.setSavepoint()
|
||||
} catch (e: SQLException) {
|
||||
throw mapSqlUsage(scope, core, "Nested transactions are not supported by this SQLite backend", e)
|
||||
}
|
||||
val nested = JdbcSqliteTransactionBackend(core, connection)
|
||||
val nested = JdbcSqlTransactionBackend(core, connection)
|
||||
val result = try {
|
||||
block(nested)
|
||||
} catch (e: Throwable) {
|
||||
@ -203,7 +209,7 @@ private class JdbcSqliteTransactionBackend(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun bindParams(statement: PreparedStatement, params: List<Obj>, scope: ScopeFacade, core: SqliteCoreModule) {
|
||||
private suspend fun bindParams(statement: PreparedStatement, params: List<Obj>, scope: ScopeFacade, core: SqlCoreModule) {
|
||||
params.forEachIndexed { index, value ->
|
||||
val jdbcIndex = index + 1
|
||||
when (value) {
|
||||
@ -231,12 +237,12 @@ private suspend fun bindParams(statement: PreparedStatement, params: List<Obj>,
|
||||
|
||||
private suspend fun readResultSet(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
resultSet: ResultSet,
|
||||
): SqliteResultSetData {
|
||||
): SqlResultSetData {
|
||||
val meta = resultSet.metaData
|
||||
val columns = (1..meta.columnCount).map { index ->
|
||||
SqliteColumnMeta(
|
||||
SqlColumnMeta(
|
||||
name = meta.getColumnLabel(index),
|
||||
sqlType = mapSqlType(core, meta.getColumnTypeName(index), meta.getColumnType(index)),
|
||||
nullable = meta.isNullable(index) != java.sql.ResultSetMetaData.columnNoNulls,
|
||||
@ -249,12 +255,12 @@ private suspend fun readResultSet(
|
||||
readColumnValue(scope, core, resultSet, index + 1, column.nativeType)
|
||||
}
|
||||
}
|
||||
return SqliteResultSetData(columns, rows)
|
||||
return SqlResultSetData(columns, rows)
|
||||
}
|
||||
|
||||
private suspend fun readColumnValue(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
resultSet: ResultSet,
|
||||
index: Int,
|
||||
nativeType: String,
|
||||
@ -278,7 +284,7 @@ private suspend fun readColumnValue(
|
||||
|
||||
private fun convertIntegerValue(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
normalizedNativeType: String,
|
||||
value: Long,
|
||||
): Obj {
|
||||
@ -294,7 +300,7 @@ private fun convertIntegerValue(
|
||||
|
||||
private suspend fun convertStringValue(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
normalizedNativeType: String,
|
||||
value: String,
|
||||
): Obj {
|
||||
@ -317,7 +323,7 @@ private fun isDecimalNativeType(normalizedNativeType: String): Boolean =
|
||||
private fun isBooleanNativeType(normalizedNativeType: String): Boolean =
|
||||
normalizedNativeType == "BOOLEAN" || normalizedNativeType == "BOOL"
|
||||
|
||||
private fun booleanFromString(scope: ScopeFacade, core: SqliteCoreModule, value: String): Obj {
|
||||
private fun booleanFromString(scope: ScopeFacade, core: SqlCoreModule, value: String): Obj {
|
||||
return when (value.trim().lowercase()) {
|
||||
"true", "t" -> ObjBool(true)
|
||||
"false", "f" -> ObjBool(false)
|
||||
@ -331,7 +337,7 @@ private suspend fun decimalFromString(scope: ScopeFacade, value: String): Obj {
|
||||
return decimalClass.invokeInstanceMethod(scope.requireScope(), "fromString", ObjString(value))
|
||||
}
|
||||
|
||||
private fun dateTimeFromString(scope: ScopeFacade, core: SqliteCoreModule, value: String): ObjDateTime {
|
||||
private fun dateTimeFromString(scope: ScopeFacade, core: SqlCoreModule, value: String): ObjDateTime {
|
||||
val trimmed = value.trim()
|
||||
if (hasExplicitTimeZone(trimmed)) {
|
||||
sqlExecutionFailure(scope, core, "SQLite TIMESTAMP/DATETIME value must not contain a timezone offset: $value")
|
||||
@ -358,7 +364,7 @@ private fun normalizeDeclaredTypeName(nativeTypeName: String): String {
|
||||
return strippedSuffix.uppercase().replace(Regex("""\s+"""), " ").trim()
|
||||
}
|
||||
|
||||
private fun mapSqlType(core: SqliteCoreModule, nativeTypeName: String, jdbcType: Int): ObjEnumEntry {
|
||||
private fun mapSqlType(core: SqlCoreModule, nativeTypeName: String, jdbcType: Int): ObjEnumEntry {
|
||||
val normalized = normalizeDeclaredTypeName(nativeTypeName)
|
||||
return when {
|
||||
normalized == "BOOLEAN" || normalized == "BOOL" -> core.sqlTypes.require("Bool")
|
||||
@ -385,9 +391,9 @@ private fun mapSqlType(core: SqliteCoreModule, nativeTypeName: String, jdbcType:
|
||||
}
|
||||
}
|
||||
|
||||
private fun emptyResultSet(core: SqliteCoreModule): SqliteResultSetData = SqliteResultSetData(emptyList(), emptyList())
|
||||
private fun emptyResultSet(core: SqlCoreModule): SqlResultSetData = SqlResultSetData(emptyList(), emptyList())
|
||||
|
||||
private fun sqlExecutionFailure(scope: ScopeFacade, core: SqliteCoreModule, message: String): Nothing {
|
||||
private fun sqlExecutionFailure(scope: ScopeFacade, core: SqlCoreModule, message: String): Nothing {
|
||||
throw ExecutionError(
|
||||
ObjException(core.sqlExecutionException, scope.requireScope(), ObjString(message)),
|
||||
scope.pos,
|
||||
@ -395,7 +401,7 @@ private fun sqlExecutionFailure(scope: ScopeFacade, core: SqliteCoreModule, mess
|
||||
)
|
||||
}
|
||||
|
||||
private fun rollbackOrThrow(scope: ScopeFacade, core: SqliteCoreModule, connection: Connection) {
|
||||
private fun rollbackOrThrow(scope: ScopeFacade, core: SqlCoreModule, connection: Connection) {
|
||||
try {
|
||||
connection.rollback()
|
||||
} catch (e: SQLException) {
|
||||
@ -405,7 +411,7 @@ private fun rollbackOrThrow(scope: ScopeFacade, core: SqliteCoreModule, connecti
|
||||
|
||||
private fun rollbackToSavepointOrThrow(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
connection: Connection,
|
||||
savepoint: java.sql.Savepoint,
|
||||
) {
|
||||
@ -418,7 +424,7 @@ private fun rollbackToSavepointOrThrow(
|
||||
|
||||
private fun releaseSavepointOrThrow(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
connection: Connection,
|
||||
savepoint: java.sql.Savepoint,
|
||||
) {
|
||||
@ -431,7 +437,7 @@ private fun releaseSavepointOrThrow(
|
||||
|
||||
private inline fun finishFailedTransaction(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
failure: Throwable,
|
||||
rollback: () -> Unit,
|
||||
): Throwable {
|
||||
@ -449,7 +455,7 @@ private inline fun finishFailedTransaction(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isRollbackSignal(failure: Throwable, core: SqliteCoreModule): Boolean {
|
||||
private fun isRollbackSignal(failure: Throwable, core: SqlCoreModule): Boolean {
|
||||
val errorObject = (failure as? ExecutionError)?.errorObject ?: return false
|
||||
return errorObject.isInstanceOf(core.rollbackException)
|
||||
}
|
||||
@ -459,7 +465,7 @@ private fun attachSecondaryFailure(primary: Throwable, secondary: Throwable) {
|
||||
primary.addSuppressed(secondary)
|
||||
}
|
||||
|
||||
private fun mapOpenException(scope: ScopeFacade, core: SqliteCoreModule, e: SQLException): Nothing {
|
||||
private fun mapOpenException(scope: ScopeFacade, core: SqlCoreModule, e: SQLException): Nothing {
|
||||
val message = e.message ?: "SQLite open failed"
|
||||
val lower = message.lowercase()
|
||||
if ("malformed" in lower || "no such access mode" in lower || "invalid uri" in lower) {
|
||||
@ -473,7 +479,7 @@ private fun mapOpenException(scope: ScopeFacade, core: SqliteCoreModule, e: SQLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapSqlException(scope: ScopeFacade, core: SqliteCoreModule, e: SQLException): ExecutionError {
|
||||
private fun mapSqlException(scope: ScopeFacade, core: SqlCoreModule, e: SQLException): ExecutionError {
|
||||
val code = SQLiteErrorCode.getErrorCode(e.errorCode)
|
||||
val exceptionClass = when (code) {
|
||||
SQLiteErrorCode.SQLITE_CONSTRAINT,
|
||||
@ -491,7 +497,7 @@ private fun mapSqlException(scope: ScopeFacade, core: SqliteCoreModule, e: SQLEx
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapSqlUsage(scope: ScopeFacade, core: SqliteCoreModule, message: String, cause: Throwable? = null): ExecutionError {
|
||||
private fun mapSqlUsage(scope: ScopeFacade, core: SqlCoreModule, message: String, cause: Throwable? = null): ExecutionError {
|
||||
return ExecutionError(
|
||||
ObjException(core.sqlUsageException, scope.requireScope(), ObjString(message)),
|
||||
scope.pos,
|
||||
|
||||
@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng.io.db.jdbc
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import net.sergeych.lyng.Compiler
|
||||
import net.sergeych.lyng.Pos
|
||||
import net.sergeych.lyng.Script
|
||||
import net.sergeych.lyng.Source
|
||||
import net.sergeych.lyng.obj.Obj
|
||||
import net.sergeych.lyng.obj.ObjExternCallable
|
||||
import net.sergeych.lyng.obj.ObjInt
|
||||
import net.sergeych.lyng.obj.ObjMap
|
||||
import net.sergeych.lyng.obj.ObjNull
|
||||
import net.sergeych.lyng.obj.ObjString
|
||||
import net.sergeych.lyng.obj.requiredArg
|
||||
import net.sergeych.lyng.requireScope
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class LyngJdbcModuleTest {
|
||||
|
||||
@Test
|
||||
fun testTypedOpenH2ExecutesQueriesAndGeneratedKeys() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createJdbcModule(scope.importManager)
|
||||
val jdbcModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.jdbc")
|
||||
val db = jdbcModule.callFn("openH2", ObjString("mem:typed_h2_${System.nanoTime()};DB_CLOSE_DELAY=-1"))
|
||||
|
||||
val insertedId = db.invokeInstanceMethod(
|
||||
scope,
|
||||
"transaction",
|
||||
ObjExternCallable.fromBridge {
|
||||
val tx = requiredArg<Obj>(0)
|
||||
tx.invokeInstanceMethod(
|
||||
requireScope(),
|
||||
"execute",
|
||||
ObjString("create table person(id bigint auto_increment primary key, name varchar(120) not null)")
|
||||
)
|
||||
val result = tx.invokeInstanceMethod(
|
||||
requireScope(),
|
||||
"execute",
|
||||
ObjString("insert into person(name) values(?)"),
|
||||
ObjString("Ada")
|
||||
)
|
||||
val rows = result.invokeInstanceMethod(requireScope(), "getGeneratedKeys")
|
||||
.invokeInstanceMethod(requireScope(), "toList")
|
||||
rows.getAt(requireScope(), ObjInt.Zero).getAt(requireScope(), ObjInt.Zero)
|
||||
}
|
||||
) as ObjInt
|
||||
|
||||
assertEquals(1L, insertedId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGenericOpenDatabaseUsesJdbcAndH2AliasProviders() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createJdbcModule(scope.importManager)
|
||||
scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.jdbc")
|
||||
val dbModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db")
|
||||
|
||||
val genericJdbcDb = dbModule.callFn(
|
||||
"openDatabase",
|
||||
ObjString("jdbc:h2:mem:generic_jdbc_${System.nanoTime()};DB_CLOSE_DELAY=-1"),
|
||||
emptyMapObj()
|
||||
)
|
||||
val h2AliasDb = dbModule.callFn(
|
||||
"openDatabase",
|
||||
ObjString("h2:mem:generic_alias_${System.nanoTime()};DB_CLOSE_DELAY=-1"),
|
||||
emptyMapObj()
|
||||
)
|
||||
|
||||
assertEquals(42L, scalarSelect(scope, genericJdbcDb, "select 42 as answer"))
|
||||
assertEquals(7L, scalarSelect(scope, h2AliasDb, "select 7 as answer"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testImportedJdbcOpenersPreserveDeclaredReturnTypesForInference() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createJdbcModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db
|
||||
import lyng.io.db.jdbc
|
||||
|
||||
val h2db = openH2("mem:inference_demo;DB_CLOSE_DELAY=-1")
|
||||
h2db.transaction { 1 }
|
||||
|
||||
val jdbcDb = openJdbc("jdbc:h2:mem:inference_demo_2;DB_CLOSE_DELAY=-1")
|
||||
jdbcDb.transaction { 2 }
|
||||
|
||||
val genericDb = openDatabase("jdbc:h2:mem:inference_demo_3;DB_CLOSE_DELAY=-1", Map())
|
||||
genericDb.transaction { 3 }
|
||||
""".trimIndent()
|
||||
|
||||
val result = Compiler.compile(Source("<jdbc-inference>", code), scope.importManager).execute(scope) as ObjInt
|
||||
assertEquals(3L, result.value)
|
||||
}
|
||||
|
||||
private suspend fun scalarSelect(scope: net.sergeych.lyng.Scope, db: Obj, sql: String): Long {
|
||||
val result = db.invokeInstanceMethod(
|
||||
scope,
|
||||
"transaction",
|
||||
ObjExternCallable.fromBridge {
|
||||
val tx = requiredArg<Obj>(0)
|
||||
val rows = tx.invokeInstanceMethod(requireScope(), "select", ObjString(sql))
|
||||
.invokeInstanceMethod(requireScope(), "toList")
|
||||
rows.getAt(requireScope(), ObjInt.Zero).getAt(requireScope(), ObjString("answer"))
|
||||
}
|
||||
) as ObjInt
|
||||
return result.value
|
||||
}
|
||||
|
||||
private suspend fun net.sergeych.lyng.ModuleScope.callFn(name: String, vararg args: Obj): Obj {
|
||||
val callee = get(name)?.value ?: error("Missing $name in module")
|
||||
return callee.invoke(this, ObjNull, *args)
|
||||
}
|
||||
|
||||
private fun emptyMapObj(): Obj = ObjMap()
|
||||
}
|
||||
@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng.io.db.jdbc
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import net.sergeych.lyng.Pos
|
||||
import net.sergeych.lyng.Script
|
||||
import net.sergeych.lyng.obj.Obj
|
||||
import net.sergeych.lyng.obj.ObjExternCallable
|
||||
import net.sergeych.lyng.obj.ObjInt
|
||||
import net.sergeych.lyng.obj.ObjMap
|
||||
import net.sergeych.lyng.obj.ObjNull
|
||||
import net.sergeych.lyng.obj.ObjString
|
||||
import net.sergeych.lyng.obj.requiredArg
|
||||
import net.sergeych.lyng.requireScope
|
||||
import org.testcontainers.containers.PostgreSQLContainer
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class LyngJdbcPostgresContainerTest {
|
||||
|
||||
@Test
|
||||
fun testOpenPostgresAgainstContainer() = runTest {
|
||||
PostgreSQLContainer("postgres:16-alpine").use { postgres ->
|
||||
postgres.start()
|
||||
|
||||
val scope = Script.newScope()
|
||||
createJdbcModule(scope.importManager)
|
||||
val jdbcModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.jdbc")
|
||||
val db = jdbcModule.callFn(
|
||||
"openPostgres",
|
||||
ObjString(postgres.jdbcUrl),
|
||||
ObjString(postgres.username),
|
||||
ObjString(postgres.password)
|
||||
)
|
||||
|
||||
val count = rowCount(scope, db, "people", "Ada", "Linus")
|
||||
assertEquals(2L, count)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGenericPostgresAliasAgainstContainer() = runTest {
|
||||
PostgreSQLContainer("postgres:16-alpine").use { postgres ->
|
||||
postgres.start()
|
||||
|
||||
val scope = Script.newScope()
|
||||
createJdbcModule(scope.importManager)
|
||||
scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.jdbc")
|
||||
val dbModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db")
|
||||
val aliasUrl = postgres.jdbcUrl.removePrefix("jdbc:postgresql:")
|
||||
val db = dbModule.callFn(
|
||||
"openDatabase",
|
||||
ObjString("postgres:$aliasUrl"),
|
||||
mapOfStrings(
|
||||
"user" to postgres.username,
|
||||
"password" to postgres.password
|
||||
)
|
||||
)
|
||||
|
||||
val count = rowCount(scope, db, "pets", "Milo", "Otis", "Pixel")
|
||||
assertEquals(3L, count)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun rowCount(scope: net.sergeych.lyng.Scope, db: Obj, table: String, vararg names: String): Long {
|
||||
val result = db.invokeInstanceMethod(
|
||||
scope,
|
||||
"transaction",
|
||||
ObjExternCallable.fromBridge {
|
||||
val tx = requiredArg<Obj>(0)
|
||||
tx.invokeInstanceMethod(
|
||||
requireScope(),
|
||||
"execute",
|
||||
ObjString("create table $table(id bigserial primary key, name text not null)")
|
||||
)
|
||||
for (name in names) {
|
||||
tx.invokeInstanceMethod(
|
||||
requireScope(),
|
||||
"execute",
|
||||
ObjString("insert into $table(name) values(?)"),
|
||||
ObjString(name)
|
||||
)
|
||||
}
|
||||
val rows = tx.invokeInstanceMethod(
|
||||
requireScope(),
|
||||
"select",
|
||||
ObjString("select count(*) as count from $table")
|
||||
).invokeInstanceMethod(requireScope(), "toList")
|
||||
rows.getAt(requireScope(), ObjInt.Zero).getAt(requireScope(), ObjString("count"))
|
||||
}
|
||||
) as ObjInt
|
||||
return result.value
|
||||
}
|
||||
|
||||
private suspend fun mapOfStrings(vararg entries: Pair<String, String>): Obj {
|
||||
val map = ObjMap()
|
||||
for ((key, value) in entries) {
|
||||
map.map[ObjString(key)] = ObjString(value)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
private suspend fun net.sergeych.lyng.ModuleScope.callFn(name: String, vararg args: Obj): Obj {
|
||||
val callee = get(name)?.value ?: error("Missing $name in module")
|
||||
return callee.invoke(this, ObjNull, *args)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -41,6 +41,12 @@ import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import net.sergeych.lyng.ExecutionError
|
||||
import net.sergeych.lyng.ScopeFacade
|
||||
import net.sergeych.lyng.io.db.SqlColumnMeta
|
||||
import net.sergeych.lyng.io.db.SqlCoreModule
|
||||
import net.sergeych.lyng.io.db.SqlDatabaseBackend
|
||||
import net.sergeych.lyng.io.db.SqlExecutionResultData
|
||||
import net.sergeych.lyng.io.db.SqlResultSetData
|
||||
import net.sergeych.lyng.io.db.SqlTransactionBackend
|
||||
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_BLOB
|
||||
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_CONSTRAINT
|
||||
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_DONE
|
||||
@ -100,25 +106,25 @@ import kotlin.time.Instant
|
||||
|
||||
internal actual suspend fun openSqliteBackend(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
options: SqliteOpenOptions,
|
||||
): SqliteDatabaseBackend {
|
||||
): SqlDatabaseBackend {
|
||||
if (options.busyTimeoutMillis < 0) {
|
||||
scope.raiseIllegalArgument("busyTimeoutMillis must be >= 0")
|
||||
}
|
||||
return NativeSqliteDatabaseBackend(core, options)
|
||||
return NativeSqlDatabaseBackend(core, options)
|
||||
}
|
||||
|
||||
private class NativeSqliteDatabaseBackend(
|
||||
private val core: SqliteCoreModule,
|
||||
private class NativeSqlDatabaseBackend(
|
||||
private val core: SqlCoreModule,
|
||||
private val options: SqliteOpenOptions,
|
||||
) : SqliteDatabaseBackend {
|
||||
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T {
|
||||
) : SqlDatabaseBackend {
|
||||
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T {
|
||||
val handle = openHandle(scope, core, options)
|
||||
val savepoints = SavepointCounter()
|
||||
try {
|
||||
handle.execUnit(scope, core, "begin")
|
||||
val tx = NativeSqliteTransactionBackend(core, handle, savepoints)
|
||||
val tx = NativeSqlTransactionBackend(core, handle, savepoints)
|
||||
val result = try {
|
||||
block(tx)
|
||||
} catch (e: Throwable) {
|
||||
@ -134,23 +140,23 @@ private class NativeSqliteDatabaseBackend(
|
||||
}
|
||||
}
|
||||
|
||||
private class NativeSqliteTransactionBackend(
|
||||
private val core: SqliteCoreModule,
|
||||
private class NativeSqlTransactionBackend(
|
||||
private val core: SqlCoreModule,
|
||||
private val handle: NativeSqliteHandle,
|
||||
private val savepoints: SavepointCounter,
|
||||
) : SqliteTransactionBackend {
|
||||
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteResultSetData {
|
||||
) : SqlTransactionBackend {
|
||||
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqlResultSetData {
|
||||
return handle.select(scope, core, clause, params)
|
||||
}
|
||||
|
||||
override suspend fun execute(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteExecutionResultData {
|
||||
override suspend fun execute(scope: ScopeFacade, clause: String, params: List<Obj>): SqlExecutionResultData {
|
||||
return handle.execute(scope, core, clause, params)
|
||||
}
|
||||
|
||||
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T {
|
||||
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqlTransactionBackend) -> T): T {
|
||||
val savepoint = "lyng_sp_${savepoints.next()}"
|
||||
handle.execUnit(scope, core, "savepoint $savepoint")
|
||||
val nested = NativeSqliteTransactionBackend(core, handle, savepoints)
|
||||
val nested = NativeSqlTransactionBackend(core, handle, savepoints)
|
||||
val result = try {
|
||||
block(nested)
|
||||
} catch (e: Throwable) {
|
||||
@ -178,10 +184,10 @@ private class NativeSqliteHandle(
|
||||
) {
|
||||
suspend fun select(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
clause: String,
|
||||
params: List<Obj>,
|
||||
): SqliteResultSetData = memScoped {
|
||||
): SqlResultSetData = memScoped {
|
||||
val stmt = prepare(scope, core, clause)
|
||||
try {
|
||||
bindParams(scope, core, stmt, params, this)
|
||||
@ -193,10 +199,10 @@ private class NativeSqliteHandle(
|
||||
|
||||
suspend fun execute(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
clause: String,
|
||||
params: List<Obj>,
|
||||
): SqliteExecutionResultData = memScoped {
|
||||
): SqlExecutionResultData = memScoped {
|
||||
if (containsRowReturningClause(clause)) {
|
||||
raiseExecuteReturningUsage(scope, core)
|
||||
}
|
||||
@ -207,7 +213,7 @@ private class NativeSqliteHandle(
|
||||
SQLITE_DONE -> {
|
||||
val affectedRows = sqlite3_changes(db)
|
||||
val generatedKeys = readGeneratedKeys(core, clause, affectedRows)
|
||||
SqliteExecutionResultData(affectedRows, generatedKeys)
|
||||
SqlExecutionResultData(affectedRows, generatedKeys)
|
||||
}
|
||||
SQLITE_ROW -> raiseExecuteReturningUsage(scope, core)
|
||||
else -> throw sqlError(scope, core, rc)
|
||||
@ -218,7 +224,7 @@ private class NativeSqliteHandle(
|
||||
}
|
||||
}
|
||||
|
||||
fun execUnit(scope: ScopeFacade, core: SqliteCoreModule, sql: String) {
|
||||
fun execUnit(scope: ScopeFacade, core: SqlCoreModule, sql: String) {
|
||||
memScoped {
|
||||
val stmt = prepare(scope, core, sql)
|
||||
try {
|
||||
@ -236,13 +242,13 @@ private class NativeSqliteHandle(
|
||||
sqlite3_close_v2(db)
|
||||
}
|
||||
|
||||
private fun MemScope.prepare(scope: ScopeFacade, core: SqliteCoreModule, sql: String): CPointer<sqlite3_stmt> {
|
||||
private fun MemScope.prepare(scope: ScopeFacade, core: SqlCoreModule, sql: String): CPointer<sqlite3_stmt> {
|
||||
return lyng_sqlite3_prepare(db, sql) ?: throw sqlError(scope, core, sqlite3_extended_errcode(db))
|
||||
}
|
||||
|
||||
private suspend fun bindParams(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
stmt: CPointer<sqlite3_stmt>,
|
||||
params: List<Obj>,
|
||||
memScope: MemScope,
|
||||
@ -308,13 +314,13 @@ private class NativeSqliteHandle(
|
||||
|
||||
private suspend fun readResultSet(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
stmt: CPointer<sqlite3_stmt>,
|
||||
): SqliteResultSetData {
|
||||
): SqlResultSetData {
|
||||
val columnCount = sqlite3_column_count(stmt)
|
||||
val columns = (0 until columnCount).map { index ->
|
||||
val nativeType = sqlite3_column_decltype(stmt, index)?.toKString().orEmpty()
|
||||
SqliteColumnMeta(
|
||||
SqlColumnMeta(
|
||||
name = sqlite3_column_name(stmt, index)?.toKString().orEmpty(),
|
||||
sqlType = mapSqlType(core, nativeType, SQLITE_NULL),
|
||||
nullable = true,
|
||||
@ -338,7 +344,7 @@ private class NativeSqliteHandle(
|
||||
}
|
||||
rows += row
|
||||
}
|
||||
SQLITE_DONE -> return SqliteResultSetData(columns, rows)
|
||||
SQLITE_DONE -> return SqlResultSetData(columns, rows)
|
||||
else -> throw sqlError(scope, core, rc)
|
||||
}
|
||||
}
|
||||
@ -346,7 +352,7 @@ private class NativeSqliteHandle(
|
||||
|
||||
private suspend fun readColumnValue(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
stmt: CPointer<sqlite3_stmt>,
|
||||
index: Int,
|
||||
nativeType: String,
|
||||
@ -383,7 +389,7 @@ private class NativeSqliteHandle(
|
||||
|
||||
private suspend fun convertStringValue(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
normalizedNativeType: String,
|
||||
value: String,
|
||||
): Obj {
|
||||
@ -401,16 +407,16 @@ private class NativeSqliteHandle(
|
||||
}
|
||||
|
||||
private fun readGeneratedKeys(
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
clause: String,
|
||||
affectedRows: Int,
|
||||
): SqliteResultSetData {
|
||||
): SqlResultSetData {
|
||||
if (affectedRows <= 0 || !looksLikeInsert(clause)) {
|
||||
return emptyResultSet()
|
||||
}
|
||||
return SqliteResultSetData(
|
||||
return SqlResultSetData(
|
||||
columns = listOf(
|
||||
SqliteColumnMeta(
|
||||
SqlColumnMeta(
|
||||
name = "generated_key",
|
||||
sqlType = core.sqlTypes.require("Int"),
|
||||
nullable = false,
|
||||
@ -425,7 +431,7 @@ private class NativeSqliteHandle(
|
||||
return sqlite3_column_text(stmt, index)?.reinterpret<ByteVar>()?.toKString().orEmpty()
|
||||
}
|
||||
|
||||
private fun sqlError(scope: ScopeFacade, core: SqliteCoreModule, rc: Int): ExecutionError {
|
||||
private fun sqlError(scope: ScopeFacade, core: SqlCoreModule, rc: Int): ExecutionError {
|
||||
val code = sqlite3_extended_errcode(db)
|
||||
val message = sqlite3_errmsg(db)?.toKString() ?: "SQLite error ($rc)"
|
||||
val exceptionClass = if ((code and 0xff) == SQLITE_CONSTRAINT) core.sqlConstraintException else core.sqlExecutionException
|
||||
@ -439,7 +445,7 @@ private class NativeSqliteHandle(
|
||||
|
||||
private fun openHandle(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
options: SqliteOpenOptions,
|
||||
): NativeSqliteHandle = memScoped {
|
||||
val flags = buildOpenFlags(options)
|
||||
@ -488,9 +494,9 @@ private fun looksLikeInsert(clause: String): Boolean = clause.trimStart().starts
|
||||
|
||||
private val SQLITE_TRANSIENT = (-1L).toCPointer<CFunction<(COpaquePointer?) -> Unit>>()
|
||||
|
||||
private fun emptyResultSet(): SqliteResultSetData = SqliteResultSetData(emptyList(), emptyList())
|
||||
private fun emptyResultSet(): SqlResultSetData = SqlResultSetData(emptyList(), emptyList())
|
||||
|
||||
private fun mapSqlType(core: SqliteCoreModule, nativeType: String, sqliteType: Int): ObjEnumEntry = when (val normalized = normalizeDeclaredTypeName(nativeType)) {
|
||||
private fun mapSqlType(core: SqlCoreModule, nativeType: String, sqliteType: Int): ObjEnumEntry = when (val normalized = normalizeDeclaredTypeName(nativeType)) {
|
||||
"BOOLEAN", "BOOL" -> core.sqlTypes.require("Bool")
|
||||
"DATE" -> core.sqlTypes.require("Date")
|
||||
"DATETIME", "TIMESTAMP" -> core.sqlTypes.require("DateTime")
|
||||
@ -521,7 +527,7 @@ private suspend fun decimalFromString(scope: ScopeFacade, value: String): Obj {
|
||||
return decimalClass.invokeInstanceMethod(scope.requireScope(), "fromString", ObjString(value))
|
||||
}
|
||||
|
||||
private fun dateTimeFromString(scope: ScopeFacade, core: SqliteCoreModule, value: String): ObjDateTime {
|
||||
private fun dateTimeFromString(scope: ScopeFacade, core: SqlCoreModule, value: String): ObjDateTime {
|
||||
val trimmed = value.trim()
|
||||
if (hasExplicitTimeZone(trimmed)) {
|
||||
throw sqlExecutionError(scope, core, "SQLite TIMESTAMP/DATETIME value must not contain a timezone offset: $value")
|
||||
@ -540,7 +546,7 @@ private fun hasExplicitTimeZone(value: String): Boolean {
|
||||
return offsetStart > tIndex
|
||||
}
|
||||
|
||||
private fun raiseExecuteReturningUsage(scope: ScopeFacade, core: SqliteCoreModule): Nothing {
|
||||
private fun raiseExecuteReturningUsage(scope: ScopeFacade, core: SqlCoreModule): Nothing {
|
||||
scope.raiseError(
|
||||
ObjException(
|
||||
core.sqlUsageException,
|
||||
@ -550,7 +556,7 @@ private fun raiseExecuteReturningUsage(scope: ScopeFacade, core: SqliteCoreModul
|
||||
)
|
||||
}
|
||||
|
||||
private fun usageError(scope: ScopeFacade, core: SqliteCoreModule, message: String): ExecutionError {
|
||||
private fun usageError(scope: ScopeFacade, core: SqlCoreModule, message: String): ExecutionError {
|
||||
return ExecutionError(
|
||||
ObjException(core.sqlUsageException, scope.requireScope(), ObjString(message)),
|
||||
scope.pos,
|
||||
@ -558,7 +564,7 @@ private fun usageError(scope: ScopeFacade, core: SqliteCoreModule, message: Stri
|
||||
)
|
||||
}
|
||||
|
||||
private fun databaseError(scope: ScopeFacade, core: SqliteCoreModule, message: String): ExecutionError {
|
||||
private fun databaseError(scope: ScopeFacade, core: SqlCoreModule, message: String): ExecutionError {
|
||||
return ExecutionError(
|
||||
ObjException(core.databaseException, scope.requireScope(), ObjString(message)),
|
||||
scope.pos,
|
||||
@ -566,14 +572,14 @@ private fun databaseError(scope: ScopeFacade, core: SqliteCoreModule, message: S
|
||||
)
|
||||
}
|
||||
|
||||
private fun integerToBool(scope: ScopeFacade, core: SqliteCoreModule, value: Long): Obj =
|
||||
private fun integerToBool(scope: ScopeFacade, core: SqlCoreModule, value: Long): Obj =
|
||||
when (value) {
|
||||
0L -> ObjBool(false)
|
||||
1L -> ObjBool(true)
|
||||
else -> throw sqlExecutionError(scope, core, "Invalid SQLite boolean value: $value")
|
||||
}
|
||||
|
||||
private fun stringToBool(scope: ScopeFacade, core: SqliteCoreModule, value: String): Obj =
|
||||
private fun stringToBool(scope: ScopeFacade, core: SqlCoreModule, value: String): Obj =
|
||||
when (value.trim().lowercase()) {
|
||||
"true", "t" -> ObjBool(true)
|
||||
"false", "f" -> ObjBool(false)
|
||||
@ -585,7 +591,7 @@ private fun normalizeDeclaredTypeName(nativeTypeName: String): String {
|
||||
return strippedSuffix.uppercase().replace(Regex("""\s+"""), " ").trim()
|
||||
}
|
||||
|
||||
private fun sqlExecutionError(scope: ScopeFacade, core: SqliteCoreModule, message: String): ExecutionError {
|
||||
private fun sqlExecutionError(scope: ScopeFacade, core: SqlCoreModule, message: String): ExecutionError {
|
||||
return ExecutionError(
|
||||
ObjException(core.sqlExecutionException, scope.requireScope(), ObjString(message)),
|
||||
scope.pos,
|
||||
@ -595,7 +601,7 @@ private fun sqlExecutionError(scope: ScopeFacade, core: SqliteCoreModule, messag
|
||||
|
||||
private inline fun finishFailedTransaction(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
core: SqlCoreModule,
|
||||
failure: Throwable,
|
||||
rollback: () -> Unit,
|
||||
): Throwable {
|
||||
@ -613,7 +619,7 @@ private inline fun finishFailedTransaction(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isRollbackSignal(failure: Throwable, core: SqliteCoreModule): Boolean {
|
||||
private fun isRollbackSignal(failure: Throwable, core: SqlCoreModule): Boolean {
|
||||
val errorObject = (failure as? ExecutionError)?.errorObject ?: return false
|
||||
return errorObject.isInstanceOf(core.rollbackException)
|
||||
}
|
||||
|
||||
57
lyngio/stdlib/lyng/io/db_jdbc.lyng
Normal file
57
lyngio/stdlib/lyng/io/db_jdbc.lyng
Normal file
@ -0,0 +1,57 @@
|
||||
package lyng.io.db.jdbc
|
||||
|
||||
import lyng.io.db
|
||||
|
||||
/*
|
||||
Generic JDBC provider for `lyng.io.db` on the JVM target.
|
||||
|
||||
Importing this module registers:
|
||||
- `jdbc:` for raw JDBC URLs such as `jdbc:h2:mem:test`
|
||||
- `h2:` as a shorthand alias for `jdbc:h2:...`
|
||||
- `postgres:` / `postgresql:` as shorthand aliases for `jdbc:postgresql:...`
|
||||
|
||||
The JVM artifact bundles and explicitly loads the SQLite, H2, and
|
||||
PostgreSQL JDBC drivers, so `openJdbc(...)`, `openH2(...)`, and
|
||||
`openPostgres(...)` work under the standard `jlyng` launcher without extra
|
||||
classpath setup.
|
||||
|
||||
Supported `openDatabase(..., extraParams)` keys for JDBC:
|
||||
- `driverClass: String`
|
||||
- `user: String`
|
||||
- `password: String`
|
||||
- `properties: Map<String, Object?>`
|
||||
|
||||
The optional `properties` map is converted to JDBC connection properties by
|
||||
stringifying each non-null value.
|
||||
*/
|
||||
extern fun openJdbc(
|
||||
connectionUrl: String,
|
||||
user: String? = null,
|
||||
password: String? = null,
|
||||
driverClass: String? = null,
|
||||
properties: Map<String, Object?>? = null
|
||||
): Database
|
||||
|
||||
/*
|
||||
Open an H2 database. `connectionUrl` may be a full JDBC URL such as
|
||||
`jdbc:h2:mem:test`, or the shorter H2-specific tail such as
|
||||
`mem:test;DB_CLOSE_DELAY=-1`.
|
||||
*/
|
||||
extern fun openH2(
|
||||
connectionUrl: String,
|
||||
user: String? = null,
|
||||
password: String? = null,
|
||||
properties: Map<String, Object?>? = null
|
||||
): Database
|
||||
|
||||
/*
|
||||
Open a PostgreSQL database. `connectionUrl` may be a full JDBC URL such as
|
||||
`jdbc:postgresql://localhost/app`, or the shorter provider tail such as
|
||||
`//localhost/app`.
|
||||
*/
|
||||
extern fun openPostgres(
|
||||
connectionUrl: String,
|
||||
user: String? = null,
|
||||
password: String? = null,
|
||||
properties: Map<String, Object?>? = null
|
||||
): Database
|
||||
Loading…
x
Reference in New Issue
Block a user