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
|
test_output*.txt
|
||||||
/site/src/version-template/lyng-version.js
|
/site/src/version-template/lyng-version.js
|
||||||
/bugcontents.db
|
/bugcontents.db
|
||||||
|
/bugs/
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
### lyng.io.db — SQL database access for Lyng scripts
|
### 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.
|
> **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(...)`.
|
`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
|
#### 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:
|
Nested transactions use real savepoint semantics:
|
||||||
|
|
||||||
```lyng
|
```lyng
|
||||||
@ -217,6 +296,76 @@ Open-time validation failures:
|
|||||||
- malformed URL or bad option shape -> `IllegalArgumentException`
|
- malformed URL or bad option shape -> `IllegalArgumentException`
|
||||||
- runtime open failure -> `DatabaseException`
|
- 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
|
#### 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` — 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.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).
|
For the broader I/O overview, see [lyngio overview](lyngio.md).
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
#### Included Modules
|
#### 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.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.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.
|
- **[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
|
```kotlin
|
||||||
import net.sergeych.lyng.EvalSession
|
import net.sergeych.lyng.EvalSession
|
||||||
import net.sergeych.lyng.io.db.createDbModule
|
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.db.sqlite.createSqliteModule
|
||||||
import net.sergeych.lyng.io.fs.createFs
|
import net.sergeych.lyng.io.fs.createFs
|
||||||
import net.sergeych.lyng.io.process.createProcessModule
|
import net.sergeych.lyng.io.process.createProcessModule
|
||||||
@ -65,6 +66,7 @@ suspend fun runMyScript() {
|
|||||||
|
|
||||||
// Install modules with policies
|
// Install modules with policies
|
||||||
createDbModule(scope)
|
createDbModule(scope)
|
||||||
|
createJdbcModule(scope)
|
||||||
createSqliteModule(scope)
|
createSqliteModule(scope)
|
||||||
createFs(PermitAllAccessPolicy, scope)
|
createFs(PermitAllAccessPolicy, scope)
|
||||||
createProcessModule(PermitAllProcessAccessPolicy, scope)
|
createProcessModule(PermitAllProcessAccessPolicy, scope)
|
||||||
@ -76,6 +78,7 @@ suspend fun runMyScript() {
|
|||||||
// Now scripts can import them
|
// Now scripts can import them
|
||||||
session.eval("""
|
session.eval("""
|
||||||
import lyng.io.db
|
import lyng.io.db
|
||||||
|
import lyng.io.db.jdbc
|
||||||
import lyng.io.db.sqlite
|
import lyng.io.db.sqlite
|
||||||
import lyng.io.fs
|
import lyng.io.fs
|
||||||
import lyng.io.process
|
import lyng.io.process
|
||||||
@ -84,6 +87,7 @@ suspend fun runMyScript() {
|
|||||||
import lyng.io.net
|
import lyng.io.net
|
||||||
import lyng.io.ws
|
import lyng.io.ws
|
||||||
|
|
||||||
|
println("H2 JDBC available: " + (openH2("mem:demo;DB_CLOSE_DELAY=-1") != null))
|
||||||
println("SQLite available: " + (openSqlite(":memory:") != null))
|
println("SQLite available: " + (openSqlite(":memory:") != null))
|
||||||
println("Working dir: " + Path(".").readUtf8())
|
println("Working dir: " + Path(".").readUtf8())
|
||||||
println("OS: " + Platform.details().name)
|
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.
|
`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).
|
- **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.
|
- **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.
|
- **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.
|
- **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 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 |
|
| 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** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| **JVM** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| **Linux Native** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| **Linux Native** | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| **Apple Native** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ |
|
| **Apple Native** | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ |
|
||||||
| **Windows Native** | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ |
|
| **Windows Native** | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ |
|
||||||
| **Android** | ⚠️ | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ |
|
| **Android** | ⚠️ | ❌ | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ |
|
||||||
| **JS / Node** | ❌ | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ |
|
| **JS / Node** | ❌ | ❌ | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ |
|
||||||
| **JS / Browser** | ❌ | ✅ (in-memory) | ❌ | ⚠️ | ✅ | ✅ | ❌ |
|
| **JS / Browser** | ❌ | ❌ | ✅ (in-memory) | ❌ | ⚠️ | ✅ | ✅ | ❌ |
|
||||||
| **Wasm** | ❌ | ✅ (in-memory) | ❌ | ⚠️ | ❌ | ❌ | ❌ |
|
| **Wasm** | ❌ | ❌ | ✅ (in-memory) | ❌ | ⚠️ | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
Legend:
|
Legend:
|
||||||
- `✅` supported
|
- `✅` 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"
|
ktor = "3.3.1"
|
||||||
slf4j = "2.0.17"
|
slf4j = "2.0.17"
|
||||||
sqlite-jdbc = "3.50.3.0"
|
sqlite-jdbc = "3.50.3.0"
|
||||||
|
h2 = "2.4.240"
|
||||||
|
postgresql = "42.7.8"
|
||||||
|
testcontainers = "1.20.6"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" }
|
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" }
|
ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor" }
|
||||||
slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" }
|
slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" }
|
||||||
sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite-jdbc" }
|
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]
|
[plugins]
|
||||||
androidLibrary = { id = "com.android.library", version.ref = "agp" }
|
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.asFacade
|
||||||
import net.sergeych.lyng.io.console.createConsoleModule
|
import net.sergeych.lyng.io.console.createConsoleModule
|
||||||
import net.sergeych.lyng.io.db.createDbModule
|
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.db.sqlite.createSqliteModule
|
||||||
import net.sergeych.lyng.io.fs.createFs
|
import net.sergeych.lyng.io.fs.createFs
|
||||||
import net.sergeych.lyng.io.http.createHttpModule
|
import net.sergeych.lyng.io.http.createHttpModule
|
||||||
@ -140,6 +141,7 @@ private val baseCliImportManagerDefer = globalDefer {
|
|||||||
private fun ImportManager.invalidateCliModuleCaches() {
|
private fun ImportManager.invalidateCliModuleCaches() {
|
||||||
invalidatePackageCache("lyng.io.fs")
|
invalidatePackageCache("lyng.io.fs")
|
||||||
invalidatePackageCache("lyng.io.console")
|
invalidatePackageCache("lyng.io.console")
|
||||||
|
invalidatePackageCache("lyng.io.db.jdbc")
|
||||||
invalidatePackageCache("lyng.io.db.sqlite")
|
invalidatePackageCache("lyng.io.db.sqlite")
|
||||||
invalidatePackageCache("lyng.io.http")
|
invalidatePackageCache("lyng.io.http")
|
||||||
invalidatePackageCache("lyng.io.ws")
|
invalidatePackageCache("lyng.io.ws")
|
||||||
@ -229,6 +231,7 @@ private fun installCliModules(manager: ImportManager) {
|
|||||||
createFs(PermitAllAccessPolicy, manager)
|
createFs(PermitAllAccessPolicy, manager)
|
||||||
createConsoleModule(PermitAllConsoleAccessPolicy, manager)
|
createConsoleModule(PermitAllConsoleAccessPolicy, manager)
|
||||||
createDbModule(manager)
|
createDbModule(manager)
|
||||||
|
createJdbcModule(manager)
|
||||||
createSqliteModule(manager)
|
createSqliteModule(manager)
|
||||||
createHttpModule(PermitAllHttpAccessPolicy, manager)
|
createHttpModule(PermitAllHttpAccessPolicy, manager)
|
||||||
createWsModule(PermitAllWsAccessPolicy, manager)
|
createWsModule(PermitAllWsAccessPolicy, manager)
|
||||||
|
|||||||
@ -181,6 +181,12 @@ kotlin {
|
|||||||
implementation(libs.kotlinx.coroutines.test)
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val jvmTest by getting {
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.testcontainers)
|
||||||
|
implementation(libs.testcontainers.postgresql)
|
||||||
|
}
|
||||||
|
}
|
||||||
val linuxTest by creating {
|
val linuxTest by creating {
|
||||||
dependsOn(commonTest)
|
dependsOn(commonTest)
|
||||||
}
|
}
|
||||||
@ -217,6 +223,8 @@ kotlin {
|
|||||||
implementation(libs.ktor.client.cio)
|
implementation(libs.ktor.client.cio)
|
||||||
implementation(libs.ktor.network)
|
implementation(libs.ktor.network)
|
||||||
implementation(libs.sqlite.jdbc)
|
implementation(libs.sqlite.jdbc)
|
||||||
|
implementation(libs.h2)
|
||||||
|
implementation(libs.postgresql)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// // For Wasm we use in-memory VFS for now
|
// // 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
|
package net.sergeych.lyng.io.db.sqlite
|
||||||
|
|
||||||
import net.sergeych.lyng.ScopeFacade
|
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.requireScope
|
||||||
import net.sergeych.lyng.obj.ObjException
|
import net.sergeych.lyng.obj.ObjException
|
||||||
import net.sergeych.lyng.obj.ObjString
|
import net.sergeych.lyng.obj.ObjString
|
||||||
|
|
||||||
internal actual suspend fun openSqliteBackend(
|
internal actual suspend fun openSqliteBackend(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
options: SqliteOpenOptions,
|
options: SqliteOpenOptions,
|
||||||
): SqliteDatabaseBackend {
|
): SqlDatabaseBackend {
|
||||||
scope.raiseError(
|
scope.raiseError(
|
||||||
ObjException(
|
ObjException(
|
||||||
core.databaseException,
|
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.ScopeFacade
|
||||||
import net.sergeych.lyng.Source
|
import net.sergeych.lyng.Source
|
||||||
import net.sergeych.lyng.asFacade
|
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.io.db.createDbModule
|
||||||
import net.sergeych.lyng.requireScope
|
import net.sergeych.lyng.requireScope
|
||||||
import net.sergeych.lyng.obj.Obj
|
import net.sergeych.lyng.obj.Obj
|
||||||
import net.sergeych.lyng.obj.ObjBool
|
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.ObjInt
|
||||||
import net.sergeych.lyng.obj.ObjNull
|
import net.sergeych.lyng.obj.ObjNull
|
||||||
import net.sergeych.lyng.obj.ObjReal
|
|
||||||
import net.sergeych.lyng.obj.ObjString
|
import net.sergeych.lyng.obj.ObjString
|
||||||
import net.sergeych.lyng.obj.ObjVoid
|
import net.sergeych.lyng.obj.ObjVoid
|
||||||
import net.sergeych.lyng.obj.requiredArg
|
import net.sergeych.lyng.obj.requiredArg
|
||||||
import net.sergeych.lyng.obj.thisAs
|
|
||||||
import net.sergeych.lyng.pacman.ImportManager
|
import net.sergeych.lyng.pacman.ImportManager
|
||||||
import net.sergeych.lyngio.stdlib_included.db_sqliteLyng
|
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) {
|
private suspend fun buildSqliteModule(module: ModuleScope) {
|
||||||
module.eval(Source(SQLITE_MODULE_NAME, db_sqliteLyng))
|
module.eval(Source(SQLITE_MODULE_NAME, db_sqliteLyng))
|
||||||
val dbModule = module.importProvider.createModuleScope(Pos.builtIn, DB_MODULE_NAME)
|
val dbModule = module.importProvider.createModuleScope(Pos.builtIn, DB_MODULE_NAME)
|
||||||
val core = SqliteCoreModule.resolve(dbModule)
|
val core = SqlCoreModule.resolve(dbModule)
|
||||||
val runtimeTypes = SqliteRuntimeTypes.create(core)
|
val runtimeTypes = SqlRuntimeTypes.create("Sqlite", core)
|
||||||
|
|
||||||
module.addFn("openSqlite") {
|
module.addFn("openSqlite") {
|
||||||
val options = parseOpenSqliteArgs(this)
|
val options = parseOpenSqliteArgs(this)
|
||||||
SqliteDatabaseObj(runtimeTypes, openSqliteBackend(this, core, options))
|
SqlDatabaseObj(runtimeTypes, openSqliteBackend(this, core, options))
|
||||||
}
|
}
|
||||||
|
|
||||||
dbModule.callFn(
|
dbModule.callFn(
|
||||||
@ -80,7 +78,7 @@ private suspend fun buildSqliteModule(module: ModuleScope) {
|
|||||||
val extraParams = args.list.getOrNull(1)
|
val extraParams = args.list.getOrNull(1)
|
||||||
?: raiseError("Expected exactly 2 arguments, got ${args.list.size}")
|
?: raiseError("Expected exactly 2 arguments, got ${args.list.size}")
|
||||||
val options = parseSqliteConnectionUrl(this, connectionUrl, extraParams)
|
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,
|
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(
|
internal expect suspend fun openSqliteBackend(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
options: SqliteOpenOptions,
|
options: SqliteOpenOptions,
|
||||||
): SqliteDatabaseBackend
|
): SqlDatabaseBackend
|
||||||
|
|
||||||
internal class SqliteCoreModule private constructor(
|
|
||||||
val module: ModuleScope,
|
|
||||||
val databaseClass: ObjClass,
|
|
||||||
val transactionClass: ObjClass,
|
|
||||||
val resultSetClass: ObjClass,
|
|
||||||
val rowClass: ObjClass,
|
|
||||||
val columnClass: ObjClass,
|
|
||||||
val executionResultClass: ObjClass,
|
|
||||||
val databaseException: ObjException.Companion.ExceptionClass,
|
|
||||||
val sqlExecutionException: ObjException.Companion.ExceptionClass,
|
|
||||||
val sqlConstraintException: ObjException.Companion.ExceptionClass,
|
|
||||||
val sqlUsageException: ObjException.Companion.ExceptionClass,
|
|
||||||
val rollbackException: ObjException.Companion.ExceptionClass,
|
|
||||||
val sqlTypes: SqlTypeEntries,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun resolve(module: ModuleScope): SqliteCoreModule = SqliteCoreModule(
|
|
||||||
module = module,
|
|
||||||
databaseClass = module.requireClass("Database"),
|
|
||||||
transactionClass = module.requireClass("SqlTransaction"),
|
|
||||||
resultSetClass = module.requireClass("ResultSet"),
|
|
||||||
rowClass = module.requireClass("SqlRow"),
|
|
||||||
columnClass = module.requireClass("SqlColumn"),
|
|
||||||
executionResultClass = module.requireClass("ExecutionResult"),
|
|
||||||
databaseException = module.requireClass("DatabaseException") as ObjException.Companion.ExceptionClass,
|
|
||||||
sqlExecutionException = module.requireClass("SqlExecutionException") as ObjException.Companion.ExceptionClass,
|
|
||||||
sqlConstraintException = module.requireClass("SqlConstraintException") as ObjException.Companion.ExceptionClass,
|
|
||||||
sqlUsageException = module.requireClass("SqlUsageException") as ObjException.Companion.ExceptionClass,
|
|
||||||
rollbackException = module.requireClass("RollbackException") as ObjException.Companion.ExceptionClass,
|
|
||||||
sqlTypes = SqlTypeEntries.resolve(module),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class SqlTypeEntries private constructor(
|
|
||||||
private val entries: Map<String, ObjEnumEntry>,
|
|
||||||
) {
|
|
||||||
fun require(name: String): ObjEnumEntry = entries[name]
|
|
||||||
?: error("lyng.io.db.SqlType entry is missing: $name")
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun resolve(module: ModuleScope): SqlTypeEntries {
|
|
||||||
val enumClass = resolveEnum(module, "SqlType")
|
|
||||||
return SqlTypeEntries(
|
|
||||||
listOf(
|
|
||||||
"Binary", "String", "Int", "Double", "Decimal",
|
|
||||||
"Bool", "Instant", "Date", "DateTime"
|
|
||||||
).associateWith { name ->
|
|
||||||
enumClass.byName[ObjString(name)] as? ObjEnumEntry
|
|
||||||
?: error("lyng.io.db.SqlType.$name is missing")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveEnum(module: ModuleScope, enumName: String): ObjEnumClass {
|
|
||||||
val local = module.get(enumName)?.value as? ObjEnumClass
|
|
||||||
if (local != null) return local
|
|
||||||
val root = module.importProvider.rootScope.get(enumName)?.value as? ObjEnumClass
|
|
||||||
return root ?: error("lyng.io.db declaration enum is missing: $enumName")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SqliteRuntimeTypes private constructor(
|
|
||||||
val core: SqliteCoreModule,
|
|
||||||
val databaseClass: ObjClass,
|
|
||||||
val transactionClass: ObjClass,
|
|
||||||
val resultSetClass: ObjClass,
|
|
||||||
val rowClass: ObjClass,
|
|
||||||
val columnClass: ObjClass,
|
|
||||||
val executionResultClass: ObjClass,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun create(core: SqliteCoreModule): SqliteRuntimeTypes {
|
|
||||||
val databaseClass = object : ObjClass("SqliteDatabase", core.databaseClass) {}
|
|
||||||
val transactionClass = object : ObjClass("SqliteTransaction", core.transactionClass) {}
|
|
||||||
val resultSetClass = object : ObjClass("SqliteResultSet", core.resultSetClass) {}
|
|
||||||
val rowClass = object : ObjClass("SqliteRow", core.rowClass) {}
|
|
||||||
val columnClass = object : ObjClass("SqliteColumn", core.columnClass) {}
|
|
||||||
val executionResultClass = object : ObjClass("SqliteExecutionResult", core.executionResultClass) {}
|
|
||||||
val runtime = SqliteRuntimeTypes(
|
|
||||||
core = core,
|
|
||||||
databaseClass = databaseClass,
|
|
||||||
transactionClass = transactionClass,
|
|
||||||
resultSetClass = resultSetClass,
|
|
||||||
rowClass = rowClass,
|
|
||||||
columnClass = columnClass,
|
|
||||||
executionResultClass = executionResultClass,
|
|
||||||
)
|
|
||||||
runtime.bind()
|
|
||||||
return runtime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bind() {
|
|
||||||
databaseClass.addFn("transaction") {
|
|
||||||
val self = thisAs<SqliteDatabaseObj>()
|
|
||||||
val block = args.list.getOrNull(0) ?: raiseError("Expected exactly 1 argument, got ${args.list.size}")
|
|
||||||
if (!block.isInstanceOf("Callable")) {
|
|
||||||
raiseClassCastError("transaction block must be callable")
|
|
||||||
}
|
|
||||||
self.backend.transaction(this) { backend ->
|
|
||||||
val lifetime = TransactionLifetime(this@SqliteRuntimeTypes.core)
|
|
||||||
try {
|
|
||||||
call(block, Arguments(SqliteTransactionObj(this@SqliteRuntimeTypes, backend, lifetime)), ObjNull)
|
|
||||||
} finally {
|
|
||||||
lifetime.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
transactionClass.addFn("select") {
|
|
||||||
val self = thisAs<SqliteTransactionObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
val clause = requiredArg<ObjString>(0).value
|
|
||||||
val params = args.list.drop(1)
|
|
||||||
SqliteResultSetObj(thisAs<SqliteTransactionObj>().types, self.lifetime, self.backend.select(this, clause, params))
|
|
||||||
}
|
|
||||||
transactionClass.addFn("execute") {
|
|
||||||
val self = thisAs<SqliteTransactionObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
val clause = requiredArg<ObjString>(0).value
|
|
||||||
val params = args.list.drop(1)
|
|
||||||
SqliteExecutionResultObj(self.types, self.lifetime, self.backend.execute(this, clause, params))
|
|
||||||
}
|
|
||||||
transactionClass.addFn("transaction") {
|
|
||||||
val self = thisAs<SqliteTransactionObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
val block = args.list.getOrNull(0) ?: raiseError("Expected exactly 1 argument, got ${args.list.size}")
|
|
||||||
if (!block.isInstanceOf("Callable")) {
|
|
||||||
raiseClassCastError("transaction block must be callable")
|
|
||||||
}
|
|
||||||
self.backend.transaction(this) { backend ->
|
|
||||||
val lifetime = TransactionLifetime(this@SqliteRuntimeTypes.core)
|
|
||||||
try {
|
|
||||||
call(block, Arguments(SqliteTransactionObj(self.types, backend, lifetime)), ObjNull)
|
|
||||||
} finally {
|
|
||||||
lifetime.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resultSetClass.addProperty("columns", getter = {
|
|
||||||
val self = thisAs<SqliteResultSetObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
ObjImmutableList(self.columns)
|
|
||||||
})
|
|
||||||
resultSetClass.addFn("size") {
|
|
||||||
val self = thisAs<SqliteResultSetObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
ObjInt.of(self.rows.size.toLong())
|
|
||||||
}
|
|
||||||
resultSetClass.addFn("isEmpty") {
|
|
||||||
val self = thisAs<SqliteResultSetObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
ObjBool(self.rows.isEmpty())
|
|
||||||
}
|
|
||||||
resultSetClass.addFn("iterator") {
|
|
||||||
val self = thisAs<SqliteResultSetObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
ObjImmutableList(self.rows).invokeInstanceMethod(requireScope(), "iterator")
|
|
||||||
}
|
|
||||||
resultSetClass.addFn("toList") {
|
|
||||||
val self = thisAs<SqliteResultSetObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
ObjImmutableList(self.rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
rowClass.addProperty("size", getter = {
|
|
||||||
val self = thisAs<SqliteRowObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
ObjInt.of(self.values.size.toLong())
|
|
||||||
})
|
|
||||||
rowClass.addProperty("values", getter = {
|
|
||||||
val self = thisAs<SqliteRowObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
ObjImmutableList(self.values)
|
|
||||||
})
|
|
||||||
|
|
||||||
columnClass.addProperty("name", getter = { ObjString(thisAs<SqliteColumnObj>().meta.name) })
|
|
||||||
columnClass.addProperty("sqlType", getter = { thisAs<SqliteColumnObj>().meta.sqlType })
|
|
||||||
columnClass.addProperty("nullable", getter = { ObjBool(thisAs<SqliteColumnObj>().meta.nullable) })
|
|
||||||
columnClass.addProperty("nativeType", getter = { ObjString(thisAs<SqliteColumnObj>().meta.nativeType) })
|
|
||||||
|
|
||||||
executionResultClass.addProperty("affectedRowsCount", getter = {
|
|
||||||
val self = thisAs<SqliteExecutionResultObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
ObjInt.of(self.result.affectedRowsCount.toLong())
|
|
||||||
})
|
|
||||||
executionResultClass.addFn("getGeneratedKeys") {
|
|
||||||
val self = thisAs<SqliteExecutionResultObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
SqliteResultSetObj(self.types, self.lifetime, self.result.generatedKeys)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class TransactionLifetime(
|
|
||||||
private val core: SqliteCoreModule,
|
|
||||||
) {
|
|
||||||
private var active = true
|
|
||||||
|
|
||||||
fun close() {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ensureActive(scope: ScopeFacade) {
|
|
||||||
if (!active) {
|
|
||||||
scope.raiseError(
|
|
||||||
ObjException(core.sqlUsageException, scope.requireScope(), ObjString("SQL result can be used only while its transaction is active"))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SqliteDatabaseObj(
|
|
||||||
val types: SqliteRuntimeTypes,
|
|
||||||
val backend: SqliteDatabaseBackend,
|
|
||||||
) : Obj() {
|
|
||||||
override val objClass: ObjClass
|
|
||||||
get() = types.databaseClass
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SqliteTransactionObj(
|
|
||||||
val types: SqliteRuntimeTypes,
|
|
||||||
val backend: SqliteTransactionBackend,
|
|
||||||
val lifetime: TransactionLifetime,
|
|
||||||
) : Obj() {
|
|
||||||
override val objClass: ObjClass
|
|
||||||
get() = types.transactionClass
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SqliteResultSetObj(
|
|
||||||
val types: SqliteRuntimeTypes,
|
|
||||||
val lifetime: TransactionLifetime,
|
|
||||||
data: SqliteResultSetData,
|
|
||||||
) : Obj() {
|
|
||||||
val columns: List<Obj> = data.columns.map { SqliteColumnObj(types, it) }
|
|
||||||
val rows: List<Obj> = buildRows(types, lifetime, data)
|
|
||||||
|
|
||||||
override val objClass: ObjClass
|
|
||||||
get() = types.resultSetClass
|
|
||||||
|
|
||||||
private fun buildRows(
|
|
||||||
types: SqliteRuntimeTypes,
|
|
||||||
lifetime: TransactionLifetime,
|
|
||||||
data: SqliteResultSetData,
|
|
||||||
): List<Obj> {
|
|
||||||
val indexByName = linkedMapOf<String, MutableList<Int>>()
|
|
||||||
data.columns.forEachIndexed { index, column ->
|
|
||||||
indexByName.getOrPut(column.name.lowercase()) { mutableListOf() }.add(index)
|
|
||||||
}
|
|
||||||
return data.rows.map { rowValues ->
|
|
||||||
SqliteRowObj(types, lifetime, rowValues, indexByName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SqliteRowObj(
|
|
||||||
val types: SqliteRuntimeTypes,
|
|
||||||
val lifetime: TransactionLifetime,
|
|
||||||
val values: List<Obj>,
|
|
||||||
private val indexByName: Map<String, List<Int>>,
|
|
||||||
) : Obj() {
|
|
||||||
override val objClass: ObjClass
|
|
||||||
get() = types.rowClass
|
|
||||||
|
|
||||||
override suspend fun getAt(scope: Scope, index: Obj): Obj {
|
|
||||||
lifetime.ensureActive(scope.asFacade())
|
|
||||||
return when (index) {
|
|
||||||
is ObjInt -> {
|
|
||||||
val idx = index.value.toInt()
|
|
||||||
if (idx !in values.indices) {
|
|
||||||
scope.raiseIndexOutOfBounds("SQL row index $idx is out of bounds")
|
|
||||||
}
|
|
||||||
values[idx]
|
|
||||||
}
|
|
||||||
is ObjString -> {
|
|
||||||
val matches = indexByName[index.value.lowercase()]
|
|
||||||
?: scope.raiseError(
|
|
||||||
ObjException(
|
|
||||||
types.core.sqlUsageException,
|
|
||||||
scope,
|
|
||||||
ObjString("No such SQL result column: ${index.value}")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (matches.size != 1) {
|
|
||||||
scope.raiseError(
|
|
||||||
ObjException(
|
|
||||||
types.core.sqlUsageException,
|
|
||||||
scope,
|
|
||||||
ObjString("Ambiguous SQL result column: ${index.value}")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
values[matches.first()]
|
|
||||||
}
|
|
||||||
else -> scope.raiseClassCastError("SQL row index must be Int or String")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SqliteColumnObj(
|
|
||||||
val types: SqliteRuntimeTypes,
|
|
||||||
val meta: SqliteColumnMeta,
|
|
||||||
) : Obj() {
|
|
||||||
override val objClass: ObjClass
|
|
||||||
get() = types.columnClass
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SqliteExecutionResultObj(
|
|
||||||
val types: SqliteRuntimeTypes,
|
|
||||||
val lifetime: TransactionLifetime,
|
|
||||||
val result: SqliteExecutionResultData,
|
|
||||||
) : Obj() {
|
|
||||||
override val objClass: ObjClass
|
|
||||||
get() = types.executionResultClass
|
|
||||||
}
|
|
||||||
|
|||||||
@ -0,0 +1,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
|
package net.sergeych.lyng.io.db.sqlite
|
||||||
|
|
||||||
import net.sergeych.lyng.ScopeFacade
|
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.requireScope
|
||||||
import net.sergeych.lyng.obj.ObjException
|
import net.sergeych.lyng.obj.ObjException
|
||||||
import net.sergeych.lyng.obj.ObjString
|
import net.sergeych.lyng.obj.ObjString
|
||||||
|
|
||||||
internal actual suspend fun openSqliteBackend(
|
internal actual suspend fun openSqliteBackend(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
options: SqliteOpenOptions,
|
options: SqliteOpenOptions,
|
||||||
): SqliteDatabaseBackend {
|
): SqlDatabaseBackend {
|
||||||
scope.raiseError(
|
scope.raiseError(
|
||||||
ObjException(
|
ObjException(
|
||||||
core.databaseException,
|
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.ExecutionError
|
||||||
import net.sergeych.lyng.Arguments
|
import net.sergeych.lyng.Arguments
|
||||||
import net.sergeych.lyng.ScopeFacade
|
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.Obj
|
||||||
import net.sergeych.lyng.obj.ObjBool
|
import net.sergeych.lyng.obj.ObjBool
|
||||||
import net.sergeych.lyng.obj.ObjBuffer
|
import net.sergeych.lyng.obj.ObjBuffer
|
||||||
@ -50,24 +56,24 @@ import kotlin.time.Instant
|
|||||||
|
|
||||||
internal actual suspend fun openSqliteBackend(
|
internal actual suspend fun openSqliteBackend(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
options: SqliteOpenOptions,
|
options: SqliteOpenOptions,
|
||||||
): SqliteDatabaseBackend {
|
): SqlDatabaseBackend {
|
||||||
if (options.busyTimeoutMillis < 0) {
|
if (options.busyTimeoutMillis < 0) {
|
||||||
scope.raiseIllegalArgument("busyTimeoutMillis must be >= 0")
|
scope.raiseIllegalArgument("busyTimeoutMillis must be >= 0")
|
||||||
}
|
}
|
||||||
return JdbcSqliteDatabaseBackend(core, options)
|
return JdbcSqlDatabaseBackend(core, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
private class JdbcSqliteDatabaseBackend(
|
private class JdbcSqlDatabaseBackend(
|
||||||
private val core: SqliteCoreModule,
|
private val core: SqlCoreModule,
|
||||||
private val options: SqliteOpenOptions,
|
private val options: SqliteOpenOptions,
|
||||||
) : SqliteDatabaseBackend {
|
) : SqlDatabaseBackend {
|
||||||
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 connection = openConnection(scope)
|
val connection = openConnection(scope)
|
||||||
try {
|
try {
|
||||||
connection.autoCommit = false
|
connection.autoCommit = false
|
||||||
val tx = JdbcSqliteTransactionBackend(core, connection)
|
val tx = JdbcSqlTransactionBackend(core, connection)
|
||||||
val result = try {
|
val result = try {
|
||||||
block(tx)
|
block(tx)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@ -124,11 +130,11 @@ private class JdbcSqliteDatabaseBackend(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class JdbcSqliteTransactionBackend(
|
private class JdbcSqlTransactionBackend(
|
||||||
private val core: SqliteCoreModule,
|
private val core: SqlCoreModule,
|
||||||
private val connection: Connection,
|
private val connection: Connection,
|
||||||
) : SqliteTransactionBackend {
|
) : SqlTransactionBackend {
|
||||||
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteResultSetData {
|
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqlResultSetData {
|
||||||
try {
|
try {
|
||||||
connection.prepareStatement(clause).use { statement ->
|
connection.prepareStatement(clause).use { statement ->
|
||||||
bindParams(statement, params, scope, core)
|
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)) {
|
if (containsRowReturningClause(clause)) {
|
||||||
scope.raiseError(
|
scope.raiseError(
|
||||||
ObjException(
|
ObjException(
|
||||||
@ -172,20 +178,20 @@ private class JdbcSqliteTransactionBackend(
|
|||||||
readResultSet(scope, core, rs)
|
readResultSet(scope, core, rs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return SqliteExecutionResultData(affected, generatedKeys)
|
return SqlExecutionResultData(affected, generatedKeys)
|
||||||
}
|
}
|
||||||
} catch (e: SQLException) {
|
} catch (e: SQLException) {
|
||||||
throw mapSqlException(scope, core, e)
|
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 {
|
val savepoint = try {
|
||||||
connection.setSavepoint()
|
connection.setSavepoint()
|
||||||
} catch (e: SQLException) {
|
} catch (e: SQLException) {
|
||||||
throw mapSqlUsage(scope, core, "Nested transactions are not supported by this SQLite backend", e)
|
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 {
|
val result = try {
|
||||||
block(nested)
|
block(nested)
|
||||||
} catch (e: Throwable) {
|
} 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 ->
|
params.forEachIndexed { index, value ->
|
||||||
val jdbcIndex = index + 1
|
val jdbcIndex = index + 1
|
||||||
when (value) {
|
when (value) {
|
||||||
@ -231,12 +237,12 @@ private suspend fun bindParams(statement: PreparedStatement, params: List<Obj>,
|
|||||||
|
|
||||||
private suspend fun readResultSet(
|
private suspend fun readResultSet(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
resultSet: ResultSet,
|
resultSet: ResultSet,
|
||||||
): SqliteResultSetData {
|
): SqlResultSetData {
|
||||||
val meta = resultSet.metaData
|
val meta = resultSet.metaData
|
||||||
val columns = (1..meta.columnCount).map { index ->
|
val columns = (1..meta.columnCount).map { index ->
|
||||||
SqliteColumnMeta(
|
SqlColumnMeta(
|
||||||
name = meta.getColumnLabel(index),
|
name = meta.getColumnLabel(index),
|
||||||
sqlType = mapSqlType(core, meta.getColumnTypeName(index), meta.getColumnType(index)),
|
sqlType = mapSqlType(core, meta.getColumnTypeName(index), meta.getColumnType(index)),
|
||||||
nullable = meta.isNullable(index) != java.sql.ResultSetMetaData.columnNoNulls,
|
nullable = meta.isNullable(index) != java.sql.ResultSetMetaData.columnNoNulls,
|
||||||
@ -249,12 +255,12 @@ private suspend fun readResultSet(
|
|||||||
readColumnValue(scope, core, resultSet, index + 1, column.nativeType)
|
readColumnValue(scope, core, resultSet, index + 1, column.nativeType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return SqliteResultSetData(columns, rows)
|
return SqlResultSetData(columns, rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun readColumnValue(
|
private suspend fun readColumnValue(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
resultSet: ResultSet,
|
resultSet: ResultSet,
|
||||||
index: Int,
|
index: Int,
|
||||||
nativeType: String,
|
nativeType: String,
|
||||||
@ -278,7 +284,7 @@ private suspend fun readColumnValue(
|
|||||||
|
|
||||||
private fun convertIntegerValue(
|
private fun convertIntegerValue(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
normalizedNativeType: String,
|
normalizedNativeType: String,
|
||||||
value: Long,
|
value: Long,
|
||||||
): Obj {
|
): Obj {
|
||||||
@ -294,7 +300,7 @@ private fun convertIntegerValue(
|
|||||||
|
|
||||||
private suspend fun convertStringValue(
|
private suspend fun convertStringValue(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
normalizedNativeType: String,
|
normalizedNativeType: String,
|
||||||
value: String,
|
value: String,
|
||||||
): Obj {
|
): Obj {
|
||||||
@ -317,7 +323,7 @@ private fun isDecimalNativeType(normalizedNativeType: String): Boolean =
|
|||||||
private fun isBooleanNativeType(normalizedNativeType: String): Boolean =
|
private fun isBooleanNativeType(normalizedNativeType: String): Boolean =
|
||||||
normalizedNativeType == "BOOLEAN" || normalizedNativeType == "BOOL"
|
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()) {
|
return when (value.trim().lowercase()) {
|
||||||
"true", "t" -> ObjBool(true)
|
"true", "t" -> ObjBool(true)
|
||||||
"false", "f" -> ObjBool(false)
|
"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))
|
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()
|
val trimmed = value.trim()
|
||||||
if (hasExplicitTimeZone(trimmed)) {
|
if (hasExplicitTimeZone(trimmed)) {
|
||||||
sqlExecutionFailure(scope, core, "SQLite TIMESTAMP/DATETIME value must not contain a timezone offset: $value")
|
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()
|
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)
|
val normalized = normalizeDeclaredTypeName(nativeTypeName)
|
||||||
return when {
|
return when {
|
||||||
normalized == "BOOLEAN" || normalized == "BOOL" -> core.sqlTypes.require("Bool")
|
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(
|
throw ExecutionError(
|
||||||
ObjException(core.sqlExecutionException, scope.requireScope(), ObjString(message)),
|
ObjException(core.sqlExecutionException, scope.requireScope(), ObjString(message)),
|
||||||
scope.pos,
|
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 {
|
try {
|
||||||
connection.rollback()
|
connection.rollback()
|
||||||
} catch (e: SQLException) {
|
} catch (e: SQLException) {
|
||||||
@ -405,7 +411,7 @@ private fun rollbackOrThrow(scope: ScopeFacade, core: SqliteCoreModule, connecti
|
|||||||
|
|
||||||
private fun rollbackToSavepointOrThrow(
|
private fun rollbackToSavepointOrThrow(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
savepoint: java.sql.Savepoint,
|
savepoint: java.sql.Savepoint,
|
||||||
) {
|
) {
|
||||||
@ -418,7 +424,7 @@ private fun rollbackToSavepointOrThrow(
|
|||||||
|
|
||||||
private fun releaseSavepointOrThrow(
|
private fun releaseSavepointOrThrow(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
savepoint: java.sql.Savepoint,
|
savepoint: java.sql.Savepoint,
|
||||||
) {
|
) {
|
||||||
@ -431,7 +437,7 @@ private fun releaseSavepointOrThrow(
|
|||||||
|
|
||||||
private inline fun finishFailedTransaction(
|
private inline fun finishFailedTransaction(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
failure: Throwable,
|
failure: Throwable,
|
||||||
rollback: () -> Unit,
|
rollback: () -> Unit,
|
||||||
): Throwable {
|
): 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
|
val errorObject = (failure as? ExecutionError)?.errorObject ?: return false
|
||||||
return errorObject.isInstanceOf(core.rollbackException)
|
return errorObject.isInstanceOf(core.rollbackException)
|
||||||
}
|
}
|
||||||
@ -459,7 +465,7 @@ private fun attachSecondaryFailure(primary: Throwable, secondary: Throwable) {
|
|||||||
primary.addSuppressed(secondary)
|
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 message = e.message ?: "SQLite open failed"
|
||||||
val lower = message.lowercase()
|
val lower = message.lowercase()
|
||||||
if ("malformed" in lower || "no such access mode" in lower || "invalid uri" in lower) {
|
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 code = SQLiteErrorCode.getErrorCode(e.errorCode)
|
||||||
val exceptionClass = when (code) {
|
val exceptionClass = when (code) {
|
||||||
SQLiteErrorCode.SQLITE_CONSTRAINT,
|
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(
|
return ExecutionError(
|
||||||
ObjException(core.sqlUsageException, scope.requireScope(), ObjString(message)),
|
ObjException(core.sqlUsageException, scope.requireScope(), ObjString(message)),
|
||||||
scope.pos,
|
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 kotlinx.datetime.toInstant
|
||||||
import net.sergeych.lyng.ExecutionError
|
import net.sergeych.lyng.ExecutionError
|
||||||
import net.sergeych.lyng.ScopeFacade
|
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_BLOB
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_CONSTRAINT
|
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_CONSTRAINT
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_DONE
|
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_DONE
|
||||||
@ -100,25 +106,25 @@ import kotlin.time.Instant
|
|||||||
|
|
||||||
internal actual suspend fun openSqliteBackend(
|
internal actual suspend fun openSqliteBackend(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
options: SqliteOpenOptions,
|
options: SqliteOpenOptions,
|
||||||
): SqliteDatabaseBackend {
|
): SqlDatabaseBackend {
|
||||||
if (options.busyTimeoutMillis < 0) {
|
if (options.busyTimeoutMillis < 0) {
|
||||||
scope.raiseIllegalArgument("busyTimeoutMillis must be >= 0")
|
scope.raiseIllegalArgument("busyTimeoutMillis must be >= 0")
|
||||||
}
|
}
|
||||||
return NativeSqliteDatabaseBackend(core, options)
|
return NativeSqlDatabaseBackend(core, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
private class NativeSqliteDatabaseBackend(
|
private class NativeSqlDatabaseBackend(
|
||||||
private val core: SqliteCoreModule,
|
private val core: SqlCoreModule,
|
||||||
private val options: SqliteOpenOptions,
|
private val options: SqliteOpenOptions,
|
||||||
) : SqliteDatabaseBackend {
|
) : SqlDatabaseBackend {
|
||||||
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 handle = openHandle(scope, core, options)
|
val handle = openHandle(scope, core, options)
|
||||||
val savepoints = SavepointCounter()
|
val savepoints = SavepointCounter()
|
||||||
try {
|
try {
|
||||||
handle.execUnit(scope, core, "begin")
|
handle.execUnit(scope, core, "begin")
|
||||||
val tx = NativeSqliteTransactionBackend(core, handle, savepoints)
|
val tx = NativeSqlTransactionBackend(core, handle, savepoints)
|
||||||
val result = try {
|
val result = try {
|
||||||
block(tx)
|
block(tx)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@ -134,23 +140,23 @@ private class NativeSqliteDatabaseBackend(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class NativeSqliteTransactionBackend(
|
private class NativeSqlTransactionBackend(
|
||||||
private val core: SqliteCoreModule,
|
private val core: SqlCoreModule,
|
||||||
private val handle: NativeSqliteHandle,
|
private val handle: NativeSqliteHandle,
|
||||||
private val savepoints: SavepointCounter,
|
private val savepoints: SavepointCounter,
|
||||||
) : SqliteTransactionBackend {
|
) : SqlTransactionBackend {
|
||||||
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteResultSetData {
|
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqlResultSetData {
|
||||||
return handle.select(scope, core, clause, params)
|
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)
|
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()}"
|
val savepoint = "lyng_sp_${savepoints.next()}"
|
||||||
handle.execUnit(scope, core, "savepoint $savepoint")
|
handle.execUnit(scope, core, "savepoint $savepoint")
|
||||||
val nested = NativeSqliteTransactionBackend(core, handle, savepoints)
|
val nested = NativeSqlTransactionBackend(core, handle, savepoints)
|
||||||
val result = try {
|
val result = try {
|
||||||
block(nested)
|
block(nested)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@ -178,10 +184,10 @@ private class NativeSqliteHandle(
|
|||||||
) {
|
) {
|
||||||
suspend fun select(
|
suspend fun select(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
clause: String,
|
clause: String,
|
||||||
params: List<Obj>,
|
params: List<Obj>,
|
||||||
): SqliteResultSetData = memScoped {
|
): SqlResultSetData = memScoped {
|
||||||
val stmt = prepare(scope, core, clause)
|
val stmt = prepare(scope, core, clause)
|
||||||
try {
|
try {
|
||||||
bindParams(scope, core, stmt, params, this)
|
bindParams(scope, core, stmt, params, this)
|
||||||
@ -193,10 +199,10 @@ private class NativeSqliteHandle(
|
|||||||
|
|
||||||
suspend fun execute(
|
suspend fun execute(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
clause: String,
|
clause: String,
|
||||||
params: List<Obj>,
|
params: List<Obj>,
|
||||||
): SqliteExecutionResultData = memScoped {
|
): SqlExecutionResultData = memScoped {
|
||||||
if (containsRowReturningClause(clause)) {
|
if (containsRowReturningClause(clause)) {
|
||||||
raiseExecuteReturningUsage(scope, core)
|
raiseExecuteReturningUsage(scope, core)
|
||||||
}
|
}
|
||||||
@ -207,7 +213,7 @@ private class NativeSqliteHandle(
|
|||||||
SQLITE_DONE -> {
|
SQLITE_DONE -> {
|
||||||
val affectedRows = sqlite3_changes(db)
|
val affectedRows = sqlite3_changes(db)
|
||||||
val generatedKeys = readGeneratedKeys(core, clause, affectedRows)
|
val generatedKeys = readGeneratedKeys(core, clause, affectedRows)
|
||||||
SqliteExecutionResultData(affectedRows, generatedKeys)
|
SqlExecutionResultData(affectedRows, generatedKeys)
|
||||||
}
|
}
|
||||||
SQLITE_ROW -> raiseExecuteReturningUsage(scope, core)
|
SQLITE_ROW -> raiseExecuteReturningUsage(scope, core)
|
||||||
else -> throw sqlError(scope, core, rc)
|
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 {
|
memScoped {
|
||||||
val stmt = prepare(scope, core, sql)
|
val stmt = prepare(scope, core, sql)
|
||||||
try {
|
try {
|
||||||
@ -236,13 +242,13 @@ private class NativeSqliteHandle(
|
|||||||
sqlite3_close_v2(db)
|
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))
|
return lyng_sqlite3_prepare(db, sql) ?: throw sqlError(scope, core, sqlite3_extended_errcode(db))
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun bindParams(
|
private suspend fun bindParams(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
stmt: CPointer<sqlite3_stmt>,
|
stmt: CPointer<sqlite3_stmt>,
|
||||||
params: List<Obj>,
|
params: List<Obj>,
|
||||||
memScope: MemScope,
|
memScope: MemScope,
|
||||||
@ -308,13 +314,13 @@ private class NativeSqliteHandle(
|
|||||||
|
|
||||||
private suspend fun readResultSet(
|
private suspend fun readResultSet(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
stmt: CPointer<sqlite3_stmt>,
|
stmt: CPointer<sqlite3_stmt>,
|
||||||
): SqliteResultSetData {
|
): SqlResultSetData {
|
||||||
val columnCount = sqlite3_column_count(stmt)
|
val columnCount = sqlite3_column_count(stmt)
|
||||||
val columns = (0 until columnCount).map { index ->
|
val columns = (0 until columnCount).map { index ->
|
||||||
val nativeType = sqlite3_column_decltype(stmt, index)?.toKString().orEmpty()
|
val nativeType = sqlite3_column_decltype(stmt, index)?.toKString().orEmpty()
|
||||||
SqliteColumnMeta(
|
SqlColumnMeta(
|
||||||
name = sqlite3_column_name(stmt, index)?.toKString().orEmpty(),
|
name = sqlite3_column_name(stmt, index)?.toKString().orEmpty(),
|
||||||
sqlType = mapSqlType(core, nativeType, SQLITE_NULL),
|
sqlType = mapSqlType(core, nativeType, SQLITE_NULL),
|
||||||
nullable = true,
|
nullable = true,
|
||||||
@ -338,7 +344,7 @@ private class NativeSqliteHandle(
|
|||||||
}
|
}
|
||||||
rows += row
|
rows += row
|
||||||
}
|
}
|
||||||
SQLITE_DONE -> return SqliteResultSetData(columns, rows)
|
SQLITE_DONE -> return SqlResultSetData(columns, rows)
|
||||||
else -> throw sqlError(scope, core, rc)
|
else -> throw sqlError(scope, core, rc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -346,7 +352,7 @@ private class NativeSqliteHandle(
|
|||||||
|
|
||||||
private suspend fun readColumnValue(
|
private suspend fun readColumnValue(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
stmt: CPointer<sqlite3_stmt>,
|
stmt: CPointer<sqlite3_stmt>,
|
||||||
index: Int,
|
index: Int,
|
||||||
nativeType: String,
|
nativeType: String,
|
||||||
@ -383,7 +389,7 @@ private class NativeSqliteHandle(
|
|||||||
|
|
||||||
private suspend fun convertStringValue(
|
private suspend fun convertStringValue(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
normalizedNativeType: String,
|
normalizedNativeType: String,
|
||||||
value: String,
|
value: String,
|
||||||
): Obj {
|
): Obj {
|
||||||
@ -401,16 +407,16 @@ private class NativeSqliteHandle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun readGeneratedKeys(
|
private fun readGeneratedKeys(
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
clause: String,
|
clause: String,
|
||||||
affectedRows: Int,
|
affectedRows: Int,
|
||||||
): SqliteResultSetData {
|
): SqlResultSetData {
|
||||||
if (affectedRows <= 0 || !looksLikeInsert(clause)) {
|
if (affectedRows <= 0 || !looksLikeInsert(clause)) {
|
||||||
return emptyResultSet()
|
return emptyResultSet()
|
||||||
}
|
}
|
||||||
return SqliteResultSetData(
|
return SqlResultSetData(
|
||||||
columns = listOf(
|
columns = listOf(
|
||||||
SqliteColumnMeta(
|
SqlColumnMeta(
|
||||||
name = "generated_key",
|
name = "generated_key",
|
||||||
sqlType = core.sqlTypes.require("Int"),
|
sqlType = core.sqlTypes.require("Int"),
|
||||||
nullable = false,
|
nullable = false,
|
||||||
@ -425,7 +431,7 @@ private class NativeSqliteHandle(
|
|||||||
return sqlite3_column_text(stmt, index)?.reinterpret<ByteVar>()?.toKString().orEmpty()
|
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 code = sqlite3_extended_errcode(db)
|
||||||
val message = sqlite3_errmsg(db)?.toKString() ?: "SQLite error ($rc)"
|
val message = sqlite3_errmsg(db)?.toKString() ?: "SQLite error ($rc)"
|
||||||
val exceptionClass = if ((code and 0xff) == SQLITE_CONSTRAINT) core.sqlConstraintException else core.sqlExecutionException
|
val exceptionClass = if ((code and 0xff) == SQLITE_CONSTRAINT) core.sqlConstraintException else core.sqlExecutionException
|
||||||
@ -439,7 +445,7 @@ private class NativeSqliteHandle(
|
|||||||
|
|
||||||
private fun openHandle(
|
private fun openHandle(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
options: SqliteOpenOptions,
|
options: SqliteOpenOptions,
|
||||||
): NativeSqliteHandle = memScoped {
|
): NativeSqliteHandle = memScoped {
|
||||||
val flags = buildOpenFlags(options)
|
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 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")
|
"BOOLEAN", "BOOL" -> core.sqlTypes.require("Bool")
|
||||||
"DATE" -> core.sqlTypes.require("Date")
|
"DATE" -> core.sqlTypes.require("Date")
|
||||||
"DATETIME", "TIMESTAMP" -> core.sqlTypes.require("DateTime")
|
"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))
|
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()
|
val trimmed = value.trim()
|
||||||
if (hasExplicitTimeZone(trimmed)) {
|
if (hasExplicitTimeZone(trimmed)) {
|
||||||
throw sqlExecutionError(scope, core, "SQLite TIMESTAMP/DATETIME value must not contain a timezone offset: $value")
|
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
|
return offsetStart > tIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun raiseExecuteReturningUsage(scope: ScopeFacade, core: SqliteCoreModule): Nothing {
|
private fun raiseExecuteReturningUsage(scope: ScopeFacade, core: SqlCoreModule): Nothing {
|
||||||
scope.raiseError(
|
scope.raiseError(
|
||||||
ObjException(
|
ObjException(
|
||||||
core.sqlUsageException,
|
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(
|
return ExecutionError(
|
||||||
ObjException(core.sqlUsageException, scope.requireScope(), ObjString(message)),
|
ObjException(core.sqlUsageException, scope.requireScope(), ObjString(message)),
|
||||||
scope.pos,
|
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(
|
return ExecutionError(
|
||||||
ObjException(core.databaseException, scope.requireScope(), ObjString(message)),
|
ObjException(core.databaseException, scope.requireScope(), ObjString(message)),
|
||||||
scope.pos,
|
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) {
|
when (value) {
|
||||||
0L -> ObjBool(false)
|
0L -> ObjBool(false)
|
||||||
1L -> ObjBool(true)
|
1L -> ObjBool(true)
|
||||||
else -> throw sqlExecutionError(scope, core, "Invalid SQLite boolean value: $value")
|
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()) {
|
when (value.trim().lowercase()) {
|
||||||
"true", "t" -> ObjBool(true)
|
"true", "t" -> ObjBool(true)
|
||||||
"false", "f" -> ObjBool(false)
|
"false", "f" -> ObjBool(false)
|
||||||
@ -585,7 +591,7 @@ private fun normalizeDeclaredTypeName(nativeTypeName: String): String {
|
|||||||
return strippedSuffix.uppercase().replace(Regex("""\s+"""), " ").trim()
|
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(
|
return ExecutionError(
|
||||||
ObjException(core.sqlExecutionException, scope.requireScope(), ObjString(message)),
|
ObjException(core.sqlExecutionException, scope.requireScope(), ObjString(message)),
|
||||||
scope.pos,
|
scope.pos,
|
||||||
@ -595,7 +601,7 @@ private fun sqlExecutionError(scope: ScopeFacade, core: SqliteCoreModule, messag
|
|||||||
|
|
||||||
private inline fun finishFailedTransaction(
|
private inline fun finishFailedTransaction(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqlCoreModule,
|
||||||
failure: Throwable,
|
failure: Throwable,
|
||||||
rollback: () -> Unit,
|
rollback: () -> Unit,
|
||||||
): Throwable {
|
): 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
|
val errorObject = (failure as? ExecutionError)?.errorObject ?: return false
|
||||||
return errorObject.isInstanceOf(core.rollbackException)
|
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