Compare commits
No commits in common. "a2e3f80ab62fff6e469deb6e8c94f61e62994c08" and "b42ceec686fed397760b219e7d0f29ff443027c0" have entirely different histories.
a2e3f80ab6
...
b42ceec686
21
CHANGELOG.md
21
CHANGELOG.md
@ -7,27 +7,6 @@ History note:
|
|||||||
- Entries below are synchronized and curated for `1.5.x`.
|
- Entries below are synchronized and curated for `1.5.x`.
|
||||||
- Earlier history may be incomplete and should be cross-checked with git tags/commits when needed.
|
- Earlier history may be incomplete and should be cross-checked with git tags/commits when needed.
|
||||||
|
|
||||||
## Unreleased
|
|
||||||
|
|
||||||
### Database access
|
|
||||||
- Added the portable `lyng.io.db` SQL contract and the first concrete provider, `lyng.io.db.sqlite`.
|
|
||||||
- Added SQLite support on JVM and Linux Native with:
|
|
||||||
- generic `openDatabase("sqlite:...")` dispatch
|
|
||||||
- typed `openSqlite(...)` helper
|
|
||||||
- real nested transactions via savepoints
|
|
||||||
- generated keys through `ExecutionResult.getGeneratedKeys()`
|
|
||||||
- strict schema-driven value conversion for `Bool`, `Decimal`, `Date`, `DateTime`, and `Instant`
|
|
||||||
- documented option handling for `readOnly`, `createIfMissing`, `foreignKeys`, and `busyTimeoutMillis`
|
|
||||||
- Added public docs for database usage and SQLite provider behavior.
|
|
||||||
|
|
||||||
### Time
|
|
||||||
- Added `Date` to `lyng.time` and the core library as a first-class calendar-date type.
|
|
||||||
- Added `Instant.toDate(...)`, `DateTime.date`, `DateTime.toDate()`, `Date.toDateTime(...)`, and related date arithmetic.
|
|
||||||
- Added docs, stdlib reference updates, serialization support, and comprehensive tests for `Date`.
|
|
||||||
|
|
||||||
### Release notes
|
|
||||||
- Full `:lyngio:jvmTest` and `:lyngio:linuxX64Test` pass on the release tree after SQLite hardening.
|
|
||||||
|
|
||||||
## 1.5.4 (2026-04-03)
|
## 1.5.4 (2026-04-03)
|
||||||
|
|
||||||
### Runtime and compiler stability
|
### Runtime and compiler stability
|
||||||
|
|||||||
@ -52,8 +52,6 @@ assertEquals(A.E.One, A.One)
|
|||||||
- [What's New in 1.5](docs/whats_new_1_5.md)
|
- [What's New in 1.5](docs/whats_new_1_5.md)
|
||||||
- [Testing and Assertions](docs/Testing.md)
|
- [Testing and Assertions](docs/Testing.md)
|
||||||
- [Filesystem and Processes (lyngio)](docs/lyngio.md)
|
- [Filesystem and Processes (lyngio)](docs/lyngio.md)
|
||||||
- [SQL Databases (lyng.io.db)](docs/lyng.io.db.md)
|
|
||||||
- [Time and Calendar Types](docs/time.md)
|
|
||||||
- [Return Statement](docs/return_statement.md)
|
- [Return Statement](docs/return_statement.md)
|
||||||
- [Efficient Iterables in Kotlin Interop](docs/EfficientIterables.md)
|
- [Efficient Iterables in Kotlin Interop](docs/EfficientIterables.md)
|
||||||
- [Samples directory](docs/samples)
|
- [Samples directory](docs/samples)
|
||||||
|
|||||||
@ -20,7 +20,7 @@
|
|||||||
set -e
|
set -e
|
||||||
echo "publishing all artifacts"
|
echo "publishing all artifacts"
|
||||||
echo
|
echo
|
||||||
./gradlew publishToMavenLocal site:jsBrowserDistribution publish buildInstallablePlugin :lyng:linkReleaseExecutableLinuxX64 :lyng:installJvmDist --parallel --no-configuration-cache
|
./gradlew publishToMavenLocal site:jsBrowserDistribution publish buildInstallablePlugin :lyng:linkReleaseExecutableLinuxX64 :lyng:installJvmDist --parallel
|
||||||
|
|
||||||
#echo
|
#echo
|
||||||
#echo "Creating plugin"
|
#echo "Creating plugin"
|
||||||
|
|||||||
@ -81,7 +81,7 @@ Sources: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt`, `lynglib/s
|
|||||||
- `import lyng.serialization`
|
- `import lyng.serialization`
|
||||||
- `Lynon` serialization utilities.
|
- `Lynon` serialization utilities.
|
||||||
- `import lyng.time`
|
- `import lyng.time`
|
||||||
- `Instant`, `Date`, `DateTime`, `Duration`, and module `delay`.
|
- `Instant`, `DateTime`, `Duration`, and module `delay`.
|
||||||
|
|
||||||
## 6. Optional (lyngio) Modules
|
## 6. Optional (lyngio) Modules
|
||||||
Requires installing `lyngio` into the import manager from host code.
|
Requires installing `lyngio` into the import manager from host code.
|
||||||
|
|||||||
@ -1,240 +0,0 @@
|
|||||||
### 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`.
|
|
||||||
|
|
||||||
> **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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Install the module into a Lyng session
|
|
||||||
|
|
||||||
For SQLite-backed database access, install both the generic DB module and the SQLite provider:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
import net.sergeych.lyng.EvalSession
|
|
||||||
import net.sergeych.lyng.Scope
|
|
||||||
import net.sergeych.lyng.io.db.createDbModule
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.createSqliteModule
|
|
||||||
|
|
||||||
suspend fun bootstrapDb() {
|
|
||||||
val session = EvalSession()
|
|
||||||
val scope: Scope = session.getScope()
|
|
||||||
createDbModule(scope)
|
|
||||||
createSqliteModule(scope)
|
|
||||||
session.eval("""
|
|
||||||
import lyng.io.db
|
|
||||||
import lyng.io.db.sqlite
|
|
||||||
""".trimIndent())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`createSqliteModule(...)` also registers the `sqlite:` scheme for generic `openDatabase(...)`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Using from Lyng scripts
|
|
||||||
|
|
||||||
Typed SQLite open helper:
|
|
||||||
|
|
||||||
```lyng
|
|
||||||
import lyng.io.db.sqlite
|
|
||||||
|
|
||||||
val db = openSqlite(":memory:")
|
|
||||||
|
|
||||||
val userCount = db.transaction { tx ->
|
|
||||||
tx.execute("create table user(id integer primary key autoincrement, name text not null)")
|
|
||||||
tx.execute("insert into user(name) values(?)", "Ada")
|
|
||||||
tx.execute("insert into user(name) values(?)", "Linus")
|
|
||||||
tx.select("select count(*) as count from user").toList()[0]["count"]
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals(2, userCount)
|
|
||||||
```
|
|
||||||
|
|
||||||
Generic provider-based open:
|
|
||||||
|
|
||||||
```lyng
|
|
||||||
import lyng.io.db
|
|
||||||
import lyng.io.db.sqlite
|
|
||||||
|
|
||||||
val db = openDatabase(
|
|
||||||
"sqlite:./app.db",
|
|
||||||
Map(
|
|
||||||
"foreignKeys" => true,
|
|
||||||
"busyTimeoutMillis" => 5000
|
|
||||||
)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Nested transactions use real savepoint semantics:
|
|
||||||
|
|
||||||
```lyng
|
|
||||||
import lyng.io.db
|
|
||||||
import lyng.io.db.sqlite
|
|
||||||
|
|
||||||
val db = openSqlite(":memory:")
|
|
||||||
|
|
||||||
db.transaction { tx ->
|
|
||||||
tx.execute("create table item(id integer primary key autoincrement, name text not null)")
|
|
||||||
tx.execute("insert into item(name) values(?)", "outer")
|
|
||||||
|
|
||||||
try {
|
|
||||||
tx.transaction { inner ->
|
|
||||||
inner.execute("insert into item(name) values(?)", "inner")
|
|
||||||
throw IllegalStateException("rollback nested")
|
|
||||||
}
|
|
||||||
} catch (_: IllegalStateException) {
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals(1, tx.select("select count(*) as count from item").toList()[0]["count"])
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Intentional rollback without treating it as a backend failure:
|
|
||||||
|
|
||||||
```lyng
|
|
||||||
import lyng.io.db
|
|
||||||
import lyng.io.db.sqlite
|
|
||||||
|
|
||||||
val db = openSqlite(":memory:")
|
|
||||||
|
|
||||||
assertThrows(RollbackException) {
|
|
||||||
db.transaction { tx ->
|
|
||||||
tx.execute("create table item(id integer primary key autoincrement, name text not null)")
|
|
||||||
tx.execute("insert into item(name) values(?)", "temporary")
|
|
||||||
throw RollbackException("stop here")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Portable API
|
|
||||||
|
|
||||||
##### `Database`
|
|
||||||
|
|
||||||
- `transaction(block)` — opens a transaction, commits on normal exit, rolls back on uncaught failure.
|
|
||||||
|
|
||||||
##### `SqlTransaction`
|
|
||||||
|
|
||||||
- `select(clause, params...)` — execute a statement whose primary result is a row set.
|
|
||||||
- `execute(clause, params...)` — execute a side-effect statement and return `ExecutionResult`.
|
|
||||||
- `transaction(block)` — nested transaction with real savepoint semantics.
|
|
||||||
|
|
||||||
##### `ResultSet`
|
|
||||||
|
|
||||||
- `columns` — positional `SqlColumn` metadata, available before iteration.
|
|
||||||
- `size()` — result row count.
|
|
||||||
- `isEmpty()` — fast emptiness check where possible.
|
|
||||||
- `iterator()` / `toList()` — normal row iteration.
|
|
||||||
|
|
||||||
##### `SqlRow`
|
|
||||||
|
|
||||||
- `row[index]` — zero-based positional access.
|
|
||||||
- `row["columnName"]` — case-insensitive lookup by output column label.
|
|
||||||
|
|
||||||
Name-based access fails with `SqlUsageException` if the name is missing or ambiguous.
|
|
||||||
|
|
||||||
##### `ExecutionResult`
|
|
||||||
|
|
||||||
- `affectedRowsCount`
|
|
||||||
- `getGeneratedKeys()`
|
|
||||||
|
|
||||||
Statements that return rows directly, such as `... returning ...`, should use `select(...)`, not `execute(...)`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Value mapping
|
|
||||||
|
|
||||||
Portable bind values:
|
|
||||||
|
|
||||||
- `null`
|
|
||||||
- `Bool`
|
|
||||||
- `Int`, `Double`, `Decimal`
|
|
||||||
- `String`
|
|
||||||
- `Buffer`
|
|
||||||
- `Date`, `DateTime`, `Instant`
|
|
||||||
|
|
||||||
Unsupported parameter values fail with `SqlUsageException`.
|
|
||||||
|
|
||||||
Portable result metadata categories:
|
|
||||||
|
|
||||||
- `Binary`
|
|
||||||
- `String`
|
|
||||||
- `Int`
|
|
||||||
- `Double`
|
|
||||||
- `Decimal`
|
|
||||||
- `Bool`
|
|
||||||
- `Date`
|
|
||||||
- `DateTime`
|
|
||||||
- `Instant`
|
|
||||||
|
|
||||||
For temporal types, see [time functions](time.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### SQLite provider
|
|
||||||
|
|
||||||
`lyng.io.db.sqlite` currently provides the first concrete backend.
|
|
||||||
|
|
||||||
Typed helper:
|
|
||||||
|
|
||||||
```lyng
|
|
||||||
openSqlite(
|
|
||||||
path: String,
|
|
||||||
readOnly: Bool = false,
|
|
||||||
createIfMissing: Bool = true,
|
|
||||||
foreignKeys: Bool = true,
|
|
||||||
busyTimeoutMillis: Int = 5000
|
|
||||||
): Database
|
|
||||||
```
|
|
||||||
|
|
||||||
Accepted generic URL forms:
|
|
||||||
|
|
||||||
- `sqlite::memory:`
|
|
||||||
- `sqlite:relative/path.db`
|
|
||||||
- `sqlite:/absolute/path.db`
|
|
||||||
|
|
||||||
Supported `openDatabase(..., extraParams)` keys for SQLite:
|
|
||||||
|
|
||||||
- `readOnly: Bool`
|
|
||||||
- `createIfMissing: Bool`
|
|
||||||
- `foreignKeys: Bool`
|
|
||||||
- `busyTimeoutMillis: Int`
|
|
||||||
|
|
||||||
SQLite write/read policy in v1:
|
|
||||||
|
|
||||||
- `Bool` writes as `0` / `1`
|
|
||||||
- `Decimal` writes as canonical text
|
|
||||||
- `Date` writes as `YYYY-MM-DD`
|
|
||||||
- `DateTime` writes as ISO local timestamp text without timezone
|
|
||||||
- `Instant` writes as ISO UTC timestamp text with explicit timezone marker
|
|
||||||
- `TIME*` values stay `String`
|
|
||||||
- `TIMESTAMP` / `DATETIME` reject timezone-bearing stored text
|
|
||||||
|
|
||||||
Open-time validation failures:
|
|
||||||
|
|
||||||
- malformed URL or bad option shape -> `IllegalArgumentException`
|
|
||||||
- runtime open failure -> `DatabaseException`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Lifetime rules
|
|
||||||
|
|
||||||
Result sets and rows are valid only while their owning transaction is active.
|
|
||||||
|
|
||||||
This means:
|
|
||||||
|
|
||||||
- do not keep `ResultSet` or `SqlRow` objects after the transaction block returns
|
|
||||||
- copy the values you need into ordinary Lyng objects inside the transaction
|
|
||||||
|
|
||||||
The same lifetime rule applies to generated keys returned by `ExecutionResult.getGeneratedKeys()`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Platform support
|
|
||||||
|
|
||||||
- `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
|
|
||||||
|
|
||||||
For the broader I/O overview, see [lyngio overview](lyngio.md).
|
|
||||||
@ -12,7 +12,6 @@
|
|||||||
|
|
||||||
#### 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.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.
|
||||||
@ -44,8 +43,6 @@ 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.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
|
||||||
import net.sergeych.lyng.io.console.createConsoleModule
|
import net.sergeych.lyng.io.console.createConsoleModule
|
||||||
@ -64,8 +61,6 @@ suspend fun runMyScript() {
|
|||||||
val scope = session.getScope()
|
val scope = session.getScope()
|
||||||
|
|
||||||
// Install modules with policies
|
// Install modules with policies
|
||||||
createDbModule(scope)
|
|
||||||
createSqliteModule(scope)
|
|
||||||
createFs(PermitAllAccessPolicy, scope)
|
createFs(PermitAllAccessPolicy, scope)
|
||||||
createProcessModule(PermitAllProcessAccessPolicy, scope)
|
createProcessModule(PermitAllProcessAccessPolicy, scope)
|
||||||
createConsoleModule(PermitAllConsoleAccessPolicy, scope)
|
createConsoleModule(PermitAllConsoleAccessPolicy, scope)
|
||||||
@ -75,8 +70,6 @@ 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.sqlite
|
|
||||||
import lyng.io.fs
|
import lyng.io.fs
|
||||||
import lyng.io.process
|
import lyng.io.process
|
||||||
import lyng.io.console
|
import lyng.io.console
|
||||||
@ -84,7 +77,6 @@ suspend fun runMyScript() {
|
|||||||
import lyng.io.net
|
import lyng.io.net
|
||||||
import lyng.io.ws
|
import lyng.io.ws
|
||||||
|
|
||||||
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)
|
||||||
println("TTY: " + Console.isTty())
|
println("TTY: " + Console.isTty())
|
||||||
@ -102,7 +94,6 @@ 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.
|
|
||||||
- **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.
|
||||||
@ -110,7 +101,6 @@ suspend fun runMyScript() {
|
|||||||
- **WebSocket Security:** Implement `WsAccessPolicy` to restrict websocket connects and message flow.
|
- **WebSocket Security:** Implement `WsAccessPolicy` to restrict websocket connects and message flow.
|
||||||
|
|
||||||
For more details, see the specific module documentation:
|
For more details, see the specific module documentation:
|
||||||
- [Database Module Details](lyng.io.db.md)
|
|
||||||
- [Filesystem Security Details](lyng.io.fs.md#access-policy-security)
|
- [Filesystem Security Details](lyng.io.fs.md#access-policy-security)
|
||||||
- [Process Security Details](lyng.io.process.md#security-policy)
|
- [Process Security Details](lyng.io.process.md#security-policy)
|
||||||
- [Console Module Details](lyng.io.console.md)
|
- [Console Module Details](lyng.io.console.md)
|
||||||
@ -122,16 +112,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.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
|
||||||
|
|||||||
173
docs/time.md
173
docs/time.md
@ -1,135 +1,74 @@
|
|||||||
# Lyng time functions
|
# Lyng time functions
|
||||||
|
|
||||||
Lyng date and time support requires importing `lyng.time`. The module provides four related types:
|
Lyng date and time support requires importing `lyng.time` packages. Lyng uses simple yet modern time object models:
|
||||||
|
|
||||||
- `Instant` for absolute timestamps.
|
- `Instant` class for absolute time stamps with platform-dependent resolution.
|
||||||
- `Date` for calendar dates without time-of-day or timezone.
|
- `DateTime` class for calendar-aware points in time within a specific time zone.
|
||||||
- `DateTime` for calendar-aware points in time in a specific timezone.
|
- `Duration` to represent amount of time not depending on the calendar (e.g., milliseconds, seconds).
|
||||||
- `Duration` for absolute elapsed time.
|
|
||||||
|
|
||||||
## Time instant: `Instant`
|
## Time instant: `Instant`
|
||||||
|
|
||||||
`Instant` represents some moment of time independently of the calendar. It is similar to SQL `TIMESTAMP`
|
Represent some moment of time not depending on the calendar. It is similar to `TIMESTAMP` in SQL or `Instant` in Kotlin.
|
||||||
or Kotlin `Instant`.
|
|
||||||
|
|
||||||
### Constructing and converting
|
### Constructing and converting
|
||||||
|
|
||||||
import lyng.time
|
import lyng.time
|
||||||
|
|
||||||
|
// default constructor returns time now:
|
||||||
val t1 = Instant()
|
val t1 = Instant()
|
||||||
val t2 = Instant(1704110400)
|
|
||||||
|
// constructing from a number is treated as seconds since unix epoch:
|
||||||
|
val t2 = Instant(1704110400) // 2024-01-01T12:00:00Z
|
||||||
|
|
||||||
|
// from RFC3339 string:
|
||||||
val t3 = Instant("2024-01-01T12:00:00.123456Z")
|
val t3 = Instant("2024-01-01T12:00:00.123456Z")
|
||||||
|
|
||||||
val t4 = t3.truncateToMinute()
|
// truncation:
|
||||||
assertEquals("2024-01-01T12:00:00Z", t4.toRFC3339())
|
val t4 = t3.truncateToMinute
|
||||||
|
assertEquals(t4.toRFC3339(), "2024-01-01T12:00:00Z")
|
||||||
|
|
||||||
|
// to localized DateTime (uses system default TZ if not specified):
|
||||||
val dt = t3.toDateTime("+02:00")
|
val dt = t3.toDateTime("+02:00")
|
||||||
assertEquals(14, dt.hour)
|
assertEquals(dt.hour, 14)
|
||||||
|
|
||||||
val d = t3.toDate("Z")
|
|
||||||
assertEquals(Date(2024, 1, 1), d)
|
|
||||||
|
|
||||||
### Instant members
|
### Instant members
|
||||||
|
|
||||||
| member | description |
|
| member | description |
|
||||||
|--------------------------------|------------------------------------------------------|
|
|--------------------------------|---------------------------------------------------------|
|
||||||
| epochSeconds: Real | offset in seconds since Unix epoch |
|
| epochSeconds: Real | positive or negative offset in seconds since Unix epoch |
|
||||||
| epochWholeSeconds: Int | whole seconds since Unix epoch |
|
| epochWholeSeconds: Int | same, but in _whole seconds_. Slightly faster |
|
||||||
| nanosecondsOfSecond: Int | nanoseconds within the current second |
|
| nanosecondsOfSecond: Int | offset from epochWholeSeconds in nanos |
|
||||||
| isDistantFuture: Bool | true if it is `Instant.distantFuture` |
|
| isDistantFuture: Bool | true if it `Instant.distantFuture` |
|
||||||
| isDistantPast: Bool | true if it is `Instant.distantPast` |
|
| isDistantPast: Bool | true if it `Instant.distantPast` |
|
||||||
| truncateToMinute(): Instant | truncate to minute precision |
|
| truncateToMinute: Instant | create new instance truncated to minute |
|
||||||
| truncateToSecond(): Instant | truncate to second precision |
|
| truncateToSecond: Instant | create new instance truncated to second |
|
||||||
| truncateToMillisecond(): Instant | truncate to millisecond precision |
|
| truncateToMillisecond: Instant | truncate new instance to millisecond |
|
||||||
| truncateToMicrosecond(): Instant | truncate to microsecond precision |
|
| truncateToMicrosecond: Instant | truncate new instance to microsecond |
|
||||||
| toRFC3339(): String | format as RFC3339 string in UTC |
|
| toRFC3339(): String | format as RFC3339 string (UTC) |
|
||||||
| toDateTime(tz?): DateTime | localize to a timezone |
|
| toDateTime(tz?): DateTime | localize to a TimeZone (ID string or offset seconds) |
|
||||||
| toDate(tz?): Date | convert to a calendar date in a timezone |
|
|
||||||
|
|
||||||
## Calendar date: `Date`
|
|
||||||
|
|
||||||
`Date` represents a pure calendar date. It has no time-of-day and no attached timezone. Use it for values
|
|
||||||
like birthdays, due dates, invoice dates, and SQL `DATE` columns.
|
|
||||||
|
|
||||||
### Constructing
|
|
||||||
|
|
||||||
import lyng.time
|
|
||||||
|
|
||||||
val today = Date()
|
|
||||||
val d1 = Date(2026, 4, 15)
|
|
||||||
val d2 = Date("2024-02-29")
|
|
||||||
val d3 = Date.parseIso("2024-02-29")
|
|
||||||
val d4 = Date(DateTime(2024, 5, 20, 15, 30, 45, "+02:00"))
|
|
||||||
val d5 = Date(Instant("2024-01-01T23:30:00Z"), "+02:00")
|
|
||||||
|
|
||||||
### Date members
|
|
||||||
|
|
||||||
| member | description |
|
|
||||||
|--------------------------------|------------------------------------------------------------|
|
|
||||||
| year: Int | year component |
|
|
||||||
| month: Int | month component (1..12) |
|
|
||||||
| day: Int | day of month (alias `dayOfMonth`) |
|
|
||||||
| dayOfWeek: Int | day of week (1=Monday, 7=Sunday) |
|
|
||||||
| dayOfYear: Int | day of year (1..365/366) |
|
|
||||||
| isLeapYear: Bool | whether this date is in a leap year |
|
|
||||||
| lengthOfMonth: Int | number of days in this month |
|
|
||||||
| lengthOfYear: Int | 365 or 366 |
|
|
||||||
| toIsoString(): String | ISO `YYYY-MM-DD` string |
|
|
||||||
| toSortableString(): String | alias to `toIsoString()` |
|
|
||||||
| toDateTime(tz="Z"): DateTime | start-of-day `DateTime` in the specified timezone |
|
|
||||||
| atStartOfDay(tz="Z"): DateTime | alias to `toDateTime()` |
|
|
||||||
| addDays(n): Date | add or subtract calendar days |
|
|
||||||
| addMonths(n): Date | add or subtract months, normalizing end-of-month |
|
|
||||||
| addYears(n): Date | add or subtract years |
|
|
||||||
| daysUntil(other): Int | calendar days until `other` |
|
|
||||||
| daysSince(other): Int | calendar days since `other` |
|
|
||||||
| static today(tz?): Date | today in the specified timezone |
|
|
||||||
| static parseIso(s): Date | parse ISO `YYYY-MM-DD` |
|
|
||||||
|
|
||||||
### Date arithmetic
|
|
||||||
|
|
||||||
`Date` supports only whole-day arithmetic. This is deliberate: calendar dates should not silently accept
|
|
||||||
sub-day durations.
|
|
||||||
|
|
||||||
import lyng.time
|
|
||||||
|
|
||||||
val d1 = Date(2026, 4, 15)
|
|
||||||
val d2 = d1.addDays(10)
|
|
||||||
|
|
||||||
assertEquals(Date(2026, 4, 25), d2)
|
|
||||||
assertEquals(Date(2026, 4, 18), d1 + 3.days)
|
|
||||||
assertEquals(Date(2026, 4, 12), d1 - 3.days)
|
|
||||||
assertEquals(10, d1.daysUntil(d2))
|
|
||||||
assertEquals(10, d2.daysSince(d1))
|
|
||||||
assertEquals(10, d2 - d1)
|
|
||||||
|
|
||||||
### Date conversions
|
|
||||||
|
|
||||||
import lyng.time
|
|
||||||
|
|
||||||
val i = Instant("2024-01-01T23:30:00Z")
|
|
||||||
assertEquals(Date(2024, 1, 1), i.toDate("Z"))
|
|
||||||
assertEquals(Date(2024, 1, 2), i.toDate("+02:00"))
|
|
||||||
|
|
||||||
val dt = DateTime(2024, 5, 20, 15, 30, 45, "+02:00")
|
|
||||||
assertEquals(Date(2024, 5, 20), dt.date)
|
|
||||||
assertEquals(Date(2024, 5, 20), dt.toDate())
|
|
||||||
assertEquals(DateTime(2024, 5, 20, 0, 0, 0, "Z"), Date(2024, 5, 20).toDateTime("Z"))
|
|
||||||
assertEquals(DateTime(2024, 5, 20, 0, 0, 0, "+02:00"), Date(2024, 5, 20).atStartOfDay("+02:00"))
|
|
||||||
|
|
||||||
## Calendar time: `DateTime`
|
## Calendar time: `DateTime`
|
||||||
|
|
||||||
`DateTime` represents a point in time in a specific timezone. It provides access to calendar components
|
`DateTime` represents a point in time in a specific timezone. It provides access to calendar components like year,
|
||||||
such as year, month, day, and hour.
|
month, and day.
|
||||||
|
|
||||||
### Constructing
|
### Constructing
|
||||||
|
|
||||||
import lyng.time
|
import lyng.time
|
||||||
|
|
||||||
|
// Current time in system default timezone
|
||||||
val now = DateTime.now()
|
val now = DateTime.now()
|
||||||
|
|
||||||
|
// Specific timezone
|
||||||
val offsetTime = DateTime.now("+02:00")
|
val offsetTime = DateTime.now("+02:00")
|
||||||
|
|
||||||
|
// From Instant
|
||||||
val dt = Instant().toDateTime("Z")
|
val dt = Instant().toDateTime("Z")
|
||||||
|
|
||||||
|
// By components (year, month, day, hour=0, minute=0, second=0, timeZone="UTC")
|
||||||
val dt2 = DateTime(2024, 1, 1, 12, 0, 0, "Z")
|
val dt2 = DateTime(2024, 1, 1, 12, 0, 0, "Z")
|
||||||
|
|
||||||
|
// From RFC3339 string
|
||||||
val dt3 = DateTime.parseRFC3339("2024-01-01T12:00:00+02:00")
|
val dt3 = DateTime.parseRFC3339("2024-01-01T12:00:00+02:00")
|
||||||
|
|
||||||
### DateTime members
|
### DateTime members
|
||||||
@ -144,9 +83,7 @@ such as year, month, day, and hour.
|
|||||||
| second: Int | second component (0..59) |
|
| second: Int | second component (0..59) |
|
||||||
| dayOfWeek: Int | day of week (1=Monday, 7=Sunday) |
|
| dayOfWeek: Int | day of week (1=Monday, 7=Sunday) |
|
||||||
| timeZone: String | timezone ID string |
|
| timeZone: String | timezone ID string |
|
||||||
| date: Date | calendar date component |
|
|
||||||
| toInstant(): Instant | convert back to absolute Instant |
|
| toInstant(): Instant | convert back to absolute Instant |
|
||||||
| toDate(): Date | extract the calendar date in this timezone |
|
|
||||||
| toUTC(): DateTime | shortcut to convert to UTC |
|
| toUTC(): DateTime | shortcut to convert to UTC |
|
||||||
| toTimeZone(tz): DateTime | convert to another timezone |
|
| toTimeZone(tz): DateTime | convert to another timezone |
|
||||||
| addMonths(n): DateTime | add/subtract months (normalizes end of month) |
|
| addMonths(n): DateTime | add/subtract months (normalizes end of month) |
|
||||||
@ -159,27 +96,28 @@ such as year, month, day, and hour.
|
|||||||
|
|
||||||
`DateTime` handles calendar arithmetic correctly:
|
`DateTime` handles calendar arithmetic correctly:
|
||||||
|
|
||||||
import lyng.time
|
|
||||||
|
|
||||||
val leapDay = Instant("2024-02-29T12:00:00Z").toDateTime("Z")
|
val leapDay = Instant("2024-02-29T12:00:00Z").toDateTime("Z")
|
||||||
val nextYear = leapDay.addYears(1)
|
val nextYear = leapDay.addYears(1)
|
||||||
assertEquals(28, nextYear.day)
|
assertEquals(nextYear.day, 28) // Feb 29, 2024 -> Feb 28, 2025
|
||||||
|
|
||||||
# `Duration` class
|
# `Duration` class
|
||||||
|
|
||||||
`Duration` represents absolute elapsed time between two instants.
|
Represent absolute time distance between two `Instant`.
|
||||||
|
|
||||||
import lyng.time
|
import lyng.time
|
||||||
|
|
||||||
val t1 = Instant()
|
val t1 = Instant()
|
||||||
delay(1.millisecond)
|
|
||||||
val t2 = Instant()
|
|
||||||
|
|
||||||
|
// yes we can delay to period, and it is not blocking. is suspends!
|
||||||
|
delay(1.millisecond)
|
||||||
|
|
||||||
|
val t2 = Instant()
|
||||||
|
// be suspend, so actual time may vary:
|
||||||
assert( t2 - t1 >= 1.millisecond)
|
assert( t2 - t1 >= 1.millisecond)
|
||||||
assert( t2 - t1 < 100.millisecond)
|
assert( t2 - t1 < 100.millisecond)
|
||||||
>>> void
|
>>> void
|
||||||
|
|
||||||
Duration values can be created from numbers using extensions on `Int` and `Real`:
|
Duration can be converted from numbers, like `5.minutes` and so on. Extensions are created for
|
||||||
|
`Int` and `Real`, so for n as Real or Int it is possible to create durations::
|
||||||
|
|
||||||
- `n.millisecond`, `n.milliseconds`
|
- `n.millisecond`, `n.milliseconds`
|
||||||
- `n.second`, `n.seconds`
|
- `n.second`, `n.seconds`
|
||||||
@ -187,9 +125,10 @@ Duration values can be created from numbers using extensions on `Int` and `Real`
|
|||||||
- `n.hour`, `n.hours`
|
- `n.hour`, `n.hours`
|
||||||
- `n.day`, `n.days`
|
- `n.day`, `n.days`
|
||||||
|
|
||||||
Larger units like months or years are calendar-dependent and are intentionally not part of `Duration`.
|
The bigger time units like months or years are calendar-dependent and can't be used with `Duration`.
|
||||||
|
|
||||||
Each duration instance can be converted to numbers in these units:
|
Each duration instance can be converted to number of any of these time units, as `Real` number, if `d` is a `Duration`
|
||||||
|
instance:
|
||||||
|
|
||||||
- `d.microseconds`
|
- `d.microseconds`
|
||||||
- `d.milliseconds`
|
- `d.milliseconds`
|
||||||
@ -198,16 +137,18 @@ Each duration instance can be converted to numbers in these units:
|
|||||||
- `d.hours`
|
- `d.hours`
|
||||||
- `d.days`
|
- `d.days`
|
||||||
|
|
||||||
Example:
|
for example
|
||||||
|
|
||||||
import lyng.time
|
import lyng.time
|
||||||
|
|
||||||
assertEquals( 60, 1.minute.seconds )
|
assertEquals( 60, 1.minute.seconds )
|
||||||
assertEquals( 10.milliseconds, 0.01.seconds )
|
assertEquals( 10.milliseconds, 0.01.seconds )
|
||||||
|
|
||||||
>>> void
|
>>> void
|
||||||
|
|
||||||
# Utility functions
|
# Utility functions
|
||||||
|
|
||||||
## `delay(duration: Duration)`
|
## delay(duration: Duration)
|
||||||
|
|
||||||
|
Suspends current coroutine for at least the specified duration.
|
||||||
|
|
||||||
|
|
||||||
Suspends the current coroutine for at least the specified duration.
|
|
||||||
|
|||||||
@ -18,9 +18,8 @@ For a programmer-focused migration summary across 1.5.x, see `docs/whats_new_1_5
|
|||||||
- Descending ranges and loops with `downTo` / `downUntil`
|
- Descending ranges and loops with `downTo` / `downUntil`
|
||||||
- String interpolation with `$name` and `${expr}`
|
- String interpolation with `$name` and `${expr}`
|
||||||
- Decimal arithmetic, matrices/vectors, and complex numbers
|
- Decimal arithmetic, matrices/vectors, and complex numbers
|
||||||
- Calendar `Date` support in `lyng.time`
|
|
||||||
- Immutable collections and opt-in `ObservableList`
|
- Immutable collections and opt-in `ObservableList`
|
||||||
- Rich `lyngio` modules for SQLite databases, console, HTTP, WebSocket, TCP, and UDP
|
- Rich `lyngio` modules for console, HTTP, WebSocket, TCP, and UDP
|
||||||
- CLI improvements including the built-in formatter `lyng fmt`
|
- CLI improvements including the built-in formatter `lyng fmt`
|
||||||
- Better IDE support and stronger docs around the released feature set
|
- Better IDE support and stronger docs around the released feature set
|
||||||
|
|
||||||
@ -580,7 +579,7 @@ ws.close()
|
|||||||
|
|
||||||
These modules are capability-gated and host-installed, keeping Lyng safe by default while making networked scripts practical when enabled.
|
These modules are capability-gated and host-installed, keeping Lyng safe by default while making networked scripts practical when enabled.
|
||||||
|
|
||||||
See [lyngio overview](lyngio.md), [lyng.io.db](lyng.io.db.md), [lyng.io.http](lyng.io.http.md), [lyng.io.ws](lyng.io.ws.md), and [lyng.io.net](lyng.io.net.md).
|
See [lyngio overview](lyngio.md), [lyng.io.http](lyng.io.http.md), [lyng.io.ws](lyng.io.ws.md), and [lyng.io.net](lyng.io.net.md).
|
||||||
|
|
||||||
### CLI: Formatting Command
|
### CLI: Formatting Command
|
||||||
A new `fmt` subcommand has been added to the Lyng CLI.
|
A new `fmt` subcommand has been added to the Lyng CLI.
|
||||||
|
|||||||
@ -1,93 +0,0 @@
|
|||||||
import lyng.io.db
|
|
||||||
import lyng.io.db.sqlite
|
|
||||||
import lyng.time
|
|
||||||
|
|
||||||
println("SQLite demo: typed open, generic open, result sets, generated keys, nested rollback")
|
|
||||||
|
|
||||||
// The typed helper is the simplest entry point when you know you want SQLite.
|
|
||||||
val db = openSqlite(":memory:")
|
|
||||||
|
|
||||||
db.transaction { tx ->
|
|
||||||
// Keep schema creation and data changes inside one transaction block.
|
|
||||||
tx.execute("create table task(id integer primary key autoincrement, title text not null, done integer not null, due_date date not null)")
|
|
||||||
|
|
||||||
// execute(...) is for side-effect statements. Generated keys are read from
|
|
||||||
// ExecutionResult rather than from a synthetic row-returning INSERT.
|
|
||||||
val firstInsert = tx.execute(
|
|
||||||
"insert into task(title, done, due_date) values(?, ?, ?)",
|
|
||||||
"Write a SQLite example",
|
|
||||||
false,
|
|
||||||
Date(2026, 4, 15)
|
|
||||||
)
|
|
||||||
val firstGeneratedKeys = firstInsert.getGeneratedKeys()
|
|
||||||
val firstId = firstGeneratedKeys.toList()[0][0]
|
|
||||||
assertEquals(1, firstId)
|
|
||||||
|
|
||||||
tx.execute(
|
|
||||||
"insert into task(title, done, due_date) values(?, ?, ?)",
|
|
||||||
"Review the DB API",
|
|
||||||
true,
|
|
||||||
Date(2026, 4, 16)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Nested transactions are real savepoints. If the inner block fails,
|
|
||||||
// only the nested work is rolled back.
|
|
||||||
try {
|
|
||||||
tx.transaction { inner ->
|
|
||||||
inner.execute(
|
|
||||||
"insert into task(title, done, due_date) values(?, ?, ?)",
|
|
||||||
"This row is rolled back",
|
|
||||||
false,
|
|
||||||
Date(2026, 4, 17)
|
|
||||||
)
|
|
||||||
throw IllegalStateException("demonstrate nested rollback")
|
|
||||||
}
|
|
||||||
} catch (_: IllegalStateException) {
|
|
||||||
println("Nested transaction rolled back as expected")
|
|
||||||
}
|
|
||||||
|
|
||||||
// select(...) is for row-producing statements. ResultSet exposes metadata,
|
|
||||||
// cheap emptiness checks, iteration, and conversion to a plain list.
|
|
||||||
val tasks = tx.select("select id, title, done, due_date from task order by id")
|
|
||||||
assertEquals(false, tasks.isEmpty())
|
|
||||||
assertEquals(2, tasks.size())
|
|
||||||
|
|
||||||
println("Columns:")
|
|
||||||
for (column in tasks.columns) {
|
|
||||||
println(" " + column.name + " -> " + column.sqlType + " (native " + column.nativeType + ")")
|
|
||||||
}
|
|
||||||
|
|
||||||
val taskRows = tasks.toList()
|
|
||||||
|
|
||||||
println("Rows:")
|
|
||||||
for (row in taskRows) {
|
|
||||||
// Name lookups are case-insensitive and values are already converted.
|
|
||||||
println(" #" + row["ID"] + " " + row["title"] + " done=" + row["done"] + " due=" + row["due_date"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// If values need to survive after the transaction closes, copy them now.
|
|
||||||
val snapshot = tx.select("select title, due_date from task order by id").toList()
|
|
||||||
assertEquals("Write a SQLite example", snapshot[0]["title"])
|
|
||||||
assertEquals(Date(2026, 4, 16), snapshot[1]["due_date"])
|
|
||||||
|
|
||||||
val count = tx.select("select count(*) as count from task").toList()[0]["count"]
|
|
||||||
assertEquals(2, count)
|
|
||||||
println("Visible rows after nested rollback: $count")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The generic entry point stays useful for config-driven code.
|
|
||||||
val genericDb = openDatabase(
|
|
||||||
"sqlite::memory:",
|
|
||||||
Map(
|
|
||||||
"foreignKeys" => true,
|
|
||||||
"busyTimeoutMillis" => 1000
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val answer = genericDb.transaction { tx ->
|
|
||||||
tx.select("select 42 as answer").toList()[0]["answer"]
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals(42, answer)
|
|
||||||
println("Generic openDatabase(...) also works: answer=$answer")
|
|
||||||
println("OK")
|
|
||||||
@ -15,7 +15,6 @@ okioVersion = "3.10.2"
|
|||||||
compiler = "3.2.0-alpha11"
|
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"
|
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" }
|
clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" }
|
||||||
@ -44,7 +43,6 @@ ktor-client-winhttp = { module = "io.ktor:ktor-client-winhttp", version.ref = "k
|
|||||||
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
||||||
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" }
|
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
androidLibrary = { id = "com.android.library", version.ref = "agp" }
|
androidLibrary = { id = "com.android.library", version.ref = "agp" }
|
||||||
|
|||||||
@ -19,39 +19,11 @@ plugins {
|
|||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
}
|
}
|
||||||
|
|
||||||
import org.gradle.api.Project
|
|
||||||
import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest
|
import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest
|
||||||
|
|
||||||
group = "net.sergeych"
|
group = "net.sergeych"
|
||||||
version = "unspecified"
|
version = "unspecified"
|
||||||
|
|
||||||
private fun Project.sqliteLinuxLinkerOpts(vararg defaultDirs: String): List<String> {
|
|
||||||
val overrideDir = providers.gradleProperty("sqlite3.lib.dir").orNull
|
|
||||||
?: providers.environmentVariable("SQLITE3_LIB_DIR").orNull
|
|
||||||
val candidateDirs = buildList {
|
|
||||||
if (!overrideDir.isNullOrBlank()) {
|
|
||||||
add(file(overrideDir))
|
|
||||||
}
|
|
||||||
defaultDirs.forEach { add(file(it)) }
|
|
||||||
}.distinctBy { it.absolutePath }
|
|
||||||
|
|
||||||
val discoveredLib = sequenceOf("libsqlite3.so", "libsqlite3.so.0")
|
|
||||||
.mapNotNull { libraryName ->
|
|
||||||
candidateDirs.firstOrNull { it.resolve(libraryName).isFile }?.let { dir ->
|
|
||||||
listOf("-L${dir.absolutePath}", "-l:$libraryName")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.firstOrNull()
|
|
||||||
?: listOf("-lsqlite3")
|
|
||||||
|
|
||||||
return discoveredLib + listOf(
|
|
||||||
"-ldl",
|
|
||||||
"-lpthread",
|
|
||||||
"-lm",
|
|
||||||
"-Wl,--allow-shlib-undefined"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven("https://maven.universablockchain.com/")
|
maven("https://maven.universablockchain.com/")
|
||||||
@ -83,16 +55,6 @@ kotlin {
|
|||||||
binaries {
|
binaries {
|
||||||
executable()
|
executable()
|
||||||
all {
|
all {
|
||||||
linkerOpts(
|
|
||||||
*project.sqliteLinuxLinkerOpts(
|
|
||||||
"/lib/x86_64-linux-gnu",
|
|
||||||
"/usr/lib/x86_64-linux-gnu",
|
|
||||||
"/lib64",
|
|
||||||
"/usr/lib64",
|
|
||||||
"/lib",
|
|
||||||
"/usr/lib"
|
|
||||||
).toTypedArray()
|
|
||||||
)
|
|
||||||
if (buildType == org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType.RELEASE) {
|
if (buildType == org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType.RELEASE) {
|
||||||
debuggable = false
|
debuggable = false
|
||||||
optimized = true
|
optimized = true
|
||||||
|
|||||||
@ -40,8 +40,6 @@ import net.sergeych.lyng.ScriptError
|
|||||||
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.console.createConsoleModule
|
import net.sergeych.lyng.io.console.createConsoleModule
|
||||||
import net.sergeych.lyng.io.db.createDbModule
|
|
||||||
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
|
||||||
import net.sergeych.lyng.io.net.createNetModule
|
import net.sergeych.lyng.io.net.createNetModule
|
||||||
@ -140,7 +138,6 @@ 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.sqlite")
|
|
||||||
invalidatePackageCache("lyng.io.http")
|
invalidatePackageCache("lyng.io.http")
|
||||||
invalidatePackageCache("lyng.io.ws")
|
invalidatePackageCache("lyng.io.ws")
|
||||||
invalidatePackageCache("lyng.io.net")
|
invalidatePackageCache("lyng.io.net")
|
||||||
@ -228,8 +225,6 @@ private fun installCliModules(manager: ImportManager) {
|
|||||||
// Scripts still need to import the modules they use explicitly.
|
// Scripts still need to import the modules they use explicitly.
|
||||||
createFs(PermitAllAccessPolicy, manager)
|
createFs(PermitAllAccessPolicy, manager)
|
||||||
createConsoleModule(PermitAllConsoleAccessPolicy, manager)
|
createConsoleModule(PermitAllConsoleAccessPolicy, manager)
|
||||||
createDbModule(manager)
|
|
||||||
createSqliteModule(manager)
|
|
||||||
createHttpModule(PermitAllHttpAccessPolicy, manager)
|
createHttpModule(PermitAllHttpAccessPolicy, manager)
|
||||||
createWsModule(PermitAllWsAccessPolicy, manager)
|
createWsModule(PermitAllWsAccessPolicy, manager)
|
||||||
createNetModule(PermitAllNetAccessPolicy, manager)
|
createNetModule(PermitAllNetAccessPolicy, manager)
|
||||||
|
|||||||
@ -19,7 +19,6 @@
|
|||||||
* LyngIO: Compose Multiplatform library module depending on :lynglib
|
* LyngIO: Compose Multiplatform library module depending on :lynglib
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import org.gradle.api.Project
|
|
||||||
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
@ -32,33 +31,6 @@ plugins {
|
|||||||
group = "net.sergeych"
|
group = "net.sergeych"
|
||||||
version = "0.0.1-SNAPSHOT"
|
version = "0.0.1-SNAPSHOT"
|
||||||
|
|
||||||
private fun Project.sqliteLinuxLinkerOpts(vararg defaultDirs: String): List<String> {
|
|
||||||
val overrideDir = providers.gradleProperty("sqlite3.lib.dir").orNull
|
|
||||||
?: providers.environmentVariable("SQLITE3_LIB_DIR").orNull
|
|
||||||
val candidateDirs = buildList {
|
|
||||||
if (!overrideDir.isNullOrBlank()) {
|
|
||||||
add(file(overrideDir))
|
|
||||||
}
|
|
||||||
defaultDirs.forEach { add(file(it)) }
|
|
||||||
}.distinctBy { it.absolutePath }
|
|
||||||
|
|
||||||
val discoveredLib = sequenceOf("libsqlite3.so", "libsqlite3.so.0")
|
|
||||||
.mapNotNull { libraryName ->
|
|
||||||
candidateDirs.firstOrNull { it.resolve(libraryName).isFile }?.let { dir ->
|
|
||||||
listOf("-L${dir.absolutePath}", "-l:$libraryName")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.firstOrNull()
|
|
||||||
?: listOf("-lsqlite3")
|
|
||||||
|
|
||||||
return discoveredLib + listOf(
|
|
||||||
"-ldl",
|
|
||||||
"-lpthread",
|
|
||||||
"-lm",
|
|
||||||
"-Wl,--allow-shlib-undefined"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(17)
|
jvmToolchain(17)
|
||||||
jvm()
|
jvm()
|
||||||
@ -86,39 +58,6 @@ kotlin {
|
|||||||
// nodejs()
|
// nodejs()
|
||||||
// }
|
// }
|
||||||
|
|
||||||
targets.withType(org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget::class.java).configureEach {
|
|
||||||
compilations.getByName("main").cinterops.create("sqlite3") {
|
|
||||||
defFile(project.file("src/nativeInterop/cinterop/sqlite/sqlite3.def"))
|
|
||||||
packageName("net.sergeych.lyng.io.db.sqlite.cinterop")
|
|
||||||
includeDirs(project.file("src/nativeInterop/cinterop/sqlite"))
|
|
||||||
}
|
|
||||||
binaries.all {
|
|
||||||
when (konanTarget.name) {
|
|
||||||
"linux_x64" -> linkerOpts(
|
|
||||||
*project.sqliteLinuxLinkerOpts(
|
|
||||||
"/lib/x86_64-linux-gnu",
|
|
||||||
"/usr/lib/x86_64-linux-gnu",
|
|
||||||
"/lib64",
|
|
||||||
"/usr/lib64",
|
|
||||||
"/lib",
|
|
||||||
"/usr/lib"
|
|
||||||
).toTypedArray()
|
|
||||||
)
|
|
||||||
"linux_arm64" -> linkerOpts(
|
|
||||||
*project.sqliteLinuxLinkerOpts(
|
|
||||||
"/lib/aarch64-linux-gnu",
|
|
||||||
"/usr/lib/aarch64-linux-gnu",
|
|
||||||
"/lib64",
|
|
||||||
"/usr/lib64",
|
|
||||||
"/lib",
|
|
||||||
"/usr/lib"
|
|
||||||
).toTypedArray()
|
|
||||||
)
|
|
||||||
else -> linkerOpts("-lsqlite3")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep expect/actual warning suppressed consistently with other modules
|
// Keep expect/actual warning suppressed consistently with other modules
|
||||||
targets.configureEach {
|
targets.configureEach {
|
||||||
compilations.configureEach {
|
compilations.configureEach {
|
||||||
@ -216,7 +155,6 @@ kotlin {
|
|||||||
implementation("org.jline:jline-terminal:3.29.0")
|
implementation("org.jline:jline-terminal:3.29.0")
|
||||||
implementation(libs.ktor.client.cio)
|
implementation(libs.ktor.client.cio)
|
||||||
implementation(libs.ktor.network)
|
implementation(libs.ktor.network)
|
||||||
implementation(libs.sqlite.jdbc)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// // For Wasm we use in-memory VFS for now
|
// // For Wasm we use in-memory VFS for now
|
||||||
|
|||||||
@ -1,37 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
package net.sergeych.lyng.io.db.sqlite
|
|
||||||
|
|
||||||
import net.sergeych.lyng.ScopeFacade
|
|
||||||
import net.sergeych.lyng.requireScope
|
|
||||||
import net.sergeych.lyng.obj.ObjException
|
|
||||||
import net.sergeych.lyng.obj.ObjString
|
|
||||||
|
|
||||||
internal actual suspend fun openSqliteBackend(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
options: SqliteOpenOptions,
|
|
||||||
): SqliteDatabaseBackend {
|
|
||||||
scope.raiseError(
|
|
||||||
ObjException(
|
|
||||||
core.databaseException,
|
|
||||||
scope.requireScope(),
|
|
||||||
ObjString("SQLite provider is not implemented on this platform yet")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
package net.sergeych.lyng.io.db
|
|
||||||
|
|
||||||
import net.sergeych.lyng.ModuleScope
|
|
||||||
import net.sergeych.lyng.Pos
|
|
||||||
import net.sergeych.lyng.Scope
|
|
||||||
import net.sergeych.lyng.ScopeFacade
|
|
||||||
import net.sergeych.lyng.Source
|
|
||||||
import net.sergeych.lyng.Arguments
|
|
||||||
import net.sergeych.lyng.obj.Obj
|
|
||||||
import net.sergeych.lyng.obj.ObjException
|
|
||||||
import net.sergeych.lyng.obj.ObjNull
|
|
||||||
import net.sergeych.lyng.obj.ObjString
|
|
||||||
import net.sergeych.lyng.obj.ObjVoid
|
|
||||||
import net.sergeych.lyng.obj.requiredArg
|
|
||||||
import net.sergeych.lyng.pacman.ImportManager
|
|
||||||
import net.sergeych.lyng.requireScope
|
|
||||||
import net.sergeych.lyngio.stdlib_included.dbLyng
|
|
||||||
|
|
||||||
private const val DB_MODULE_NAME = "lyng.io.db"
|
|
||||||
|
|
||||||
fun createDbModule(scope: Scope): Boolean = createDbModule(scope.importManager)
|
|
||||||
|
|
||||||
fun createDb(scope: Scope): Boolean = createDbModule(scope)
|
|
||||||
|
|
||||||
fun createDbModule(manager: ImportManager): Boolean {
|
|
||||||
if (manager.packageNames.contains(DB_MODULE_NAME)) return false
|
|
||||||
manager.addPackage(DB_MODULE_NAME) { module ->
|
|
||||||
buildDbModule(module)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createDb(manager: ImportManager): Boolean = createDbModule(manager)
|
|
||||||
|
|
||||||
private suspend fun buildDbModule(module: ModuleScope) {
|
|
||||||
module.eval(Source(DB_MODULE_NAME, dbLyng))
|
|
||||||
val exceptions = installDbExceptionClasses(module)
|
|
||||||
val registry = DbProviderRegistry()
|
|
||||||
|
|
||||||
module.addFn("registerDatabaseProvider") {
|
|
||||||
val scheme = requiredArg<ObjString>(0).value
|
|
||||||
val opener = args.list.getOrNull(1)
|
|
||||||
?: raiseError("Expected exactly 2 arguments, got ${args.list.size}")
|
|
||||||
registry.register(this, scheme, opener)
|
|
||||||
ObjVoid
|
|
||||||
}
|
|
||||||
|
|
||||||
module.addFn("openDatabase") {
|
|
||||||
val connectionUrl = requiredArg<ObjString>(0).value
|
|
||||||
val extraParams = args.list.getOrNull(1)
|
|
||||||
?: raiseError("Expected exactly 2 arguments, got ${args.list.size}")
|
|
||||||
if (!extraParams.isInstanceOf("Map")) {
|
|
||||||
raiseIllegalArgument("extraParams must be Map")
|
|
||||||
}
|
|
||||||
val scheme = parseConnectionScheme(connectionUrl)
|
|
||||||
?: raiseIllegalArgument("Malformed database connection URL: $connectionUrl")
|
|
||||||
val opener = registry.providers[scheme]
|
|
||||||
?: raiseDatabaseException(exceptions.database, "No database provider registered for scheme '$scheme'")
|
|
||||||
call(opener, Arguments(listOf(ObjString(connectionUrl), extraParams)), newThisObj = ObjNull)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class DbExceptionClasses(
|
|
||||||
val database: ObjException.Companion.ExceptionClass,
|
|
||||||
val sqlExecution: ObjException.Companion.ExceptionClass,
|
|
||||||
val sqlConstraint: ObjException.Companion.ExceptionClass,
|
|
||||||
val sqlUsage: ObjException.Companion.ExceptionClass,
|
|
||||||
val rollback: ObjException.Companion.ExceptionClass,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun installDbExceptionClasses(module: ModuleScope): DbExceptionClasses {
|
|
||||||
val database = ObjException.Companion.ExceptionClass("DatabaseException", ObjException.Root)
|
|
||||||
val sqlExecution = ObjException.Companion.ExceptionClass("SqlExecutionException", database)
|
|
||||||
val sqlConstraint = ObjException.Companion.ExceptionClass("SqlConstraintException", sqlExecution)
|
|
||||||
val sqlUsage = ObjException.Companion.ExceptionClass("SqlUsageException", database)
|
|
||||||
val rollback = ObjException.Companion.ExceptionClass("RollbackException", ObjException.Root)
|
|
||||||
|
|
||||||
module.addConst("DatabaseException", database)
|
|
||||||
module.addConst("SqlExecutionException", sqlExecution)
|
|
||||||
module.addConst("SqlConstraintException", sqlConstraint)
|
|
||||||
module.addConst("SqlUsageException", sqlUsage)
|
|
||||||
module.addConst("RollbackException", rollback)
|
|
||||||
|
|
||||||
return DbExceptionClasses(
|
|
||||||
database = database,
|
|
||||||
sqlExecution = sqlExecution,
|
|
||||||
sqlConstraint = sqlConstraint,
|
|
||||||
sqlUsage = sqlUsage,
|
|
||||||
rollback = rollback,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private class DbProviderRegistry {
|
|
||||||
val providers: MutableMap<String, Obj> = linkedMapOf()
|
|
||||||
|
|
||||||
fun register(scope: ScopeFacade, rawScheme: String, opener: Obj) {
|
|
||||||
val scheme = normalizeScheme(rawScheme)
|
|
||||||
?: scope.raiseIllegalArgument("Database provider scheme must not be empty")
|
|
||||||
if (!opener.isInstanceOf("Callable")) {
|
|
||||||
scope.raiseIllegalArgument("Database provider opener must be callable")
|
|
||||||
}
|
|
||||||
if (providers.containsKey(scheme)) {
|
|
||||||
scope.raiseIllegalState("Database provider already registered for scheme '$scheme'")
|
|
||||||
}
|
|
||||||
providers[scheme] = opener
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeScheme(rawScheme: String): String? {
|
|
||||||
val trimmed = rawScheme.trim()
|
|
||||||
if (trimmed.isEmpty()) return null
|
|
||||||
if (':' in trimmed) return null
|
|
||||||
return trimmed.lowercase()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseConnectionScheme(connectionUrl: String): String? {
|
|
||||||
val colonIndex = connectionUrl.indexOf(':')
|
|
||||||
if (colonIndex <= 0) return null
|
|
||||||
return normalizeScheme(connectionUrl.substring(0, colonIndex))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ScopeFacade.raiseDatabaseException(
|
|
||||||
exceptionClass: ObjException.Companion.ExceptionClass,
|
|
||||||
message: String,
|
|
||||||
): Nothing = raiseError(ObjException(exceptionClass, requireScope(), ObjString(message)))
|
|
||||||
@ -1,542 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
package net.sergeych.lyng.io.db.sqlite
|
|
||||||
|
|
||||||
import net.sergeych.lyng.Arguments
|
|
||||||
import net.sergeych.lyng.ModuleScope
|
|
||||||
import net.sergeych.lyng.Pos
|
|
||||||
import net.sergeych.lyng.Scope
|
|
||||||
import net.sergeych.lyng.ScopeFacade
|
|
||||||
import net.sergeych.lyng.Source
|
|
||||||
import net.sergeych.lyng.asFacade
|
|
||||||
import net.sergeych.lyng.io.db.createDbModule
|
|
||||||
import net.sergeych.lyng.requireScope
|
|
||||||
import net.sergeych.lyng.obj.Obj
|
|
||||||
import net.sergeych.lyng.obj.ObjBool
|
|
||||||
import net.sergeych.lyng.obj.ObjClass
|
|
||||||
import net.sergeych.lyng.obj.ObjEnumClass
|
|
||||||
import net.sergeych.lyng.obj.ObjEnumEntry
|
|
||||||
import net.sergeych.lyng.obj.ObjException
|
|
||||||
import net.sergeych.lyng.obj.ObjImmutableList
|
|
||||||
import net.sergeych.lyng.obj.ObjInt
|
|
||||||
import net.sergeych.lyng.obj.ObjNull
|
|
||||||
import net.sergeych.lyng.obj.ObjReal
|
|
||||||
import net.sergeych.lyng.obj.ObjString
|
|
||||||
import net.sergeych.lyng.obj.ObjVoid
|
|
||||||
import net.sergeych.lyng.obj.requiredArg
|
|
||||||
import net.sergeych.lyng.obj.thisAs
|
|
||||||
import net.sergeych.lyng.pacman.ImportManager
|
|
||||||
import net.sergeych.lyngio.stdlib_included.db_sqliteLyng
|
|
||||||
|
|
||||||
private const val SQLITE_MODULE_NAME = "lyng.io.db.sqlite"
|
|
||||||
private const val DB_MODULE_NAME = "lyng.io.db"
|
|
||||||
|
|
||||||
fun createSqliteModule(scope: Scope): Boolean = createSqliteModule(scope.importManager)
|
|
||||||
|
|
||||||
fun createSqlite(scope: Scope): Boolean = createSqliteModule(scope)
|
|
||||||
|
|
||||||
fun createSqliteModule(manager: ImportManager): Boolean {
|
|
||||||
createDbModule(manager)
|
|
||||||
if (manager.packageNames.contains(SQLITE_MODULE_NAME)) return false
|
|
||||||
manager.addPackage(SQLITE_MODULE_NAME) { module ->
|
|
||||||
buildSqliteModule(module)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createSqlite(manager: ImportManager): Boolean = createSqliteModule(manager)
|
|
||||||
|
|
||||||
private suspend fun buildSqliteModule(module: ModuleScope) {
|
|
||||||
module.eval(Source(SQLITE_MODULE_NAME, db_sqliteLyng))
|
|
||||||
val dbModule = module.importProvider.createModuleScope(Pos.builtIn, DB_MODULE_NAME)
|
|
||||||
val core = SqliteCoreModule.resolve(dbModule)
|
|
||||||
val runtimeTypes = SqliteRuntimeTypes.create(core)
|
|
||||||
|
|
||||||
module.addFn("openSqlite") {
|
|
||||||
val options = parseOpenSqliteArgs(this)
|
|
||||||
SqliteDatabaseObj(runtimeTypes, openSqliteBackend(this, core, options))
|
|
||||||
}
|
|
||||||
|
|
||||||
dbModule.callFn(
|
|
||||||
"registerDatabaseProvider",
|
|
||||||
ObjString("sqlite"),
|
|
||||||
net.sergeych.lyng.obj.ObjExternCallable.fromBridge {
|
|
||||||
val connectionUrl = requiredArg<ObjString>(0).value
|
|
||||||
val extraParams = args.list.getOrNull(1)
|
|
||||||
?: raiseError("Expected exactly 2 arguments, got ${args.list.size}")
|
|
||||||
val options = parseSqliteConnectionUrl(this, connectionUrl, extraParams)
|
|
||||||
SqliteDatabaseObj(runtimeTypes, openSqliteBackend(this, core, options))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun parseOpenSqliteArgs(scope: ScopeFacade): SqliteOpenOptions {
|
|
||||||
val pathValue = readArg(scope, "path", 0) ?: scope.raiseError("argument 'path' is required")
|
|
||||||
val path = (pathValue as? ObjString)?.value ?: scope.raiseClassCastError("path must be String")
|
|
||||||
val readOnly = readBoolArg(scope, "readOnly", 1, false)
|
|
||||||
val createIfMissing = readBoolArg(scope, "createIfMissing", 2, true)
|
|
||||||
val foreignKeys = readBoolArg(scope, "foreignKeys", 3, true)
|
|
||||||
val busyTimeoutMillis = readIntArg(scope, "busyTimeoutMillis", 4, 5000)
|
|
||||||
return SqliteOpenOptions(
|
|
||||||
path = normalizeSqlitePath(path, scope),
|
|
||||||
readOnly = readOnly,
|
|
||||||
createIfMissing = createIfMissing,
|
|
||||||
foreignKeys = foreignKeys,
|
|
||||||
busyTimeoutMillis = busyTimeoutMillis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun parseSqliteConnectionUrl(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
connectionUrl: String,
|
|
||||||
extraParams: Obj,
|
|
||||||
): SqliteOpenOptions {
|
|
||||||
val prefix = "sqlite:"
|
|
||||||
if (!connectionUrl.startsWith(prefix, ignoreCase = true)) {
|
|
||||||
scope.raiseIllegalArgument("Malformed SQLite connection URL: $connectionUrl")
|
|
||||||
}
|
|
||||||
val rawPath = connectionUrl.substring(prefix.length)
|
|
||||||
val path = normalizeSqlitePath(rawPath, scope)
|
|
||||||
val readOnly = mapBool(extraParams, scope, "readOnly") ?: false
|
|
||||||
val createIfMissing = mapBool(extraParams, scope, "createIfMissing") ?: true
|
|
||||||
val foreignKeys = mapBool(extraParams, scope, "foreignKeys") ?: true
|
|
||||||
val busyTimeoutMillis = mapInt(extraParams, scope, "busyTimeoutMillis") ?: 5000
|
|
||||||
return SqliteOpenOptions(
|
|
||||||
path = path,
|
|
||||||
readOnly = readOnly,
|
|
||||||
createIfMissing = createIfMissing,
|
|
||||||
foreignKeys = foreignKeys,
|
|
||||||
busyTimeoutMillis = busyTimeoutMillis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeSqlitePath(rawPath: String, scope: ScopeFacade): String {
|
|
||||||
val path = rawPath.trim()
|
|
||||||
if (path.isEmpty()) {
|
|
||||||
scope.raiseIllegalArgument("SQLite path must not be empty")
|
|
||||||
}
|
|
||||||
if (path.startsWith("//")) {
|
|
||||||
scope.raiseIllegalArgument("Unsupported SQLite URL form: sqlite:$path")
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun readArg(scope: ScopeFacade, name: String, position: Int): Obj? {
|
|
||||||
val named = scope.args.named[name]
|
|
||||||
val positional = scope.args.list.getOrNull(position)
|
|
||||||
if (named != null && positional != null) {
|
|
||||||
scope.raiseIllegalArgument("argument '$name' is already set")
|
|
||||||
}
|
|
||||||
return named ?: positional
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun readBoolArg(scope: ScopeFacade, name: String, position: Int, default: Boolean): Boolean {
|
|
||||||
val value = readArg(scope, name, position) ?: return default
|
|
||||||
return (value as? ObjBool)?.value ?: scope.raiseClassCastError("$name must be Bool")
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun readIntArg(scope: ScopeFacade, name: String, position: Int, default: Int): Int {
|
|
||||||
val value = readArg(scope, name, position) ?: return default
|
|
||||||
return when (value) {
|
|
||||||
is ObjInt -> value.value.toInt()
|
|
||||||
else -> scope.raiseClassCastError("$name must be Int")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun mapBool(map: Obj, scope: ScopeFacade, key: String): Boolean? {
|
|
||||||
val value = map.getAt(scope.requireScope(), ObjString(key))
|
|
||||||
return when (value) {
|
|
||||||
ObjNull -> null
|
|
||||||
is ObjBool -> value.value
|
|
||||||
else -> scope.raiseClassCastError("extraParams.$key must be Bool")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun mapInt(map: Obj, scope: ScopeFacade, key: String): Int? {
|
|
||||||
val value = map.getAt(scope.requireScope(), ObjString(key))
|
|
||||||
return when (value) {
|
|
||||||
ObjNull -> null
|
|
||||||
is ObjInt -> value.value.toInt()
|
|
||||||
else -> scope.raiseClassCastError("extraParams.$key must be Int")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun ModuleScope.callFn(name: String, vararg args: Obj): Obj {
|
|
||||||
val callee = get(name)?.value ?: error("Missing $name in module")
|
|
||||||
return callee.invoke(this, ObjNull, *args)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal data class SqliteOpenOptions(
|
|
||||||
val path: String,
|
|
||||||
val readOnly: Boolean,
|
|
||||||
val createIfMissing: Boolean,
|
|
||||||
val foreignKeys: Boolean,
|
|
||||||
val busyTimeoutMillis: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal data class SqliteColumnMeta(
|
|
||||||
val name: String,
|
|
||||||
val sqlType: ObjEnumEntry,
|
|
||||||
val nullable: Boolean,
|
|
||||||
val nativeType: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal data class SqliteResultSetData(
|
|
||||||
val columns: List<SqliteColumnMeta>,
|
|
||||||
val rows: List<List<Obj>>,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal data class SqliteExecutionResultData(
|
|
||||||
val affectedRowsCount: Int,
|
|
||||||
val generatedKeys: SqliteResultSetData,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal interface SqliteDatabaseBackend {
|
|
||||||
suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T
|
|
||||||
}
|
|
||||||
|
|
||||||
internal interface SqliteTransactionBackend {
|
|
||||||
suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteResultSetData
|
|
||||||
suspend fun execute(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteExecutionResultData
|
|
||||||
suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T
|
|
||||||
}
|
|
||||||
|
|
||||||
internal expect suspend fun openSqliteBackend(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
options: SqliteOpenOptions,
|
|
||||||
): SqliteDatabaseBackend
|
|
||||||
|
|
||||||
internal class SqliteCoreModule private constructor(
|
|
||||||
val module: ModuleScope,
|
|
||||||
val databaseClass: ObjClass,
|
|
||||||
val transactionClass: ObjClass,
|
|
||||||
val resultSetClass: ObjClass,
|
|
||||||
val rowClass: ObjClass,
|
|
||||||
val columnClass: ObjClass,
|
|
||||||
val executionResultClass: ObjClass,
|
|
||||||
val databaseException: ObjException.Companion.ExceptionClass,
|
|
||||||
val sqlExecutionException: ObjException.Companion.ExceptionClass,
|
|
||||||
val sqlConstraintException: ObjException.Companion.ExceptionClass,
|
|
||||||
val sqlUsageException: ObjException.Companion.ExceptionClass,
|
|
||||||
val rollbackException: ObjException.Companion.ExceptionClass,
|
|
||||||
val sqlTypes: SqlTypeEntries,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun resolve(module: ModuleScope): SqliteCoreModule = SqliteCoreModule(
|
|
||||||
module = module,
|
|
||||||
databaseClass = module.requireClass("Database"),
|
|
||||||
transactionClass = module.requireClass("SqlTransaction"),
|
|
||||||
resultSetClass = module.requireClass("ResultSet"),
|
|
||||||
rowClass = module.requireClass("SqlRow"),
|
|
||||||
columnClass = module.requireClass("SqlColumn"),
|
|
||||||
executionResultClass = module.requireClass("ExecutionResult"),
|
|
||||||
databaseException = module.requireClass("DatabaseException") as ObjException.Companion.ExceptionClass,
|
|
||||||
sqlExecutionException = module.requireClass("SqlExecutionException") as ObjException.Companion.ExceptionClass,
|
|
||||||
sqlConstraintException = module.requireClass("SqlConstraintException") as ObjException.Companion.ExceptionClass,
|
|
||||||
sqlUsageException = module.requireClass("SqlUsageException") as ObjException.Companion.ExceptionClass,
|
|
||||||
rollbackException = module.requireClass("RollbackException") as ObjException.Companion.ExceptionClass,
|
|
||||||
sqlTypes = SqlTypeEntries.resolve(module),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class SqlTypeEntries private constructor(
|
|
||||||
private val entries: Map<String, ObjEnumEntry>,
|
|
||||||
) {
|
|
||||||
fun require(name: String): ObjEnumEntry = entries[name]
|
|
||||||
?: error("lyng.io.db.SqlType entry is missing: $name")
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun resolve(module: ModuleScope): SqlTypeEntries {
|
|
||||||
val enumClass = resolveEnum(module, "SqlType")
|
|
||||||
return SqlTypeEntries(
|
|
||||||
listOf(
|
|
||||||
"Binary", "String", "Int", "Double", "Decimal",
|
|
||||||
"Bool", "Instant", "Date", "DateTime"
|
|
||||||
).associateWith { name ->
|
|
||||||
enumClass.byName[ObjString(name)] as? ObjEnumEntry
|
|
||||||
?: error("lyng.io.db.SqlType.$name is missing")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveEnum(module: ModuleScope, enumName: String): ObjEnumClass {
|
|
||||||
val local = module.get(enumName)?.value as? ObjEnumClass
|
|
||||||
if (local != null) return local
|
|
||||||
val root = module.importProvider.rootScope.get(enumName)?.value as? ObjEnumClass
|
|
||||||
return root ?: error("lyng.io.db declaration enum is missing: $enumName")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SqliteRuntimeTypes private constructor(
|
|
||||||
val core: SqliteCoreModule,
|
|
||||||
val databaseClass: ObjClass,
|
|
||||||
val transactionClass: ObjClass,
|
|
||||||
val resultSetClass: ObjClass,
|
|
||||||
val rowClass: ObjClass,
|
|
||||||
val columnClass: ObjClass,
|
|
||||||
val executionResultClass: ObjClass,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun create(core: SqliteCoreModule): SqliteRuntimeTypes {
|
|
||||||
val databaseClass = object : ObjClass("SqliteDatabase", core.databaseClass) {}
|
|
||||||
val transactionClass = object : ObjClass("SqliteTransaction", core.transactionClass) {}
|
|
||||||
val resultSetClass = object : ObjClass("SqliteResultSet", core.resultSetClass) {}
|
|
||||||
val rowClass = object : ObjClass("SqliteRow", core.rowClass) {}
|
|
||||||
val columnClass = object : ObjClass("SqliteColumn", core.columnClass) {}
|
|
||||||
val executionResultClass = object : ObjClass("SqliteExecutionResult", core.executionResultClass) {}
|
|
||||||
val runtime = SqliteRuntimeTypes(
|
|
||||||
core = core,
|
|
||||||
databaseClass = databaseClass,
|
|
||||||
transactionClass = transactionClass,
|
|
||||||
resultSetClass = resultSetClass,
|
|
||||||
rowClass = rowClass,
|
|
||||||
columnClass = columnClass,
|
|
||||||
executionResultClass = executionResultClass,
|
|
||||||
)
|
|
||||||
runtime.bind()
|
|
||||||
return runtime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bind() {
|
|
||||||
databaseClass.addFn("transaction") {
|
|
||||||
val self = thisAs<SqliteDatabaseObj>()
|
|
||||||
val block = args.list.getOrNull(0) ?: raiseError("Expected exactly 1 argument, got ${args.list.size}")
|
|
||||||
if (!block.isInstanceOf("Callable")) {
|
|
||||||
raiseClassCastError("transaction block must be callable")
|
|
||||||
}
|
|
||||||
self.backend.transaction(this) { backend ->
|
|
||||||
val lifetime = TransactionLifetime(this@SqliteRuntimeTypes.core)
|
|
||||||
try {
|
|
||||||
call(block, Arguments(SqliteTransactionObj(this@SqliteRuntimeTypes, backend, lifetime)), ObjNull)
|
|
||||||
} finally {
|
|
||||||
lifetime.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
transactionClass.addFn("select") {
|
|
||||||
val self = thisAs<SqliteTransactionObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
val clause = requiredArg<ObjString>(0).value
|
|
||||||
val params = args.list.drop(1)
|
|
||||||
SqliteResultSetObj(thisAs<SqliteTransactionObj>().types, self.lifetime, self.backend.select(this, clause, params))
|
|
||||||
}
|
|
||||||
transactionClass.addFn("execute") {
|
|
||||||
val self = thisAs<SqliteTransactionObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
val clause = requiredArg<ObjString>(0).value
|
|
||||||
val params = args.list.drop(1)
|
|
||||||
SqliteExecutionResultObj(self.types, self.lifetime, self.backend.execute(this, clause, params))
|
|
||||||
}
|
|
||||||
transactionClass.addFn("transaction") {
|
|
||||||
val self = thisAs<SqliteTransactionObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
val block = args.list.getOrNull(0) ?: raiseError("Expected exactly 1 argument, got ${args.list.size}")
|
|
||||||
if (!block.isInstanceOf("Callable")) {
|
|
||||||
raiseClassCastError("transaction block must be callable")
|
|
||||||
}
|
|
||||||
self.backend.transaction(this) { backend ->
|
|
||||||
val lifetime = TransactionLifetime(this@SqliteRuntimeTypes.core)
|
|
||||||
try {
|
|
||||||
call(block, Arguments(SqliteTransactionObj(self.types, backend, lifetime)), ObjNull)
|
|
||||||
} finally {
|
|
||||||
lifetime.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resultSetClass.addProperty("columns", getter = {
|
|
||||||
val self = thisAs<SqliteResultSetObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
ObjImmutableList(self.columns)
|
|
||||||
})
|
|
||||||
resultSetClass.addFn("size") {
|
|
||||||
val self = thisAs<SqliteResultSetObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
ObjInt.of(self.rows.size.toLong())
|
|
||||||
}
|
|
||||||
resultSetClass.addFn("isEmpty") {
|
|
||||||
val self = thisAs<SqliteResultSetObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
ObjBool(self.rows.isEmpty())
|
|
||||||
}
|
|
||||||
resultSetClass.addFn("iterator") {
|
|
||||||
val self = thisAs<SqliteResultSetObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
ObjImmutableList(self.rows).invokeInstanceMethod(requireScope(), "iterator")
|
|
||||||
}
|
|
||||||
resultSetClass.addFn("toList") {
|
|
||||||
val self = thisAs<SqliteResultSetObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
ObjImmutableList(self.rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
rowClass.addProperty("size", getter = {
|
|
||||||
val self = thisAs<SqliteRowObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
ObjInt.of(self.values.size.toLong())
|
|
||||||
})
|
|
||||||
rowClass.addProperty("values", getter = {
|
|
||||||
val self = thisAs<SqliteRowObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
ObjImmutableList(self.values)
|
|
||||||
})
|
|
||||||
|
|
||||||
columnClass.addProperty("name", getter = { ObjString(thisAs<SqliteColumnObj>().meta.name) })
|
|
||||||
columnClass.addProperty("sqlType", getter = { thisAs<SqliteColumnObj>().meta.sqlType })
|
|
||||||
columnClass.addProperty("nullable", getter = { ObjBool(thisAs<SqliteColumnObj>().meta.nullable) })
|
|
||||||
columnClass.addProperty("nativeType", getter = { ObjString(thisAs<SqliteColumnObj>().meta.nativeType) })
|
|
||||||
|
|
||||||
executionResultClass.addProperty("affectedRowsCount", getter = {
|
|
||||||
val self = thisAs<SqliteExecutionResultObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
ObjInt.of(self.result.affectedRowsCount.toLong())
|
|
||||||
})
|
|
||||||
executionResultClass.addFn("getGeneratedKeys") {
|
|
||||||
val self = thisAs<SqliteExecutionResultObj>()
|
|
||||||
self.lifetime.ensureActive(this)
|
|
||||||
SqliteResultSetObj(self.types, self.lifetime, self.result.generatedKeys)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class TransactionLifetime(
|
|
||||||
private val core: SqliteCoreModule,
|
|
||||||
) {
|
|
||||||
private var active = true
|
|
||||||
|
|
||||||
fun close() {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ensureActive(scope: ScopeFacade) {
|
|
||||||
if (!active) {
|
|
||||||
scope.raiseError(
|
|
||||||
ObjException(core.sqlUsageException, scope.requireScope(), ObjString("SQL result can be used only while its transaction is active"))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SqliteDatabaseObj(
|
|
||||||
val types: SqliteRuntimeTypes,
|
|
||||||
val backend: SqliteDatabaseBackend,
|
|
||||||
) : Obj() {
|
|
||||||
override val objClass: ObjClass
|
|
||||||
get() = types.databaseClass
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SqliteTransactionObj(
|
|
||||||
val types: SqliteRuntimeTypes,
|
|
||||||
val backend: SqliteTransactionBackend,
|
|
||||||
val lifetime: TransactionLifetime,
|
|
||||||
) : Obj() {
|
|
||||||
override val objClass: ObjClass
|
|
||||||
get() = types.transactionClass
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SqliteResultSetObj(
|
|
||||||
val types: SqliteRuntimeTypes,
|
|
||||||
val lifetime: TransactionLifetime,
|
|
||||||
data: SqliteResultSetData,
|
|
||||||
) : Obj() {
|
|
||||||
val columns: List<Obj> = data.columns.map { SqliteColumnObj(types, it) }
|
|
||||||
val rows: List<Obj> = buildRows(types, lifetime, data)
|
|
||||||
|
|
||||||
override val objClass: ObjClass
|
|
||||||
get() = types.resultSetClass
|
|
||||||
|
|
||||||
private fun buildRows(
|
|
||||||
types: SqliteRuntimeTypes,
|
|
||||||
lifetime: TransactionLifetime,
|
|
||||||
data: SqliteResultSetData,
|
|
||||||
): List<Obj> {
|
|
||||||
val indexByName = linkedMapOf<String, MutableList<Int>>()
|
|
||||||
data.columns.forEachIndexed { index, column ->
|
|
||||||
indexByName.getOrPut(column.name.lowercase()) { mutableListOf() }.add(index)
|
|
||||||
}
|
|
||||||
return data.rows.map { rowValues ->
|
|
||||||
SqliteRowObj(types, lifetime, rowValues, indexByName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SqliteRowObj(
|
|
||||||
val types: SqliteRuntimeTypes,
|
|
||||||
val lifetime: TransactionLifetime,
|
|
||||||
val values: List<Obj>,
|
|
||||||
private val indexByName: Map<String, List<Int>>,
|
|
||||||
) : Obj() {
|
|
||||||
override val objClass: ObjClass
|
|
||||||
get() = types.rowClass
|
|
||||||
|
|
||||||
override suspend fun getAt(scope: Scope, index: Obj): Obj {
|
|
||||||
lifetime.ensureActive(scope.asFacade())
|
|
||||||
return when (index) {
|
|
||||||
is ObjInt -> {
|
|
||||||
val idx = index.value.toInt()
|
|
||||||
if (idx !in values.indices) {
|
|
||||||
scope.raiseIndexOutOfBounds("SQL row index $idx is out of bounds")
|
|
||||||
}
|
|
||||||
values[idx]
|
|
||||||
}
|
|
||||||
is ObjString -> {
|
|
||||||
val matches = indexByName[index.value.lowercase()]
|
|
||||||
?: scope.raiseError(
|
|
||||||
ObjException(
|
|
||||||
types.core.sqlUsageException,
|
|
||||||
scope,
|
|
||||||
ObjString("No such SQL result column: ${index.value}")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (matches.size != 1) {
|
|
||||||
scope.raiseError(
|
|
||||||
ObjException(
|
|
||||||
types.core.sqlUsageException,
|
|
||||||
scope,
|
|
||||||
ObjString("Ambiguous SQL result column: ${index.value}")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
values[matches.first()]
|
|
||||||
}
|
|
||||||
else -> scope.raiseClassCastError("SQL row index must be Int or String")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SqliteColumnObj(
|
|
||||||
val types: SqliteRuntimeTypes,
|
|
||||||
val meta: SqliteColumnMeta,
|
|
||||||
) : Obj() {
|
|
||||||
override val objClass: ObjClass
|
|
||||||
get() = types.columnClass
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SqliteExecutionResultObj(
|
|
||||||
val types: SqliteRuntimeTypes,
|
|
||||||
val lifetime: TransactionLifetime,
|
|
||||||
val result: SqliteExecutionResultData,
|
|
||||||
) : Obj() {
|
|
||||||
override val objClass: ObjClass
|
|
||||||
get() = types.executionResultClass
|
|
||||||
}
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
package net.sergeych.lyng.io.db
|
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import net.sergeych.lyng.Compiler
|
|
||||||
import net.sergeych.lyng.ExecutionError
|
|
||||||
import net.sergeych.lyng.pacman.ImportManager
|
|
||||||
import net.sergeych.lyng.Pos
|
|
||||||
import net.sergeych.lyng.Script
|
|
||||||
import net.sergeych.lyng.Source
|
|
||||||
import net.sergeych.lyng.obj.Obj
|
|
||||||
import net.sergeych.lyng.obj.ObjExternCallable
|
|
||||||
import net.sergeych.lyng.obj.ObjInt
|
|
||||||
import net.sergeych.lyng.obj.ObjNull
|
|
||||||
import net.sergeych.lyng.obj.ObjString
|
|
||||||
import net.sergeych.lyng.obj.requiredArg
|
|
||||||
import net.sergeych.lyng.requireScope
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertFailsWith
|
|
||||||
import kotlin.test.assertFalse
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
|
|
||||||
class LyngDbModuleTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testModuleRegistrationIsIdempotent() = runTest {
|
|
||||||
val importManager = ImportManager()
|
|
||||||
assertTrue(createDbModule(importManager))
|
|
||||||
assertFalse(createDbModule(importManager))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testOpenDatabaseDispatchesByNormalizedScheme() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createDbModule(scope.importManager)
|
|
||||||
val module = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db")
|
|
||||||
|
|
||||||
module.callFn(
|
|
||||||
"registerDatabaseProvider",
|
|
||||||
ObjString("TeSt"),
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val url = requiredArg<ObjString>(0).value
|
|
||||||
val params = requiredArg<Obj>(1)
|
|
||||||
val size = (params.invokeInstanceMethod(requireScope(), "size") as ObjInt).value
|
|
||||||
ObjString("$url|$size")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val code = """
|
|
||||||
import lyng.io.db
|
|
||||||
openDatabase("TEST:demo", Map("a" => 1, "b" => 2))
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
val result = Compiler.compile(Source("<db-test>", code), scope.importManager).execute(scope) as ObjString
|
|
||||||
assertEquals("TEST:demo|2", result.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testDuplicateSchemeRegistrationFailsCaseInsensitively() = runTest {
|
|
||||||
val importManager = ImportManager()
|
|
||||||
createDbModule(importManager)
|
|
||||||
val module = importManager.createModuleScope(Pos.builtIn, "lyng.io.db")
|
|
||||||
val scheme = "provider_test"
|
|
||||||
|
|
||||||
module.callFn("registerDatabaseProvider", ObjString(scheme), trivialOpener())
|
|
||||||
val error = try {
|
|
||||||
module.callFn("registerDatabaseProvider", ObjString(scheme.uppercase()), trivialOpener())
|
|
||||||
kotlin.test.fail("expected duplicate registration to fail")
|
|
||||||
} catch (e: ExecutionError) {
|
|
||||||
e
|
|
||||||
}
|
|
||||||
|
|
||||||
assertTrue(error.errorMessage.contains("already registered"), error.errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testMalformedUrlFailsWithIllegalArgument() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createDbModule(scope.importManager)
|
|
||||||
|
|
||||||
val code = """
|
|
||||||
import lyng.io.db
|
|
||||||
openDatabase("not-a-url", Map())
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
Compiler.compile(Source("<db-test>", code), scope.importManager).execute(scope)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("IllegalArgumentException", error.errorObject.objClass.className)
|
|
||||||
assertTrue(error.errorMessage.contains("Malformed database connection URL"), error.errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testUnknownSchemeFailsWithDatabaseException() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createDbModule(scope.importManager)
|
|
||||||
|
|
||||||
val code = """
|
|
||||||
import lyng.io.db
|
|
||||||
openDatabase("unknown:demo", Map())
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
Compiler.compile(Source("<db-test>", code), scope.importManager).execute(scope)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("DatabaseException", error.errorObject.objClass.className)
|
|
||||||
assertTrue(error.errorMessage.contains("No database provider registered"), error.errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun net.sergeych.lyng.ModuleScope.callFn(name: String, vararg args: Obj): Obj {
|
|
||||||
val callee = get(name)?.value ?: error("Missing $name in module")
|
|
||||||
return callee.invoke(this, ObjNull, *args)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun trivialOpener(): Obj = ObjExternCallable.fromBridge { ObjInt.One }
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
package net.sergeych.lyng.io.db.sqlite
|
|
||||||
|
|
||||||
import net.sergeych.lyng.ScopeFacade
|
|
||||||
import net.sergeych.lyng.requireScope
|
|
||||||
import net.sergeych.lyng.obj.ObjException
|
|
||||||
import net.sergeych.lyng.obj.ObjString
|
|
||||||
|
|
||||||
internal actual suspend fun openSqliteBackend(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
options: SqliteOpenOptions,
|
|
||||||
): SqliteDatabaseBackend {
|
|
||||||
scope.raiseError(
|
|
||||||
ObjException(
|
|
||||||
core.databaseException,
|
|
||||||
scope.requireScope(),
|
|
||||||
ObjString("SQLite provider is not implemented on this platform yet")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,501 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
package net.sergeych.lyng.io.db.sqlite
|
|
||||||
|
|
||||||
import net.sergeych.lyng.ExecutionError
|
|
||||||
import net.sergeych.lyng.Arguments
|
|
||||||
import net.sergeych.lyng.ScopeFacade
|
|
||||||
import net.sergeych.lyng.obj.Obj
|
|
||||||
import net.sergeych.lyng.obj.ObjBool
|
|
||||||
import net.sergeych.lyng.obj.ObjBuffer
|
|
||||||
import net.sergeych.lyng.obj.ObjDate
|
|
||||||
import net.sergeych.lyng.obj.ObjDateTime
|
|
||||||
import net.sergeych.lyng.obj.ObjEnumEntry
|
|
||||||
import net.sergeych.lyng.obj.ObjException
|
|
||||||
import net.sergeych.lyng.obj.ObjInt
|
|
||||||
import net.sergeych.lyng.obj.ObjInstant
|
|
||||||
import net.sergeych.lyng.obj.ObjNull
|
|
||||||
import net.sergeych.lyng.obj.ObjReal
|
|
||||||
import net.sergeych.lyng.obj.ObjString
|
|
||||||
import net.sergeych.lyng.requireScope
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.LocalDateTime
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import org.sqlite.SQLiteConfig
|
|
||||||
import org.sqlite.SQLiteErrorCode
|
|
||||||
import org.sqlite.SQLiteOpenMode
|
|
||||||
import java.sql.Connection
|
|
||||||
import java.sql.DriverManager
|
|
||||||
import java.sql.PreparedStatement
|
|
||||||
import java.sql.ResultSet
|
|
||||||
import java.sql.SQLException
|
|
||||||
import java.sql.Statement
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
internal actual suspend fun openSqliteBackend(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
options: SqliteOpenOptions,
|
|
||||||
): SqliteDatabaseBackend {
|
|
||||||
if (options.busyTimeoutMillis < 0) {
|
|
||||||
scope.raiseIllegalArgument("busyTimeoutMillis must be >= 0")
|
|
||||||
}
|
|
||||||
return JdbcSqliteDatabaseBackend(core, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
private class JdbcSqliteDatabaseBackend(
|
|
||||||
private val core: SqliteCoreModule,
|
|
||||||
private val options: SqliteOpenOptions,
|
|
||||||
) : SqliteDatabaseBackend {
|
|
||||||
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T {
|
|
||||||
val connection = openConnection(scope)
|
|
||||||
try {
|
|
||||||
connection.autoCommit = false
|
|
||||||
val tx = JdbcSqliteTransactionBackend(core, connection)
|
|
||||||
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 {
|
|
||||||
try {
|
|
||||||
val config = SQLiteConfig().apply {
|
|
||||||
setOpenMode(SQLiteOpenMode.OPEN_URI)
|
|
||||||
if (options.readOnly) {
|
|
||||||
setReadOnly(true)
|
|
||||||
setOpenMode(SQLiteOpenMode.READONLY)
|
|
||||||
} else {
|
|
||||||
setReadOnly(false)
|
|
||||||
setOpenMode(SQLiteOpenMode.READWRITE)
|
|
||||||
if (options.createIfMissing) {
|
|
||||||
setOpenMode(SQLiteOpenMode.CREATE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
enforceForeignKeys(options.foreignKeys)
|
|
||||||
busyTimeout = options.busyTimeoutMillis
|
|
||||||
}
|
|
||||||
return DriverManager.getConnection(jdbcUrl(options.path), config.toProperties())
|
|
||||||
} catch (e: SQLException) {
|
|
||||||
throw mapOpenException(scope, core, e)
|
|
||||||
} catch (e: IllegalArgumentException) {
|
|
||||||
scope.raiseIllegalArgument(e.message ?: "Invalid SQLite configuration")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun jdbcUrl(path: String): String {
|
|
||||||
return when (path) {
|
|
||||||
":memory:" -> "jdbc:sqlite::memory:"
|
|
||||||
else -> if (path.startsWith("/")) "jdbc:sqlite:$path" else "jdbc:sqlite:$path"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class JdbcSqliteTransactionBackend(
|
|
||||||
private val core: SqliteCoreModule,
|
|
||||||
private val connection: Connection,
|
|
||||||
) : SqliteTransactionBackend {
|
|
||||||
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteResultSetData {
|
|
||||||
try {
|
|
||||||
connection.prepareStatement(clause).use { statement ->
|
|
||||||
bindParams(statement, params, scope, 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>): SqliteExecutionResultData {
|
|
||||||
if (containsRowReturningClause(clause)) {
|
|
||||||
scope.raiseError(
|
|
||||||
ObjException(
|
|
||||||
core.sqlUsageException,
|
|
||||||
scope.requireScope(),
|
|
||||||
ObjString("execute(...) cannot be used with statements that return rows; use select(...)")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
connection.prepareStatement(clause, Statement.RETURN_GENERATED_KEYS).use { statement ->
|
|
||||||
bindParams(statement, params, scope, 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 affected = statement.updateCount
|
|
||||||
val generatedKeys = statement.generatedKeys.use { rs ->
|
|
||||||
if (rs == null) {
|
|
||||||
emptyResultSet(core)
|
|
||||||
} else {
|
|
||||||
readResultSet(scope, core, rs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return SqliteExecutionResultData(affected, generatedKeys)
|
|
||||||
}
|
|
||||||
} catch (e: SQLException) {
|
|
||||||
throw mapSqlException(scope, core, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T {
|
|
||||||
val savepoint = try {
|
|
||||||
connection.setSavepoint()
|
|
||||||
} catch (e: SQLException) {
|
|
||||||
throw mapSqlUsage(scope, core, "Nested transactions are not supported by this SQLite backend", e)
|
|
||||||
}
|
|
||||||
val nested = JdbcSqliteTransactionBackend(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: SqliteCoreModule) {
|
|
||||||
params.forEachIndexed { index, value ->
|
|
||||||
val jdbcIndex = index + 1
|
|
||||||
when (value) {
|
|
||||||
ObjNull -> statement.setObject(jdbcIndex, null)
|
|
||||||
is ObjBool -> statement.setLong(jdbcIndex, if (value.value) 1L else 0L)
|
|
||||||
is ObjInt -> statement.setLong(jdbcIndex, value.value)
|
|
||||||
is ObjReal -> statement.setDouble(jdbcIndex, value.value)
|
|
||||||
is ObjString -> statement.setString(jdbcIndex, value.value)
|
|
||||||
is ObjBuffer -> statement.setBytes(jdbcIndex, value.byteArray.toByteArray())
|
|
||||||
is ObjInstant -> statement.setString(jdbcIndex, value.instant.toString())
|
|
||||||
is ObjDateTime -> statement.setString(jdbcIndex, value.localDateTime.toString())
|
|
||||||
else -> when (value.objClass.className) {
|
|
||||||
"Date", "Decimal" -> statement.setString(jdbcIndex, scope.toStringOf(value).value)
|
|
||||||
else -> scope.raiseError(
|
|
||||||
ObjException(
|
|
||||||
core.sqlUsageException,
|
|
||||||
scope.requireScope(),
|
|
||||||
ObjString("Unsupported SQLite parameter type: ${value.objClass.className}")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun readResultSet(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
resultSet: ResultSet,
|
|
||||||
): SqliteResultSetData {
|
|
||||||
val meta = resultSet.metaData
|
|
||||||
val columns = (1..meta.columnCount).map { index ->
|
|
||||||
SqliteColumnMeta(
|
|
||||||
name = meta.getColumnLabel(index),
|
|
||||||
sqlType = mapSqlType(core, meta.getColumnTypeName(index), meta.getColumnType(index)),
|
|
||||||
nullable = meta.isNullable(index) != java.sql.ResultSetMetaData.columnNoNulls,
|
|
||||||
nativeType = meta.getColumnTypeName(index) ?: "",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val rows = mutableListOf<List<Obj>>()
|
|
||||||
while (resultSet.next()) {
|
|
||||||
rows += columns.mapIndexed { index, column ->
|
|
||||||
readColumnValue(scope, core, resultSet, index + 1, column.nativeType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return SqliteResultSetData(columns, rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun readColumnValue(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
resultSet: ResultSet,
|
|
||||||
index: Int,
|
|
||||||
nativeType: String,
|
|
||||||
): Obj {
|
|
||||||
val value = resultSet.getObject(index) ?: return ObjNull
|
|
||||||
val normalizedNativeType = normalizeDeclaredTypeName(nativeType)
|
|
||||||
if (isDecimalNativeType(normalizedNativeType) && value is Number) {
|
|
||||||
return decimalFromString(scope, value.toString())
|
|
||||||
}
|
|
||||||
return when (value) {
|
|
||||||
is Boolean -> if (isBooleanNativeType(normalizedNativeType)) ObjBool(value) else ObjInt.of(if (value) 1 else 0)
|
|
||||||
is Byte, is Short, is Int -> convertIntegerValue(scope, core, normalizedNativeType, (value as Number).toLong())
|
|
||||||
is Long -> convertIntegerValue(scope, core, normalizedNativeType, value)
|
|
||||||
is Float, is Double -> ObjReal.of((value as Number).toDouble())
|
|
||||||
is ByteArray -> ObjBuffer(value.toUByteArray())
|
|
||||||
is String -> convertStringValue(scope, core, normalizedNativeType, value)
|
|
||||||
is java.math.BigDecimal -> decimalFromString(scope, value.toPlainString())
|
|
||||||
else -> ObjString(value.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun convertIntegerValue(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
normalizedNativeType: String,
|
|
||||||
value: Long,
|
|
||||||
): Obj {
|
|
||||||
if (!isBooleanNativeType(normalizedNativeType)) {
|
|
||||||
return ObjInt.of(value)
|
|
||||||
}
|
|
||||||
return when (value) {
|
|
||||||
0L -> ObjBool(false)
|
|
||||||
1L -> ObjBool(true)
|
|
||||||
else -> sqlExecutionFailure(scope, core, "Invalid SQLite boolean value: $value")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun convertStringValue(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
normalizedNativeType: String,
|
|
||||||
value: String,
|
|
||||||
): Obj {
|
|
||||||
return when {
|
|
||||||
isBooleanNativeType(normalizedNativeType) -> booleanFromString(scope, core, value)
|
|
||||||
isDecimalNativeType(normalizedNativeType) -> decimalFromString(scope, value.trim())
|
|
||||||
normalizedNativeType == "DATE" -> ObjDate(LocalDate.parse(value.trim()))
|
|
||||||
normalizedNativeType == "DATETIME" || normalizedNativeType == "TIMESTAMP" ->
|
|
||||||
dateTimeFromString(scope, core, value)
|
|
||||||
normalizedNativeType == "TIMESTAMP WITH TIME ZONE" ||
|
|
||||||
normalizedNativeType == "TIMESTAMPTZ" ||
|
|
||||||
normalizedNativeType == "DATETIME WITH TIME ZONE" -> ObjInstant(Instant.parse(value.trim()))
|
|
||||||
else -> ObjString(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isDecimalNativeType(normalizedNativeType: String): Boolean =
|
|
||||||
normalizedNativeType == "DECIMAL" || normalizedNativeType == "NUMERIC"
|
|
||||||
|
|
||||||
private fun isBooleanNativeType(normalizedNativeType: String): Boolean =
|
|
||||||
normalizedNativeType == "BOOLEAN" || normalizedNativeType == "BOOL"
|
|
||||||
|
|
||||||
private fun booleanFromString(scope: ScopeFacade, core: SqliteCoreModule, value: String): Obj {
|
|
||||||
return when (value.trim().lowercase()) {
|
|
||||||
"true", "t" -> ObjBool(true)
|
|
||||||
"false", "f" -> ObjBool(false)
|
|
||||||
else -> sqlExecutionFailure(scope, core, "Invalid SQLite boolean value: $value")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun decimalFromString(scope: ScopeFacade, value: String): Obj {
|
|
||||||
val decimalModule = scope.requireScope().currentImportProvider.createModuleScope(scope.pos, "lyng.decimal")
|
|
||||||
val decimalClass = decimalModule.requireClass("Decimal")
|
|
||||||
return decimalClass.invokeInstanceMethod(scope.requireScope(), "fromString", ObjString(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun dateTimeFromString(scope: ScopeFacade, core: SqliteCoreModule, value: String): ObjDateTime {
|
|
||||||
val trimmed = value.trim()
|
|
||||||
if (hasExplicitTimeZone(trimmed)) {
|
|
||||||
sqlExecutionFailure(scope, core, "SQLite TIMESTAMP/DATETIME value must not contain a timezone offset: $value")
|
|
||||||
}
|
|
||||||
val local = LocalDateTime.parse(trimmed)
|
|
||||||
return ObjDateTime(local.toInstant(TimeZone.UTC), TimeZone.UTC)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasExplicitTimeZone(value: String): Boolean {
|
|
||||||
if (value.endsWith("Z", ignoreCase = true)) return true
|
|
||||||
val tIndex = value.indexOf('T')
|
|
||||||
if (tIndex < 0) return false
|
|
||||||
val plus = value.lastIndexOf('+')
|
|
||||||
val minus = value.lastIndexOf('-')
|
|
||||||
val offsetStart = maxOf(plus, minus)
|
|
||||||
return offsetStart > tIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun containsRowReturningClause(clause: String): Boolean =
|
|
||||||
Regex("""\breturning\b""", RegexOption.IGNORE_CASE).containsMatchIn(clause)
|
|
||||||
|
|
||||||
private fun normalizeDeclaredTypeName(nativeTypeName: String): String {
|
|
||||||
val strippedSuffix = nativeTypeName.trim().replace(Regex("""\s*\(.*\)\s*$"""), "")
|
|
||||||
return strippedSuffix.uppercase().replace(Regex("""\s+"""), " ").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mapSqlType(core: SqliteCoreModule, 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 == "DATETIME" || normalized == "TIMESTAMP" -> core.sqlTypes.require("DateTime")
|
|
||||||
normalized == "TIMESTAMP WITH TIME ZONE" ||
|
|
||||||
normalized == "TIMESTAMPTZ" ||
|
|
||||||
normalized == "DATETIME WITH TIME ZONE" -> 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") -> core.sqlTypes.require("Binary")
|
|
||||||
normalized.contains("INT") -> core.sqlTypes.require("Int")
|
|
||||||
normalized.contains("CHAR") || normalized.contains("TEXT") || normalized.contains("CLOB") -> core.sqlTypes.require("String")
|
|
||||||
normalized.contains("REAL") || normalized.contains("FLOA") || normalized.contains("DOUB") -> core.sqlTypes.require("Double")
|
|
||||||
jdbcType == java.sql.Types.BOOLEAN -> core.sqlTypes.require("Bool")
|
|
||||||
jdbcType == java.sql.Types.BLOB || jdbcType == java.sql.Types.BINARY || jdbcType == java.sql.Types.VARBINARY -> core.sqlTypes.require("Binary")
|
|
||||||
jdbcType == java.sql.Types.INTEGER -> core.sqlTypes.require("Int")
|
|
||||||
jdbcType == java.sql.Types.BIGINT -> core.sqlTypes.require("Int")
|
|
||||||
jdbcType == java.sql.Types.DECIMAL || jdbcType == java.sql.Types.NUMERIC -> core.sqlTypes.require("Decimal")
|
|
||||||
jdbcType == java.sql.Types.FLOAT || jdbcType == java.sql.Types.REAL || jdbcType == java.sql.Types.DOUBLE -> core.sqlTypes.require("Double")
|
|
||||||
else -> core.sqlTypes.require("String")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun emptyResultSet(core: SqliteCoreModule): SqliteResultSetData = SqliteResultSetData(emptyList(), emptyList())
|
|
||||||
|
|
||||||
private fun sqlExecutionFailure(scope: ScopeFacade, core: SqliteCoreModule, message: String): Nothing {
|
|
||||||
throw ExecutionError(
|
|
||||||
ObjException(core.sqlExecutionException, scope.requireScope(), ObjString(message)),
|
|
||||||
scope.pos,
|
|
||||||
message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun rollbackOrThrow(scope: ScopeFacade, core: SqliteCoreModule, connection: Connection) {
|
|
||||||
try {
|
|
||||||
connection.rollback()
|
|
||||||
} catch (e: SQLException) {
|
|
||||||
throw mapSqlException(scope, core, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun rollbackToSavepointOrThrow(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
connection: Connection,
|
|
||||||
savepoint: java.sql.Savepoint,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
connection.rollback(savepoint)
|
|
||||||
} catch (e: SQLException) {
|
|
||||||
throw mapSqlException(scope, core, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun releaseSavepointOrThrow(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
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: SqliteCoreModule,
|
|
||||||
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: SqliteCoreModule): 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: SqliteCoreModule, e: SQLException): Nothing {
|
|
||||||
val message = e.message ?: "SQLite open failed"
|
|
||||||
val lower = message.lowercase()
|
|
||||||
if ("malformed" in lower || "no such access mode" in lower || "invalid uri" in lower) {
|
|
||||||
scope.raiseIllegalArgument(message)
|
|
||||||
}
|
|
||||||
throw ExecutionError(
|
|
||||||
ObjException(core.databaseException, scope.requireScope(), ObjString(message)),
|
|
||||||
scope.pos,
|
|
||||||
message,
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mapSqlException(scope: ScopeFacade, core: SqliteCoreModule, e: SQLException): ExecutionError {
|
|
||||||
val code = SQLiteErrorCode.getErrorCode(e.errorCode)
|
|
||||||
val exceptionClass = when (code) {
|
|
||||||
SQLiteErrorCode.SQLITE_CONSTRAINT,
|
|
||||||
SQLiteErrorCode.SQLITE_CONSTRAINT_PRIMARYKEY,
|
|
||||||
SQLiteErrorCode.SQLITE_CONSTRAINT_UNIQUE,
|
|
||||||
SQLiteErrorCode.SQLITE_CONSTRAINT_FOREIGNKEY,
|
|
||||||
SQLiteErrorCode.SQLITE_CONSTRAINT_NOTNULL -> core.sqlConstraintException
|
|
||||||
else -> core.sqlExecutionException
|
|
||||||
}
|
|
||||||
return ExecutionError(
|
|
||||||
ObjException(exceptionClass, scope.requireScope(), ObjString(e.message ?: "SQLite error")),
|
|
||||||
scope.pos,
|
|
||||||
e.message ?: "SQLite error",
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mapSqlUsage(scope: ScopeFacade, core: SqliteCoreModule, message: String, cause: Throwable? = null): ExecutionError {
|
|
||||||
return ExecutionError(
|
|
||||||
ObjException(core.sqlUsageException, scope.requireScope(), ObjString(message)),
|
|
||||||
scope.pos,
|
|
||||||
message,
|
|
||||||
cause,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,927 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
package net.sergeych.lyng.io.db.sqlite
|
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import net.sergeych.lyng.Compiler
|
|
||||||
import net.sergeych.lyng.ExecutionError
|
|
||||||
import net.sergeych.lyng.ModuleScope
|
|
||||||
import net.sergeych.lyng.Pos
|
|
||||||
import net.sergeych.lyng.Scope
|
|
||||||
import net.sergeych.lyng.Script
|
|
||||||
import net.sergeych.lyng.Source
|
|
||||||
import net.sergeych.lyng.obj.*
|
|
||||||
import net.sergeych.lyng.obj.requiredArg
|
|
||||||
import net.sergeych.lyng.requireScope
|
|
||||||
import java.nio.file.Files
|
|
||||||
import kotlin.io.path.deleteIfExists
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertFailsWith
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
class LyngSqliteModuleTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testTypedOpenSqliteExecutesQueriesAndGeneratedKeys() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
|
|
||||||
val db = sqliteModule.callFn("openSqlite", ObjString(":memory:"))
|
|
||||||
|
|
||||||
val insertedId = db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("create table person(id integer primary key autoincrement, name text not null)"))
|
|
||||||
val result = tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into person(name) values(?)"),
|
|
||||||
ObjString("John Doe")
|
|
||||||
)
|
|
||||||
val generatedKeys = result.invokeInstanceMethod(requireScope(), "getGeneratedKeys")
|
|
||||||
val rows = generatedKeys.invokeInstanceMethod(requireScope(), "toList")
|
|
||||||
rows.getAt(requireScope(), ObjInt.Zero).getAt(requireScope(), ObjInt.Zero)
|
|
||||||
}
|
|
||||||
) as ObjInt
|
|
||||||
|
|
||||||
assertEquals(1L, insertedId.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testGenericOpenDatabaseUsesRegisteredSqliteProvider() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
|
|
||||||
val dbModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db")
|
|
||||||
val db = dbModule.callFn("openDatabase", ObjString("sqlite::memory:"), emptyMapObj())
|
|
||||||
|
|
||||||
val count = db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("create table items(id integer primary key autoincrement, name text not null)"))
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into items(name) values(?)"), ObjString("alpha"))
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into items(name) values(?)"), ObjString("beta"))
|
|
||||||
val resultSet = tx.invokeInstanceMethod(requireScope(), "select", ObjString("select count(*) as count from items"))
|
|
||||||
val rows = resultSet.invokeInstanceMethod(requireScope(), "toList")
|
|
||||||
rows.getAt(requireScope(), ObjInt.Zero).getAt(requireScope(), ObjString("count"))
|
|
||||||
}
|
|
||||||
) as ObjInt
|
|
||||||
|
|
||||||
assertEquals(2L, count.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testImportedDatabaseOpenersPreserveDeclaredReturnTypesForInference() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
|
|
||||||
val code = """
|
|
||||||
import lyng.io.db
|
|
||||||
import lyng.io.db.sqlite
|
|
||||||
|
|
||||||
val typedDb = openSqlite(":memory:")
|
|
||||||
typedDb.transaction { 1 }
|
|
||||||
|
|
||||||
val genericDb = openDatabase("sqlite::memory:", Map())
|
|
||||||
genericDb.transaction { 2 }
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
Compiler.compile(Source("<sqlite-inference>", code), scope.importManager).execute(scope)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testTransactionLambdaParameterTypeIsInferredFromDatabaseSignature() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
|
|
||||||
val code = """
|
|
||||||
import lyng.io.db
|
|
||||||
import lyng.io.db.sqlite
|
|
||||||
import lyng.time
|
|
||||||
|
|
||||||
val db = openSqlite(":memory:")
|
|
||||||
db.transaction { tx ->
|
|
||||||
tx.execute("create table item(id integer primary key autoincrement, name text not null, due_date date not null)")
|
|
||||||
tx.execute("insert into item(name, due_date) values(?, ?)", "outer", Date(2026, 4, 16))
|
|
||||||
tx.transaction { inner ->
|
|
||||||
inner.execute("insert into item(name, due_date) values(?, ?)", "inner", Date(2026, 4, 17))
|
|
||||||
1
|
|
||||||
}
|
|
||||||
2
|
|
||||||
}
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
val result = Compiler.compile(Source("<sqlite-lambda-inference>", code), scope.importManager).execute(scope) as ObjInt
|
|
||||||
assertEquals(2L, result.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testNestedTransactionRollbackUsesSavepoint() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
|
|
||||||
val db = sqliteModule.callFn("openSqlite", ObjString(":memory:"))
|
|
||||||
|
|
||||||
val count = db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("create table items(id integer primary key autoincrement, name text not null)"))
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into items(name) values(?)"), ObjString("outer"))
|
|
||||||
try {
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val inner = requiredArg<Obj>(0)
|
|
||||||
inner.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into items(name) values(?)"), ObjString("inner"))
|
|
||||||
throw IllegalStateException("rollback nested")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (_: IllegalStateException) {
|
|
||||||
}
|
|
||||||
val resultSet = tx.invokeInstanceMethod(requireScope(), "select", ObjString("select count(*) as count from items"))
|
|
||||||
val rows = resultSet.invokeInstanceMethod(requireScope(), "toList")
|
|
||||||
rows.getAt(requireScope(), ObjInt.Zero).getAt(requireScope(), ObjString("count"))
|
|
||||||
}
|
|
||||||
) as ObjInt
|
|
||||||
|
|
||||||
assertEquals(1L, count.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testRollbackExceptionRollsBackAndPropagates() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
withTempDb(scope) { db ->
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("create table items(id integer primary key autoincrement, name text not null)")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into items(name) values(?)"),
|
|
||||||
ObjString("rolled-back")
|
|
||||||
)
|
|
||||||
rollbackException(requireScope(), "stop here").raiseAsExecutionError(requireScope())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("RollbackException", error.errorObject.objClass.className)
|
|
||||||
|
|
||||||
val count = db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
val resultSet = tx.invokeInstanceMethod(requireScope(), "select", ObjString("select count(*) as count from items"))
|
|
||||||
rowsOf(requireScope(), resultSet)[0].getAt(requireScope(), ObjString("count"))
|
|
||||||
}
|
|
||||||
) as ObjInt
|
|
||||||
|
|
||||||
assertEquals(0L, count.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testResultSetFailsAfterTransactionEnds() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
|
|
||||||
val db = sqliteModule.callFn("openSqlite", ObjString(":memory:"))
|
|
||||||
var leakedResultSet: Obj = ObjNull
|
|
||||||
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("create table items(id integer primary key autoincrement, name text not null)"))
|
|
||||||
leakedResultSet = tx.invokeInstanceMethod(requireScope(), "select", ObjString("select 42 as answer"))
|
|
||||||
ObjNull
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
leakedResultSet.invokeInstanceMethod(scope, "size")
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
|
||||||
assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testRowFailsAfterTransactionEnds() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
val db = openMemoryDb(scope)
|
|
||||||
var leakedRow: Obj = ObjNull
|
|
||||||
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
leakedRow = rowsOf(requireScope(), tx.invokeInstanceMethod(requireScope(), "select", ObjString("select 42 as answer")))[0]
|
|
||||||
ObjNull
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
leakedRow.getAt(scope, ObjString("answer"))
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
|
||||||
assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testInvalidSqliteUrlFailsWithIllegalArgument() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
|
|
||||||
val dbModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db")
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
dbModule.callFn("openDatabase", ObjString("sqlite://bad"), emptyMapObj())
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("IllegalArgumentException", error.errorObject.objClass.className)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testConstraintViolationIsMappedToSqlConstraintException() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
val db = openMemoryDb(scope)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("create table person(id integer primary key autoincrement, email text unique not null)")
|
|
||||||
)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into person(email) values(?)"),
|
|
||||||
ObjString("a@example.com")
|
|
||||||
)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into person(email) values(?)"),
|
|
||||||
ObjString("a@example.com")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlConstraintException", error.errorObject.objClass.className)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testAmbiguousColumnNameAccessFailsWithSqlUsageException() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
val db = openMemoryDb(scope)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
val resultSet = tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"select",
|
|
||||||
ObjString("select 1 as value, 2 as value")
|
|
||||||
)
|
|
||||||
val row = rowsOf(requireScope(), resultSet)[0]
|
|
||||||
row.getAt(requireScope(), ObjString("value"))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
|
||||||
assertTrue(error.errorMessage.contains("Ambiguous SQL result column"), error.errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testExecuteRejectsReturningButSelectSupportsIt() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
withTempDb(scope) { db ->
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("create table item(id integer primary key autoincrement, name text not null)")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into item(name) values(?) returning id"),
|
|
||||||
ObjString("bad")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
|
||||||
|
|
||||||
val insertedId = db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
val resultSet = tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"select",
|
|
||||||
ObjString("insert into item(name) values(?) returning id"),
|
|
||||||
ObjString("good")
|
|
||||||
)
|
|
||||||
val row = rowsOf(requireScope(), resultSet)[0]
|
|
||||||
row.getAt(requireScope(), ObjString("id"))
|
|
||||||
}
|
|
||||||
) as ObjInt
|
|
||||||
|
|
||||||
assertEquals(1L, insertedId.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testColumnMetadataAndTypedValueConversion() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
val db = openMemoryDb(scope)
|
|
||||||
|
|
||||||
val summary = db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString(
|
|
||||||
"create table events(" +
|
|
||||||
"amount NUMERIC not null, " +
|
|
||||||
"happened TIMESTAMPTZ not null, " +
|
|
||||||
"scheduled TIMESTAMP not null, " +
|
|
||||||
"note TEXT not null, " +
|
|
||||||
"payload BLOB not null)"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val decimal = decimalOf(requireScope(), "12.50")
|
|
||||||
val happened = ObjInstant(Instant.parse("2024-05-06T07:08:09Z"))
|
|
||||||
val scheduled = ObjDateTime(Instant.parse("2024-05-06T10:11:12Z"), TimeZone.UTC)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into events(amount, happened, scheduled, note, payload) values(?, ?, ?, ?, ?)"),
|
|
||||||
decimal,
|
|
||||||
happened,
|
|
||||||
scheduled,
|
|
||||||
ObjString("hello"),
|
|
||||||
ObjBuffer(byteArrayOf(1, 2, 3).toUByteArray())
|
|
||||||
)
|
|
||||||
val resultSet = tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"select",
|
|
||||||
ObjString("select amount, happened, scheduled, note, payload from events")
|
|
||||||
)
|
|
||||||
val columns = field(requireScope(), resultSet, "columns")
|
|
||||||
val firstColumn = columns.getAt(requireScope(), ObjInt.Zero)
|
|
||||||
val row = rowsOf(requireScope(), resultSet)[0]
|
|
||||||
ObjString(
|
|
||||||
listOf(
|
|
||||||
stringValue(requireScope(), field(requireScope(), firstColumn, "name")),
|
|
||||||
enumName(requireScope(), field(requireScope(), firstColumn, "sqlType")),
|
|
||||||
stringValue(requireScope(), field(requireScope(), firstColumn, "nativeType")),
|
|
||||||
row.getAt(requireScope(), ObjString("amount")).objClass.className,
|
|
||||||
row.getAt(requireScope(), ObjString("happened")).objClass.className,
|
|
||||||
row.getAt(requireScope(), ObjString("scheduled")).objClass.className,
|
|
||||||
stringValue(requireScope(), row.getAt(requireScope(), ObjString("note"))),
|
|
||||||
row.getAt(requireScope(), ObjString("payload")).objClass.className,
|
|
||||||
).joinToString("|")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) as ObjString
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
"amount|Decimal|NUMERIC|Decimal|Instant|DateTime|hello|Buffer",
|
|
||||||
summary.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testDateAndBooleanConversionRules() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
val db = openMemoryDb(scope)
|
|
||||||
|
|
||||||
val summary = db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString(
|
|
||||||
"create table sample(" +
|
|
||||||
"flag BOOL not null, " +
|
|
||||||
"day DATE not null, " +
|
|
||||||
"clock TIME not null)"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into sample(flag, day, clock) values(?, ?, ?)"),
|
|
||||||
ObjBool(true),
|
|
||||||
dateOf(requireScope(), "2026-04-15"),
|
|
||||||
ObjString("12:34:56")
|
|
||||||
)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into sample(flag, day, clock) values(?, ?, ?)"),
|
|
||||||
ObjString("t"),
|
|
||||||
ObjString("2026-04-16"),
|
|
||||||
ObjString("23:59:59")
|
|
||||||
)
|
|
||||||
val resultSet = tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"select",
|
|
||||||
ObjString("select flag, day, clock from sample order by day")
|
|
||||||
)
|
|
||||||
val rows = rowsOf(requireScope(), resultSet)
|
|
||||||
ObjString(
|
|
||||||
listOf(
|
|
||||||
rows[0].getAt(requireScope(), ObjString("flag")).objClass.className,
|
|
||||||
rows[0].getAt(requireScope(), ObjString("day")).objClass.className,
|
|
||||||
stringValue(requireScope(), rows[0].getAt(requireScope(), ObjString("clock"))),
|
|
||||||
rows[1].getAt(requireScope(), ObjString("flag")).objClass.className,
|
|
||||||
).joinToString("|")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) as ObjString
|
|
||||||
|
|
||||||
assertEquals("Bool|Date|12:34:56|Bool", summary.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testUnsupportedParameterTypeFailsWithSqlUsageException() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
val db = openMemoryDb(scope)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("create table sample(value text not null)")
|
|
||||||
)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into sample(value) values(?)"),
|
|
||||||
emptyMapObj()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
|
||||||
assertTrue(error.errorMessage.contains("Unsupported SQLite parameter type"), error.errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testTimestampAndDatetimeRejectTimezoneBearingText() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
withTempDb(scope) { db ->
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("create table sample(ts TIMESTAMP not null, dt DATETIME not null)")
|
|
||||||
)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into sample(ts, dt) values(?, ?)"),
|
|
||||||
ObjString("2024-05-06T07:08:09Z"),
|
|
||||||
ObjString("2024-05-06T10:11:12+03:00")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val timestampError = assertFailsWith<ExecutionError> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "select", ObjString("select ts from sample"))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
assertEquals("SqlExecutionException", timestampError.errorObject.objClass.className)
|
|
||||||
assertTrue(timestampError.errorMessage.contains("must not contain a timezone offset"), timestampError.errorMessage)
|
|
||||||
|
|
||||||
val datetimeError = assertFailsWith<ExecutionError> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "select", ObjString("select dt from sample"))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
assertEquals("SqlExecutionException", datetimeError.errorObject.objClass.className)
|
|
||||||
assertTrue(datetimeError.errorMessage.contains("must not contain a timezone offset"), datetimeError.errorMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testReadOnlyOpenPreventsWrites() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
|
|
||||||
val tempFile = Files.createTempFile("lyng-sqlite-", ".db")
|
|
||||||
try {
|
|
||||||
val writableDb = sqliteModule.callFn("openSqlite", ObjString(tempFile.toString()))
|
|
||||||
writableDb.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("create table item(id integer primary key autoincrement, name text not null)")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val readOnlyDb = sqliteModule.callFn(
|
|
||||||
"openSqlite",
|
|
||||||
ObjString(tempFile.toString()),
|
|
||||||
net.sergeych.lyng.obj.ObjTrue,
|
|
||||||
net.sergeych.lyng.obj.ObjFalse
|
|
||||||
)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
readOnlyDb.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into item(name) values(?)"),
|
|
||||||
ObjString("blocked")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlExecutionException", error.errorObject.objClass.className)
|
|
||||||
} finally {
|
|
||||||
tempFile.deleteIfExists()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testMissingFileWithCreateIfMissingFalseFailsWithDatabaseException() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
|
|
||||||
val missingDir = Files.createTempDirectory("lyng-sqlite-missing-dir-")
|
|
||||||
missingDir.deleteIfExists()
|
|
||||||
val missingFile = missingDir.resolve("missing.db")
|
|
||||||
|
|
||||||
try {
|
|
||||||
val db = sqliteModule.callFn(
|
|
||||||
"openSqlite",
|
|
||||||
ObjString(missingFile.toString()),
|
|
||||||
net.sergeych.lyng.obj.ObjFalse,
|
|
||||||
net.sergeych.lyng.obj.ObjFalse
|
|
||||||
)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge { ObjNull }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("DatabaseException", error.errorObject.objClass.className)
|
|
||||||
} finally {
|
|
||||||
missingFile.deleteIfExists()
|
|
||||||
missingDir.deleteIfExists()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testGenericOpenDatabaseReadOnlyOptionMatchesTypedHelper() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
|
|
||||||
val dbModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db")
|
|
||||||
val tempFile = Files.createTempFile("lyng-sqlite-generic-", ".db")
|
|
||||||
|
|
||||||
try {
|
|
||||||
val writableDb = dbModule.callFn("openDatabase", ObjString("sqlite:${tempFile}"), sqliteOptions(scope))
|
|
||||||
writableDb.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("create table item(id integer primary key autoincrement, name text not null)")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val readOnlyDb = dbModule.callFn(
|
|
||||||
"openDatabase",
|
|
||||||
ObjString("sqlite:${tempFile}"),
|
|
||||||
sqliteOptions(
|
|
||||||
scope,
|
|
||||||
"readOnly" to net.sergeych.lyng.obj.ObjTrue,
|
|
||||||
"createIfMissing" to net.sergeych.lyng.obj.ObjFalse
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
readOnlyDb.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into item(name) values(?)"),
|
|
||||||
ObjString("blocked")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlExecutionException", error.errorObject.objClass.className)
|
|
||||||
} finally {
|
|
||||||
tempFile.deleteIfExists()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testForeignKeysOptionControlsConstraintEnforcement() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
|
|
||||||
val tempFile = Files.createTempFile("lyng-sqlite-fk-", ".db")
|
|
||||||
|
|
||||||
try {
|
|
||||||
val dbNoFk = sqliteModule.callFn(
|
|
||||||
"openSqlite",
|
|
||||||
ObjString(tempFile.toString()),
|
|
||||||
net.sergeych.lyng.obj.ObjFalse,
|
|
||||||
net.sergeych.lyng.obj.ObjTrue,
|
|
||||||
net.sergeych.lyng.obj.ObjFalse
|
|
||||||
)
|
|
||||||
dbNoFk.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("create table parent(id integer primary key)"))
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("create table child(parent_id integer not null references parent(id))")
|
|
||||||
)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into child(parent_id) values(?)"),
|
|
||||||
ObjInt.One
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val dbWithFk = sqliteModule.callFn("openSqlite", ObjString(tempFile.toString()))
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
dbWithFk.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into child(parent_id) values(?)"),
|
|
||||||
ObjInt.of(2)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlConstraintException", error.errorObject.objClass.className)
|
|
||||||
} finally {
|
|
||||||
tempFile.deleteIfExists()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testCommitFailureBecomesPrimaryAfterNormalCompletion() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
val db = openMemoryDb(scope)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("rollback"))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlExecutionException", error.errorObject.objClass.className)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testUserExceptionStaysPrimaryWhenRollbackFails() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
val db = openMemoryDb(scope)
|
|
||||||
|
|
||||||
val error = assertFailsWith<IllegalStateException> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("rollback"))
|
|
||||||
throw IllegalStateException("boom")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("boom", error.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testRollbackFailureBecomesPrimaryAfterRollbackException() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
val db = openMemoryDb(scope)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("rollback"))
|
|
||||||
rollbackException(requireScope(), "rollback requested").raiseAsExecutionError(requireScope())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlExecutionException", error.errorObject.objClass.className)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun ModuleScope.callFn(name: String, vararg args: Obj): Obj {
|
|
||||||
val callee = get(name)?.value ?: error("Missing $name in module")
|
|
||||||
return callee.invoke(this, ObjNull, *args)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun sqliteOptions(scope: Scope, vararg entries: Pair<String, Obj>): ObjMap {
|
|
||||||
val result = ObjMap()
|
|
||||||
for ((key, value) in entries) {
|
|
||||||
result.putAt(scope, ObjString(key), value)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun openMemoryDb(scope: Scope): Obj {
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
|
|
||||||
return sqliteModule.callFn("openSqlite", ObjString(":memory:"))
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun withTempDb(scope: Scope, block: suspend (Obj) -> Unit) {
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
|
|
||||||
val tempFile = Files.createTempFile("lyng-sqlite-", ".db")
|
|
||||||
try {
|
|
||||||
val db = sqliteModule.callFn("openSqlite", ObjString(tempFile.toString()))
|
|
||||||
block(db)
|
|
||||||
} finally {
|
|
||||||
tempFile.deleteIfExists()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun field(scope: Scope, obj: Obj, name: String): Obj =
|
|
||||||
obj.readField(scope, name).value
|
|
||||||
|
|
||||||
private suspend fun rowsOf(scope: Scope, resultSet: Obj): List<Obj> {
|
|
||||||
val rows = resultSet.invokeInstanceMethod(scope, "toList")
|
|
||||||
val size = (field(scope, rows, "size") as ObjInt).value.toInt()
|
|
||||||
return (0 until size).map { index -> rows.getAt(scope, ObjInt.of(index.toLong())) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun stringValue(scope: Scope, obj: Obj): String =
|
|
||||||
(obj as? ObjString ?: obj.toString(scope)) .value
|
|
||||||
|
|
||||||
private suspend fun enumName(scope: Scope, obj: Obj): String =
|
|
||||||
stringValue(scope, field(scope, obj, "name"))
|
|
||||||
|
|
||||||
private suspend fun decimalOf(scope: Scope, value: String): Obj {
|
|
||||||
val decimalModule = scope.currentImportProvider.createModuleScope(scope.pos, "lyng.decimal")
|
|
||||||
val decimalClass = decimalModule.requireClass("Decimal")
|
|
||||||
return decimalClass.invokeInstanceMethod(scope, "fromString", ObjString(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun rollbackException(scope: Scope, message: String): net.sergeych.lyng.obj.ObjException {
|
|
||||||
val dbModule = scope.currentImportProvider.createModuleScope(scope.pos, "lyng.io.db")
|
|
||||||
val rollbackClass = dbModule.requireClass("RollbackException")
|
|
||||||
return rollbackClass.invoke(scope, ObjNull, ObjString(message)) as net.sergeych.lyng.obj.ObjException
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun dateOf(scope: Scope, value: String): Obj {
|
|
||||||
val timeModule = scope.currentImportProvider.createModuleScope(scope.pos, "lyng.time")
|
|
||||||
val dateClass = timeModule.requireClass("Date")
|
|
||||||
return dateClass.invoke(scope, ObjNull, ObjString(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun emptyMapObj(): Obj = ObjMap()
|
|
||||||
}
|
|
||||||
@ -1,785 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
package net.sergeych.lyng.io.db.sqlite
|
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import net.sergeych.lyng.ExecutionError
|
|
||||||
import net.sergeych.lyng.ModuleScope
|
|
||||||
import net.sergeych.lyng.Pos
|
|
||||||
import net.sergeych.lyng.Scope
|
|
||||||
import net.sergeych.lyng.Script
|
|
||||||
import net.sergeych.lyng.obj.Obj
|
|
||||||
import net.sergeych.lyng.obj.ObjBool
|
|
||||||
import net.sergeych.lyng.obj.ObjBuffer
|
|
||||||
import net.sergeych.lyng.obj.ObjDateTime
|
|
||||||
import net.sergeych.lyng.obj.ObjExternCallable
|
|
||||||
import net.sergeych.lyng.obj.ObjInt
|
|
||||||
import net.sergeych.lyng.obj.ObjInstant
|
|
||||||
import net.sergeych.lyng.obj.ObjMap
|
|
||||||
import net.sergeych.lyng.obj.ObjNull
|
|
||||||
import net.sergeych.lyng.obj.ObjString
|
|
||||||
import net.sergeych.lyng.obj.raiseAsExecutionError
|
|
||||||
import net.sergeych.lyng.obj.requiredArg
|
|
||||||
import net.sergeych.lyng.requireScope
|
|
||||||
import okio.FileSystem
|
|
||||||
import okio.Path
|
|
||||||
import okio.Path.Companion.toPath
|
|
||||||
import kotlin.random.Random
|
|
||||||
import kotlin.time.Instant
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertFailsWith
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
|
|
||||||
class LyngSqliteModuleNativeTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testTypedOpenSqliteExecutesQueriesAndGeneratedKeys() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
|
|
||||||
val db = sqliteModule.callFn("openSqlite", ObjString(":memory:"))
|
|
||||||
|
|
||||||
val insertedId = db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("create table person(id integer primary key autoincrement, name text not null)"))
|
|
||||||
val result = tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into person(name) values(?)"),
|
|
||||||
ObjString("John Doe")
|
|
||||||
)
|
|
||||||
val generatedKeys = result.invokeInstanceMethod(requireScope(), "getGeneratedKeys")
|
|
||||||
val rows = generatedKeys.invokeInstanceMethod(requireScope(), "toList")
|
|
||||||
rows.getAt(requireScope(), ObjInt.Zero).getAt(requireScope(), ObjInt.Zero)
|
|
||||||
}
|
|
||||||
) as ObjInt
|
|
||||||
|
|
||||||
assertEquals(1L, insertedId.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testNestedTransactionRollbackUsesSavepoint() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
val db = openMemoryDb(scope)
|
|
||||||
|
|
||||||
val count = db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("create table items(id integer primary key autoincrement, name text not null)"))
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into items(name) values(?)"), ObjString("outer"))
|
|
||||||
try {
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val inner = requiredArg<Obj>(0)
|
|
||||||
inner.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into items(name) values(?)"), ObjString("inner"))
|
|
||||||
throw IllegalStateException("rollback nested")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (_: IllegalStateException) {
|
|
||||||
}
|
|
||||||
val resultSet = tx.invokeInstanceMethod(requireScope(), "select", ObjString("select count(*) as count from items"))
|
|
||||||
val rows = resultSet.invokeInstanceMethod(requireScope(), "toList")
|
|
||||||
rows.getAt(requireScope(), ObjInt.Zero).getAt(requireScope(), ObjString("count"))
|
|
||||||
}
|
|
||||||
) as ObjInt
|
|
||||||
|
|
||||||
assertEquals(1L, count.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testRollbackExceptionRollsBackAndPropagates() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
withTempDb(scope) { db ->
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("create table items(id integer primary key autoincrement, name text not null)")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into items(name) values(?)"),
|
|
||||||
ObjString("rolled-back")
|
|
||||||
)
|
|
||||||
rollbackException(requireScope(), "stop here").raiseAsExecutionError(requireScope())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("RollbackException", error.errorObject.objClass.className)
|
|
||||||
|
|
||||||
val count = db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
val resultSet = tx.invokeInstanceMethod(requireScope(), "select", ObjString("select count(*) as count from items"))
|
|
||||||
rowsOf(requireScope(), resultSet)[0].getAt(requireScope(), ObjString("count"))
|
|
||||||
}
|
|
||||||
) as ObjInt
|
|
||||||
|
|
||||||
assertEquals(0L, count.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testResultSetFailsAfterTransactionEnds() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
val db = openMemoryDb(scope)
|
|
||||||
var leakedResultSet: Obj = ObjNull
|
|
||||||
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("create table items(id integer primary key autoincrement, name text not null)"))
|
|
||||||
leakedResultSet = tx.invokeInstanceMethod(requireScope(), "select", ObjString("select 42 as answer"))
|
|
||||||
ObjNull
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
leakedResultSet.invokeInstanceMethod(scope, "size")
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
|
||||||
assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testRowFailsAfterTransactionEnds() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
val db = openMemoryDb(scope)
|
|
||||||
var leakedRow: Obj = ObjNull
|
|
||||||
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
leakedRow = rowsOf(requireScope(), tx.invokeInstanceMethod(requireScope(), "select", ObjString("select 42 as answer")))[0]
|
|
||||||
ObjNull
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
leakedRow.getAt(scope, ObjString("answer"))
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
|
||||||
assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testExecuteRejectsReturningButSelectSupportsIt() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
withTempDb(scope) { db ->
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("create table item(id integer primary key autoincrement, name text not null)")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into item(name) values(?) returning id"),
|
|
||||||
ObjString("bad")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
|
||||||
|
|
||||||
val insertedId = db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
val resultSet = tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"select",
|
|
||||||
ObjString("insert into item(name) values(?) returning id"),
|
|
||||||
ObjString("good")
|
|
||||||
)
|
|
||||||
val row = rowsOf(requireScope(), resultSet)[0]
|
|
||||||
row.getAt(requireScope(), ObjString("id"))
|
|
||||||
}
|
|
||||||
) as ObjInt
|
|
||||||
|
|
||||||
assertEquals(1L, insertedId.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testColumnMetadataAndTypedValueConversion() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
val db = openMemoryDb(scope)
|
|
||||||
|
|
||||||
val summary = db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString(
|
|
||||||
"create table events(" +
|
|
||||||
"amount NUMERIC not null, " +
|
|
||||||
"happened TIMESTAMPTZ not null, " +
|
|
||||||
"scheduled TIMESTAMP not null, " +
|
|
||||||
"note TEXT not null, " +
|
|
||||||
"payload BLOB not null)"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val decimal = decimalOf(requireScope(), "12.50")
|
|
||||||
val happened = ObjInstant(Instant.parse("2024-05-06T07:08:09Z"))
|
|
||||||
val scheduled = ObjDateTime(Instant.parse("2024-05-06T10:11:12Z"), TimeZone.UTC)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into events(amount, happened, scheduled, note, payload) values(?, ?, ?, ?, ?)"),
|
|
||||||
decimal,
|
|
||||||
happened,
|
|
||||||
scheduled,
|
|
||||||
ObjString("hello"),
|
|
||||||
ObjBuffer(byteArrayOf(1, 2, 3).toUByteArray())
|
|
||||||
)
|
|
||||||
val resultSet = tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"select",
|
|
||||||
ObjString("select amount, happened, scheduled, note, payload from events")
|
|
||||||
)
|
|
||||||
val columns = field(requireScope(), resultSet, "columns")
|
|
||||||
val firstColumn = columns.getAt(requireScope(), ObjInt.Zero)
|
|
||||||
val row = rowsOf(requireScope(), resultSet)[0]
|
|
||||||
ObjString(
|
|
||||||
listOf(
|
|
||||||
stringValue(requireScope(), field(requireScope(), firstColumn, "name")),
|
|
||||||
enumName(requireScope(), field(requireScope(), firstColumn, "sqlType")),
|
|
||||||
stringValue(requireScope(), field(requireScope(), firstColumn, "nativeType")),
|
|
||||||
row.getAt(requireScope(), ObjString("amount")).objClass.className,
|
|
||||||
row.getAt(requireScope(), ObjString("happened")).objClass.className,
|
|
||||||
row.getAt(requireScope(), ObjString("scheduled")).objClass.className,
|
|
||||||
stringValue(requireScope(), row.getAt(requireScope(), ObjString("note"))),
|
|
||||||
row.getAt(requireScope(), ObjString("payload")).objClass.className,
|
|
||||||
).joinToString("|")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) as ObjString
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
"amount|Decimal|NUMERIC|Decimal|Instant|DateTime|hello|Buffer",
|
|
||||||
summary.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testDateAndBooleanConversionRules() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
val db = openMemoryDb(scope)
|
|
||||||
|
|
||||||
val summary = db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString(
|
|
||||||
"create table sample(" +
|
|
||||||
"flag BOOL not null, " +
|
|
||||||
"day DATE not null, " +
|
|
||||||
"clock TIME not null)"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into sample(flag, day, clock) values(?, ?, ?)"),
|
|
||||||
ObjBool(true),
|
|
||||||
dateOf(requireScope(), "2026-04-15"),
|
|
||||||
ObjString("12:34:56")
|
|
||||||
)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into sample(flag, day, clock) values(?, ?, ?)"),
|
|
||||||
ObjString("t"),
|
|
||||||
ObjString("2026-04-16"),
|
|
||||||
ObjString("23:59:59")
|
|
||||||
)
|
|
||||||
val resultSet = tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"select",
|
|
||||||
ObjString("select flag, day, clock from sample order by day")
|
|
||||||
)
|
|
||||||
val rows = rowsOf(requireScope(), resultSet)
|
|
||||||
ObjString(
|
|
||||||
listOf(
|
|
||||||
rows[0].getAt(requireScope(), ObjString("flag")).objClass.className,
|
|
||||||
rows[0].getAt(requireScope(), ObjString("day")).objClass.className,
|
|
||||||
stringValue(requireScope(), rows[0].getAt(requireScope(), ObjString("clock"))),
|
|
||||||
rows[1].getAt(requireScope(), ObjString("flag")).objClass.className,
|
|
||||||
).joinToString("|")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) as ObjString
|
|
||||||
|
|
||||||
assertEquals("Bool|Date|12:34:56|Bool", summary.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testUnsupportedParameterTypeFailsWithSqlUsageException() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
val db = openMemoryDb(scope)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("create table sample(value text not null)")
|
|
||||||
)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into sample(value) values(?)"),
|
|
||||||
emptyMapObj()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
|
||||||
assertTrue(error.errorMessage.contains("Unsupported SQLite parameter type"), error.errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testTimestampAndDatetimeRejectTimezoneBearingText() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
withTempDb(scope) { db ->
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("create table sample(ts TIMESTAMP not null, dt DATETIME not null)")
|
|
||||||
)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into sample(ts, dt) values(?, ?)"),
|
|
||||||
ObjString("2024-05-06T07:08:09Z"),
|
|
||||||
ObjString("2024-05-06T10:11:12+03:00")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val timestampError = assertFailsWith<ExecutionError> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "select", ObjString("select ts from sample"))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
assertEquals("SqlExecutionException", timestampError.errorObject.objClass.className)
|
|
||||||
assertTrue(timestampError.errorMessage.contains("must not contain a timezone offset"), timestampError.errorMessage)
|
|
||||||
|
|
||||||
val datetimeError = assertFailsWith<ExecutionError> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "select", ObjString("select dt from sample"))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
assertEquals("SqlExecutionException", datetimeError.errorObject.objClass.className)
|
|
||||||
assertTrue(datetimeError.errorMessage.contains("must not contain a timezone offset"), datetimeError.errorMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testReadOnlyOpenPreventsWrites() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
|
|
||||||
withTempPath { tempPath ->
|
|
||||||
val writableDb = sqliteModule.callFn("openSqlite", ObjString(tempPath.toString()))
|
|
||||||
writableDb.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("create table item(id integer primary key autoincrement, name text not null)")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val readOnlyDb = sqliteModule.callFn(
|
|
||||||
"openSqlite",
|
|
||||||
ObjString(tempPath.toString()),
|
|
||||||
net.sergeych.lyng.obj.ObjTrue,
|
|
||||||
net.sergeych.lyng.obj.ObjFalse
|
|
||||||
)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
readOnlyDb.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into item(name) values(?)"),
|
|
||||||
ObjString("blocked")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlExecutionException", error.errorObject.objClass.className)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testMissingFileWithCreateIfMissingFalseFailsWithDatabaseException() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
|
|
||||||
val missingDir = "/tmp/lyng-sqlite-missing-${Random.nextInt(Int.MAX_VALUE)}".toPath()
|
|
||||||
val missingFile = (missingDir.toString() + "/missing.db").toPath()
|
|
||||||
try {
|
|
||||||
FileSystem.SYSTEM.delete(missingFile, mustExist = false)
|
|
||||||
FileSystem.SYSTEM.delete(missingDir, mustExist = false)
|
|
||||||
val db = sqliteModule.callFn(
|
|
||||||
"openSqlite",
|
|
||||||
ObjString(missingFile.toString()),
|
|
||||||
net.sergeych.lyng.obj.ObjFalse,
|
|
||||||
net.sergeych.lyng.obj.ObjFalse
|
|
||||||
)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge { ObjNull }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("DatabaseException", error.errorObject.objClass.className)
|
|
||||||
} finally {
|
|
||||||
FileSystem.SYSTEM.delete(missingFile, mustExist = false)
|
|
||||||
FileSystem.SYSTEM.delete(missingDir, mustExist = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testGenericOpenDatabaseReadOnlyOptionMatchesTypedHelper() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
|
|
||||||
val dbModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db")
|
|
||||||
|
|
||||||
withTempPath { tempPath ->
|
|
||||||
val writableDb = dbModule.callFn("openDatabase", ObjString("sqlite:${tempPath}"), sqliteOptions(scope))
|
|
||||||
writableDb.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("create table item(id integer primary key autoincrement, name text not null)")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val readOnlyDb = dbModule.callFn(
|
|
||||||
"openDatabase",
|
|
||||||
ObjString("sqlite:${tempPath}"),
|
|
||||||
sqliteOptions(
|
|
||||||
scope,
|
|
||||||
"readOnly" to net.sergeych.lyng.obj.ObjTrue,
|
|
||||||
"createIfMissing" to net.sergeych.lyng.obj.ObjFalse
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
readOnlyDb.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into item(name) values(?)"),
|
|
||||||
ObjString("blocked")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlExecutionException", error.errorObject.objClass.className)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testForeignKeysOptionControlsConstraintEnforcement() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
|
|
||||||
|
|
||||||
withTempPath { tempPath ->
|
|
||||||
val dbNoFk = sqliteModule.callFn(
|
|
||||||
"openSqlite",
|
|
||||||
ObjString(tempPath.toString()),
|
|
||||||
net.sergeych.lyng.obj.ObjFalse,
|
|
||||||
net.sergeych.lyng.obj.ObjTrue,
|
|
||||||
net.sergeych.lyng.obj.ObjFalse
|
|
||||||
)
|
|
||||||
dbNoFk.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("create table parent(id integer primary key)"))
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("create table child(parent_id integer not null references parent(id))")
|
|
||||||
)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into child(parent_id) values(?)"),
|
|
||||||
ObjInt.One
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val dbWithFk = sqliteModule.callFn("openSqlite", ObjString(tempPath.toString()))
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
dbWithFk.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(
|
|
||||||
requireScope(),
|
|
||||||
"execute",
|
|
||||||
ObjString("insert into child(parent_id) values(?)"),
|
|
||||||
ObjInt.of(2)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlConstraintException", error.errorObject.objClass.className)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testCommitFailureBecomesPrimaryAfterNormalCompletion() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
val db = openMemoryDb(scope)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("rollback"))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlExecutionException", error.errorObject.objClass.className)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testUserExceptionStaysPrimaryWhenRollbackFails() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
val db = openMemoryDb(scope)
|
|
||||||
|
|
||||||
val error = assertFailsWith<IllegalStateException> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("rollback"))
|
|
||||||
throw IllegalStateException("boom")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("boom", error.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testRollbackFailureBecomesPrimaryAfterRollbackException() = runTest {
|
|
||||||
val scope = Script.newScope()
|
|
||||||
val db = openMemoryDb(scope)
|
|
||||||
|
|
||||||
val error = assertFailsWith<ExecutionError> {
|
|
||||||
db.invokeInstanceMethod(
|
|
||||||
scope,
|
|
||||||
"transaction",
|
|
||||||
ObjExternCallable.fromBridge {
|
|
||||||
val tx = requiredArg<Obj>(0)
|
|
||||||
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("rollback"))
|
|
||||||
rollbackException(requireScope(), "rollback requested").raiseAsExecutionError(requireScope())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("SqlExecutionException", error.errorObject.objClass.className)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun ModuleScope.callFn(name: String, vararg args: Obj): Obj {
|
|
||||||
val callee = get(name)?.value ?: error("Missing $name in module")
|
|
||||||
return callee.invoke(this, ObjNull, *args)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun sqliteOptions(scope: Scope, vararg entries: Pair<String, Obj>): ObjMap {
|
|
||||||
val result = ObjMap()
|
|
||||||
for ((key, value) in entries) {
|
|
||||||
result.putAt(scope, ObjString(key), value)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun openMemoryDb(scope: Scope): Obj {
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
|
|
||||||
return sqliteModule.callFn("openSqlite", ObjString(":memory:"))
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun withTempDb(scope: Scope, block: suspend (Obj) -> Unit) {
|
|
||||||
createSqliteModule(scope.importManager)
|
|
||||||
val sqliteModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.sqlite")
|
|
||||||
withTempPath { tempPath ->
|
|
||||||
val db = sqliteModule.callFn("openSqlite", ObjString(tempPath.toString()))
|
|
||||||
block(db)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun withTempPath(block: suspend (Path) -> Unit) {
|
|
||||||
val path = "/tmp/lyng-sqlite-${Random.nextInt(Int.MAX_VALUE)}.db".toPath()
|
|
||||||
try {
|
|
||||||
block(path)
|
|
||||||
} finally {
|
|
||||||
FileSystem.SYSTEM.delete(path, mustExist = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun field(scope: Scope, obj: Obj, name: String): Obj =
|
|
||||||
obj.readField(scope, name).value
|
|
||||||
|
|
||||||
private suspend fun rowsOf(scope: Scope, resultSet: Obj): List<Obj> {
|
|
||||||
val rows = resultSet.invokeInstanceMethod(scope, "toList")
|
|
||||||
val size = (field(scope, rows, "size") as ObjInt).value.toInt()
|
|
||||||
return (0 until size).map { index -> rows.getAt(scope, ObjInt.of(index.toLong())) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun stringValue(scope: Scope, obj: Obj): String =
|
|
||||||
(obj as? ObjString ?: obj.toString(scope)).value
|
|
||||||
|
|
||||||
private suspend fun enumName(scope: Scope, obj: Obj): String =
|
|
||||||
stringValue(scope, field(scope, obj, "name"))
|
|
||||||
|
|
||||||
private suspend fun decimalOf(scope: Scope, value: String): Obj {
|
|
||||||
val decimalModule = scope.currentImportProvider.createModuleScope(scope.pos, "lyng.decimal")
|
|
||||||
val decimalClass = decimalModule.requireClass("Decimal")
|
|
||||||
return decimalClass.invokeInstanceMethod(scope, "fromString", ObjString(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun rollbackException(scope: Scope, message: String): net.sergeych.lyng.obj.ObjException {
|
|
||||||
val dbModule = scope.currentImportProvider.createModuleScope(scope.pos, "lyng.io.db")
|
|
||||||
val rollbackClass = dbModule.requireClass("RollbackException")
|
|
||||||
return rollbackClass.invoke(scope, ObjNull, ObjString(message)) as net.sergeych.lyng.obj.ObjException
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun dateOf(scope: Scope, value: String): Obj {
|
|
||||||
val timeModule = scope.currentImportProvider.createModuleScope(scope.pos, "lyng.time")
|
|
||||||
val dateClass = timeModule.requireClass("Date")
|
|
||||||
return dateClass.invoke(scope, ObjNull, ObjString(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun emptyMapObj(): Obj = ObjMap()
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
headers = sqlite3_lyng.h
|
|
||||||
package = net.sergeych.lyng.io.db.sqlite.cinterop
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
#ifndef LYNG_SQLITE3_LYNG_H
|
|
||||||
#define LYNG_SQLITE3_LYNG_H
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
typedef struct sqlite3 sqlite3;
|
|
||||||
typedef struct sqlite3_stmt sqlite3_stmt;
|
|
||||||
typedef long long sqlite3_int64;
|
|
||||||
typedef void (*sqlite3_destructor_type)(void*);
|
|
||||||
|
|
||||||
#define SQLITE_OK 0
|
|
||||||
#define SQLITE_ERROR 1
|
|
||||||
#define SQLITE_INTERNAL 2
|
|
||||||
#define SQLITE_PERM 3
|
|
||||||
#define SQLITE_ABORT 4
|
|
||||||
#define SQLITE_BUSY 5
|
|
||||||
#define SQLITE_LOCKED 6
|
|
||||||
#define SQLITE_NOMEM 7
|
|
||||||
#define SQLITE_READONLY 8
|
|
||||||
#define SQLITE_INTERRUPT 9
|
|
||||||
#define SQLITE_IOERR 10
|
|
||||||
#define SQLITE_CORRUPT 11
|
|
||||||
#define SQLITE_NOTFOUND 12
|
|
||||||
#define SQLITE_FULL 13
|
|
||||||
#define SQLITE_CANTOPEN 14
|
|
||||||
#define SQLITE_PROTOCOL 15
|
|
||||||
#define SQLITE_EMPTY 16
|
|
||||||
#define SQLITE_SCHEMA 17
|
|
||||||
#define SQLITE_TOOBIG 18
|
|
||||||
#define SQLITE_CONSTRAINT 19
|
|
||||||
#define SQLITE_MISMATCH 20
|
|
||||||
#define SQLITE_MISUSE 21
|
|
||||||
#define SQLITE_NOLFS 22
|
|
||||||
#define SQLITE_AUTH 23
|
|
||||||
#define SQLITE_FORMAT 24
|
|
||||||
#define SQLITE_RANGE 25
|
|
||||||
#define SQLITE_NOTADB 26
|
|
||||||
#define SQLITE_NOTICE 27
|
|
||||||
#define SQLITE_WARNING 28
|
|
||||||
#define SQLITE_ROW 100
|
|
||||||
#define SQLITE_DONE 101
|
|
||||||
|
|
||||||
#define SQLITE_INTEGER 1
|
|
||||||
#define SQLITE_FLOAT 2
|
|
||||||
#define SQLITE_TEXT 3
|
|
||||||
#define SQLITE_BLOB 4
|
|
||||||
#define SQLITE_NULL 5
|
|
||||||
|
|
||||||
#define SQLITE_OPEN_READONLY 0x00000001
|
|
||||||
#define SQLITE_OPEN_READWRITE 0x00000002
|
|
||||||
#define SQLITE_OPEN_CREATE 0x00000004
|
|
||||||
#define SQLITE_OPEN_URI 0x00000040
|
|
||||||
|
|
||||||
int sqlite3_open_v2(const char* filename, sqlite3** ppDb, int flags, const char* zVfs);
|
|
||||||
int sqlite3_close_v2(sqlite3*);
|
|
||||||
int sqlite3_extended_result_codes(sqlite3*, int onoff);
|
|
||||||
int sqlite3_busy_timeout(sqlite3*, int ms);
|
|
||||||
int sqlite3_prepare_v2(sqlite3* db, const char* zSql, int nByte, sqlite3_stmt** ppStmt, const char** pzTail);
|
|
||||||
int sqlite3_finalize(sqlite3_stmt* pStmt);
|
|
||||||
int sqlite3_step(sqlite3_stmt*);
|
|
||||||
int sqlite3_reset(sqlite3_stmt* pStmt);
|
|
||||||
int sqlite3_clear_bindings(sqlite3_stmt*);
|
|
||||||
int sqlite3_bind_parameter_count(sqlite3_stmt*);
|
|
||||||
int sqlite3_bind_null(sqlite3_stmt*, int);
|
|
||||||
int sqlite3_bind_int64(sqlite3_stmt*, int, sqlite3_int64);
|
|
||||||
int sqlite3_bind_double(sqlite3_stmt*, int, double);
|
|
||||||
int sqlite3_bind_text(sqlite3_stmt*, int, const char*, int n, sqlite3_destructor_type);
|
|
||||||
int sqlite3_bind_blob(sqlite3_stmt*, int, const void*, int n, sqlite3_destructor_type);
|
|
||||||
int sqlite3_column_count(sqlite3_stmt* pStmt);
|
|
||||||
const char* sqlite3_column_name(sqlite3_stmt*, int N);
|
|
||||||
const char* sqlite3_column_decltype(sqlite3_stmt*, int);
|
|
||||||
int sqlite3_column_type(sqlite3_stmt*, int iCol);
|
|
||||||
sqlite3_int64 sqlite3_column_int64(sqlite3_stmt*, int iCol);
|
|
||||||
double sqlite3_column_double(sqlite3_stmt*, int iCol);
|
|
||||||
const unsigned char* sqlite3_column_text(sqlite3_stmt*, int iCol);
|
|
||||||
const void* sqlite3_column_blob(sqlite3_stmt*, int iCol);
|
|
||||||
int sqlite3_column_bytes(sqlite3_stmt*, int iCol);
|
|
||||||
const char* sqlite3_errmsg(sqlite3*);
|
|
||||||
int sqlite3_extended_errcode(sqlite3*);
|
|
||||||
int sqlite3_changes(sqlite3*);
|
|
||||||
sqlite3_int64 sqlite3_last_insert_rowid(sqlite3*);
|
|
||||||
int sqlite3_db_readonly(sqlite3*, const char* zDbName);
|
|
||||||
|
|
||||||
static inline sqlite3* lyng_sqlite3_open(const char* filename, int flags) {
|
|
||||||
sqlite3* db = 0;
|
|
||||||
sqlite3_open_v2(filename, &db, flags, 0);
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline sqlite3_stmt* lyng_sqlite3_prepare(sqlite3* db, const char* sql) {
|
|
||||||
sqlite3_stmt* stmt = 0;
|
|
||||||
sqlite3_prepare_v2(db, sql, -1, &stmt, 0);
|
|
||||||
return stmt;
|
|
||||||
}
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif
|
|
||||||
@ -1,624 +0,0 @@
|
|||||||
@file:OptIn(ExperimentalForeignApi::class)
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
package net.sergeych.lyng.io.db.sqlite
|
|
||||||
|
|
||||||
import cnames.structs.sqlite3
|
|
||||||
import cnames.structs.sqlite3_stmt
|
|
||||||
import kotlinx.cinterop.ByteVar
|
|
||||||
import kotlinx.cinterop.CFunction
|
|
||||||
import kotlinx.cinterop.CPointer
|
|
||||||
import kotlinx.cinterop.COpaquePointer
|
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
|
||||||
import kotlinx.cinterop.MemScope
|
|
||||||
import kotlinx.cinterop.allocArray
|
|
||||||
import kotlinx.cinterop.addressOf
|
|
||||||
import kotlinx.cinterop.memScoped
|
|
||||||
import kotlinx.cinterop.readBytes
|
|
||||||
import kotlinx.cinterop.reinterpret
|
|
||||||
import kotlinx.cinterop.toCPointer
|
|
||||||
import kotlinx.cinterop.toKString
|
|
||||||
import kotlinx.cinterop.usePinned
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.LocalDateTime
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import net.sergeych.lyng.ExecutionError
|
|
||||||
import net.sergeych.lyng.ScopeFacade
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_BLOB
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_CONSTRAINT
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_DONE
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_FLOAT
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_INTEGER
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_NULL
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_OK
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_OPEN_CREATE
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_OPEN_READONLY
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_OPEN_READWRITE
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_OPEN_URI
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_ROW
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.SQLITE_TEXT
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_bind_blob
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_bind_double
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_bind_int64
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_bind_null
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_bind_parameter_count
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_bind_text
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_busy_timeout
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_changes
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_close_v2
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_column_blob
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_column_bytes
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_column_count
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_column_decltype
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_column_double
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_column_int64
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_column_name
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_column_text
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_column_type
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_errmsg
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_extended_errcode
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_extended_result_codes
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_finalize
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_last_insert_rowid
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.lyng_sqlite3_open
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.lyng_sqlite3_prepare
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_open_v2
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_reset
|
|
||||||
import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_step
|
|
||||||
import net.sergeych.lyng.obj.Obj
|
|
||||||
import net.sergeych.lyng.obj.ObjBool
|
|
||||||
import net.sergeych.lyng.obj.ObjBuffer
|
|
||||||
import net.sergeych.lyng.obj.ObjDate
|
|
||||||
import net.sergeych.lyng.obj.ObjDateTime
|
|
||||||
import net.sergeych.lyng.obj.ObjEnumEntry
|
|
||||||
import net.sergeych.lyng.obj.ObjException
|
|
||||||
import net.sergeych.lyng.obj.ObjInt
|
|
||||||
import net.sergeych.lyng.obj.ObjInstant
|
|
||||||
import net.sergeych.lyng.obj.ObjNull
|
|
||||||
import net.sergeych.lyng.obj.ObjReal
|
|
||||||
import net.sergeych.lyng.obj.ObjString
|
|
||||||
import net.sergeych.lyng.requireScope
|
|
||||||
import platform.posix.memcpy
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
internal actual suspend fun openSqliteBackend(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
options: SqliteOpenOptions,
|
|
||||||
): SqliteDatabaseBackend {
|
|
||||||
if (options.busyTimeoutMillis < 0) {
|
|
||||||
scope.raiseIllegalArgument("busyTimeoutMillis must be >= 0")
|
|
||||||
}
|
|
||||||
return NativeSqliteDatabaseBackend(core, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
private class NativeSqliteDatabaseBackend(
|
|
||||||
private val core: SqliteCoreModule,
|
|
||||||
private val options: SqliteOpenOptions,
|
|
||||||
) : SqliteDatabaseBackend {
|
|
||||||
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T {
|
|
||||||
val handle = openHandle(scope, core, options)
|
|
||||||
val savepoints = SavepointCounter()
|
|
||||||
try {
|
|
||||||
handle.execUnit(scope, core, "begin")
|
|
||||||
val tx = NativeSqliteTransactionBackend(core, handle, savepoints)
|
|
||||||
val result = try {
|
|
||||||
block(tx)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
throw finishFailedTransaction(scope, core, e) {
|
|
||||||
handle.execUnit(scope, core, "rollback")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handle.execUnit(scope, core, "commit")
|
|
||||||
return result
|
|
||||||
} finally {
|
|
||||||
handle.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class NativeSqliteTransactionBackend(
|
|
||||||
private val core: SqliteCoreModule,
|
|
||||||
private val handle: NativeSqliteHandle,
|
|
||||||
private val savepoints: SavepointCounter,
|
|
||||||
) : SqliteTransactionBackend {
|
|
||||||
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteResultSetData {
|
|
||||||
return handle.select(scope, core, clause, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun execute(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteExecutionResultData {
|
|
||||||
return handle.execute(scope, core, clause, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T {
|
|
||||||
val savepoint = "lyng_sp_${savepoints.next()}"
|
|
||||||
handle.execUnit(scope, core, "savepoint $savepoint")
|
|
||||||
val nested = NativeSqliteTransactionBackend(core, handle, savepoints)
|
|
||||||
val result = try {
|
|
||||||
block(nested)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
throw finishFailedTransaction(scope, core, e) {
|
|
||||||
handle.execUnit(scope, core, "rollback to savepoint $savepoint")
|
|
||||||
handle.execUnit(scope, core, "release savepoint $savepoint")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handle.execUnit(scope, core, "release savepoint $savepoint")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SavepointCounter {
|
|
||||||
private var nextValue = 0
|
|
||||||
|
|
||||||
fun next(): Int {
|
|
||||||
nextValue += 1
|
|
||||||
return nextValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class NativeSqliteHandle(
|
|
||||||
private val db: CPointer<sqlite3>,
|
|
||||||
) {
|
|
||||||
suspend fun select(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
clause: String,
|
|
||||||
params: List<Obj>,
|
|
||||||
): SqliteResultSetData = memScoped {
|
|
||||||
val stmt = prepare(scope, core, clause)
|
|
||||||
try {
|
|
||||||
bindParams(scope, core, stmt, params, this)
|
|
||||||
readResultSet(scope, core, stmt)
|
|
||||||
} finally {
|
|
||||||
sqlite3_finalize(stmt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun execute(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
clause: String,
|
|
||||||
params: List<Obj>,
|
|
||||||
): SqliteExecutionResultData = memScoped {
|
|
||||||
if (containsRowReturningClause(clause)) {
|
|
||||||
raiseExecuteReturningUsage(scope, core)
|
|
||||||
}
|
|
||||||
val stmt = prepare(scope, core, clause)
|
|
||||||
try {
|
|
||||||
bindParams(scope, core, stmt, params, this)
|
|
||||||
when (val rc = sqlite3_step(stmt)) {
|
|
||||||
SQLITE_DONE -> {
|
|
||||||
val affectedRows = sqlite3_changes(db)
|
|
||||||
val generatedKeys = readGeneratedKeys(core, clause, affectedRows)
|
|
||||||
SqliteExecutionResultData(affectedRows, generatedKeys)
|
|
||||||
}
|
|
||||||
SQLITE_ROW -> raiseExecuteReturningUsage(scope, core)
|
|
||||||
else -> throw sqlError(scope, core, rc)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
sqlite3_reset(stmt)
|
|
||||||
sqlite3_finalize(stmt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun execUnit(scope: ScopeFacade, core: SqliteCoreModule, sql: String) {
|
|
||||||
memScoped {
|
|
||||||
val stmt = prepare(scope, core, sql)
|
|
||||||
try {
|
|
||||||
when (val rc = sqlite3_step(stmt)) {
|
|
||||||
SQLITE_DONE, SQLITE_ROW -> Unit
|
|
||||||
else -> throw sqlError(scope, core, rc)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
sqlite3_finalize(stmt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun close() {
|
|
||||||
sqlite3_close_v2(db)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun MemScope.prepare(scope: ScopeFacade, core: SqliteCoreModule, sql: String): CPointer<sqlite3_stmt> {
|
|
||||||
return lyng_sqlite3_prepare(db, sql) ?: throw sqlError(scope, core, sqlite3_extended_errcode(db))
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun bindParams(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
stmt: CPointer<sqlite3_stmt>,
|
|
||||||
params: List<Obj>,
|
|
||||||
memScope: MemScope,
|
|
||||||
) {
|
|
||||||
val expectedCount = sqlite3_bind_parameter_count(stmt)
|
|
||||||
if (expectedCount != params.size) {
|
|
||||||
throw usageError(
|
|
||||||
scope,
|
|
||||||
core,
|
|
||||||
"SQL parameter count mismatch: statement expects $expectedCount value(s), got ${params.size}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
params.forEachIndexed { index, value ->
|
|
||||||
val parameterIndex = index + 1
|
|
||||||
val rc = when (value) {
|
|
||||||
ObjNull -> sqlite3_bind_null(stmt, parameterIndex)
|
|
||||||
is ObjBool -> sqlite3_bind_int64(stmt, parameterIndex, if (value.value) 1L else 0L)
|
|
||||||
is ObjInt -> sqlite3_bind_int64(stmt, parameterIndex, value.value)
|
|
||||||
is ObjReal -> sqlite3_bind_double(stmt, parameterIndex, value.value)
|
|
||||||
is ObjString -> bindText(stmt, parameterIndex, value.value, memScope)
|
|
||||||
is ObjBuffer -> bindBlob(stmt, parameterIndex, value.byteArray.toByteArray(), memScope)
|
|
||||||
is ObjInstant -> bindText(stmt, parameterIndex, value.instant.toString(), memScope)
|
|
||||||
is ObjDateTime -> bindText(stmt, parameterIndex, value.localDateTime.toString(), memScope)
|
|
||||||
else -> when (value.objClass.className) {
|
|
||||||
"Date", "Decimal" -> bindText(stmt, parameterIndex, scope.toStringOf(value).value, memScope)
|
|
||||||
else -> throw usageError(
|
|
||||||
scope,
|
|
||||||
core,
|
|
||||||
"Unsupported SQLite parameter type: ${value.objClass.className}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rc != SQLITE_OK) {
|
|
||||||
throw sqlError(scope, core, rc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindText(
|
|
||||||
stmt: CPointer<sqlite3_stmt>,
|
|
||||||
parameterIndex: Int,
|
|
||||||
value: String,
|
|
||||||
memScope: MemScope,
|
|
||||||
): Int {
|
|
||||||
return sqlite3_bind_text(stmt, parameterIndex, value, -1, SQLITE_TRANSIENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindBlob(
|
|
||||||
stmt: CPointer<sqlite3_stmt>,
|
|
||||||
parameterIndex: Int,
|
|
||||||
value: ByteArray,
|
|
||||||
memScope: MemScope,
|
|
||||||
): Int {
|
|
||||||
if (value.isEmpty()) {
|
|
||||||
return sqlite3_bind_blob(stmt, parameterIndex, null, 0, null)
|
|
||||||
}
|
|
||||||
val target = memScope.allocArray<ByteVar>(value.size)
|
|
||||||
value.usePinned { pinned ->
|
|
||||||
memcpy(target, pinned.addressOf(0), value.size.toULong())
|
|
||||||
}
|
|
||||||
return sqlite3_bind_blob(stmt, parameterIndex, target, value.size, SQLITE_TRANSIENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun readResultSet(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
stmt: CPointer<sqlite3_stmt>,
|
|
||||||
): SqliteResultSetData {
|
|
||||||
val columnCount = sqlite3_column_count(stmt)
|
|
||||||
val columns = (0 until columnCount).map { index ->
|
|
||||||
val nativeType = sqlite3_column_decltype(stmt, index)?.toKString().orEmpty()
|
|
||||||
SqliteColumnMeta(
|
|
||||||
name = sqlite3_column_name(stmt, index)?.toKString().orEmpty(),
|
|
||||||
sqlType = mapSqlType(core, nativeType, SQLITE_NULL),
|
|
||||||
nullable = true,
|
|
||||||
nativeType = nativeType,
|
|
||||||
)
|
|
||||||
}.toMutableList()
|
|
||||||
if (columnCount == 0) {
|
|
||||||
return emptyResultSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
val rows = mutableListOf<List<Obj>>()
|
|
||||||
while (true) {
|
|
||||||
when (val rc = sqlite3_step(stmt)) {
|
|
||||||
SQLITE_ROW -> {
|
|
||||||
val row = (0 until columnCount).map { index ->
|
|
||||||
val dynamicType = sqlite3_column_type(stmt, index)
|
|
||||||
if (columns[index].nativeType.isBlank()) {
|
|
||||||
columns[index] = columns[index].copy(sqlType = mapSqlType(core, columns[index].nativeType, dynamicType))
|
|
||||||
}
|
|
||||||
readColumnValue(scope, core, stmt, index, columns[index].nativeType)
|
|
||||||
}
|
|
||||||
rows += row
|
|
||||||
}
|
|
||||||
SQLITE_DONE -> return SqliteResultSetData(columns, rows)
|
|
||||||
else -> throw sqlError(scope, core, rc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun readColumnValue(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
stmt: CPointer<sqlite3_stmt>,
|
|
||||||
index: Int,
|
|
||||||
nativeType: String,
|
|
||||||
): Obj {
|
|
||||||
val normalizedNativeType = normalizeDeclaredTypeName(nativeType)
|
|
||||||
return when (val type = sqlite3_column_type(stmt, index)) {
|
|
||||||
SQLITE_NULL -> ObjNull
|
|
||||||
SQLITE_INTEGER -> {
|
|
||||||
val value = sqlite3_column_int64(stmt, index)
|
|
||||||
when {
|
|
||||||
isBooleanNativeType(normalizedNativeType) -> integerToBool(scope, core, value)
|
|
||||||
isDecimalNativeType(normalizedNativeType) -> decimalFromString(scope, value.toString())
|
|
||||||
else -> ObjInt.of(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SQLITE_FLOAT -> {
|
|
||||||
val value = sqlite3_column_double(stmt, index)
|
|
||||||
if (isDecimalNativeType(normalizedNativeType)) decimalFromString(scope, value.toString()) else ObjReal.of(value)
|
|
||||||
}
|
|
||||||
SQLITE_TEXT -> {
|
|
||||||
val textPtr = sqlite3_column_text(stmt, index)?.reinterpret<ByteVar>()
|
|
||||||
val value = textPtr?.toKString() ?: ""
|
|
||||||
convertStringValue(scope, core, normalizedNativeType, value)
|
|
||||||
}
|
|
||||||
SQLITE_BLOB -> {
|
|
||||||
val size = sqlite3_column_bytes(stmt, index)
|
|
||||||
val blob = sqlite3_column_blob(stmt, index)
|
|
||||||
val bytes = if (blob == null || size <= 0) byteArrayOf() else blob.reinterpret<ByteVar>().readBytes(size)
|
|
||||||
ObjBuffer(bytes.toUByteArray())
|
|
||||||
}
|
|
||||||
else -> ObjString(columnText(stmt, index))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun convertStringValue(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
normalizedNativeType: String,
|
|
||||||
value: String,
|
|
||||||
): Obj {
|
|
||||||
return when {
|
|
||||||
isBooleanNativeType(normalizedNativeType) -> stringToBool(scope, core, value)
|
|
||||||
isDecimalNativeType(normalizedNativeType) -> decimalFromString(scope, value.trim())
|
|
||||||
normalizedNativeType == "DATE" -> ObjDate(LocalDate.parse(value.trim()))
|
|
||||||
normalizedNativeType == "DATETIME" || normalizedNativeType == "TIMESTAMP" ->
|
|
||||||
dateTimeFromString(scope, core, value)
|
|
||||||
normalizedNativeType == "TIMESTAMP WITH TIME ZONE" ||
|
|
||||||
normalizedNativeType == "TIMESTAMPTZ" ||
|
|
||||||
normalizedNativeType == "DATETIME WITH TIME ZONE" -> ObjInstant(Instant.parse(value.trim()))
|
|
||||||
else -> ObjString(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readGeneratedKeys(
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
clause: String,
|
|
||||||
affectedRows: Int,
|
|
||||||
): SqliteResultSetData {
|
|
||||||
if (affectedRows <= 0 || !looksLikeInsert(clause)) {
|
|
||||||
return emptyResultSet()
|
|
||||||
}
|
|
||||||
return SqliteResultSetData(
|
|
||||||
columns = listOf(
|
|
||||||
SqliteColumnMeta(
|
|
||||||
name = "generated_key",
|
|
||||||
sqlType = core.sqlTypes.require("Int"),
|
|
||||||
nullable = false,
|
|
||||||
nativeType = "INTEGER",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
rows = listOf(listOf(ObjInt.of(sqlite3_last_insert_rowid(db))))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun columnText(stmt: CPointer<sqlite3_stmt>, index: Int): String {
|
|
||||||
return sqlite3_column_text(stmt, index)?.reinterpret<ByteVar>()?.toKString().orEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sqlError(scope: ScopeFacade, core: SqliteCoreModule, rc: Int): ExecutionError {
|
|
||||||
val code = sqlite3_extended_errcode(db)
|
|
||||||
val message = sqlite3_errmsg(db)?.toKString() ?: "SQLite error ($rc)"
|
|
||||||
val exceptionClass = if ((code and 0xff) == SQLITE_CONSTRAINT) core.sqlConstraintException else core.sqlExecutionException
|
|
||||||
return ExecutionError(
|
|
||||||
ObjException(exceptionClass, scope.requireScope(), ObjString(message)),
|
|
||||||
scope.pos,
|
|
||||||
message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openHandle(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
options: SqliteOpenOptions,
|
|
||||||
): NativeSqliteHandle = memScoped {
|
|
||||||
val flags = buildOpenFlags(options)
|
|
||||||
val db = lyng_sqlite3_open(options.path, flags)
|
|
||||||
val rc = db?.let { sqlite3_extended_errcode(it) } ?: SQLITE_OK
|
|
||||||
if (db == null || rc != SQLITE_OK) {
|
|
||||||
val message = db?.let { sqlite3_errmsg(it)?.toKString() } ?: "SQLite open failed"
|
|
||||||
if (db != null) {
|
|
||||||
sqlite3_close_v2(db)
|
|
||||||
}
|
|
||||||
throw databaseError(scope, core, message)
|
|
||||||
}
|
|
||||||
sqlite3_extended_result_codes(db, 1)
|
|
||||||
if (sqlite3_busy_timeout(db, options.busyTimeoutMillis) != SQLITE_OK) {
|
|
||||||
val message = sqlite3_errmsg(db)?.toKString() ?: "Failed to configure SQLite busy timeout"
|
|
||||||
sqlite3_close_v2(db)
|
|
||||||
throw databaseError(scope, core, message)
|
|
||||||
}
|
|
||||||
val handle = NativeSqliteHandle(db)
|
|
||||||
try {
|
|
||||||
handle.execUnit(scope, core, if (options.foreignKeys) "pragma foreign_keys = on" else "pragma foreign_keys = off")
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
handle.close()
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
handle
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildOpenFlags(options: SqliteOpenOptions): Int {
|
|
||||||
var flags = SQLITE_OPEN_URI
|
|
||||||
if (options.readOnly) {
|
|
||||||
flags = flags or SQLITE_OPEN_READONLY
|
|
||||||
} else {
|
|
||||||
flags = flags or SQLITE_OPEN_READWRITE
|
|
||||||
if (options.createIfMissing) {
|
|
||||||
flags = flags or SQLITE_OPEN_CREATE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flags
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun containsRowReturningClause(clause: String): Boolean =
|
|
||||||
Regex("""\breturning\b""", RegexOption.IGNORE_CASE).containsMatchIn(clause)
|
|
||||||
|
|
||||||
private fun looksLikeInsert(clause: String): Boolean = clause.trimStart().startsWith("insert", ignoreCase = true)
|
|
||||||
|
|
||||||
private val SQLITE_TRANSIENT = (-1L).toCPointer<CFunction<(COpaquePointer?) -> Unit>>()
|
|
||||||
|
|
||||||
private fun emptyResultSet(): SqliteResultSetData = SqliteResultSetData(emptyList(), emptyList())
|
|
||||||
|
|
||||||
private fun mapSqlType(core: SqliteCoreModule, nativeType: String, sqliteType: Int): ObjEnumEntry = when (val normalized = normalizeDeclaredTypeName(nativeType)) {
|
|
||||||
"BOOLEAN", "BOOL" -> core.sqlTypes.require("Bool")
|
|
||||||
"DATE" -> core.sqlTypes.require("Date")
|
|
||||||
"DATETIME", "TIMESTAMP" -> core.sqlTypes.require("DateTime")
|
|
||||||
"TIMESTAMP WITH TIME ZONE", "TIMESTAMPTZ", "DATETIME WITH TIME ZONE" -> core.sqlTypes.require("Instant")
|
|
||||||
"DECIMAL", "NUMERIC" -> core.sqlTypes.require("Decimal")
|
|
||||||
"TIME", "TIME WITHOUT TIME ZONE", "TIME WITH TIME ZONE" -> core.sqlTypes.require("String")
|
|
||||||
else -> when {
|
|
||||||
normalized.contains("BLOB") -> core.sqlTypes.require("Binary")
|
|
||||||
normalized.contains("INT") -> core.sqlTypes.require("Int")
|
|
||||||
normalized.contains("CHAR") || normalized.contains("TEXT") || normalized.contains("CLOB") -> core.sqlTypes.require("String")
|
|
||||||
normalized.contains("REAL") || normalized.contains("FLOA") || normalized.contains("DOUB") -> core.sqlTypes.require("Double")
|
|
||||||
sqliteType == SQLITE_INTEGER -> core.sqlTypes.require("Int")
|
|
||||||
sqliteType == SQLITE_FLOAT -> core.sqlTypes.require("Double")
|
|
||||||
sqliteType == SQLITE_BLOB -> core.sqlTypes.require("Binary")
|
|
||||||
else -> core.sqlTypes.require("String")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isDecimalNativeType(normalizedNativeType: String): Boolean =
|
|
||||||
normalizedNativeType == "DECIMAL" || normalizedNativeType == "NUMERIC"
|
|
||||||
|
|
||||||
private fun isBooleanNativeType(normalizedNativeType: String): Boolean =
|
|
||||||
normalizedNativeType == "BOOLEAN" || normalizedNativeType == "BOOL"
|
|
||||||
|
|
||||||
private suspend fun decimalFromString(scope: ScopeFacade, value: String): Obj {
|
|
||||||
val decimalModule = scope.requireScope().currentImportProvider.createModuleScope(scope.pos, "lyng.decimal")
|
|
||||||
val decimalClass = decimalModule.requireClass("Decimal")
|
|
||||||
return decimalClass.invokeInstanceMethod(scope.requireScope(), "fromString", ObjString(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun dateTimeFromString(scope: ScopeFacade, core: SqliteCoreModule, value: String): ObjDateTime {
|
|
||||||
val trimmed = value.trim()
|
|
||||||
if (hasExplicitTimeZone(trimmed)) {
|
|
||||||
throw sqlExecutionError(scope, core, "SQLite TIMESTAMP/DATETIME value must not contain a timezone offset: $value")
|
|
||||||
}
|
|
||||||
val local = LocalDateTime.parse(trimmed)
|
|
||||||
return ObjDateTime(local.toInstant(TimeZone.UTC), TimeZone.UTC)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasExplicitTimeZone(value: String): Boolean {
|
|
||||||
if (value.endsWith("Z", ignoreCase = true)) return true
|
|
||||||
val tIndex = value.indexOf('T')
|
|
||||||
if (tIndex < 0) return false
|
|
||||||
val plus = value.lastIndexOf('+')
|
|
||||||
val minus = value.lastIndexOf('-')
|
|
||||||
val offsetStart = maxOf(plus, minus)
|
|
||||||
return offsetStart > tIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun raiseExecuteReturningUsage(scope: ScopeFacade, core: SqliteCoreModule): Nothing {
|
|
||||||
scope.raiseError(
|
|
||||||
ObjException(
|
|
||||||
core.sqlUsageException,
|
|
||||||
scope.requireScope(),
|
|
||||||
ObjString("execute(...) cannot be used with statements that return rows; use select(...)")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun usageError(scope: ScopeFacade, core: SqliteCoreModule, message: String): ExecutionError {
|
|
||||||
return ExecutionError(
|
|
||||||
ObjException(core.sqlUsageException, scope.requireScope(), ObjString(message)),
|
|
||||||
scope.pos,
|
|
||||||
message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun databaseError(scope: ScopeFacade, core: SqliteCoreModule, message: String): ExecutionError {
|
|
||||||
return ExecutionError(
|
|
||||||
ObjException(core.databaseException, scope.requireScope(), ObjString(message)),
|
|
||||||
scope.pos,
|
|
||||||
message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun integerToBool(scope: ScopeFacade, core: SqliteCoreModule, value: Long): Obj =
|
|
||||||
when (value) {
|
|
||||||
0L -> ObjBool(false)
|
|
||||||
1L -> ObjBool(true)
|
|
||||||
else -> throw sqlExecutionError(scope, core, "Invalid SQLite boolean value: $value")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stringToBool(scope: ScopeFacade, core: SqliteCoreModule, value: String): Obj =
|
|
||||||
when (value.trim().lowercase()) {
|
|
||||||
"true", "t" -> ObjBool(true)
|
|
||||||
"false", "f" -> ObjBool(false)
|
|
||||||
else -> throw sqlExecutionError(scope, core, "Invalid SQLite boolean value: $value")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeDeclaredTypeName(nativeTypeName: String): String {
|
|
||||||
val strippedSuffix = nativeTypeName.trim().replace(Regex("""\s*\(.*\)\s*$"""), "")
|
|
||||||
return strippedSuffix.uppercase().replace(Regex("""\s+"""), " ").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sqlExecutionError(scope: ScopeFacade, core: SqliteCoreModule, message: String): ExecutionError {
|
|
||||||
return ExecutionError(
|
|
||||||
ObjException(core.sqlExecutionException, scope.requireScope(), ObjString(message)),
|
|
||||||
scope.pos,
|
|
||||||
message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun finishFailedTransaction(
|
|
||||||
scope: ScopeFacade,
|
|
||||||
core: SqliteCoreModule,
|
|
||||||
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: SqliteCoreModule): 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)
|
|
||||||
}
|
|
||||||
@ -1,209 +0,0 @@
|
|||||||
package lyng.io.db
|
|
||||||
|
|
||||||
/*
|
|
||||||
Portable value categories exposed by the Lyng SQL layer and used in
|
|
||||||
`SqlColumn`.
|
|
||||||
*/
|
|
||||||
enum SqlType {
|
|
||||||
Binary, String, Int, Double, Decimal,
|
|
||||||
Bool, Instant, Date, DateTime
|
|
||||||
}
|
|
||||||
|
|
||||||
extern class SqlColumn {
|
|
||||||
val name: String
|
|
||||||
val sqlType: SqlType
|
|
||||||
val nullable: Bool
|
|
||||||
/*
|
|
||||||
Original database type name as reported by the backend, such as
|
|
||||||
VARCHAR, TEXT, INT8, TIMESTAMPTZ, or BYTEA.
|
|
||||||
*/
|
|
||||||
val nativeType: String
|
|
||||||
}
|
|
||||||
|
|
||||||
extern class SqlRow {
|
|
||||||
/* Number of columns in the row */
|
|
||||||
val size: Int
|
|
||||||
val values: List<Object?>
|
|
||||||
|
|
||||||
/*
|
|
||||||
Return the already converted Lyng value for a column addressed by
|
|
||||||
index or output column label. SQL NULL is returned as null.
|
|
||||||
|
|
||||||
Name lookup uses result-column labels. If several columns share the same
|
|
||||||
label, name-based access is ambiguous and should fail. Missing column
|
|
||||||
names and invalid indexes should also fail.
|
|
||||||
*/
|
|
||||||
override fun getAt(indexOrName: String | Int): Object?
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
A result set is valid only while its owning transaction is active.
|
|
||||||
|
|
||||||
Implementations may stream rows or buffer them internally, but:
|
|
||||||
- rows must be exposed through normal iteration
|
|
||||||
- iteration to the end or canceled iteration should close the underlying
|
|
||||||
resources automatically
|
|
||||||
- using the result set after its transaction ends is invalid
|
|
||||||
- rows obtained from the result set are also invalid after the owning
|
|
||||||
transaction ends, even if the implementation had already buffered them
|
|
||||||
|
|
||||||
If user code wants row data to survive independently, it should copy the
|
|
||||||
values it needs into ordinary Lyng objects while the transaction is active.
|
|
||||||
*/
|
|
||||||
extern class ResultSet : Iterable<SqlRow> {
|
|
||||||
/*
|
|
||||||
Column metadata for the result rows, in positional order.
|
|
||||||
*/
|
|
||||||
val columns: List<SqlColumn>
|
|
||||||
|
|
||||||
/*
|
|
||||||
Number of rows if the implementation can determine it. Implementations
|
|
||||||
may need to consume or buffer the whole result in order to answer, but
|
|
||||||
this must not change visible later iteration behavior.
|
|
||||||
*/
|
|
||||||
fun size(): Int
|
|
||||||
|
|
||||||
/*
|
|
||||||
Fast emptiness check when the implementation can provide it without
|
|
||||||
consuming the result. Implementations may still peek or buffer
|
|
||||||
internally, but this must not change visible later iteration behavior.
|
|
||||||
*/
|
|
||||||
override fun isEmpty(): Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
extern class ExecutionResult {
|
|
||||||
val affectedRowsCount: Int
|
|
||||||
|
|
||||||
/*
|
|
||||||
Return implementation-supported auto-generated values produced by
|
|
||||||
`execute`. This is intentionally stricter than arbitrary SQL result
|
|
||||||
sets: statements such as INSERT/UPDATE/DELETE ... RETURNING should be
|
|
||||||
executed with `select`, not exposed here.
|
|
||||||
|
|
||||||
The returned result set has the same transaction-scoped lifetime as any
|
|
||||||
other result set.
|
|
||||||
|
|
||||||
If the statement produced no generated values, the returned result set
|
|
||||||
is empty.
|
|
||||||
*/
|
|
||||||
fun getGeneratedKeys(): ResultSet
|
|
||||||
}
|
|
||||||
|
|
||||||
extern class DatabaseException: Exception
|
|
||||||
extern class SqlExecutionException: DatabaseException
|
|
||||||
extern class SqlConstraintException: SqlExecutionException
|
|
||||||
extern class SqlUsageException: DatabaseException
|
|
||||||
|
|
||||||
/*
|
|
||||||
Special exception to be thrown from `SqlTransaction.transaction` when an
|
|
||||||
intentional rollback is requested without treating it as a backend failure.
|
|
||||||
|
|
||||||
It causes rollback and is propagated to the caller, but should not be
|
|
||||||
treated as a backend/driver failure.
|
|
||||||
|
|
||||||
If rollback itself fails, that rollback failure becomes the primary backend
|
|
||||||
error instead.
|
|
||||||
*/
|
|
||||||
extern class RollbackException: Exception
|
|
||||||
|
|
||||||
/*
|
|
||||||
Transaction represents a database transaction.
|
|
||||||
|
|
||||||
Important: a transaction has no explicit commit; instead it commits when
|
|
||||||
leaving the transaction block normally.
|
|
||||||
|
|
||||||
If the transaction block throws any exception not caught inside the calling
|
|
||||||
code, it will be rolled back.
|
|
||||||
*/
|
|
||||||
extern class SqlTransaction {
|
|
||||||
/*
|
|
||||||
Execute a SQL statement that returns rows. This includes plain SELECT
|
|
||||||
queries and database-specific DML statements with row-returning clauses
|
|
||||||
such as RETURNING or OUTPUT.
|
|
||||||
|
|
||||||
Portable SQL uses positional `?` placeholders only.
|
|
||||||
|
|
||||||
Portable bindable values are:
|
|
||||||
- null
|
|
||||||
- Bool
|
|
||||||
- Int, Double, Decimal
|
|
||||||
- String
|
|
||||||
- Buffer
|
|
||||||
- Date, DateTime, Instant
|
|
||||||
|
|
||||||
Unsupported parameter values should fail with `SqlUsageException`.
|
|
||||||
*/
|
|
||||||
fun select(clause: String, params...): ResultSet
|
|
||||||
|
|
||||||
/*
|
|
||||||
Execute a SQL statement for side effects. Use `select` for any statement
|
|
||||||
whose primary result is a row set.
|
|
||||||
|
|
||||||
Parameters follow the same binding rules as `select`.
|
|
||||||
*/
|
|
||||||
fun execute(clause: String, params...): ExecutionResult
|
|
||||||
|
|
||||||
/*
|
|
||||||
Create a nested transaction with real nested semantics, typically using
|
|
||||||
database savepoints.
|
|
||||||
|
|
||||||
If the backend cannot provide real nested transaction semantics, this
|
|
||||||
call should fail with `SqlUsageException` rather than flattening into
|
|
||||||
the outer transaction.
|
|
||||||
|
|
||||||
Failure inside the nested transaction rolls back only the nested scope;
|
|
||||||
the outer transaction remains active unless the exception is allowed to
|
|
||||||
propagate further.
|
|
||||||
*/
|
|
||||||
fun transaction<T>(block: (SqlTransaction) -> T): T
|
|
||||||
}
|
|
||||||
|
|
||||||
extern class Database {
|
|
||||||
/*
|
|
||||||
Open a transaction. Any pooling, physical connection lifecycle, and
|
|
||||||
implementation-specific configuration are owned by the database
|
|
||||||
implementation and hidden from the user.
|
|
||||||
|
|
||||||
The transaction commits when the block finishes normally,
|
|
||||||
and rolls back if the block exits with an uncaught exception.
|
|
||||||
|
|
||||||
Failure precedence is:
|
|
||||||
- user exception + successful rollback -> original exception escapes
|
|
||||||
- user exception + rollback failure -> original exception stays primary
|
|
||||||
- RollbackException + rollback failure -> rollback failure is primary
|
|
||||||
- commit failure after normal completion -> commit failure is primary
|
|
||||||
*/
|
|
||||||
fun transaction<T>(block: (SqlTransaction) -> T): T
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
Register a database provider for a URL scheme.
|
|
||||||
|
|
||||||
Provider modules should call this during module initialization when first
|
|
||||||
imported. Scheme matching is case-insensitive and normalized to lowercase.
|
|
||||||
|
|
||||||
Registering the same scheme more than once should fail.
|
|
||||||
*/
|
|
||||||
extern fun registerDatabaseProvider(
|
|
||||||
scheme: String,
|
|
||||||
opener: (String, Map<String, Object?>) -> Database
|
|
||||||
)
|
|
||||||
|
|
||||||
/*
|
|
||||||
The mandatory generic entry point for all providers. It opens a database
|
|
||||||
handle from a provider-specific connection URL plus extra parameters.
|
|
||||||
|
|
||||||
Providers may expose additional typed constructors such as `openSqlite(...)`
|
|
||||||
or `openPostgres(...)`, but `openDatabase(...)` should remain available for
|
|
||||||
configuration-driven usage.
|
|
||||||
|
|
||||||
It should throw IllegalArgumentException for malformed connection URLs or
|
|
||||||
invalid extra parameter shapes detected before opening the backend.
|
|
||||||
Runtime opening failures such as authentication, connectivity, or provider
|
|
||||||
initialization errors should be reported as DatabaseException.
|
|
||||||
|
|
||||||
The matching provider must already be registered, normally because its
|
|
||||||
module was imported and executed. Unknown schemes or missing providers
|
|
||||||
should fail with DatabaseException.
|
|
||||||
*/
|
|
||||||
extern fun openDatabase(connectionUrl: String, extraParams: Map<String, Object?>): Database
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
package lyng.io.db.sqlite
|
|
||||||
|
|
||||||
import lyng.io.db
|
|
||||||
|
|
||||||
/*
|
|
||||||
SQLite provider for `lyng.io.db`.
|
|
||||||
|
|
||||||
Importing this module registers the `sqlite:` URL scheme for
|
|
||||||
`openDatabase(...)`.
|
|
||||||
|
|
||||||
Accepted generic URL forms:
|
|
||||||
- `sqlite::memory:`
|
|
||||||
- `sqlite:relative/path.db`
|
|
||||||
- `sqlite:/absolute/path.db`
|
|
||||||
|
|
||||||
Provider-specific `openDatabase(..., extraParams)` options:
|
|
||||||
- `readOnly: Bool`
|
|
||||||
- `createIfMissing: Bool`
|
|
||||||
- `foreignKeys: Bool`
|
|
||||||
- `busyTimeoutMillis: Int`
|
|
||||||
|
|
||||||
Open-time URL/config validation failures should surface as
|
|
||||||
`IllegalArgumentException`.
|
|
||||||
Runtime SQLite open failures such as opening a missing file with
|
|
||||||
`createIfMissing = false` should surface as `DatabaseException`.
|
|
||||||
|
|
||||||
SQLite provider defaults:
|
|
||||||
- `Bool` is written as `0` / `1`
|
|
||||||
- `Decimal` is written as canonical text
|
|
||||||
- `Date` is written as `YYYY-MM-DD`
|
|
||||||
- `DateTime` is written as an ISO local timestamp without timezone
|
|
||||||
- `Instant` is written as an ISO UTC timestamp with explicit timezone marker
|
|
||||||
*/
|
|
||||||
extern fun openSqlite(
|
|
||||||
path: String,
|
|
||||||
readOnly: Bool = false,
|
|
||||||
createIfMissing: Bool = true,
|
|
||||||
foreignKeys: Bool = true,
|
|
||||||
busyTimeoutMillis: Int = 5000
|
|
||||||
): Database
|
|
||||||
@ -85,15 +85,6 @@ internal suspend fun executeClassDecl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (spec.isExtern) {
|
if (spec.isExtern) {
|
||||||
val parentClasses = spec.baseSpecs.mapNotNull { baseSpec ->
|
|
||||||
val rec = scope[baseSpec.name]
|
|
||||||
val cls = rec?.value as? ObjClass
|
|
||||||
when {
|
|
||||||
cls != null -> cls
|
|
||||||
baseSpec.name == "Exception" -> ObjException.Root
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val rec = scope[spec.className]
|
val rec = scope[spec.className]
|
||||||
val existing = rec?.value as? ObjClass
|
val existing = rec?.value as? ObjClass
|
||||||
val resolved = if (existing != null) {
|
val resolved = if (existing != null) {
|
||||||
@ -103,42 +94,8 @@ internal suspend fun executeClassDecl(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
val stub = resolved ?: ObjInstanceClass(spec.className, *parentClasses.toTypedArray()).apply {
|
val stub = resolved ?: ObjInstanceClass(spec.className).apply { this.isAbstract = true }
|
||||||
this.isAbstract = true
|
spec.declaredName?.let { scope.addItem(it, false, stub) }
|
||||||
constructorMeta = spec.constructorArgs
|
|
||||||
spec.constructorArgs?.params?.forEach { p ->
|
|
||||||
if (p.accessType != null) {
|
|
||||||
createField(
|
|
||||||
p.name,
|
|
||||||
ObjNull,
|
|
||||||
isMutable = p.accessType == AccessType.Var,
|
|
||||||
visibility = p.visibility ?: Visibility.Public,
|
|
||||||
declaringClass = this,
|
|
||||||
pos = Pos.builtIn,
|
|
||||||
isTransient = p.isTransient,
|
|
||||||
type = ObjRecord.Type.ConstructorField,
|
|
||||||
fieldId = spec.constructorFieldIds?.get(p.name)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
spec.declaredName?.let { name ->
|
|
||||||
if (scope.getLocalRecordDirect(name)?.value !== stub) {
|
|
||||||
scope.addItem(name, false, stub)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (resolved == null && (spec.bodyInit != null || spec.initScope.isNotEmpty())) {
|
|
||||||
val classScope = stub.classScope ?: scope.createChildScope(newThisObj = stub).also {
|
|
||||||
it.currentClassCtx = stub
|
|
||||||
stub.classScope = it
|
|
||||||
}
|
|
||||||
spec.bodyInit?.let { executeBytecodeWithSeed(classScope, it, "extern class body init") }
|
|
||||||
if (spec.initScope.isNotEmpty()) {
|
|
||||||
for (s in spec.initScope) {
|
|
||||||
executeBytecodeWithSeed(classScope, s, "extern class init")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return stub
|
return stub
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -191,7 +191,6 @@ class Compiler(
|
|||||||
private val lambdaCaptureEntriesByRef: MutableMap<ValueFnRef, List<net.sergeych.lyng.bytecode.LambdaCaptureEntry>> =
|
private val lambdaCaptureEntriesByRef: MutableMap<ValueFnRef, List<net.sergeych.lyng.bytecode.LambdaCaptureEntry>> =
|
||||||
mutableMapOf()
|
mutableMapOf()
|
||||||
private val classFieldTypesByName: MutableMap<String, MutableMap<String, ObjClass>> = mutableMapOf()
|
private val classFieldTypesByName: MutableMap<String, MutableMap<String, ObjClass>> = mutableMapOf()
|
||||||
private val classMemberTypeDeclByName: MutableMap<String, MutableMap<String, TypeDecl>> = mutableMapOf()
|
|
||||||
private val classMethodReturnTypeByName: MutableMap<String, MutableMap<String, ObjClass>> = mutableMapOf()
|
private val classMethodReturnTypeByName: MutableMap<String, MutableMap<String, ObjClass>> = mutableMapOf()
|
||||||
private val classMethodReturnTypeDeclByName: MutableMap<String, MutableMap<String, TypeDecl>> = mutableMapOf()
|
private val classMethodReturnTypeDeclByName: MutableMap<String, MutableMap<String, TypeDecl>> = mutableMapOf()
|
||||||
private val classScopeMembersByClassName: MutableMap<String, MutableSet<String>> = mutableMapOf()
|
private val classScopeMembersByClassName: MutableMap<String, MutableSet<String>> = mutableMapOf()
|
||||||
@ -653,31 +652,10 @@ class Compiler(
|
|||||||
val cls = clsFromScope ?: clsFromImports ?: resolveClassByName(name) ?: return null
|
val cls = clsFromScope ?: clsFromImports ?: resolveClassByName(name) ?: return null
|
||||||
val fieldIds = cls.instanceFieldIdMap()
|
val fieldIds = cls.instanceFieldIdMap()
|
||||||
val methodIds = cls.instanceMethodIdMap(includeAbstract = true)
|
val methodIds = cls.instanceMethodIdMap(includeAbstract = true)
|
||||||
val memberTypeDecls = collectClassMemberTypeDecls(cls)
|
|
||||||
val baseNames = cls.directParents.map { it.className }
|
val baseNames = cls.directParents.map { it.className }
|
||||||
val nextFieldId = (fieldIds.values.maxOrNull() ?: -1) + 1
|
val nextFieldId = (fieldIds.values.maxOrNull() ?: -1) + 1
|
||||||
val nextMethodId = (methodIds.values.maxOrNull() ?: -1) + 1
|
val nextMethodId = (methodIds.values.maxOrNull() ?: -1) + 1
|
||||||
return CompileClassInfo(
|
return CompileClassInfo(name, cls.logicalPackageName, fieldIds, methodIds, nextFieldId, nextMethodId, baseNames)
|
||||||
name,
|
|
||||||
cls.logicalPackageName,
|
|
||||||
fieldIds,
|
|
||||||
methodIds,
|
|
||||||
nextFieldId,
|
|
||||||
nextMethodId,
|
|
||||||
baseNames,
|
|
||||||
memberTypeDecls = memberTypeDecls
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun collectClassMemberTypeDecls(cls: ObjClass): Map<String, TypeDecl> {
|
|
||||||
val result = mutableMapOf<String, TypeDecl>()
|
|
||||||
for (name in cls.instanceFieldIdMap().keys) {
|
|
||||||
cls.getInstanceMemberOrNull(name, includeAbstract = true)?.typeDecl?.let { result[name] = it }
|
|
||||||
}
|
|
||||||
for (name in cls.instanceMethodIdMap(includeAbstract = true).keys) {
|
|
||||||
cls.getInstanceMemberOrNull(name, includeAbstract = true)?.typeDecl?.let { result[name] = it }
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class BaseMemberIds(
|
private data class BaseMemberIds(
|
||||||
@ -1694,8 +1672,7 @@ class Compiler(
|
|||||||
val methodIds: Map<String, Int>,
|
val methodIds: Map<String, Int>,
|
||||||
val nextFieldId: Int,
|
val nextFieldId: Int,
|
||||||
val nextMethodId: Int,
|
val nextMethodId: Int,
|
||||||
val baseNames: List<String>,
|
val baseNames: List<String>
|
||||||
val memberTypeDecls: Map<String, TypeDecl> = emptyMap()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private val compileClassInfos = mutableMapOf<String, CompileClassInfo>()
|
private val compileClassInfos = mutableMapOf<String, CompileClassInfo>()
|
||||||
@ -2957,15 +2934,7 @@ class Compiler(
|
|||||||
inferReceiverTypeFromRef(left)
|
inferReceiverTypeFromRef(left)
|
||||||
} else null
|
} else null
|
||||||
val itType = implicitItTypeForMemberLambda(left, next.value)
|
val itType = implicitItTypeForMemberLambda(left, next.value)
|
||||||
val expectedCallableType = expectedCallableArgumentType(
|
val lambda = parseLambdaExpression(receiverType, implicitItType = itType)
|
||||||
FieldRef(left, next.value, isOptional),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
val lambda = parseLambdaExpression(
|
|
||||||
receiverType,
|
|
||||||
implicitItType = itType,
|
|
||||||
expectedCallableType = expectedCallableType
|
|
||||||
)
|
|
||||||
val argPos = next.pos
|
val argPos = next.pos
|
||||||
val args = listOf(ParsedArgument(ExpressionStatement(lambda, argPos), next.pos))
|
val args = listOf(ParsedArgument(ExpressionStatement(lambda, argPos), next.pos))
|
||||||
operand = when (left) {
|
operand = when (left) {
|
||||||
@ -3372,8 +3341,7 @@ class Compiler(
|
|||||||
private suspend fun parseLambdaExpression(
|
private suspend fun parseLambdaExpression(
|
||||||
expectedReceiverType: String? = null,
|
expectedReceiverType: String? = null,
|
||||||
wrapAsExtensionCallable: Boolean = false,
|
wrapAsExtensionCallable: Boolean = false,
|
||||||
implicitItType: TypeDecl? = null,
|
implicitItType: TypeDecl? = null
|
||||||
expectedCallableType: TypeDecl.Function? = null
|
|
||||||
): ObjRef {
|
): ObjRef {
|
||||||
// lambda args are different:
|
// lambda args are different:
|
||||||
val startPos = cc.currentPos()
|
val startPos = cc.currentPos()
|
||||||
@ -3389,36 +3357,16 @@ class Compiler(
|
|||||||
val hasImplicitIt = argsDeclaration == null
|
val hasImplicitIt = argsDeclaration == null
|
||||||
val slotParamNames = if (hasImplicitIt) paramNames + "it" else paramNames
|
val slotParamNames = if (hasImplicitIt) paramNames + "it" else paramNames
|
||||||
val paramSlotPlan = buildParamSlotPlan(slotParamNames)
|
val paramSlotPlan = buildParamSlotPlan(slotParamNames)
|
||||||
fun seedLambdaParamType(name: String, typeDecl: TypeDecl?) {
|
if (implicitItType != null) {
|
||||||
if (typeDecl == null) return
|
val cls = resolveTypeDeclObjClass(implicitItType)
|
||||||
val slot = paramSlotPlan.slots[name]?.index ?: return
|
val itSlot = paramSlotPlan.slots["it"]?.index
|
||||||
resolveTypeDeclObjClass(typeDecl)?.let { cls ->
|
if (itSlot != null) {
|
||||||
|
if (cls != null) {
|
||||||
val paramTypeMap = slotTypeByScopeId.getOrPut(paramSlotPlan.id) { mutableMapOf() }
|
val paramTypeMap = slotTypeByScopeId.getOrPut(paramSlotPlan.id) { mutableMapOf() }
|
||||||
paramTypeMap[slot] = cls
|
paramTypeMap[itSlot] = cls
|
||||||
}
|
}
|
||||||
val paramTypeDeclMap = slotTypeDeclByScopeId.getOrPut(paramSlotPlan.id) { mutableMapOf() }
|
val paramTypeDeclMap = slotTypeDeclByScopeId.getOrPut(paramSlotPlan.id) { mutableMapOf() }
|
||||||
paramTypeDeclMap[slot] = typeDecl
|
paramTypeDeclMap[itSlot] = implicitItType
|
||||||
}
|
|
||||||
|
|
||||||
if (argsDeclaration != null) {
|
|
||||||
val expectedParams = expectedCallableType?.params.orEmpty()
|
|
||||||
argsDeclaration.params.forEachIndexed { index, param ->
|
|
||||||
val effectiveType = if ((param.type == TypeDecl.TypeAny || param.type == TypeDecl.TypeNullableAny) &&
|
|
||||||
index < expectedParams.size
|
|
||||||
) {
|
|
||||||
expectedParams[index]
|
|
||||||
} else {
|
|
||||||
param.type
|
|
||||||
}
|
|
||||||
if (effectiveType != TypeDecl.TypeAny && effectiveType != TypeDecl.TypeNullableAny) {
|
|
||||||
seedLambdaParamType(param.name, effectiveType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val effectiveImplicitItType = implicitItType
|
|
||||||
?: expectedCallableType?.params?.singleOrNull()
|
|
||||||
if (effectiveImplicitItType != null) {
|
|
||||||
seedLambdaParamType("it", effectiveImplicitItType)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4859,23 +4807,6 @@ class Compiler(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun classMemberTypeDecl(targetClass: ObjClass?, name: String): TypeDecl? {
|
|
||||||
if (targetClass == null) return null
|
|
||||||
if (targetClass == ObjDynamic.type) return TypeDecl.TypeAny
|
|
||||||
val member = targetClass.getInstanceMemberOrNull(name, includeAbstract = true)
|
|
||||||
val declaringName = member?.declaringClass?.className
|
|
||||||
val declaringClass = member?.declaringClass
|
|
||||||
declaringClass?.classScope?.getLocalRecordDirect(name)?.typeDecl?.let { return it }
|
|
||||||
targetClass.classScope?.getLocalRecordDirect(name)?.typeDecl?.let { return it }
|
|
||||||
if (declaringName != null) {
|
|
||||||
classMemberTypeDeclByName[declaringName]?.get(name)?.let { return it }
|
|
||||||
resolveCompileClassInfo(declaringName)?.memberTypeDecls?.get(name)?.let { return it }
|
|
||||||
}
|
|
||||||
classMemberTypeDeclByName[targetClass.className]?.get(name)?.let { return it }
|
|
||||||
resolveCompileClassInfo(targetClass.className)?.memberTypeDecls?.get(name)?.let { return it }
|
|
||||||
return member?.typeDecl
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun classMethodReturnClass(targetClass: ObjClass?, name: String): ObjClass? {
|
private fun classMethodReturnClass(targetClass: ObjClass?, name: String): ObjClass? {
|
||||||
if (targetClass == null) return null
|
if (targetClass == null) return null
|
||||||
if (targetClass == ObjDynamic.type) return ObjDynamic.type
|
if (targetClass == ObjDynamic.type) return ObjDynamic.type
|
||||||
@ -4965,7 +4896,7 @@ class Compiler(
|
|||||||
is ImplicitThisMemberRef -> {
|
is ImplicitThisMemberRef -> {
|
||||||
val typeName = ref.preferredThisTypeName() ?: currentImplicitThisTypeName()
|
val typeName = ref.preferredThisTypeName() ?: currentImplicitThisTypeName()
|
||||||
val targetClass = typeName?.let { resolveClassByName(it) } ?: return null
|
val targetClass = typeName?.let { resolveClassByName(it) } ?: return null
|
||||||
classMemberTypeDecl(targetClass, ref.name)?.let { return it }
|
targetClass.getInstanceMemberOrNull(ref.name, includeAbstract = true)?.typeDecl?.let { return it }
|
||||||
classFieldTypesByName[targetClass.className]?.get(ref.name)
|
classFieldTypesByName[targetClass.className]?.get(ref.name)
|
||||||
?.let { return TypeDecl.Simple(it.className, false) }
|
?.let { return TypeDecl.Simple(it.className, false) }
|
||||||
classMethodReturnTypeDeclByName[targetClass.className]?.get(ref.name)?.let { return it }
|
classMethodReturnTypeDeclByName[targetClass.className]?.get(ref.name)?.let { return it }
|
||||||
@ -4974,7 +4905,7 @@ class Compiler(
|
|||||||
is FieldRef -> {
|
is FieldRef -> {
|
||||||
val targetDecl = resolveReceiverTypeDecl(ref.target) ?: return null
|
val targetDecl = resolveReceiverTypeDecl(ref.target) ?: return null
|
||||||
val targetClass = resolveTypeDeclObjClass(targetDecl) ?: resolveReceiverClassForMember(ref.target)
|
val targetClass = resolveTypeDeclObjClass(targetDecl) ?: resolveReceiverClassForMember(ref.target)
|
||||||
classMemberTypeDecl(targetClass, ref.name)?.let { return it }
|
targetClass?.getInstanceMemberOrNull(ref.name, includeAbstract = true)?.typeDecl?.let { return it }
|
||||||
classFieldTypesByName[targetClass?.className]?.get(ref.name)
|
classFieldTypesByName[targetClass?.className]?.get(ref.name)
|
||||||
?.let { return TypeDecl.Simple(it.className, false) }
|
?.let { return TypeDecl.Simple(it.className, false) }
|
||||||
when (targetDecl) {
|
when (targetDecl) {
|
||||||
@ -5072,16 +5003,9 @@ class Compiler(
|
|||||||
is QualifiedThisRef -> resolveClassByName(ref.typeName)
|
is QualifiedThisRef -> resolveClassByName(ref.typeName)
|
||||||
is StatementRef -> (ref.statement as? ExpressionStatement)?.let { resolveReceiverClassForMember(it.ref) }
|
is StatementRef -> (ref.statement as? ExpressionStatement)?.let { resolveReceiverClassForMember(it.ref) }
|
||||||
is MethodCallRef -> inferMethodCallReturnClass(ref)
|
is MethodCallRef -> inferMethodCallReturnClass(ref)
|
||||||
is ImplicitThisMethodCallRef -> {
|
is ImplicitThisMethodCallRef -> inferMethodCallReturnClass(ref.methodName())
|
||||||
val typeName = ref.preferredThisTypeName() ?: currentImplicitThisTypeName()
|
is ThisMethodSlotCallRef -> inferMethodCallReturnClass(ref.methodName())
|
||||||
inferMethodCallReturnClass(typeName?.let { resolveClassByName(it) }, ref.methodName())
|
is QualifiedThisMethodSlotCallRef -> inferMethodCallReturnClass(ref.methodName())
|
||||||
}
|
|
||||||
is ThisMethodSlotCallRef -> {
|
|
||||||
val typeName = currentImplicitThisTypeName()
|
|
||||||
inferMethodCallReturnClass(typeName?.let { resolveClassByName(it) }, ref.methodName())
|
|
||||||
}
|
|
||||||
is QualifiedThisMethodSlotCallRef ->
|
|
||||||
inferMethodCallReturnClass(resolveClassByName(ref.receiverTypeName()), ref.methodName())
|
|
||||||
is CallRef -> inferCallReturnTypeDecl(ref)?.let { resolveTypeDeclObjClass(it) } ?: inferCallReturnClass(ref)
|
is CallRef -> inferCallReturnTypeDecl(ref)?.let { resolveTypeDeclObjClass(it) } ?: inferCallReturnClass(ref)
|
||||||
is BinaryOpRef -> inferBinaryOpReturnClass(ref)
|
is BinaryOpRef -> inferBinaryOpReturnClass(ref)
|
||||||
is FieldRef -> {
|
is FieldRef -> {
|
||||||
@ -5203,8 +5127,6 @@ class Compiler(
|
|||||||
leftClass == ObjInstant.type && rightClass == ObjDuration.type -> ObjInstant.type
|
leftClass == ObjInstant.type && rightClass == ObjDuration.type -> ObjInstant.type
|
||||||
leftClass == ObjDuration.type && rightClass == ObjInstant.type && ref.op == BinOp.PLUS -> ObjInstant.type
|
leftClass == ObjDuration.type && rightClass == ObjInstant.type && ref.op == BinOp.PLUS -> ObjInstant.type
|
||||||
leftClass == ObjDuration.type && rightClass == ObjDuration.type -> ObjDuration.type
|
leftClass == ObjDuration.type && rightClass == ObjDuration.type -> ObjDuration.type
|
||||||
leftClass == ObjDate.type && rightClass == ObjDate.type && ref.op == BinOp.MINUS -> ObjInt.type
|
|
||||||
leftClass == ObjDate.type && rightClass == ObjDuration.type -> ObjDate.type
|
|
||||||
(leftClass == ObjBuffer.type || leftClass.allParentsSet.contains(ObjBuffer.type)) &&
|
(leftClass == ObjBuffer.type || leftClass.allParentsSet.contains(ObjBuffer.type)) &&
|
||||||
(rightClass == ObjBuffer.type || rightClass.allParentsSet.contains(ObjBuffer.type)) &&
|
(rightClass == ObjBuffer.type || rightClass.allParentsSet.contains(ObjBuffer.type)) &&
|
||||||
ref.op == BinOp.PLUS -> ObjBuffer.type
|
ref.op == BinOp.PLUS -> ObjBuffer.type
|
||||||
@ -5276,37 +5198,7 @@ class Compiler(
|
|||||||
if (receiverClass != null && isClassScopeCallableMember(receiverClass.className, ref.name)) {
|
if (receiverClass != null && isClassScopeCallableMember(receiverClass.className, ref.name)) {
|
||||||
resolveClassByName("${receiverClass.className}.${ref.name}")?.let { return it }
|
resolveClassByName("${receiverClass.className}.${ref.name}")?.let { return it }
|
||||||
}
|
}
|
||||||
return inferMethodCallReturnClass(receiverClass, ref.name)
|
return inferMethodCallReturnClass(ref.name)
|
||||||
}
|
|
||||||
|
|
||||||
private fun inferMethodCallReturnClass(targetClass: ObjClass?, name: String): ObjClass? {
|
|
||||||
when (targetClass) {
|
|
||||||
ObjDate.type -> {
|
|
||||||
return when (name) {
|
|
||||||
"toIsoString", "toSortableString", "toString" -> ObjString.type
|
|
||||||
"toDateTime", "atStartOfDay" -> ObjDateTime.type
|
|
||||||
"addDays", "addMonths", "addYears", "today", "parseIso" -> ObjDate.type
|
|
||||||
"daysUntil", "daysSince" -> ObjInt.type
|
|
||||||
else -> inferMethodCallReturnClass(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ObjInstant.type -> {
|
|
||||||
return when (name) {
|
|
||||||
"toDateTime" -> ObjDateTime.type
|
|
||||||
"toDate" -> ObjDate.type
|
|
||||||
else -> inferMethodCallReturnClass(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ObjDateTime.type -> {
|
|
||||||
return when (name) {
|
|
||||||
"toDate" -> ObjDate.type
|
|
||||||
"toInstant" -> ObjInstant.type
|
|
||||||
"toUTC", "toTimeZone", "parseRFC3339", "addYears", "addMonths" -> ObjDateTime.type
|
|
||||||
else -> inferMethodCallReturnClass(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> return inferMethodCallReturnClass(name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun inferMethodCallReturnTypeDecl(ref: MethodCallRef): TypeDecl? {
|
private fun inferMethodCallReturnTypeDecl(ref: MethodCallRef): TypeDecl? {
|
||||||
@ -5531,13 +5423,13 @@ class Compiler(
|
|||||||
"truncateToSecond",
|
"truncateToSecond",
|
||||||
"truncateToMinute",
|
"truncateToMinute",
|
||||||
"truncateToMillisecond" -> ObjInstant.type
|
"truncateToMillisecond" -> ObjInstant.type
|
||||||
"today",
|
"toDateTime",
|
||||||
"parseIso" -> ObjDate.type
|
|
||||||
"daysUntil",
|
|
||||||
"daysSince" -> ObjInt.type
|
|
||||||
"toTimeZone",
|
"toTimeZone",
|
||||||
"toUTC",
|
"toUTC",
|
||||||
"parseRFC3339",
|
"parseRFC3339",
|
||||||
|
"addYears",
|
||||||
|
"addMonths",
|
||||||
|
"addDays",
|
||||||
"addHours",
|
"addHours",
|
||||||
"addMinutes",
|
"addMinutes",
|
||||||
"addSeconds" -> ObjDateTime.type
|
"addSeconds" -> ObjDateTime.type
|
||||||
@ -5594,20 +5486,6 @@ class Compiler(
|
|||||||
if (targetClass == ObjInstant.type && (name == "distantFuture" || name == "distantPast")) {
|
if (targetClass == ObjInstant.type && (name == "distantFuture" || name == "distantPast")) {
|
||||||
return ObjInstant.type
|
return ObjInstant.type
|
||||||
}
|
}
|
||||||
if (targetClass == ObjDate.type) {
|
|
||||||
return when (name) {
|
|
||||||
"year",
|
|
||||||
"month",
|
|
||||||
"day",
|
|
||||||
"dayOfMonth",
|
|
||||||
"dayOfWeek",
|
|
||||||
"dayOfYear",
|
|
||||||
"lengthOfMonth",
|
|
||||||
"lengthOfYear" -> ObjInt.type
|
|
||||||
"isLeapYear" -> ObjBool.type
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (targetClass == ObjInstant.type && name in listOf(
|
if (targetClass == ObjInstant.type && name in listOf(
|
||||||
"truncateToMinute",
|
"truncateToMinute",
|
||||||
"truncateToSecond",
|
"truncateToSecond",
|
||||||
@ -5660,7 +5538,6 @@ class Compiler(
|
|||||||
}
|
}
|
||||||
if (targetClass == ObjDateTime.type) {
|
if (targetClass == ObjDateTime.type) {
|
||||||
return when (name) {
|
return when (name) {
|
||||||
"date" -> ObjDate.type
|
|
||||||
"year",
|
"year",
|
||||||
"month",
|
"month",
|
||||||
"day",
|
"day",
|
||||||
@ -5917,7 +5794,7 @@ class Compiler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val seededCallable = lookupNamedCallableRecord(target)
|
val seededCallable = lookupNamedCallableRecord(target)
|
||||||
if (seededCallable != null && seededCallable.type == ObjRecord.Type.Fun) {
|
if (seededCallable != null && seededCallable.type == ObjRecord.Type.Fun && seededCallable.value !is ObjExternCallable) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val decl = (resolveReceiverTypeDecl(target) as? TypeDecl.Function)
|
val decl = (resolveReceiverTypeDecl(target) as? TypeDecl.Function)
|
||||||
@ -6495,8 +6372,7 @@ class Compiler(
|
|||||||
*/
|
*/
|
||||||
private suspend fun parseArgs(
|
private suspend fun parseArgs(
|
||||||
expectedTailBlockReceiver: String? = null,
|
expectedTailBlockReceiver: String? = null,
|
||||||
implicitItType: TypeDecl? = null,
|
implicitItType: TypeDecl? = null
|
||||||
expectedTailBlockTarget: ObjRef? = null
|
|
||||||
): Pair<List<ParsedArgument>, Boolean> {
|
): Pair<List<ParsedArgument>, Boolean> {
|
||||||
|
|
||||||
val args = mutableListOf<ParsedArgument>()
|
val args = mutableListOf<ParsedArgument>()
|
||||||
@ -6554,11 +6430,7 @@ class Compiler(
|
|||||||
var lastBlockArgument = false
|
var lastBlockArgument = false
|
||||||
if (end.type == Token.Type.LBRACE) {
|
if (end.type == Token.Type.LBRACE) {
|
||||||
// last argument - callable
|
// last argument - callable
|
||||||
val callableAccessor = parseLambdaExpression(
|
val callableAccessor = parseLambdaExpression(expectedTailBlockReceiver, implicitItType = implicitItType)
|
||||||
expectedTailBlockReceiver,
|
|
||||||
implicitItType = implicitItType,
|
|
||||||
expectedCallableType = expectedTailBlockTarget?.let { expectedCallableArgumentType(it, args.size) }
|
|
||||||
)
|
|
||||||
args += ParsedArgument(
|
args += ParsedArgument(
|
||||||
ExpressionStatement(callableAccessor, end.pos),
|
ExpressionStatement(callableAccessor, end.pos),
|
||||||
end.pos
|
end.pos
|
||||||
@ -6634,7 +6506,6 @@ class Compiler(
|
|||||||
): ObjRef {
|
): ObjRef {
|
||||||
var detectedBlockArgument = blockArgument
|
var detectedBlockArgument = blockArgument
|
||||||
val expectedReceiver = tailBlockReceiverType(left)
|
val expectedReceiver = tailBlockReceiverType(left)
|
||||||
val expectedTailCallable = expectedCallableArgumentType(left, 0)
|
|
||||||
val withReceiver = when (left) {
|
val withReceiver = when (left) {
|
||||||
is LocalVarRef -> if (left.name == "with") left.name else null
|
is LocalVarRef -> if (left.name == "with") left.name else null
|
||||||
is LocalSlotRef -> if (left.name == "with") left.name else null
|
is LocalSlotRef -> if (left.name == "with") left.name else null
|
||||||
@ -6646,10 +6517,7 @@ class Compiler(
|
|||||||
// allow any subsequent selectors (like ".last()") to be absorbed
|
// allow any subsequent selectors (like ".last()") to be absorbed
|
||||||
// into the lambda body. This ensures expected order:
|
// into the lambda body. This ensures expected order:
|
||||||
// foo { ... }.bar() == (foo { ... }).bar()
|
// foo { ... }.bar() == (foo { ... }).bar()
|
||||||
val callableAccessor = parseLambdaExpression(
|
val callableAccessor = parseLambdaExpression(expectedReceiver)
|
||||||
expectedReceiver,
|
|
||||||
expectedCallableType = expectedTailCallable
|
|
||||||
)
|
|
||||||
listOf(ParsedArgument(ExpressionStatement(callableAccessor, cc.currentPos()), cc.currentPos()))
|
listOf(ParsedArgument(ExpressionStatement(callableAccessor, cc.currentPos()), cc.currentPos()))
|
||||||
} else {
|
} else {
|
||||||
if (withReceiver != null) {
|
if (withReceiver != null) {
|
||||||
@ -6658,11 +6526,7 @@ class Compiler(
|
|||||||
val end = cc.next()
|
val end = cc.next()
|
||||||
if (end.type == Token.Type.LBRACE) {
|
if (end.type == Token.Type.LBRACE) {
|
||||||
val receiverType = inferReceiverTypeFromArgs(parsedArgs)
|
val receiverType = inferReceiverTypeFromArgs(parsedArgs)
|
||||||
val callableAccessor = parseLambdaExpression(
|
val callableAccessor = parseLambdaExpression(receiverType, wrapAsExtensionCallable = true)
|
||||||
receiverType,
|
|
||||||
wrapAsExtensionCallable = true,
|
|
||||||
expectedCallableType = expectedCallableArgumentType(left, parsedArgs.size)
|
|
||||||
)
|
|
||||||
parsedArgs += ParsedArgument(ExpressionStatement(callableAccessor, end.pos), end.pos)
|
parsedArgs += ParsedArgument(ExpressionStatement(callableAccessor, end.pos), end.pos)
|
||||||
detectedBlockArgument = true
|
detectedBlockArgument = true
|
||||||
} else {
|
} else {
|
||||||
@ -6670,7 +6534,7 @@ class Compiler(
|
|||||||
}
|
}
|
||||||
parsedArgs
|
parsedArgs
|
||||||
} else {
|
} else {
|
||||||
val r = parseArgs(expectedReceiver, expectedTailBlockTarget = left)
|
val r = parseArgs(expectedReceiver)
|
||||||
detectedBlockArgument = r.second
|
detectedBlockArgument = r.second
|
||||||
r.first
|
r.first
|
||||||
}
|
}
|
||||||
@ -6792,26 +6656,6 @@ class Compiler(
|
|||||||
return cls?.className
|
return cls?.className
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun expectedCallableArgumentType(target: ObjRef, argIndex: Int): TypeDecl.Function? {
|
|
||||||
val decl = (resolveReceiverTypeDecl(target) ?: seedTypeDeclFromRef(target)) as? TypeDecl.Function
|
|
||||||
?: return null
|
|
||||||
val params = when (target) {
|
|
||||||
is FieldRef,
|
|
||||||
is ImplicitThisMemberRef,
|
|
||||||
is ThisFieldSlotRef,
|
|
||||||
is ClassScopeMemberRef -> decl.params
|
|
||||||
else -> buildList {
|
|
||||||
decl.receiver?.let { add(it) }
|
|
||||||
addAll(decl.params)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (argIndex < params.size) {
|
|
||||||
return params[argIndex] as? TypeDecl.Function
|
|
||||||
}
|
|
||||||
val ellipsis = params.lastOrNull() as? TypeDecl.Ellipsis ?: return null
|
|
||||||
return ellipsis.elementType as? TypeDecl.Function
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun inferReceiverTypeFromRef(ref: ObjRef): String? {
|
private fun inferReceiverTypeFromRef(ref: ObjRef): String? {
|
||||||
return when (ref) {
|
return when (ref) {
|
||||||
is LocalSlotRef -> {
|
is LocalSlotRef -> {
|
||||||
@ -8030,8 +7874,7 @@ class Compiler(
|
|||||||
methodIds = ctx.memberMethodIds.toMap(),
|
methodIds = ctx.memberMethodIds.toMap(),
|
||||||
nextFieldId = ctx.nextFieldId,
|
nextFieldId = ctx.nextFieldId,
|
||||||
nextMethodId = ctx.nextMethodId,
|
nextMethodId = ctx.nextMethodId,
|
||||||
baseNames = baseSpecs.map { it.name },
|
baseNames = baseSpecs.map { it.name }
|
||||||
memberTypeDecls = classMemberTypeDeclByName[qualifiedName]?.toMap() ?: emptyMap()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -8047,8 +7890,7 @@ class Compiler(
|
|||||||
methodIds = ctx.memberMethodIds.toMap(),
|
methodIds = ctx.memberMethodIds.toMap(),
|
||||||
nextFieldId = ctx.nextFieldId,
|
nextFieldId = ctx.nextFieldId,
|
||||||
nextMethodId = ctx.nextMethodId,
|
nextMethodId = ctx.nextMethodId,
|
||||||
baseNames = baseSpecs.map { it.name },
|
baseNames = baseSpecs.map { it.name }
|
||||||
memberTypeDecls = classMemberTypeDeclByName[qualifiedName]?.toMap() ?: emptyMap()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -8135,8 +7977,7 @@ class Compiler(
|
|||||||
methodIds = ctx.memberMethodIds.toMap(),
|
methodIds = ctx.memberMethodIds.toMap(),
|
||||||
nextFieldId = ctx.nextFieldId,
|
nextFieldId = ctx.nextFieldId,
|
||||||
nextMethodId = ctx.nextMethodId,
|
nextMethodId = ctx.nextMethodId,
|
||||||
baseNames = baseSpecs.map { it.name },
|
baseNames = baseSpecs.map { it.name }
|
||||||
memberTypeDecls = classMemberTypeDeclByName[qualifiedName]?.toMap() ?: emptyMap()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -9141,15 +8982,6 @@ class Compiler(
|
|||||||
pendingTypeParamStack.removeLast()
|
pendingTypeParamStack.removeLast()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parentContext is CodeContext.ClassBody && !isStatic && extTypeName == null) {
|
|
||||||
classMemberTypeDeclByName.getOrPut(parentContext.name) { mutableMapOf() }[name] = TypeDecl.Function(
|
|
||||||
receiver = receiverTypeDecl,
|
|
||||||
params = argsDeclaration.params.map { it.type },
|
|
||||||
returnType = returnTypeDecl ?: TypeDecl.TypeAny,
|
|
||||||
nullable = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var isDelegated = false
|
var isDelegated = false
|
||||||
var delegateExpression: Statement? = null
|
var delegateExpression: Statement? = null
|
||||||
if (cc.peekNextNonWhitespace().type == Token.Type.BY) {
|
if (cc.peekNextNonWhitespace().type == Token.Type.BY) {
|
||||||
@ -9309,19 +9141,9 @@ class Compiler(
|
|||||||
val rawFnStatements = parsedFnStatements?.let { unwrapBytecodeDeep(it) }
|
val rawFnStatements = parsedFnStatements?.let { unwrapBytecodeDeep(it) }
|
||||||
val inferredReturnClass = returnTypeDecl?.let { resolveTypeDeclObjClass(it) }
|
val inferredReturnClass = returnTypeDecl?.let { resolveTypeDeclObjClass(it) }
|
||||||
?: inferReturnClassFromStatement(rawFnStatements)
|
?: inferReturnClassFromStatement(rawFnStatements)
|
||||||
if (parentContext is CodeContext.ClassBody && !isStatic && extTypeName == null) {
|
if (declKind == SymbolKind.MEMBER && extTypeName == null) {
|
||||||
val ownerClassName = parentContext.name
|
val ownerClassName = (parentContext as? CodeContext.ClassBody)?.name
|
||||||
run {
|
if (ownerClassName != null) {
|
||||||
val memberTypeDecl = TypeDecl.Function(
|
|
||||||
receiver = receiverTypeDecl,
|
|
||||||
params = argsDeclaration.params.map { it.type },
|
|
||||||
returnType = returnTypeDecl
|
|
||||||
?: inferredReturnClass?.let { TypeDecl.Simple(it.className, false) }
|
|
||||||
?: TypeDecl.TypeAny,
|
|
||||||
nullable = false
|
|
||||||
)
|
|
||||||
classMemberTypeDeclByName
|
|
||||||
.getOrPut(ownerClassName) { mutableMapOf() }[name] = memberTypeDecl
|
|
||||||
val returnDecl = returnTypeDecl
|
val returnDecl = returnTypeDecl
|
||||||
?: inferredReturnClass?.let { TypeDecl.Simple(it.className, false) }
|
?: inferredReturnClass?.let { TypeDecl.Simple(it.className, false) }
|
||||||
if (returnDecl != null) {
|
if (returnDecl != null) {
|
||||||
@ -9855,7 +9677,6 @@ class Compiler(
|
|||||||
"ChangeRejectionException" -> ObjChangeRejectionExceptionClass
|
"ChangeRejectionException" -> ObjChangeRejectionExceptionClass
|
||||||
"Exception" -> ObjException.Root
|
"Exception" -> ObjException.Root
|
||||||
"Instant" -> ObjInstant.type
|
"Instant" -> ObjInstant.type
|
||||||
"Date" -> ObjDate.type
|
|
||||||
"DateTime" -> ObjDateTime.type
|
"DateTime" -> ObjDateTime.type
|
||||||
"Duration" -> ObjDuration.type
|
"Duration" -> ObjDuration.type
|
||||||
"Buffer" -> ObjBuffer.type
|
"Buffer" -> ObjBuffer.type
|
||||||
@ -9895,8 +9716,7 @@ class Compiler(
|
|||||||
pos = Pos.builtIn,
|
pos = Pos.builtIn,
|
||||||
declaringClass = stub,
|
declaringClass = stub,
|
||||||
type = ObjRecord.Type.Field,
|
type = ObjRecord.Type.Field,
|
||||||
fieldId = fieldId,
|
fieldId = fieldId
|
||||||
typeDecl = info.memberTypeDecls[fieldName]
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -9911,8 +9731,7 @@ class Compiler(
|
|||||||
declaringClass = stub,
|
declaringClass = stub,
|
||||||
isAbstract = true,
|
isAbstract = true,
|
||||||
type = ObjRecord.Type.Fun,
|
type = ObjRecord.Type.Fun,
|
||||||
methodId = methodId,
|
methodId = methodId
|
||||||
typeDecl = info.memberTypeDecls[methodName]
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,35 +73,6 @@ internal suspend fun executeFunctionDecl(
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (spec.actualExtern && spec.extTypeName == null && spec.parentIsClassBody) {
|
|
||||||
val cls = scope.thisObj as? ObjClass
|
|
||||||
if (cls != null) {
|
|
||||||
val existing = cls.members[spec.name]
|
|
||||||
if (existing != null) {
|
|
||||||
cls.members[spec.name] = existing.copy(
|
|
||||||
typeDecl = existing.typeDecl ?: spec.typeDecl
|
|
||||||
)
|
|
||||||
val memberValue = cls.members[spec.name]?.value ?: existing.value
|
|
||||||
val local = scope.getLocalRecordDirect(spec.name)
|
|
||||||
if (local != null) {
|
|
||||||
scope.objects[spec.name] = local.copy(
|
|
||||||
value = memberValue,
|
|
||||||
typeDecl = local.typeDecl ?: spec.typeDecl
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
scope.addItem(
|
|
||||||
spec.name,
|
|
||||||
false,
|
|
||||||
memberValue,
|
|
||||||
spec.visibility,
|
|
||||||
callSignature = spec.externCallSignature,
|
|
||||||
typeDecl = spec.typeDecl
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return memberValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (spec.isDelegated) {
|
if (spec.isDelegated) {
|
||||||
val delegateExpr = spec.delegateExpression ?: scope.raiseError("delegated function missing delegate")
|
val delegateExpr = spec.delegateExpression ?: scope.raiseError("delegated function missing delegate")
|
||||||
|
|||||||
@ -803,19 +803,12 @@ open class Scope(
|
|||||||
fun addFn(vararg names: String, callSignature: CallSignature? = null, fn: suspend ScopeFacade.() -> Obj) {
|
fun addFn(vararg names: String, callSignature: CallSignature? = null, fn: suspend ScopeFacade.() -> Obj) {
|
||||||
val newFn = net.sergeych.lyng.obj.ObjExternCallable.fromBridge { fn() }
|
val newFn = net.sergeych.lyng.obj.ObjExternCallable.fromBridge { fn() }
|
||||||
for (name in names) {
|
for (name in names) {
|
||||||
val existing = objects[name]
|
|
||||||
addItem(
|
addItem(
|
||||||
name,
|
name,
|
||||||
existing?.isMutable ?: false,
|
false,
|
||||||
newFn,
|
newFn,
|
||||||
visibility = existing?.visibility ?: Visibility.Public,
|
|
||||||
writeVisibility = existing?.writeVisibility,
|
|
||||||
recordType = ObjRecord.Type.Fun,
|
recordType = ObjRecord.Type.Fun,
|
||||||
isAbstract = false,
|
callSignature = callSignature
|
||||||
isClosed = existing?.isClosed ?: false,
|
|
||||||
isOverride = existing?.isOverride ?: false,
|
|
||||||
callSignature = callSignature ?: existing?.callSignature,
|
|
||||||
typeDecl = existing?.typeDecl
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -952,12 +952,6 @@ class Script(
|
|||||||
doc = "Point in time (epoch-based).",
|
doc = "Point in time (epoch-based).",
|
||||||
type = type("lyng.Class")
|
type = type("lyng.Class")
|
||||||
)
|
)
|
||||||
it.addConstDoc(
|
|
||||||
name = "Date",
|
|
||||||
value = ObjDate.type,
|
|
||||||
doc = "Calendar date without time-of-day or time zone.",
|
|
||||||
type = type("lyng.Class")
|
|
||||||
)
|
|
||||||
it.addConstDoc(
|
it.addConstDoc(
|
||||||
name = "DateTime",
|
name = "DateTime",
|
||||||
value = ObjDateTime.type,
|
value = ObjDateTime.type,
|
||||||
|
|||||||
@ -1171,16 +1171,14 @@ class BytecodeCompiler(
|
|||||||
ObjMap.type,
|
ObjMap.type,
|
||||||
ObjBuffer.type,
|
ObjBuffer.type,
|
||||||
ObjInstant.type,
|
ObjInstant.type,
|
||||||
ObjDateTime.type,
|
ObjDateTime.type
|
||||||
ObjDate.type
|
|
||||||
)
|
)
|
||||||
BinOp.MINUS -> receiverClass in setOf(
|
BinOp.MINUS -> receiverClass in setOf(
|
||||||
ObjInt.type,
|
ObjInt.type,
|
||||||
ObjReal.type,
|
ObjReal.type,
|
||||||
ObjSet.type,
|
ObjSet.type,
|
||||||
ObjInstant.type,
|
ObjInstant.type,
|
||||||
ObjDateTime.type,
|
ObjDateTime.type
|
||||||
ObjDate.type
|
|
||||||
)
|
)
|
||||||
BinOp.STAR -> receiverClass in setOf(ObjInt.type, ObjReal.type, ObjString.type)
|
BinOp.STAR -> receiverClass in setOf(ObjInt.type, ObjReal.type, ObjString.type)
|
||||||
BinOp.SLASH, BinOp.PERCENT -> receiverClass in setOf(ObjInt.type, ObjReal.type)
|
BinOp.SLASH, BinOp.PERCENT -> receiverClass in setOf(ObjInt.type, ObjReal.type)
|
||||||
@ -7292,7 +7290,7 @@ class BytecodeCompiler(
|
|||||||
if (targetClass == ObjString.type && ref.name == "re" && ref.args.isEmpty() && !ref.isOptional) {
|
if (targetClass == ObjString.type && ref.name == "re" && ref.args.isEmpty() && !ref.isOptional) {
|
||||||
ObjRegex.type
|
ObjRegex.type
|
||||||
} else {
|
} else {
|
||||||
inferMethodCallReturnClass(targetClass, ref.name)
|
inferMethodCallReturnClass(ref.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is CallRef -> inferCallReturnClass(ref)
|
is CallRef -> inferCallReturnClass(ref)
|
||||||
@ -7465,7 +7463,7 @@ class BytecodeCompiler(
|
|||||||
if (targetClass == ObjString.type && ref.name == "re" && ref.args.isEmpty() && !ref.isOptional) {
|
if (targetClass == ObjString.type && ref.name == "re" && ref.args.isEmpty() && !ref.isOptional) {
|
||||||
ObjRegex.type
|
ObjRegex.type
|
||||||
} else {
|
} else {
|
||||||
inferMethodCallReturnClass(targetClass, ref.name)
|
inferMethodCallReturnClass(ref.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is CallRef -> inferCallReturnClass(ref)
|
is CallRef -> inferCallReturnClass(ref)
|
||||||
@ -7548,7 +7546,6 @@ class BytecodeCompiler(
|
|||||||
"ObservableList" -> ObjObservableList.type
|
"ObservableList" -> ObjObservableList.type
|
||||||
"ChangeRejectionException" -> ObjChangeRejectionExceptionClass
|
"ChangeRejectionException" -> ObjChangeRejectionExceptionClass
|
||||||
"Instant" -> ObjInstant.type
|
"Instant" -> ObjInstant.type
|
||||||
"Date" -> ObjDate.type
|
|
||||||
"DateTime" -> ObjDateTime.type
|
"DateTime" -> ObjDateTime.type
|
||||||
"Duration" -> ObjDuration.type
|
"Duration" -> ObjDuration.type
|
||||||
"Exception" -> ObjException.Root
|
"Exception" -> ObjException.Root
|
||||||
@ -7587,36 +7584,6 @@ class BytecodeCompiler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun inferMethodCallReturnClass(targetClass: ObjClass?, name: String): ObjClass? {
|
|
||||||
when (targetClass) {
|
|
||||||
ObjDate.type -> {
|
|
||||||
return when (name) {
|
|
||||||
"toIsoString", "toSortableString", "toString" -> ObjString.type
|
|
||||||
"toDateTime", "atStartOfDay" -> ObjDateTime.type
|
|
||||||
"addDays", "addMonths", "addYears", "today", "parseIso" -> ObjDate.type
|
|
||||||
"daysUntil", "daysSince" -> ObjInt.type
|
|
||||||
else -> inferMethodCallReturnClass(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ObjInstant.type -> {
|
|
||||||
return when (name) {
|
|
||||||
"toDateTime" -> ObjDateTime.type
|
|
||||||
"toDate" -> ObjDate.type
|
|
||||||
else -> inferMethodCallReturnClass(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ObjDateTime.type -> {
|
|
||||||
return when (name) {
|
|
||||||
"toDate" -> ObjDate.type
|
|
||||||
"toInstant" -> ObjInstant.type
|
|
||||||
"toUTC", "toTimeZone", "parseRFC3339", "addYears", "addMonths" -> ObjDateTime.type
|
|
||||||
else -> inferMethodCallReturnClass(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> return inferMethodCallReturnClass(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun inferMethodCallReturnClass(name: String): ObjClass? = when (name) {
|
private fun inferMethodCallReturnClass(name: String): ObjClass? = when (name) {
|
||||||
"map",
|
"map",
|
||||||
"mapNotNull",
|
"mapNotNull",
|
||||||
@ -7649,13 +7616,13 @@ class BytecodeCompiler(
|
|||||||
"truncateToSecond",
|
"truncateToSecond",
|
||||||
"truncateToMinute",
|
"truncateToMinute",
|
||||||
"truncateToMillisecond" -> ObjInstant.type
|
"truncateToMillisecond" -> ObjInstant.type
|
||||||
"today",
|
"toDateTime",
|
||||||
"parseIso" -> ObjDate.type
|
|
||||||
"daysUntil",
|
|
||||||
"daysSince" -> ObjInt.type
|
|
||||||
"toTimeZone",
|
"toTimeZone",
|
||||||
"toUTC",
|
"toUTC",
|
||||||
"parseRFC3339",
|
"parseRFC3339",
|
||||||
|
"addYears",
|
||||||
|
"addMonths",
|
||||||
|
"addDays",
|
||||||
"addHours",
|
"addHours",
|
||||||
"addMinutes",
|
"addMinutes",
|
||||||
"addSeconds" -> ObjDateTime.type
|
"addSeconds" -> ObjDateTime.type
|
||||||
@ -7700,20 +7667,6 @@ class BytecodeCompiler(
|
|||||||
if (targetClass == ObjInstant.type && (name == "distantFuture" || name == "distantPast")) {
|
if (targetClass == ObjInstant.type && (name == "distantFuture" || name == "distantPast")) {
|
||||||
return ObjInstant.type
|
return ObjInstant.type
|
||||||
}
|
}
|
||||||
if (targetClass == ObjDate.type) {
|
|
||||||
return when (name) {
|
|
||||||
"year",
|
|
||||||
"month",
|
|
||||||
"day",
|
|
||||||
"dayOfMonth",
|
|
||||||
"dayOfWeek",
|
|
||||||
"dayOfYear",
|
|
||||||
"lengthOfMonth",
|
|
||||||
"lengthOfYear" -> ObjInt.type
|
|
||||||
"isLeapYear" -> ObjBool.type
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (targetClass == ObjString.type && name == "re") {
|
if (targetClass == ObjString.type && name == "re") {
|
||||||
return ObjRegex.type
|
return ObjRegex.type
|
||||||
}
|
}
|
||||||
@ -7757,7 +7710,6 @@ class BytecodeCompiler(
|
|||||||
}
|
}
|
||||||
if (targetClass == ObjDateTime.type) {
|
if (targetClass == ObjDateTime.type) {
|
||||||
return when (name) {
|
return when (name) {
|
||||||
"date" -> ObjDate.type
|
|
||||||
"year",
|
"year",
|
||||||
"month",
|
"month",
|
||||||
"day",
|
"day",
|
||||||
|
|||||||
@ -64,13 +64,11 @@ object StdlibDocsBootstrap {
|
|||||||
val _buffer = net.sergeych.lyng.obj.ObjBuffer.type
|
val _buffer = net.sergeych.lyng.obj.ObjBuffer.type
|
||||||
|
|
||||||
// Also touch time module types so their docs (moduleName = "lyng.time") are registered
|
// Also touch time module types so their docs (moduleName = "lyng.time") are registered
|
||||||
// This enables completion/quick docs for symbols imported via `import lyng.time` (e.g., Instant, Date, DateTime, Duration)
|
// This enables completion/quick docs for symbols imported via `import lyng.time` (e.g., Instant, DateTime, Duration)
|
||||||
try {
|
try {
|
||||||
@Suppress("UNUSED_VARIABLE")
|
@Suppress("UNUSED_VARIABLE")
|
||||||
val _instant = net.sergeych.lyng.obj.ObjInstant.type
|
val _instant = net.sergeych.lyng.obj.ObjInstant.type
|
||||||
@Suppress("UNUSED_VARIABLE")
|
@Suppress("UNUSED_VARIABLE")
|
||||||
val _date = net.sergeych.lyng.obj.ObjDate.type
|
|
||||||
@Suppress("UNUSED_VARIABLE")
|
|
||||||
val _datetime = net.sergeych.lyng.obj.ObjDateTime.type
|
val _datetime = net.sergeych.lyng.obj.ObjDateTime.type
|
||||||
@Suppress("UNUSED_VARIABLE")
|
@Suppress("UNUSED_VARIABLE")
|
||||||
val _duration = net.sergeych.lyng.obj.ObjDuration.type
|
val _duration = net.sergeych.lyng.obj.ObjDuration.type
|
||||||
|
|||||||
@ -1,303 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.obj
|
|
||||||
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.LocalDateTime
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.UtcOffset
|
|
||||||
import kotlinx.datetime.asTimeZone
|
|
||||||
import kotlinx.datetime.isoDayNumber
|
|
||||||
import kotlinx.datetime.number
|
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import net.sergeych.lyng.Scope
|
|
||||||
import net.sergeych.lyng.ScopeFacade
|
|
||||||
import net.sergeych.lyng.asFacade
|
|
||||||
import net.sergeych.lyng.miniast.ParamDoc
|
|
||||||
import net.sergeych.lyng.miniast.addClassFnDoc
|
|
||||||
import net.sergeych.lyng.miniast.addFnDoc
|
|
||||||
import net.sergeych.lyng.miniast.addPropertyDoc
|
|
||||||
import net.sergeych.lyng.miniast.type
|
|
||||||
import net.sergeych.lynon.LynonDecoder
|
|
||||||
import net.sergeych.lynon.LynonEncoder
|
|
||||||
import net.sergeych.lynon.LynonType
|
|
||||||
import kotlin.math.absoluteValue
|
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Duration
|
|
||||||
import kotlin.time.Duration.Companion.days
|
|
||||||
|
|
||||||
class ObjDate(val date: LocalDate) : Obj() {
|
|
||||||
override val objClass: ObjClass get() = type
|
|
||||||
|
|
||||||
override fun toString(): String = date.toString()
|
|
||||||
|
|
||||||
override suspend fun plus(scope: Scope, other: Obj): Obj {
|
|
||||||
return when (other) {
|
|
||||||
is ObjDuration -> ObjDate(addDays(date, requireWholeDays(scope, other.duration)))
|
|
||||||
else -> super.plus(scope, other)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun minus(scope: Scope, other: Obj): Obj {
|
|
||||||
return when (other) {
|
|
||||||
is ObjDuration -> ObjDate(addDays(date, -requireWholeDays(scope, other.duration)))
|
|
||||||
is ObjDate -> ObjInt.of(daysBetween(other.date, date).toLong())
|
|
||||||
else -> super.minus(scope, other)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun compareTo(scope: Scope, other: Obj): Int {
|
|
||||||
return if (other is ObjDate) {
|
|
||||||
date.compareTo(other.date)
|
|
||||||
} else super.compareTo(scope, other)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun toKotlin(scope: Scope): Any = date
|
|
||||||
|
|
||||||
override fun hashCode(): Int = date.hashCode()
|
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean = other is ObjDate && date == other.date
|
|
||||||
|
|
||||||
override suspend fun lynonType(): LynonType = LynonType.Date
|
|
||||||
|
|
||||||
override suspend fun serialize(scope: Scope, encoder: LynonEncoder, lynonType: LynonType?) {
|
|
||||||
encoder.encodeSigned(date.year.toLong())
|
|
||||||
encoder.encodeUnsigned(date.month.number.toULong())
|
|
||||||
encoder.encodeUnsigned(date.day.toULong())
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun toJson(scope: Scope): JsonElement = JsonPrimitive(date.toString())
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val type = object : ObjClass("Date") {
|
|
||||||
override suspend fun callOn(scope: Scope): Obj {
|
|
||||||
val args = scope.args
|
|
||||||
return when (val a0 = args.list.getOrNull(0)) {
|
|
||||||
null -> ObjDate(today(TimeZone.currentSystemDefault()))
|
|
||||||
is ObjDate -> a0
|
|
||||||
is ObjInt -> {
|
|
||||||
val year = a0.value.toInt()
|
|
||||||
val month = args.list.getOrNull(1)?.toInt() ?: scope.raiseIllegalArgument("month is required")
|
|
||||||
val day = args.list.getOrNull(2)?.toInt() ?: scope.raiseIllegalArgument("day is required")
|
|
||||||
ObjDate(createDate(scope, year, month, day))
|
|
||||||
}
|
|
||||||
is ObjString -> ObjDate(parseIso(scope, a0.value))
|
|
||||||
is ObjDateTime -> ObjDate(toDate(a0.instant, a0.timeZone))
|
|
||||||
is ObjInstant -> {
|
|
||||||
val tz = parseTimeZoneArg(scope, args.list.getOrNull(1), TimeZone.currentSystemDefault())
|
|
||||||
ObjDate(toDate(a0.instant, tz))
|
|
||||||
}
|
|
||||||
else -> scope.raiseIllegalArgument("can't construct Date from ${args.inspect(scope)}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj {
|
|
||||||
val year = decoder.unpackSigned().toInt()
|
|
||||||
val month = decoder.unpackUnsigned().toInt()
|
|
||||||
val day = decoder.unpackUnsigned().toInt()
|
|
||||||
return ObjDate(createDate(scope, year, month, day))
|
|
||||||
}
|
|
||||||
}.apply {
|
|
||||||
addPropertyDoc("year", "The year component.", type("lyng.Int"), moduleName = "lyng.time",
|
|
||||||
getter = { thisAs<ObjDate>().date.year.toObj() })
|
|
||||||
addPropertyDoc("month", "The month component (1..12).", type("lyng.Int"), moduleName = "lyng.time",
|
|
||||||
getter = { thisAs<ObjDate>().date.month.number.toObj() })
|
|
||||||
addPropertyDoc("dayOfMonth", "The day of month component.", type("lyng.Int"), moduleName = "lyng.time",
|
|
||||||
getter = { thisAs<ObjDate>().date.day.toObj() })
|
|
||||||
addPropertyDoc("day", "Alias to dayOfMonth.", type("lyng.Int"), moduleName = "lyng.time",
|
|
||||||
getter = { thisAs<ObjDate>().date.day.toObj() })
|
|
||||||
addPropertyDoc("dayOfWeek", "The day of week (1=Monday, 7=Sunday).", type("lyng.Int"), moduleName = "lyng.time",
|
|
||||||
getter = { thisAs<ObjDate>().date.dayOfWeek.isoDayNumber.toObj() })
|
|
||||||
addPropertyDoc("dayOfYear", "The day of year (1..365/366).", type("lyng.Int"), moduleName = "lyng.time",
|
|
||||||
getter = { thisAs<ObjDate>().date.dayOfYear.toObj() })
|
|
||||||
addPropertyDoc("isLeapYear", "Whether this date is in a leap year.", type("lyng.Bool"), moduleName = "lyng.time",
|
|
||||||
getter = { isLeapYear(thisAs<ObjDate>().date.year).toObj() })
|
|
||||||
addPropertyDoc("lengthOfMonth", "Number of days in this date's month.", type("lyng.Int"), moduleName = "lyng.time",
|
|
||||||
getter = { monthLength(thisAs<ObjDate>().date.year, thisAs<ObjDate>().date.month.number).toObj() })
|
|
||||||
addPropertyDoc("lengthOfYear", "Number of days in this date's year.", type("lyng.Int"), moduleName = "lyng.time",
|
|
||||||
getter = { (if (isLeapYear(thisAs<ObjDate>().date.year)) 366 else 365).toObj() })
|
|
||||||
|
|
||||||
addFnDoc("toIsoString", "Return the canonical ISO date string representation (`YYYY-MM-DD`).",
|
|
||||||
returns = type("lyng.String"), moduleName = "lyng.time") {
|
|
||||||
thisAs<ObjDate>().date.toString().toObj()
|
|
||||||
}
|
|
||||||
addFnDoc("toSortableString", "Alias to toIsoString.", returns = type("lyng.String"), moduleName = "lyng.time") {
|
|
||||||
thisAs<ObjDate>().date.toString().toObj()
|
|
||||||
}
|
|
||||||
addFnDoc("toDateTime", "Convert this date to a DateTime at the start of day in the specified timezone.",
|
|
||||||
params = listOf(ParamDoc("tz", type = type("lyng.Any", true))),
|
|
||||||
returns = type("lyng.DateTime"), moduleName = "lyng.time") {
|
|
||||||
val tz = parseTimeZoneArg(this, args.list.getOrNull(0), TimeZone.UTC)
|
|
||||||
toDateTime(thisAs<ObjDate>().date, tz)
|
|
||||||
}
|
|
||||||
addFnDoc("atStartOfDay", "Alias to toDateTime.",
|
|
||||||
params = listOf(ParamDoc("tz", type = type("lyng.Any", true))),
|
|
||||||
returns = type("lyng.DateTime"), moduleName = "lyng.time") {
|
|
||||||
val tz = parseTimeZoneArg(this, args.list.getOrNull(0), TimeZone.UTC)
|
|
||||||
toDateTime(thisAs<ObjDate>().date, tz)
|
|
||||||
}
|
|
||||||
addFnDoc("addDays", "Return a new Date with the specified number of days added (or subtracted if negative).",
|
|
||||||
params = listOf(ParamDoc("days", type = type("lyng.Int"))),
|
|
||||||
returns = type("lyng.Date"), moduleName = "lyng.time") {
|
|
||||||
val n = args.list.getOrNull(0)?.toInt() ?: 0
|
|
||||||
ObjDate(addDays(thisAs<ObjDate>().date, n))
|
|
||||||
}
|
|
||||||
addFnDoc("addMonths", "Return a new Date with the specified number of months added (or subtracted if negative). End-of-month values are normalized.",
|
|
||||||
params = listOf(ParamDoc("months", type = type("lyng.Int"))),
|
|
||||||
returns = type("lyng.Date"), moduleName = "lyng.time") {
|
|
||||||
val n = args.list.getOrNull(0)?.toInt() ?: 0
|
|
||||||
ObjDate(addMonths(thisAs<ObjDate>().date, n))
|
|
||||||
}
|
|
||||||
addFnDoc("addYears", "Return a new Date with the specified number of years added (or subtracted if negative).",
|
|
||||||
params = listOf(ParamDoc("years", type = type("lyng.Int"))),
|
|
||||||
returns = type("lyng.Date"), moduleName = "lyng.time") {
|
|
||||||
val n = args.list.getOrNull(0)?.toInt() ?: 0
|
|
||||||
ObjDate(addYears(thisAs<ObjDate>().date, n))
|
|
||||||
}
|
|
||||||
addFnDoc("daysUntil", "Return the number of whole calendar days until the other date.",
|
|
||||||
params = listOf(ParamDoc("other", type = type("lyng.Date"))),
|
|
||||||
returns = type("lyng.Int"), moduleName = "lyng.time") {
|
|
||||||
val other = requiredArg<ObjDate>(0)
|
|
||||||
daysBetween(thisAs<ObjDate>().date, other.date).toObj()
|
|
||||||
}
|
|
||||||
addFnDoc("daysSince", "Return the number of whole calendar days since the other date.",
|
|
||||||
params = listOf(ParamDoc("other", type = type("lyng.Date"))),
|
|
||||||
returns = type("lyng.Int"), moduleName = "lyng.time") {
|
|
||||||
val other = requiredArg<ObjDate>(0)
|
|
||||||
daysBetween(other.date, thisAs<ObjDate>().date).toObj()
|
|
||||||
}
|
|
||||||
|
|
||||||
addClassFnDoc("today", "Return today's date in the specified timezone, or in the current system timezone if omitted.",
|
|
||||||
params = listOf(ParamDoc("tz", type = type("lyng.Any", true))),
|
|
||||||
returns = type("lyng.Date"), moduleName = "lyng.time") {
|
|
||||||
val tz = parseTimeZoneArg(this, args.list.getOrNull(0), TimeZone.currentSystemDefault())
|
|
||||||
ObjDate(today(tz))
|
|
||||||
}
|
|
||||||
addClassFnDoc("parseIso", "Parse an ISO date string (`YYYY-MM-DD`) into a Date.",
|
|
||||||
params = listOf(ParamDoc("string", type = type("lyng.String"))),
|
|
||||||
returns = type("lyng.Date"), moduleName = "lyng.time") {
|
|
||||||
ObjDate(parseIso(this, requiredArg<ObjString>(0).value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun parseTimeZoneArg(scope: ScopeFacade, value: Obj?, default: TimeZone): TimeZone {
|
|
||||||
return when (value) {
|
|
||||||
null -> default
|
|
||||||
is ObjString -> TimeZone.of(value.value)
|
|
||||||
is ObjInt -> UtcOffset(seconds = value.value.toInt()).asTimeZone()
|
|
||||||
else -> scope.raiseIllegalArgument("invalid timezone: $value")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun parseTimeZoneArg(scope: Scope, value: Obj?, default: TimeZone): TimeZone =
|
|
||||||
parseTimeZoneArg(scope.asFacade(), value, default)
|
|
||||||
|
|
||||||
internal fun toDate(instant: kotlin.time.Instant, tz: TimeZone): LocalDate {
|
|
||||||
val ldt = instant.toLocalDateTime(tz)
|
|
||||||
return LocalDate(ldt.year, ldt.month.number, ldt.day)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun toDateTime(date: LocalDate, tz: TimeZone): ObjDateTime {
|
|
||||||
val ldt = LocalDateTime(date.year, date.month.number, date.day, 0, 0, 0)
|
|
||||||
return ObjDateTime(ldt.toInstant(tz), tz)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseIso(scope: ScopeFacade, value: String): LocalDate {
|
|
||||||
val match = DATE_REGEX.matchEntire(value.trim()) ?: scope.raiseIllegalArgument("invalid ISO date string: $value")
|
|
||||||
val year = match.groupValues[1].toInt()
|
|
||||||
val month = match.groupValues[2].toInt()
|
|
||||||
val day = match.groupValues[3].toInt()
|
|
||||||
return createDate(scope, year, month, day)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseIso(scope: Scope, value: String): LocalDate = parseIso(scope.asFacade(), value)
|
|
||||||
|
|
||||||
private fun createDate(scope: ScopeFacade, year: Int, month: Int, day: Int): LocalDate {
|
|
||||||
if (month !in 1..12) scope.raiseIllegalArgument("month must be in 1..12")
|
|
||||||
val maxDay = monthLength(year, month)
|
|
||||||
if (day !in 1..maxDay) scope.raiseIllegalArgument("day must be in 1..$maxDay")
|
|
||||||
return LocalDate(year, month, day)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createDate(scope: Scope, year: Int, month: Int, day: Int): LocalDate =
|
|
||||||
createDate(scope.asFacade(), year, month, day)
|
|
||||||
|
|
||||||
private fun today(tz: TimeZone): LocalDate = toDate(Clock.System.now(), tz)
|
|
||||||
|
|
||||||
private fun addDays(date: LocalDate, days: Int): LocalDate {
|
|
||||||
val start = LocalDateTime(date.year, date.month.number, date.day, 0, 0, 0).toInstant(TimeZone.UTC)
|
|
||||||
return toDate(start + days.days, TimeZone.UTC)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun daysBetween(start: LocalDate, end: LocalDate): Int {
|
|
||||||
val startInstant = LocalDateTime(start.year, start.month.number, start.day, 0, 0, 0).toInstant(TimeZone.UTC)
|
|
||||||
val endInstant = LocalDateTime(end.year, end.month.number, end.day, 0, 0, 0).toInstant(TimeZone.UTC)
|
|
||||||
return ((endInstant.epochSeconds - startInstant.epochSeconds) / 86_400L).toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addMonths(date: LocalDate, months: Int): LocalDate {
|
|
||||||
if (months == 0) return date
|
|
||||||
val totalMonths = date.year.toLong() * 12L + (date.month.number - 1).toLong() + months.toLong()
|
|
||||||
val newYear = floorDiv(totalMonths, 12L).toInt()
|
|
||||||
val newMonth = floorMod(totalMonths, 12L).toInt() + 1
|
|
||||||
val newDay = minOf(date.day, monthLength(newYear, newMonth))
|
|
||||||
return LocalDate(newYear, newMonth, newDay)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addYears(date: LocalDate, years: Int): LocalDate {
|
|
||||||
if (years == 0) return date
|
|
||||||
val newYear = date.year + years
|
|
||||||
val newDay = minOf(date.day, monthLength(newYear, date.month.number))
|
|
||||||
return LocalDate(newYear, date.month.number, newDay)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requireWholeDays(scope: Scope, duration: Duration): Int {
|
|
||||||
val days = duration.inWholeDays
|
|
||||||
if (days.absoluteValue > Int.MAX_VALUE.toLong()) {
|
|
||||||
scope.raiseIllegalArgument("date arithmetic day count is too large")
|
|
||||||
}
|
|
||||||
if (duration != days.days) {
|
|
||||||
scope.raiseIllegalArgument("Date arithmetic supports only whole-day durations")
|
|
||||||
}
|
|
||||||
return days.toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isLeapYear(year: Int): Boolean =
|
|
||||||
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
|
||||||
|
|
||||||
private fun monthLength(year: Int, month: Int): Int = when (month) {
|
|
||||||
1, 3, 5, 7, 8, 10, 12 -> 31
|
|
||||||
4, 6, 9, 11 -> 30
|
|
||||||
2 -> if (isLeapYear(year)) 29 else 28
|
|
||||||
else -> error("invalid month: $month")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun floorDiv(a: Long, b: Long): Long {
|
|
||||||
var q = a / b
|
|
||||||
if ((a xor b) < 0 && q * b != a) q -= 1
|
|
||||||
return q
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun floorMod(a: Long, b: Long): Long = a - floorDiv(a, b) * b
|
|
||||||
|
|
||||||
private val DATE_REGEX = Regex("""([+-]?\d{4,})-(\d{2})-(\d{2})""")
|
|
||||||
@ -202,15 +202,10 @@ class ObjDateTime(val instant: Instant, val timeZone: TimeZone) : Obj() {
|
|||||||
getter = { thisAs<ObjDateTime>().localDateTime.dayOfWeek.isoDayNumber.toObj() })
|
getter = { thisAs<ObjDateTime>().localDateTime.dayOfWeek.isoDayNumber.toObj() })
|
||||||
addPropertyDoc("timeZone", "The time zone ID (e.g. 'Z', '+02:00', 'Europe/Prague').", type("lyng.String"), moduleName = "lyng.time",
|
addPropertyDoc("timeZone", "The time zone ID (e.g. 'Z', '+02:00', 'Europe/Prague').", type("lyng.String"), moduleName = "lyng.time",
|
||||||
getter = { thisAs<ObjDateTime>().timeZone.id.toObj() })
|
getter = { thisAs<ObjDateTime>().timeZone.id.toObj() })
|
||||||
addPropertyDoc("date", "The calendar date component of this DateTime.", type("lyng.Date"), moduleName = "lyng.time",
|
|
||||||
getter = { ObjDate(toDate(thisAs<ObjDateTime>().instant, thisAs<ObjDateTime>().timeZone)) })
|
|
||||||
|
|
||||||
addFnDoc("toInstant", "Convert this localized date time back to an absolute Instant.", returns = type("lyng.Instant"), moduleName = "lyng.time") {
|
addFnDoc("toInstant", "Convert this localized date time back to an absolute Instant.", returns = type("lyng.Instant"), moduleName = "lyng.time") {
|
||||||
ObjInstant(thisAs<ObjDateTime>().instant)
|
ObjInstant(thisAs<ObjDateTime>().instant)
|
||||||
}
|
}
|
||||||
addFnDoc("toDate", "Return the calendar date component of this DateTime.", returns = type("lyng.Date"), moduleName = "lyng.time") {
|
|
||||||
ObjDate(toDate(thisAs<ObjDateTime>().instant, thisAs<ObjDateTime>().timeZone))
|
|
||||||
}
|
|
||||||
addFnDoc("toEpochSeconds", "Return the number of full seconds since the Unix epoch (UTC).", returns = type("lyng.Int"), moduleName = "lyng.time") {
|
addFnDoc("toEpochSeconds", "Return the number of full seconds since the Unix epoch (UTC).", returns = type("lyng.Int"), moduleName = "lyng.time") {
|
||||||
thisAs<ObjDateTime>().instant.epochSeconds.toObj()
|
thisAs<ObjDateTime>().instant.epochSeconds.toObj()
|
||||||
}
|
}
|
||||||
@ -228,7 +223,11 @@ class ObjDateTime(val instant: Instant, val timeZone: TimeZone) : Obj() {
|
|||||||
"Accepts a timezone ID string (e.g., 'UTC', '+02:00') or an integer offset in seconds.",
|
"Accepts a timezone ID string (e.g., 'UTC', '+02:00') or an integer offset in seconds.",
|
||||||
params = listOf(net.sergeych.lyng.miniast.ParamDoc("tz", type = type("lyng.Any"))),
|
params = listOf(net.sergeych.lyng.miniast.ParamDoc("tz", type = type("lyng.Any"))),
|
||||||
returns = type("lyng.DateTime"), moduleName = "lyng.time") {
|
returns = type("lyng.DateTime"), moduleName = "lyng.time") {
|
||||||
val tz = parseTimeZoneArg(this, args.list.getOrNull(0), TimeZone.currentSystemDefault())
|
val tz = when (val a = args.list.getOrNull(0)) {
|
||||||
|
is ObjString -> TimeZone.of(a.value)
|
||||||
|
is ObjInt -> UtcOffset(seconds = a.value.toInt()).asTimeZone()
|
||||||
|
else -> raiseIllegalArgument("invalid timezone: $a")
|
||||||
|
}
|
||||||
ObjDateTime(thisAs<ObjDateTime>().instant, tz)
|
ObjDateTime(thisAs<ObjDateTime>().instant, tz)
|
||||||
}
|
}
|
||||||
addFnDoc("toUTC", "Shortcut to convert this date time to the UTC time zone.", returns = type("lyng.DateTime"), moduleName = "lyng.time") {
|
addFnDoc("toUTC", "Shortcut to convert this date time to the UTC time zone.", returns = type("lyng.DateTime"), moduleName = "lyng.time") {
|
||||||
|
|||||||
@ -268,18 +268,13 @@ class ObjInstant(val instant: Instant,val truncateMode: LynonSettings.InstantTru
|
|||||||
returns = type("lyng.DateTime"),
|
returns = type("lyng.DateTime"),
|
||||||
moduleName = "lyng.time"
|
moduleName = "lyng.time"
|
||||||
) {
|
) {
|
||||||
val tz = parseTimeZoneArg(this, args.list.getOrNull(0), TimeZone.currentSystemDefault())
|
val tz = when (val a = args.list.getOrNull(0)) {
|
||||||
ObjDateTime(thisAs<ObjInstant>().instant, tz)
|
null -> TimeZone.currentSystemDefault()
|
||||||
|
is ObjString -> TimeZone.of(a.value)
|
||||||
|
is ObjInt -> UtcOffset(seconds = a.value.toInt()).asTimeZone()
|
||||||
|
else -> raiseIllegalArgument("invalid timezone: $a")
|
||||||
}
|
}
|
||||||
addFnDoc(
|
ObjDateTime(thisAs<ObjInstant>().instant, tz)
|
||||||
name = "toDate",
|
|
||||||
doc = "Convert this instant to a calendar Date in the specified time zone. If omitted, the system default time zone is used.",
|
|
||||||
params = listOf(net.sergeych.lyng.miniast.ParamDoc("tz", type = type("lyng.Any", true))),
|
|
||||||
returns = type("lyng.Date"),
|
|
||||||
moduleName = "lyng.time"
|
|
||||||
) {
|
|
||||||
val tz = parseTimeZoneArg(this, args.list.getOrNull(0), TimeZone.currentSystemDefault())
|
|
||||||
ObjDate(toDate(thisAs<ObjInstant>().instant, tz))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// class members
|
// class members
|
||||||
|
|||||||
@ -36,8 +36,7 @@ enum class LynonType(val objClass: ObjClass, val defaultFrequency: Int = 1) {
|
|||||||
Buffer(ObjBuffer.type, 50),
|
Buffer(ObjBuffer.type, 50),
|
||||||
Instant(ObjInstant.type, 30),
|
Instant(ObjInstant.type, 30),
|
||||||
Duration(ObjDuration.type),
|
Duration(ObjDuration.type),
|
||||||
Other(Obj.rootObjectType, 60),
|
Other(Obj.rootObjectType, 60);
|
||||||
Date(ObjDate.type, 20);
|
|
||||||
|
|
||||||
fun generalizeTo(other: LynonType): LynonType? {
|
fun generalizeTo(other: LynonType): LynonType? {
|
||||||
if (this == other) return this
|
if (this == other) return this
|
||||||
|
|||||||
@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.toList
|
|||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
@ -3480,68 +3479,6 @@ class ScriptTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testDateComprehensive() = runTest {
|
|
||||||
eval(
|
|
||||||
"""
|
|
||||||
import lyng.time
|
|
||||||
import lyng.serialization
|
|
||||||
|
|
||||||
val d1 = Date(2026, 4, 15)
|
|
||||||
assertEquals(2026, d1.year)
|
|
||||||
assertEquals(4, d1.month)
|
|
||||||
assertEquals(15, d1.day)
|
|
||||||
assertEquals(15, d1.dayOfMonth)
|
|
||||||
assertEquals("2026-04-15", d1.toIsoString())
|
|
||||||
assertEquals("2026-04-15", d1.toSortableString())
|
|
||||||
|
|
||||||
val d2 = Date.parseIso("2024-02-29")
|
|
||||||
assertEquals(true, d2.isLeapYear)
|
|
||||||
assertEquals(29, d2.lengthOfMonth)
|
|
||||||
assertEquals(366, d2.lengthOfYear)
|
|
||||||
assertEquals(60, d2.dayOfYear)
|
|
||||||
assertEquals(4, d2.dayOfWeek) // Thursday
|
|
||||||
|
|
||||||
val d3 = d2.addYears(1)
|
|
||||||
assertEquals(Date(2025, 2, 28), d3)
|
|
||||||
|
|
||||||
val d4 = Date(2024, 1, 31).addMonths(1)
|
|
||||||
assertEquals(Date(2024, 2, 29), d4)
|
|
||||||
|
|
||||||
val d5 = d1.addDays(10)
|
|
||||||
assertEquals(Date(2026, 4, 25), d5)
|
|
||||||
assertEquals(Date(2026, 4, 18), d1 + 3.days)
|
|
||||||
assertEquals(Date(2026, 4, 12), d1 - 3.days)
|
|
||||||
assertEquals(10, d1.daysUntil(d5))
|
|
||||||
assertEquals(10, d5.daysSince(d1))
|
|
||||||
assertEquals(10, d5 - d1)
|
|
||||||
|
|
||||||
val i = Instant("2024-01-01T23:30:00Z")
|
|
||||||
assertEquals(Date(2024, 1, 1), i.toDate("Z"))
|
|
||||||
assertEquals(Date(2024, 1, 2), i.toDate("+02:00"))
|
|
||||||
|
|
||||||
val dt = DateTime(2024, 5, 20, 15, 30, 45, "+02:00")
|
|
||||||
assertEquals(Date(2024, 5, 20), dt.date)
|
|
||||||
assertEquals(Date(2024, 5, 20), dt.toDate())
|
|
||||||
assertEquals(DateTime(2024, 5, 20, 0, 0, 0, "Z"), Date(2024, 5, 20).toDateTime("Z"))
|
|
||||||
assertEquals(DateTime(2024, 5, 20, 0, 0, 0, "+02:00"), Date(2024, 5, 20).atStartOfDay("+02:00"))
|
|
||||||
|
|
||||||
val roundTrip = Lynon.decode(Lynon.encode(d1))
|
|
||||||
assertEquals(d1, roundTrip)
|
|
||||||
assertEquals("\"2026-04-15\"", d1.toJsonString())
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
|
|
||||||
val decoded = eval(
|
|
||||||
"""
|
|
||||||
import lyng.time
|
|
||||||
Date(2026, 4, 15)
|
|
||||||
""".trimIndent()
|
|
||||||
).decodeSerializable<LocalDate>()
|
|
||||||
|
|
||||||
assertEquals(LocalDate(2026, 4, 15), decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testDoubleImports() = runTest {
|
fun testDoubleImports() = runTest {
|
||||||
val s = Scope.new()
|
val s = Scope.new()
|
||||||
|
|||||||
@ -1,94 +0,0 @@
|
|||||||
# Lyng.io.db
|
|
||||||
|
|
||||||
Core level interface to SQL implementations. Pooling, physical connections, and implementation-specific configuration are hidden behind the opened database handle.
|
|
||||||
|
|
||||||
All providers should support the generic `openDatabase(connectionUrl, extraParams)`
|
|
||||||
entry point for configuration-driven usage. Providers may also expose typed
|
|
||||||
helpers such as `openSqlite(...)` or `openPostgres(...)`.
|
|
||||||
|
|
||||||
Provider modules should register their URL schemes when first imported. The
|
|
||||||
generic `openDatabase(...)` then dispatches by normalized URL scheme to the
|
|
||||||
registered provider.
|
|
||||||
|
|
||||||
See [db definitions](lyngdb.lyng).
|
|
||||||
|
|
||||||
## Platforms support:
|
|
||||||
|
|
||||||
| DB | JVM | Native |
|
|
||||||
|----------|-----|--------|
|
|
||||||
| Postgres | + | ? |
|
|
||||||
| H2 | + | + |
|
|
||||||
| SQLITE | + | + |
|
|
||||||
|
|
||||||
|
|
||||||
Question for the future: what to do on JS platforms? Browsers have their crazy own storages, bit it is not SQL. Probably for this case we need some simpler standard compatible with browsers and with special implementation on JVM.
|
|
||||||
|
|
||||||
## Proposed type mapping:
|
|
||||||
|
|
||||||
| SQL | Lyng | comments |
|
|
||||||
|--------------------------------------------|----------|----------|
|
|
||||||
| SMALLINT | Int | Lyng `Int` is already 64-bit |
|
|
||||||
| INTEGER / INT | Int | |
|
|
||||||
| BIGINT | Int | Lyng `Int` is already 64-bit |
|
|
||||||
| REAL / FLOAT / DOUBLE PRECISION | Double | |
|
|
||||||
| DECIMAL / NUMERIC | Decimal | exact numeric |
|
|
||||||
| BOOLEAN / BOOL | Bool | |
|
|
||||||
| CHAR / VARCHAR / TEXT | String | |
|
|
||||||
| BINARY / VARBINARY / BLOB / BYTEA | Buffer | |
|
|
||||||
| DATE | Date | calendar date |
|
|
||||||
| TIME / TIME WITHOUT TIME ZONE | String | v1: no standalone Lyng `Time` type yet |
|
|
||||||
| TIMESTAMP / TIMESTAMP WITHOUT TIME ZONE | DateTime | local calendar-dependent timestamp |
|
|
||||||
| TIME WITH TIME ZONE | String | v1: preserve exact value textually |
|
|
||||||
| TIMESTAMP WITH TIME ZONE | Instant | absolute point in time |
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `TIME` mappings in v1 should stay textual. A SQL time-of-day value has no
|
|
||||||
date component, so mapping it to `DateTime` would require inventing one, and
|
|
||||||
mapping it to `Instant` would incorrectly assign absolute-time semantics.
|
|
||||||
- Because of that, `TIME` and `TIME WITH TIME ZONE` should be exposed as
|
|
||||||
`String` in portable code. The original backend type is still available
|
|
||||||
through `SqlColumn.nativeType`.
|
|
||||||
- Name-based `SqlRow` access uses result-column labels and should fail on
|
|
||||||
missing or ambiguous names.
|
|
||||||
- `SqlColumn` should expose both the normalized portable `SqlType` and the
|
|
||||||
original backend-reported type name.
|
|
||||||
- For any non-null cell, the converted row value should match the column's
|
|
||||||
portable `SqlType`. Providers should not advertise `SqlType.Date`,
|
|
||||||
`SqlType.Decimal`, etc. and then return mismatched raw values for those
|
|
||||||
columns.
|
|
||||||
- If a backend-reported value cannot be converted to the advertised Lyng value
|
|
||||||
type for that column, row production should fail with `SqlExecutionException`
|
|
||||||
rather than silently degrading to some other visible type.
|
|
||||||
- `ResultSet` should stay iterable, but also expose `isEmpty()` for cheap
|
|
||||||
emptiness checks where possible and `size()` as a separate operation.
|
|
||||||
- `ResultSet` and all `SqlRow` instances obtained from it are valid only while
|
|
||||||
the owning transaction is active. After transaction end, any further row or
|
|
||||||
result-set access should fail with `SqlUsageException`, even if the provider
|
|
||||||
had buffered data internally.
|
|
||||||
- Portable SQL parameter values should match the row conversion set: `null`,
|
|
||||||
`Bool`, `Int`, `Double`, `Decimal`, `String`, `Buffer`,
|
|
||||||
`Date`, `DateTime`, and `Instant`.
|
|
||||||
- Lyng has a single integer type, `Int`, with 64-bit range. Portable SQL
|
|
||||||
integer values should therefore normalize to `Int` rather than exposing
|
|
||||||
separate `Short` / `Int` / `Long` categories.
|
|
||||||
- Portable SQL placeholder syntax is positional `?` only.
|
|
||||||
- The core exception model should stay small: `DatabaseException`,
|
|
||||||
`SqlExecutionException`, `SqlConstraintException`, and `SqlUsageException`,
|
|
||||||
plus propagated `RollbackException`.
|
|
||||||
- `openDatabase(...)` should use `IllegalArgumentException` for malformed
|
|
||||||
arguments detected before opening, and `DatabaseException` for runtime open
|
|
||||||
failures such as authentication, connectivity, or provider initialization.
|
|
||||||
- Nested `SqlTransaction.transaction {}` must provide real nested transaction
|
|
||||||
semantics, usually via savepoints. If the backend cannot support this, it
|
|
||||||
should throw `SqlUsageException`.
|
|
||||||
- Transaction failure precedence should be:
|
|
||||||
- if user code throws and rollback succeeds, rethrow the original exception
|
|
||||||
- if user code throws and rollback fails, the original exception stays
|
|
||||||
primary and the rollback failure is secondary/suppressed where possible
|
|
||||||
- if `RollbackException` was used intentionally and rollback itself fails,
|
|
||||||
the rollback failure becomes primary
|
|
||||||
- if commit fails after normal block completion, the commit failure is
|
|
||||||
primary
|
|
||||||
- Provider URL schemes should be matched case-insensitively. Duplicate scheme
|
|
||||||
registration should fail. Unknown schemes or missing providers should fail
|
|
||||||
with `DatabaseException`.
|
|
||||||
@ -1,213 +0,0 @@
|
|||||||
/*
|
|
||||||
Portable value categories exposed by the Lyng SQL layer and used in
|
|
||||||
[SqlColumn].
|
|
||||||
*/
|
|
||||||
enum SqlType {
|
|
||||||
Binary, String, Int, Double, Decimal,
|
|
||||||
Bool, Instant, Date, DateTime
|
|
||||||
}
|
|
||||||
|
|
||||||
class SqlColumn(
|
|
||||||
val name: String,
|
|
||||||
val sqlType: SqlType,
|
|
||||||
val nullable: Bool,
|
|
||||||
/*
|
|
||||||
Original database type name as reported by the backend, such as
|
|
||||||
VARCHAR, TEXT, INT8, TIMESTAMPTZ, or BYTEA.
|
|
||||||
*/
|
|
||||||
val nativeType: String
|
|
||||||
)
|
|
||||||
|
|
||||||
class SqlRow(
|
|
||||||
/* Number of columns in the row */
|
|
||||||
val size: Int,
|
|
||||||
val values: ImmutableList<Object?>
|
|
||||||
) {
|
|
||||||
/*
|
|
||||||
Return the already converted Lyng value for a column addressed by
|
|
||||||
index or output column label. SQL NULL is returned as null.
|
|
||||||
|
|
||||||
Name lookup uses result-column labels. If several columns share the same
|
|
||||||
label, name-based access is ambiguous and should fail. Missing column
|
|
||||||
names and invalid indexes should also fail.
|
|
||||||
*/
|
|
||||||
abstract override fun getAt(indexOrName: String|Int): Object?
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
A result set is valid only while its owning transaction is active.
|
|
||||||
|
|
||||||
Implementations may stream rows or buffer them internally, but:
|
|
||||||
- rows must be exposed through normal iteration
|
|
||||||
- iteration to the end or canceled iteration should close the underlying
|
|
||||||
resources automatically
|
|
||||||
- using the result set after its transaction ends is invalid
|
|
||||||
- rows obtained from the result set are also invalid after the owning
|
|
||||||
transaction ends, even if the implementation had already buffered them
|
|
||||||
|
|
||||||
If user code wants row data to survive independently, it should copy the
|
|
||||||
values it needs into ordinary Lyng objects while the transaction is active.
|
|
||||||
*/
|
|
||||||
interface ResultSet : Iterable<SqlRow> {
|
|
||||||
/*
|
|
||||||
Column metadata for the result rows, in positional order.
|
|
||||||
*/
|
|
||||||
abstract val columns: ImmutableList<SqlColumn>
|
|
||||||
|
|
||||||
/*
|
|
||||||
Number of rows if the implementation can determine it. Implementations
|
|
||||||
may need to consume or buffer the whole result in order to answer.
|
|
||||||
*/
|
|
||||||
abstract override fun size(): Int
|
|
||||||
|
|
||||||
/*
|
|
||||||
Fast emptiness check when the implementation can provide it without
|
|
||||||
consuming the result.
|
|
||||||
*/
|
|
||||||
abstract override fun isEmpty(): Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class ExecutionResult(
|
|
||||||
val affectedRowsCount: Int
|
|
||||||
) {
|
|
||||||
/*
|
|
||||||
Return implementation-supported auto-generated values produced by
|
|
||||||
[execute]. This is intentionally stricter than arbitrary SQL result
|
|
||||||
sets: statements such as INSERT/UPDATE/DELETE ... RETURNING should be
|
|
||||||
executed with [select], not exposed here.
|
|
||||||
|
|
||||||
If the statement produced no generated values, the returned result set
|
|
||||||
is empty.
|
|
||||||
*/
|
|
||||||
abstract fun getGeneratedKeys(): ResultSet
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
Base exception for the SQL database module.
|
|
||||||
*/
|
|
||||||
open class DatabaseException: Exception
|
|
||||||
|
|
||||||
/*
|
|
||||||
The SQL statement could not be executed successfully by the backend.
|
|
||||||
*/
|
|
||||||
open class SqlExecutionException: DatabaseException
|
|
||||||
|
|
||||||
/*
|
|
||||||
Execution failed because of a constraint violation, such as UNIQUE,
|
|
||||||
FOREIGN KEY, CHECK, or NOT NULL.
|
|
||||||
*/
|
|
||||||
class SqlConstraintException: SqlExecutionException
|
|
||||||
|
|
||||||
/*
|
|
||||||
The DB API was used incorrectly, such as invalid transaction state,
|
|
||||||
ambiguous column-name access, or invalid row indexes.
|
|
||||||
*/
|
|
||||||
class SqlUsageException: DatabaseException
|
|
||||||
|
|
||||||
/*
|
|
||||||
Transaction represents a database transaction (non-transactional operations
|
|
||||||
we intentionally do not support).
|
|
||||||
|
|
||||||
Important: a transaction has __no commit__; instead it commits when
|
|
||||||
leaving the transaction block normally.
|
|
||||||
|
|
||||||
If the transaction block throws any exception not caught inside the calling
|
|
||||||
code, it will be rolled back.
|
|
||||||
*/
|
|
||||||
interface SqlTransaction {
|
|
||||||
/*
|
|
||||||
Execute a SQL statement that returns rows. This includes plain SELECT
|
|
||||||
queries and database-specific DML statements with row-returning clauses
|
|
||||||
such as RETURNING or OUTPUT.
|
|
||||||
|
|
||||||
Portable SQL uses positional ? placeholders only.
|
|
||||||
|
|
||||||
Parameters are already-converted Lyng values bound positionally to the
|
|
||||||
statement. Portable bindable values are:
|
|
||||||
- null
|
|
||||||
- Bool
|
|
||||||
- Int, Double, Decimal
|
|
||||||
- String
|
|
||||||
- Buffer
|
|
||||||
- Date, DateTime, Instant
|
|
||||||
|
|
||||||
Backends may support additional parameter types, but portable code
|
|
||||||
should limit itself to the values above.
|
|
||||||
*/
|
|
||||||
abstract fun select(clause: String,params: Object...): ResultSet
|
|
||||||
|
|
||||||
/*
|
|
||||||
Execute a SQL statement for side effects. Use [select] for any statement
|
|
||||||
whose primary result is a row set.
|
|
||||||
|
|
||||||
Parameters follow the same binding rules as [select].
|
|
||||||
*/
|
|
||||||
abstract fun execute(clause: String,params: Object...): ExecutionResult
|
|
||||||
|
|
||||||
/*
|
|
||||||
Create a nested transaction with real nested semantics, typically using
|
|
||||||
database savepoints.
|
|
||||||
|
|
||||||
If the backend cannot provide real nested transaction semantics, this
|
|
||||||
call should fail with SqlUsageException rather than flattening into the
|
|
||||||
outer transaction.
|
|
||||||
|
|
||||||
Failure inside the nested transaction rolls back only the nested scope;
|
|
||||||
the outer transaction remains active unless the exception is allowed to
|
|
||||||
propagate further.
|
|
||||||
*/
|
|
||||||
abstract fun transaction<T>( block: (SqlTransaction)->T): T
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
Special exception to be thrown from SqlTransaction.transaction
|
|
||||||
when nothing else matters/needed (DRY).
|
|
||||||
|
|
||||||
It causes rollback and is propagated to the caller, but should not be
|
|
||||||
treated as a backend/driver failure.
|
|
||||||
|
|
||||||
If rollback itself fails, that rollback failure becomes the primary backend
|
|
||||||
error instead.
|
|
||||||
*/
|
|
||||||
class RollbackException: Exception
|
|
||||||
|
|
||||||
interface Database {
|
|
||||||
/*
|
|
||||||
Open a transaction. Any pooling, physical connection lifecycle, and implementation-specific configuration are owned by the database implementation and hidden from the user.
|
|
||||||
The transaction commits when the block finishes normally,
|
|
||||||
and rolls back if the block exits with an uncaught exception.
|
|
||||||
*/
|
|
||||||
abstract fun transaction<T>(block: (SqlTransaction) -> T): T
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
Register a database provider for a URL scheme.
|
|
||||||
|
|
||||||
Provider modules should call this during module initialization when first
|
|
||||||
imported. Scheme matching is case-insensitive and normalized to lowercase.
|
|
||||||
|
|
||||||
Registering the same scheme more than once should fail.
|
|
||||||
*/
|
|
||||||
fun registerDatabaseProvider(
|
|
||||||
scheme: String,
|
|
||||||
opener: (connectionUrl: String, extraParams: Map<String,Object?>) -> Database
|
|
||||||
)
|
|
||||||
|
|
||||||
/*
|
|
||||||
The mandatory generic entry point for all providers. It opens a database
|
|
||||||
handle from a provider-specific connection URL plus extra parameters.
|
|
||||||
|
|
||||||
Providers may expose additional typed constructors such as openSqlite(...)
|
|
||||||
or openPostgres(...), but openDatabase(...) should remain available for
|
|
||||||
configuration-driven usage.
|
|
||||||
|
|
||||||
It should throw IllegalArgumentException for malformed connection URLs or
|
|
||||||
invalid extra parameter shapes detected before opening the backend.
|
|
||||||
Runtime opening failures such as authentication, connectivity, or provider
|
|
||||||
initialization errors should be reported as DatabaseException.
|
|
||||||
|
|
||||||
The matching provider must already be registered, normally because its
|
|
||||||
module was imported and executed. Unknown schemes or missing providers
|
|
||||||
should fail with DatabaseException.
|
|
||||||
*/
|
|
||||||
extern fun openDatabase(connectionUrl: String,extraParams: Map<String,Object?>): Database
|
|
||||||
@ -1,261 +0,0 @@
|
|||||||
# SQLite provider implementation plan
|
|
||||||
|
|
||||||
Implementation checklist for the first concrete DB provider:
|
|
||||||
- module: `lyng.io.db.sqlite`
|
|
||||||
- targets: JVM and Native
|
|
||||||
- role: reference implementation for the core `lyng.io.db` contract
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
In scope for the first implementation:
|
|
||||||
- provider registration on module import
|
|
||||||
- `sqlite:` URL dispatch through `openDatabase(...)`
|
|
||||||
- typed `openSqlite(...)` helper
|
|
||||||
- `Database` and `SqlTransaction` implementation
|
|
||||||
- result-set implementation
|
|
||||||
- row and column metadata conversion
|
|
||||||
- SQLite savepoint-based nested transactions
|
|
||||||
- generated keys for `execute(...)`
|
|
||||||
- JVM and Native test coverage for the core portable contract
|
|
||||||
|
|
||||||
Out of scope for the first implementation:
|
|
||||||
- schema inspection APIs beyond result-set column metadata
|
|
||||||
- public prepared statement API
|
|
||||||
- batch API
|
|
||||||
- JS support
|
|
||||||
- heuristic temporal parsing
|
|
||||||
- heuristic decimal parsing
|
|
||||||
|
|
||||||
## Proposed module layout
|
|
||||||
|
|
||||||
Core/public declarations:
|
|
||||||
- `.lyng` declaration source for `lyng.io.db`
|
|
||||||
- `.lyng` declaration source for `lyng.io.db.sqlite`
|
|
||||||
|
|
||||||
Backend/runtime implementation:
|
|
||||||
- common transaction/result-set abstractions in `commonMain` where possible
|
|
||||||
- JVM SQLite implementation in `jvmMain`
|
|
||||||
- Native SQLite implementation in `nativeMain`
|
|
||||||
|
|
||||||
## Milestone 1: core DB module skeleton
|
|
||||||
|
|
||||||
1. Move or copy the current DB declarations from `notes/db/lyngdb.lyng` into the
|
|
||||||
real module declaration source location.
|
|
||||||
2. Add the provider registry runtime for:
|
|
||||||
- normalized lowercase scheme lookup
|
|
||||||
- duplicate registration failure
|
|
||||||
- unknown-scheme failure
|
|
||||||
3. Implement generic `openDatabase(connectionUrl, extraParams)` dispatch.
|
|
||||||
4. Add basic tests for:
|
|
||||||
- successful provider registration
|
|
||||||
- duplicate scheme registration failure
|
|
||||||
- unknown scheme failure
|
|
||||||
- malformed URL failure
|
|
||||||
|
|
||||||
## Milestone 2: SQLite provider skeleton
|
|
||||||
|
|
||||||
1. Create `lyng.io.db.sqlite` declaration source with:
|
|
||||||
- typed `openSqlite(...)`
|
|
||||||
- provider-specific documentation
|
|
||||||
2. Register `sqlite` scheme at module initialization time.
|
|
||||||
3. Parse supported URL forms:
|
|
||||||
- `sqlite::memory:`
|
|
||||||
- `sqlite:relative/path.db`
|
|
||||||
- `sqlite:/absolute/path.db`
|
|
||||||
4. Convert typed helper arguments into the same normalized open options used by
|
|
||||||
the generic `openDatabase(...)` path.
|
|
||||||
5. Add tests for:
|
|
||||||
- import-time registration
|
|
||||||
- typed helper open
|
|
||||||
- generic URL-based open
|
|
||||||
- invalid URL handling
|
|
||||||
|
|
||||||
## Milestone 3: JVM backend
|
|
||||||
|
|
||||||
Implementation strategy:
|
|
||||||
- use SQLite JDBC under the hood
|
|
||||||
- keep JDBC fully internal to the provider
|
|
||||||
- preserve the Lyng-facing transaction/result contracts rather than exposing
|
|
||||||
JDBC semantics directly
|
|
||||||
|
|
||||||
Steps:
|
|
||||||
1. Create JVM `Database` implementation that stores normalized SQLite config.
|
|
||||||
2. For each outer `Database.transaction {}`:
|
|
||||||
- open/acquire one JDBC connection
|
|
||||||
- configure connection-level options
|
|
||||||
- begin transaction
|
|
||||||
- commit on normal exit
|
|
||||||
- rollback on uncaught exception
|
|
||||||
- close/release connection
|
|
||||||
- preserve error precedence:
|
|
||||||
- original user exception stays primary on rollback failure
|
|
||||||
- rollback failure becomes primary for intentional `RollbackException`
|
|
||||||
- commit failure is primary on normal-exit commit failure
|
|
||||||
3. For nested `SqlTransaction.transaction {}`:
|
|
||||||
- create savepoint
|
|
||||||
- release savepoint on success
|
|
||||||
- rollback to savepoint on failure
|
|
||||||
- preserve the same primary/secondary exception rules for savepoint rollback
|
|
||||||
failures
|
|
||||||
4. Implement `select(...)`:
|
|
||||||
- bind positional `?` parameters
|
|
||||||
- expose result rows through `ResultSet`
|
|
||||||
- preserve transaction-scoped lifetime
|
|
||||||
- invalidate both the result set and any rows obtained from it when the
|
|
||||||
owning transaction ends
|
|
||||||
5. Implement `execute(...)`:
|
|
||||||
- bind positional parameters
|
|
||||||
- collect affected row count
|
|
||||||
- expose generated keys when supported
|
|
||||||
6. Implement column metadata normalization:
|
|
||||||
- output column label
|
|
||||||
- nullable flag where available
|
|
||||||
- portable `SqlType`
|
|
||||||
- backend native type name
|
|
||||||
7. Add JVM tests for:
|
|
||||||
- transaction commit
|
|
||||||
- transaction rollback
|
|
||||||
- nested transaction savepoint behavior
|
|
||||||
- rollback failure precedence
|
|
||||||
- commit failure precedence
|
|
||||||
- row lookup by index and name
|
|
||||||
- ambiguous name failure
|
|
||||||
- result-set use-after-transaction failure
|
|
||||||
- row use-after-transaction failure
|
|
||||||
- generated keys
|
|
||||||
- `RETURNING` via `select(...)` if the backend supports it
|
|
||||||
|
|
||||||
## Milestone 4: Native backend
|
|
||||||
|
|
||||||
Implementation strategy:
|
|
||||||
- use direct SQLite C bindings
|
|
||||||
- keep semantics aligned with the JVM backend
|
|
||||||
|
|
||||||
Steps:
|
|
||||||
1. Create Native `Database` implementation that stores normalized SQLite config.
|
|
||||||
2. For each outer transaction:
|
|
||||||
- open one SQLite handle if needed
|
|
||||||
- configure pragmas/options
|
|
||||||
- begin transaction
|
|
||||||
- commit or rollback
|
|
||||||
3. Implement nested transactions with SQLite savepoints.
|
|
||||||
4. Implement prepared statement lifecycle internally for `select(...)` and
|
|
||||||
`execute(...)`.
|
|
||||||
5. Implement result-set iteration and statement finalization.
|
|
||||||
6. Implement the same value-conversion policy as JVM:
|
|
||||||
- exact integer mapping
|
|
||||||
- `Double`
|
|
||||||
- `String`
|
|
||||||
- `Buffer`
|
|
||||||
- schema-driven `Bool`
|
|
||||||
- schema-driven `Decimal`
|
|
||||||
- schema-driven temporal parsing
|
|
||||||
7. Add Native tests matching the JVM behavioral suite as closely as possible.
|
|
||||||
|
|
||||||
## Milestone 5: conversion policy
|
|
||||||
|
|
||||||
1. Normalize integer reads:
|
|
||||||
- return `Int`
|
|
||||||
2. Normalize floating-point reads to `Double`.
|
|
||||||
3. Normalize text reads to `String`.
|
|
||||||
4. Normalize blob reads to `Buffer`.
|
|
||||||
5. Parse `Decimal` only for declared/native `DECIMAL` / `NUMERIC` columns.
|
|
||||||
- bind Lyng `Decimal` as canonical text using existing Decimal formatting
|
|
||||||
- read back with the existing Decimal parser
|
|
||||||
6. Parse `Bool` only for declared/native `BOOLEAN` / `BOOL` columns:
|
|
||||||
- prefer integer `0` / `1`
|
|
||||||
- also accept legacy text `true`, `false`, `t`, `f` case-insensitively
|
|
||||||
- always write `Bool` as integer `0` / `1`
|
|
||||||
7. Parse temporal values only from an explicit normalized type-name whitelist:
|
|
||||||
- `DATE`
|
|
||||||
- `DATETIME`
|
|
||||||
- `TIMESTAMP`
|
|
||||||
- `TIMESTAMP WITH TIME ZONE`
|
|
||||||
- `TIMESTAMPTZ`
|
|
||||||
- `DATETIME WITH TIME ZONE`
|
|
||||||
- `TIME`
|
|
||||||
- `TIME WITHOUT TIME ZONE`
|
|
||||||
- `TIME WITH TIME ZONE`
|
|
||||||
8. Never heuristically parse arbitrary string or numeric values into temporal or
|
|
||||||
decimal values.
|
|
||||||
9. If a declared strong type (`Bool`, `Decimal`, `Date`, `DateTime`, `Instant`)
|
|
||||||
cannot be converted from the stored value, fail with `SqlExecutionException`.
|
|
||||||
10. Add tests for each conversion rule.
|
|
||||||
|
|
||||||
## Milestone 6: result-set contract
|
|
||||||
|
|
||||||
1. Ensure `ResultSet.columns` is available before iteration.
|
|
||||||
2. Implement name lookup using result-column labels.
|
|
||||||
3. Throw `SqlUsageException` for:
|
|
||||||
- invalid row index
|
|
||||||
- missing column name
|
|
||||||
- ambiguous column name
|
|
||||||
- use after transaction end
|
|
||||||
4. Implement cheap `isEmpty()` where practical.
|
|
||||||
5. Implement `size()` separately, allowing buffering/consumption when required.
|
|
||||||
6. Verify resource cleanup on:
|
|
||||||
- full iteration
|
|
||||||
- canceled iteration
|
|
||||||
- transaction rollback
|
|
||||||
|
|
||||||
## Milestone 7: provider options
|
|
||||||
|
|
||||||
Support these typed helper options first:
|
|
||||||
- `readOnly`
|
|
||||||
- `createIfMissing`
|
|
||||||
- `foreignKeys`
|
|
||||||
- `busyTimeoutMillis`
|
|
||||||
|
|
||||||
Expected behavior:
|
|
||||||
- `foreignKeys` defaults to `true`
|
|
||||||
- `busyTimeoutMillis` has a non-zero sensible default
|
|
||||||
- `readOnly` is explicit
|
|
||||||
- `createIfMissing` is explicit
|
|
||||||
|
|
||||||
Add tests for option handling where backend behavior is observable.
|
|
||||||
|
|
||||||
## Milestone 8: documentation and examples
|
|
||||||
|
|
||||||
1. Add user docs for:
|
|
||||||
- importing `lyng.io.db.sqlite`
|
|
||||||
- generic `openDatabase(...)`
|
|
||||||
- typed `openSqlite(...)`
|
|
||||||
- transaction usage
|
|
||||||
- nested transactions
|
|
||||||
2. Add small sample snippets for:
|
|
||||||
- in-memory DB
|
|
||||||
- file-backed DB
|
|
||||||
- schema creation
|
|
||||||
- insert/select/update
|
|
||||||
- rollback on exception
|
|
||||||
|
|
||||||
## Testing priorities
|
|
||||||
|
|
||||||
Highest priority behavioral tests:
|
|
||||||
1. provider registration and scheme dispatch
|
|
||||||
2. outer transaction commit/rollback
|
|
||||||
3. nested savepoint semantics
|
|
||||||
4. row metadata and name lookup behavior
|
|
||||||
5. generated keys for inserts
|
|
||||||
6. result-set lifetime rules
|
|
||||||
7. value conversion rules
|
|
||||||
8. helper vs generic-open parity
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
Main implementation risks:
|
|
||||||
- keeping JVM and Native value-conversion behavior identical
|
|
||||||
- correctly enforcing result-set lifetime across both backends
|
|
||||||
- SQLite temporal conversion ambiguity
|
|
||||||
- JDBC metadata differences on JVM
|
|
||||||
- native statement/finalizer lifecycle bugs
|
|
||||||
|
|
||||||
## Suggested implementation order
|
|
||||||
|
|
||||||
1. core registry runtime
|
|
||||||
2. SQLite `.lyng` provider declarations
|
|
||||||
3. JVM SQLite backend
|
|
||||||
4. JVM test suite
|
|
||||||
5. Native SQLite backend
|
|
||||||
6. shared behavioral test suite refinement
|
|
||||||
7. documentation/examples
|
|
||||||
@ -1,272 +0,0 @@
|
|||||||
# SQLite provider for `lyng.io.db`
|
|
||||||
|
|
||||||
First concrete provider candidate for the DB module.
|
|
||||||
|
|
||||||
Module name:
|
|
||||||
- `lyng.io.db.sqlite`
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- register SQLite URL schemes on first import
|
|
||||||
- provide the generic `openDatabase(...)` entry point for SQLite URLs
|
|
||||||
- provide typed SQLite-specific helpers for ergonomic opening
|
|
||||||
- implement the core `Database` / `SqlTransaction` API on both JVM and Native
|
|
||||||
|
|
||||||
## Registration and URL schemes
|
|
||||||
|
|
||||||
On first import, the module should register at least:
|
|
||||||
- `sqlite`
|
|
||||||
|
|
||||||
Possible accepted URL forms:
|
|
||||||
- `sqlite::memory:`
|
|
||||||
- `sqlite:./local.db`
|
|
||||||
- `sqlite:/absolute/path/data.db`
|
|
||||||
|
|
||||||
The exact accepted path grammar can be tightened during implementation, but it
|
|
||||||
should stay simple and configuration-friendly.
|
|
||||||
|
|
||||||
## Typed helper
|
|
||||||
|
|
||||||
The provider should also expose a typed helper, e.g.:
|
|
||||||
|
|
||||||
```lyng
|
|
||||||
fun openSqlite(
|
|
||||||
path: String,
|
|
||||||
readOnly: Bool = false,
|
|
||||||
createIfMissing: Bool = true,
|
|
||||||
foreignKeys: Bool = true,
|
|
||||||
busyTimeoutMillis: Int = 5000
|
|
||||||
): Database
|
|
||||||
```
|
|
||||||
|
|
||||||
Possible special values:
|
|
||||||
- `":memory:"` for in-memory DB
|
|
||||||
|
|
||||||
The helper is provider-specific sugar with explicit typed arguments. The generic
|
|
||||||
`openDatabase(...)` path must stay fully supported for configuration-driven
|
|
||||||
usage.
|
|
||||||
|
|
||||||
## Implementation strategy
|
|
||||||
|
|
||||||
SQLite should be the first real provider because it is available on both JVM and
|
|
||||||
Native and exercises almost the whole core API surface.
|
|
||||||
|
|
||||||
### JVM
|
|
||||||
|
|
||||||
Preferred implementation:
|
|
||||||
- JDBC-backed SQLite provider for the JVM-specific backend implementation
|
|
||||||
|
|
||||||
This is acceptable because SQLite itself is local and the JDBC bridge is much
|
|
||||||
simpler here than for network databases.
|
|
||||||
|
|
||||||
### Native
|
|
||||||
|
|
||||||
Preferred implementation:
|
|
||||||
- direct SQLite C library binding
|
|
||||||
|
|
||||||
The provider should present the same Lyng-facing semantics on both backends.
|
|
||||||
|
|
||||||
## Transactions
|
|
||||||
|
|
||||||
Required behavior:
|
|
||||||
- `Database.transaction {}` starts a real SQLite transaction
|
|
||||||
- `SqlTransaction.transaction {}` uses SQLite savepoints
|
|
||||||
- nested transactions must be supported
|
|
||||||
- failures in nested transactions roll back only to the nested savepoint unless
|
|
||||||
the exception escapes further
|
|
||||||
- error precedence follows the core DB contract:
|
|
||||||
- user exception + successful rollback -> user exception escapes unchanged
|
|
||||||
- user exception + rollback failure -> user exception stays primary
|
|
||||||
- intentional `RollbackException` + rollback failure -> rollback failure is
|
|
||||||
primary
|
|
||||||
- commit failure after normal completion -> commit failure is primary
|
|
||||||
|
|
||||||
SQLite is a good fit here because savepoints are well-supported.
|
|
||||||
|
|
||||||
Connection/handle semantics:
|
|
||||||
- one outer `Database.transaction {}` must use exactly one physical SQLite
|
|
||||||
connection/handle for its whole lifetime
|
|
||||||
- nested transactions must stay on that same connection/handle
|
|
||||||
- a transaction must never hop across connections
|
|
||||||
|
|
||||||
This is required because SQLite transaction state, savepoints, and generated
|
|
||||||
row-id behavior are all connection-local.
|
|
||||||
|
|
||||||
## Result sets
|
|
||||||
|
|
||||||
The provider may stream rows or buffer them, but it must preserve the core
|
|
||||||
contract:
|
|
||||||
- result sets are valid only while the owning transaction is active
|
|
||||||
- rows obtained from a result set are also invalid after the owning
|
|
||||||
transaction ends, even if they were already buffered
|
|
||||||
- iteration closes underlying resources when finished or canceled
|
|
||||||
- `isEmpty()` should be cheap where possible
|
|
||||||
- `size()` may consume or buffer the full result
|
|
||||||
|
|
||||||
## SQLite-specific type mapping notes
|
|
||||||
|
|
||||||
SQLite uses dynamic typing and affinity rules, so the provider must normalize
|
|
||||||
returned values into the portable Lyng types.
|
|
||||||
|
|
||||||
Recommended mapping strategy:
|
|
||||||
- integer values -> `Int`
|
|
||||||
- floating-point values -> `Double`
|
|
||||||
- text values -> `String`
|
|
||||||
- blob values -> `Buffer`
|
|
||||||
- declared/native `BOOLEAN` / `BOOL` -> `Bool`
|
|
||||||
- numeric values that are explicitly read/declared as decimal -> `Decimal` when
|
|
||||||
the provider can determine this reliably
|
|
||||||
- date/time values should be parsed only when the declared/native column type
|
|
||||||
indicates temporal intent
|
|
||||||
|
|
||||||
The provider should not heuristically parse arbitrary `TEXT`, `INTEGER`, or
|
|
||||||
`REAL` values into temporal or decimal types just because the stored value looks
|
|
||||||
like one.
|
|
||||||
|
|
||||||
If a column is exposed with a stronger portable `SqlType` such as `Bool`,
|
|
||||||
`Decimal`, `Date`, `DateTime`, or `Instant`, then the produced row value should
|
|
||||||
either be that Lyng value or `null`. Invalid stored representations should fail
|
|
||||||
with `SqlExecutionException`.
|
|
||||||
|
|
||||||
Boolean policy exception:
|
|
||||||
- unlike temporal values, legacy boolean encodings are cheap to recognize and
|
|
||||||
have low ambiguity
|
|
||||||
- therefore SQLite `BOOL` / `BOOLEAN` columns may accept a small tolerant set of
|
|
||||||
legacy boolean encodings on read
|
|
||||||
- writes should still always use integer `0` / `1`
|
|
||||||
- temporal and decimal conversions remain strict/schema-driven
|
|
||||||
|
|
||||||
Declared type-name normalization for SQLite v1:
|
|
||||||
- trim surrounding whitespace
|
|
||||||
- uppercase
|
|
||||||
- collapse internal whitespace runs to a single space
|
|
||||||
- strip a trailing `( ... )` size/precision suffix before matching
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- ` numeric(10,2) ` -> `NUMERIC`
|
|
||||||
- `timestamp with time zone` -> `TIMESTAMP WITH TIME ZONE`
|
|
||||||
|
|
||||||
SQLite v1 declared type-name whitelist:
|
|
||||||
|
|
||||||
| normalized declared/native type | portable `SqlType` |
|
|
||||||
|---------------------------------|--------------------|
|
|
||||||
| `BOOLEAN` | `Bool` |
|
|
||||||
| `BOOL` | `Bool` |
|
|
||||||
| `DECIMAL` | `Decimal` |
|
|
||||||
| `NUMERIC` | `Decimal` |
|
|
||||||
| `DATE` | `Date` |
|
|
||||||
| `DATETIME` | `DateTime` |
|
|
||||||
| `TIMESTAMP` | `DateTime` |
|
|
||||||
| `TIMESTAMP WITH TIME ZONE` | `Instant` |
|
|
||||||
| `TIMESTAMPTZ` | `Instant` |
|
|
||||||
| `DATETIME WITH TIME ZONE` | `Instant` |
|
|
||||||
| `TIME` | `String` |
|
|
||||||
| `TIME WITHOUT TIME ZONE` | `String` |
|
|
||||||
| `TIME WITH TIME ZONE` | `String` |
|
|
||||||
|
|
||||||
Anything not in this table should not be promoted to a stronger portable type
|
|
||||||
just from its declared name.
|
|
||||||
|
|
||||||
## SQLite temporal policy
|
|
||||||
|
|
||||||
SQLite has no strong built-in temporal storage types, so the provider should use
|
|
||||||
a strict schema-driven conversion policy.
|
|
||||||
|
|
||||||
Binding:
|
|
||||||
- `null` -> SQL `NULL`
|
|
||||||
- `Bool` -> integer `0` / `1`
|
|
||||||
- `Int` -> SQLite integer
|
|
||||||
- `Double` -> SQLite real
|
|
||||||
- `Decimal` -> canonical decimal text representation using the existing Lyng
|
|
||||||
Decimal formatter
|
|
||||||
- `String` -> text
|
|
||||||
- `Buffer` -> blob
|
|
||||||
- `Date` -> ISO text `YYYY-MM-DD`
|
|
||||||
- `DateTime` -> ISO text without timezone
|
|
||||||
- `Instant` -> ISO text in UTC with explicit timezone marker
|
|
||||||
|
|
||||||
Reading:
|
|
||||||
- storage class `NULL` -> `null`
|
|
||||||
- normalized declared/native type `BOOLEAN` or `BOOL` -> parse as `Bool` with
|
|
||||||
this ordered rule:
|
|
||||||
- integer `0` / `1` first
|
|
||||||
- then legacy text forms, case-insensitively: `true`, `false`, `t`, `f`
|
|
||||||
- other stored values are conversion errors
|
|
||||||
- normalized declared/native type `DATE` -> parse as `Date`
|
|
||||||
- normalized declared/native type `DATETIME` or `TIMESTAMP` -> parse as `DateTime`
|
|
||||||
- normalized declared/native type `TIMESTAMP WITH TIME ZONE`,
|
|
||||||
`TIMESTAMPTZ`, or `DATETIME WITH TIME ZONE` -> parse as `Instant`
|
|
||||||
- normalized declared/native type `TIME`, `TIME WITHOUT TIME ZONE`, or
|
|
||||||
`TIME WITH TIME ZONE` -> keep as `String` in v1
|
|
||||||
- normalized declared/native type `DECIMAL` or `NUMERIC` -> parse as `Decimal`
|
|
||||||
- otherwise integer storage -> `Int`
|
|
||||||
- otherwise real storage -> `Double`
|
|
||||||
- otherwise text storage -> `String`
|
|
||||||
- otherwise blob storage -> `Buffer`
|
|
||||||
- otherwise do not guess, and return the raw normalized SQLite value type
|
|
||||||
|
|
||||||
For v1, the provider should not automatically interpret numeric epoch values or
|
|
||||||
Julian date encodings unless this is later added as an explicit provider option.
|
|
||||||
|
|
||||||
## SQLite decimal policy
|
|
||||||
|
|
||||||
Decimal conversion should also be schema-driven:
|
|
||||||
- normalized declared/native type `DECIMAL` or `NUMERIC` -> parse as `Decimal`
|
|
||||||
- otherwise do not guess from text or floating-point storage alone
|
|
||||||
|
|
||||||
Decimal exactness note:
|
|
||||||
- SQLite has no native decimal storage class; values are stored as `INTEGER`,
|
|
||||||
`REAL`, `TEXT`, `BLOB`, or `NULL`
|
|
||||||
- binding Lyng `Decimal` as text is only the provider's chosen encoding, not a
|
|
||||||
native SQLite decimal representation
|
|
||||||
- SQLite v1 should therefore store Lyng `Decimal` values as canonical text and
|
|
||||||
parse them back with the existing Lyng Decimal parser/formatter stack
|
|
||||||
- schemas that care about decimal semantics should still declare
|
|
||||||
`DECIMAL` / `NUMERIC` affinity so the provider knows to expose `Decimal`
|
|
||||||
- exact round-tripping therefore cannot be guaranteed for generic
|
|
||||||
`DECIMAL` / `NUMERIC` columns, because SQLite affinity rules may coerce stored
|
|
||||||
values before they are read back
|
|
||||||
- SQLite values already stored as `REAL` in `DECIMAL` / `NUMERIC` columns may
|
|
||||||
already reflect floating-point precision loss before Lyng sees them
|
|
||||||
- if exact decimal preservation is required, the schema and provider policy must
|
|
||||||
intentionally store decimal values in an exact representation, most simply as
|
|
||||||
canonical text
|
|
||||||
|
|
||||||
This area will need careful implementation rules because SQLite itself does not
|
|
||||||
have a strong native temporal type system.
|
|
||||||
|
|
||||||
## Generated keys
|
|
||||||
|
|
||||||
`ExecutionResult.getGeneratedKeys()` for SQLite should return implementation-
|
|
||||||
supported generated values for `execute(...)`.
|
|
||||||
|
|
||||||
Typical example:
|
|
||||||
- row id generated by an insert into a table with integer primary key
|
|
||||||
|
|
||||||
Statements that explicitly return rows should still go through `select(...)`,
|
|
||||||
for example if the provider eventually supports SQLite `RETURNING`.
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
Likely provider-specific options:
|
|
||||||
- read-only mode
|
|
||||||
- create-if-missing
|
|
||||||
- busy timeout
|
|
||||||
- foreign keys on/off
|
|
||||||
|
|
||||||
These options can be accepted both through SQLite helper functions and through
|
|
||||||
`openDatabase(..., extraParams)` when the URL scheme is `sqlite`.
|
|
||||||
|
|
||||||
Recommended defaults:
|
|
||||||
- foreign keys enabled by default
|
|
||||||
- busy timeout may be configurable, but should have a sensible default
|
|
||||||
- read-only and create-if-missing should be explicit options rather than hidden
|
|
||||||
URL magic
|
|
||||||
|
|
||||||
## Non-goals for v1
|
|
||||||
|
|
||||||
Not required for the first SQLite provider:
|
|
||||||
- schema metadata beyond result-column metadata
|
|
||||||
- prepared statement API in public surface
|
|
||||||
- batch execution API
|
|
||||||
- provider capability flags
|
|
||||||
- JS/browser support
|
|
||||||
Loading…
x
Reference in New Issue
Block a user