Add Date type and database release docs
This commit is contained in:
parent
14dc73db3e
commit
b3be908242
21
CHANGELOG.md
21
CHANGELOG.md
@ -7,6 +7,27 @@ History note:
|
||||
- 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.
|
||||
|
||||
## 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)
|
||||
|
||||
### Runtime and compiler stability
|
||||
|
||||
@ -52,6 +52,8 @@ assertEquals(A.E.One, A.One)
|
||||
- [What's New in 1.5](docs/whats_new_1_5.md)
|
||||
- [Testing and Assertions](docs/Testing.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)
|
||||
- [Efficient Iterables in Kotlin Interop](docs/EfficientIterables.md)
|
||||
- [Samples directory](docs/samples)
|
||||
|
||||
@ -81,7 +81,7 @@ Sources: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt`, `lynglib/s
|
||||
- `import lyng.serialization`
|
||||
- `Lynon` serialization utilities.
|
||||
- `import lyng.time`
|
||||
- `Instant`, `DateTime`, `Duration`, and module `delay`.
|
||||
- `Instant`, `Date`, `DateTime`, `Duration`, and module `delay`.
|
||||
|
||||
## 6. Optional (lyngio) Modules
|
||||
Requires installing `lyngio` into the import manager from host code.
|
||||
|
||||
240
docs/lyng.io.db.md
Normal file
240
docs/lyng.io.db.md
Normal file
@ -0,0 +1,240 @@
|
||||
### 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,6 +12,7 @@
|
||||
|
||||
#### Included Modules
|
||||
|
||||
- **[lyng.io.db](lyng.io.db.md):** Portable SQL database access. Provides `Database`, `SqlTransaction`, `ResultSet`, and SQLite support through `lyng.io.db.sqlite`.
|
||||
- **[lyng.io.fs](lyng.io.fs.md):** Async filesystem access. Provides the `Path` class for file/directory operations, streaming, and globbing.
|
||||
- **[lyng.io.process](lyng.io.process.md):** External process execution and shell commands. Provides `Process`, `RunningProcess`, and `Platform` information.
|
||||
- **[lyng.io.console](lyng.io.console.md):** Rich console/TTY access. Provides `Console` capability detection, geometry, output, and iterable events.
|
||||
@ -43,6 +44,8 @@ To use `lyngio` modules in your scripts, you must install them into your Lyng sc
|
||||
|
||||
```kotlin
|
||||
import net.sergeych.lyng.EvalSession
|
||||
import net.sergeych.lyng.io.db.createDbModule
|
||||
import net.sergeych.lyng.io.db.sqlite.createSqliteModule
|
||||
import net.sergeych.lyng.io.fs.createFs
|
||||
import net.sergeych.lyng.io.process.createProcessModule
|
||||
import net.sergeych.lyng.io.console.createConsoleModule
|
||||
@ -61,6 +64,8 @@ suspend fun runMyScript() {
|
||||
val scope = session.getScope()
|
||||
|
||||
// Install modules with policies
|
||||
createDbModule(scope)
|
||||
createSqliteModule(scope)
|
||||
createFs(PermitAllAccessPolicy, scope)
|
||||
createProcessModule(PermitAllProcessAccessPolicy, scope)
|
||||
createConsoleModule(PermitAllConsoleAccessPolicy, scope)
|
||||
@ -70,6 +75,8 @@ suspend fun runMyScript() {
|
||||
|
||||
// Now scripts can import them
|
||||
session.eval("""
|
||||
import lyng.io.db
|
||||
import lyng.io.db.sqlite
|
||||
import lyng.io.fs
|
||||
import lyng.io.process
|
||||
import lyng.io.console
|
||||
@ -77,6 +84,7 @@ suspend fun runMyScript() {
|
||||
import lyng.io.net
|
||||
import lyng.io.ws
|
||||
|
||||
println("SQLite available: " + (openSqlite(":memory:") != null))
|
||||
println("Working dir: " + Path(".").readUtf8())
|
||||
println("OS: " + Platform.details().name)
|
||||
println("TTY: " + Console.isTty())
|
||||
@ -94,6 +102,7 @@ suspend fun runMyScript() {
|
||||
`lyngio` is built with a "Secure by Default" philosophy. Every I/O or process operation is checked against a policy.
|
||||
|
||||
- **Filesystem Security:** Implement `FsAccessPolicy` to restrict access to specific paths or operations (e.g., read-only access to a sandbox directory).
|
||||
- **Database Installation:** Database access is still explicit-capability style. The host must install `lyng.io.db` and at least one provider such as `lyng.io.db.sqlite`; otherwise scripts cannot open databases.
|
||||
- **Process Security:** Implement `ProcessAccessPolicy` to restrict which executables can be run or to disable shell execution entirely.
|
||||
- **Console Security:** Implement `ConsoleAccessPolicy` to control output writes, event reads, and raw mode switching.
|
||||
- **HTTP Security:** Implement `HttpAccessPolicy` to restrict which requests scripts may send.
|
||||
@ -101,6 +110,7 @@ suspend fun runMyScript() {
|
||||
- **WebSocket Security:** Implement `WsAccessPolicy` to restrict websocket connects and message flow.
|
||||
|
||||
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)
|
||||
- [Process Security Details](lyng.io.process.md#security-policy)
|
||||
- [Console Module Details](lyng.io.console.md)
|
||||
@ -112,16 +122,16 @@ For more details, see the specific module documentation:
|
||||
|
||||
#### Platform Support Overview
|
||||
|
||||
| Platform | lyng.io.fs | lyng.io.process | lyng.io.console | lyng.io.http | lyng.io.ws | lyng.io.net |
|
||||
| :--- | :---: | :---: | :---: | :---: | :---: | :---: |
|
||||
| **JVM** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Linux Native** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Apple Native** | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ |
|
||||
| **Windows Native** | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **Android** | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ |
|
||||
| **JS / Node** | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ |
|
||||
| **JS / Browser** | ✅ (in-memory) | ❌ | ⚠️ | ✅ | ✅ | ❌ |
|
||||
| **Wasm** | ✅ (in-memory) | ❌ | ⚠️ | ❌ | ❌ | ❌ |
|
||||
| Platform | lyng.io.db/sqlite | lyng.io.fs | lyng.io.process | lyng.io.console | lyng.io.http | lyng.io.ws | lyng.io.net |
|
||||
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
|
||||
| **JVM** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Linux Native** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Apple Native** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ |
|
||||
| **Windows Native** | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **Android** | ⚠️ | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ |
|
||||
| **JS / Node** | ❌ | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ |
|
||||
| **JS / Browser** | ❌ | ✅ (in-memory) | ❌ | ⚠️ | ✅ | ✅ | ❌ |
|
||||
| **Wasm** | ❌ | ✅ (in-memory) | ❌ | ⚠️ | ❌ | ❌ | ❌ |
|
||||
|
||||
Legend:
|
||||
- `✅` supported
|
||||
|
||||
179
docs/time.md
179
docs/time.md
@ -1,74 +1,135 @@
|
||||
# Lyng time functions
|
||||
|
||||
Lyng date and time support requires importing `lyng.time` packages. Lyng uses simple yet modern time object models:
|
||||
Lyng date and time support requires importing `lyng.time`. The module provides four related types:
|
||||
|
||||
- `Instant` class for absolute time stamps with platform-dependent resolution.
|
||||
- `DateTime` class for calendar-aware points in time within a specific time zone.
|
||||
- `Duration` to represent amount of time not depending on the calendar (e.g., milliseconds, seconds).
|
||||
- `Instant` for absolute timestamps.
|
||||
- `Date` for calendar dates without time-of-day or timezone.
|
||||
- `DateTime` for calendar-aware points in time in a specific timezone.
|
||||
- `Duration` for absolute elapsed time.
|
||||
|
||||
## Time instant: `Instant`
|
||||
|
||||
Represent some moment of time not depending on the calendar. It is similar to `TIMESTAMP` in SQL or `Instant` in Kotlin.
|
||||
`Instant` represents some moment of time independently of the calendar. It is similar to SQL `TIMESTAMP`
|
||||
or Kotlin `Instant`.
|
||||
|
||||
### Constructing and converting
|
||||
|
||||
import lyng.time
|
||||
|
||||
// default constructor returns time now:
|
||||
val t1 = Instant()
|
||||
|
||||
// constructing from a number is treated as seconds since unix epoch:
|
||||
val t2 = Instant(1704110400) // 2024-01-01T12:00:00Z
|
||||
|
||||
// from RFC3339 string:
|
||||
val t2 = Instant(1704110400)
|
||||
val t3 = Instant("2024-01-01T12:00:00.123456Z")
|
||||
|
||||
// truncation:
|
||||
val t4 = t3.truncateToMinute
|
||||
assertEquals(t4.toRFC3339(), "2024-01-01T12:00:00Z")
|
||||
val t4 = t3.truncateToMinute()
|
||||
assertEquals("2024-01-01T12:00:00Z", t4.toRFC3339())
|
||||
|
||||
// to localized DateTime (uses system default TZ if not specified):
|
||||
val dt = t3.toDateTime("+02:00")
|
||||
assertEquals(dt.hour, 14)
|
||||
assertEquals(14, dt.hour)
|
||||
|
||||
val d = t3.toDate("Z")
|
||||
assertEquals(Date(2024, 1, 1), d)
|
||||
|
||||
### Instant members
|
||||
|
||||
| member | description |
|
||||
|--------------------------------|---------------------------------------------------------|
|
||||
| epochSeconds: Real | positive or negative offset in seconds since Unix epoch |
|
||||
| epochWholeSeconds: Int | same, but in _whole seconds_. Slightly faster |
|
||||
| nanosecondsOfSecond: Int | offset from epochWholeSeconds in nanos |
|
||||
| isDistantFuture: Bool | true if it `Instant.distantFuture` |
|
||||
| isDistantPast: Bool | true if it `Instant.distantPast` |
|
||||
| truncateToMinute: Instant | create new instance truncated to minute |
|
||||
| truncateToSecond: Instant | create new instance truncated to second |
|
||||
| truncateToMillisecond: Instant | truncate new instance to millisecond |
|
||||
| truncateToMicrosecond: Instant | truncate new instance to microsecond |
|
||||
| toRFC3339(): String | format as RFC3339 string (UTC) |
|
||||
| toDateTime(tz?): DateTime | localize to a TimeZone (ID string or offset seconds) |
|
||||
|--------------------------------|------------------------------------------------------|
|
||||
| epochSeconds: Real | offset in seconds since Unix epoch |
|
||||
| epochWholeSeconds: Int | whole seconds since Unix epoch |
|
||||
| nanosecondsOfSecond: Int | nanoseconds within the current second |
|
||||
| isDistantFuture: Bool | true if it is `Instant.distantFuture` |
|
||||
| isDistantPast: Bool | true if it is `Instant.distantPast` |
|
||||
| truncateToMinute(): Instant | truncate to minute precision |
|
||||
| truncateToSecond(): Instant | truncate to second precision |
|
||||
| truncateToMillisecond(): Instant | truncate to millisecond precision |
|
||||
| truncateToMicrosecond(): Instant | truncate to microsecond precision |
|
||||
| toRFC3339(): String | format as RFC3339 string in UTC |
|
||||
| toDateTime(tz?): DateTime | localize to a timezone |
|
||||
| toDate(tz?): Date | convert to a calendar date in a timezone |
|
||||
|
||||
## Calendar time: `DateTime`
|
||||
## Calendar date: `Date`
|
||||
|
||||
`DateTime` represents a point in time in a specific timezone. It provides access to calendar components like year,
|
||||
month, and day.
|
||||
`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`
|
||||
|
||||
`DateTime` represents a point in time in a specific timezone. It provides access to calendar components
|
||||
such as year, month, day, and hour.
|
||||
|
||||
### Constructing
|
||||
|
||||
import lyng.time
|
||||
|
||||
// Current time in system default timezone
|
||||
val now = DateTime.now()
|
||||
|
||||
// Specific timezone
|
||||
val offsetTime = DateTime.now("+02:00")
|
||||
|
||||
// From Instant
|
||||
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")
|
||||
|
||||
// From RFC3339 string
|
||||
val dt3 = DateTime.parseRFC3339("2024-01-01T12:00:00+02:00")
|
||||
|
||||
### DateTime members
|
||||
@ -83,7 +144,9 @@ month, and day.
|
||||
| second: Int | second component (0..59) |
|
||||
| dayOfWeek: Int | day of week (1=Monday, 7=Sunday) |
|
||||
| timeZone: String | timezone ID string |
|
||||
| date: Date | calendar date component |
|
||||
| toInstant(): Instant | convert back to absolute Instant |
|
||||
| toDate(): Date | extract the calendar date in this timezone |
|
||||
| toUTC(): DateTime | shortcut to convert to UTC |
|
||||
| toTimeZone(tz): DateTime | convert to another timezone |
|
||||
| addMonths(n): DateTime | add/subtract months (normalizes end of month) |
|
||||
@ -96,28 +159,27 @@ month, and day.
|
||||
|
||||
`DateTime` handles calendar arithmetic correctly:
|
||||
|
||||
import lyng.time
|
||||
|
||||
val leapDay = Instant("2024-02-29T12:00:00Z").toDateTime("Z")
|
||||
val nextYear = leapDay.addYears(1)
|
||||
assertEquals(nextYear.day, 28) // Feb 29, 2024 -> Feb 28, 2025
|
||||
assertEquals(28, nextYear.day)
|
||||
|
||||
# `Duration` class
|
||||
|
||||
Represent absolute time distance between two `Instant`.
|
||||
`Duration` represents absolute elapsed time between two instants.
|
||||
|
||||
import lyng.time
|
||||
|
||||
val t1 = 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 < 100.millisecond)
|
||||
|
||||
assert(t2 - t1 >= 1.millisecond)
|
||||
assert(t2 - t1 < 100.millisecond)
|
||||
>>> void
|
||||
|
||||
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::
|
||||
Duration values can be created from numbers using extensions on `Int` and `Real`:
|
||||
|
||||
- `n.millisecond`, `n.milliseconds`
|
||||
- `n.second`, `n.seconds`
|
||||
@ -125,10 +187,9 @@ Duration can be converted from numbers, like `5.minutes` and so on. Extensions a
|
||||
- `n.hour`, `n.hours`
|
||||
- `n.day`, `n.days`
|
||||
|
||||
The bigger time units like months or years are calendar-dependent and can't be used with `Duration`.
|
||||
Larger units like months or years are calendar-dependent and are intentionally not part of `Duration`.
|
||||
|
||||
Each duration instance can be converted to number of any of these time units, as `Real` number, if `d` is a `Duration`
|
||||
instance:
|
||||
Each duration instance can be converted to numbers in these units:
|
||||
|
||||
- `d.microseconds`
|
||||
- `d.milliseconds`
|
||||
@ -137,18 +198,16 @@ instance:
|
||||
- `d.hours`
|
||||
- `d.days`
|
||||
|
||||
for example
|
||||
Example:
|
||||
|
||||
import lyng.time
|
||||
assertEquals( 60, 1.minute.seconds )
|
||||
assertEquals( 10.milliseconds, 0.01.seconds )
|
||||
|
||||
assertEquals(60, 1.minute.seconds)
|
||||
assertEquals(10.milliseconds, 0.01.seconds)
|
||||
>>> void
|
||||
|
||||
# Utility functions
|
||||
|
||||
## delay(duration: Duration)
|
||||
|
||||
Suspends current coroutine for at least the specified duration.
|
||||
|
||||
## `delay(duration: Duration)`
|
||||
|
||||
Suspends the current coroutine for at least the specified duration.
|
||||
|
||||
@ -18,8 +18,9 @@ For a programmer-focused migration summary across 1.5.x, see `docs/whats_new_1_5
|
||||
- Descending ranges and loops with `downTo` / `downUntil`
|
||||
- String interpolation with `$name` and `${expr}`
|
||||
- Decimal arithmetic, matrices/vectors, and complex numbers
|
||||
- Calendar `Date` support in `lyng.time`
|
||||
- Immutable collections and opt-in `ObservableList`
|
||||
- Rich `lyngio` modules for console, HTTP, WebSocket, TCP, and UDP
|
||||
- Rich `lyngio` modules for SQLite databases, console, HTTP, WebSocket, TCP, and UDP
|
||||
- CLI improvements including the built-in formatter `lyng fmt`
|
||||
- Better IDE support and stronger docs around the released feature set
|
||||
|
||||
@ -579,7 +580,7 @@ ws.close()
|
||||
|
||||
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.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.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).
|
||||
|
||||
### CLI: Formatting Command
|
||||
A new `fmt` subcommand has been added to the Lyng CLI.
|
||||
|
||||
@ -40,6 +40,8 @@ import net.sergeych.lyng.ScriptError
|
||||
import net.sergeych.lyng.Source
|
||||
import net.sergeych.lyng.asFacade
|
||||
import net.sergeych.lyng.io.console.createConsoleModule
|
||||
import net.sergeych.lyng.io.db.createDbModule
|
||||
import net.sergeych.lyng.io.db.sqlite.createSqliteModule
|
||||
import net.sergeych.lyng.io.fs.createFs
|
||||
import net.sergeych.lyng.io.http.createHttpModule
|
||||
import net.sergeych.lyng.io.net.createNetModule
|
||||
@ -138,6 +140,7 @@ private val baseCliImportManagerDefer = globalDefer {
|
||||
private fun ImportManager.invalidateCliModuleCaches() {
|
||||
invalidatePackageCache("lyng.io.fs")
|
||||
invalidatePackageCache("lyng.io.console")
|
||||
invalidatePackageCache("lyng.io.db.sqlite")
|
||||
invalidatePackageCache("lyng.io.http")
|
||||
invalidatePackageCache("lyng.io.ws")
|
||||
invalidatePackageCache("lyng.io.net")
|
||||
@ -225,6 +228,8 @@ private fun installCliModules(manager: ImportManager) {
|
||||
// Scripts still need to import the modules they use explicitly.
|
||||
createFs(PermitAllAccessPolicy, manager)
|
||||
createConsoleModule(PermitAllConsoleAccessPolicy, manager)
|
||||
createDbModule(manager)
|
||||
createSqliteModule(manager)
|
||||
createHttpModule(PermitAllHttpAccessPolicy, manager)
|
||||
createWsModule(PermitAllWsAccessPolicy, manager)
|
||||
createNetModule(PermitAllNetAccessPolicy, manager)
|
||||
|
||||
@ -5003,9 +5003,16 @@ class Compiler(
|
||||
is QualifiedThisRef -> resolveClassByName(ref.typeName)
|
||||
is StatementRef -> (ref.statement as? ExpressionStatement)?.let { resolveReceiverClassForMember(it.ref) }
|
||||
is MethodCallRef -> inferMethodCallReturnClass(ref)
|
||||
is ImplicitThisMethodCallRef -> inferMethodCallReturnClass(ref.methodName())
|
||||
is ThisMethodSlotCallRef -> inferMethodCallReturnClass(ref.methodName())
|
||||
is QualifiedThisMethodSlotCallRef -> inferMethodCallReturnClass(ref.methodName())
|
||||
is ImplicitThisMethodCallRef -> {
|
||||
val typeName = ref.preferredThisTypeName() ?: currentImplicitThisTypeName()
|
||||
inferMethodCallReturnClass(typeName?.let { resolveClassByName(it) }, 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 BinaryOpRef -> inferBinaryOpReturnClass(ref)
|
||||
is FieldRef -> {
|
||||
@ -5127,6 +5134,8 @@ class Compiler(
|
||||
leftClass == ObjInstant.type && rightClass == ObjDuration.type -> ObjInstant.type
|
||||
leftClass == ObjDuration.type && rightClass == ObjInstant.type && ref.op == BinOp.PLUS -> ObjInstant.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)) &&
|
||||
(rightClass == ObjBuffer.type || rightClass.allParentsSet.contains(ObjBuffer.type)) &&
|
||||
ref.op == BinOp.PLUS -> ObjBuffer.type
|
||||
@ -5198,7 +5207,37 @@ class Compiler(
|
||||
if (receiverClass != null && isClassScopeCallableMember(receiverClass.className, ref.name)) {
|
||||
resolveClassByName("${receiverClass.className}.${ref.name}")?.let { return it }
|
||||
}
|
||||
return inferMethodCallReturnClass(ref.name)
|
||||
return inferMethodCallReturnClass(receiverClass, 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? {
|
||||
@ -5423,13 +5462,13 @@ class Compiler(
|
||||
"truncateToSecond",
|
||||
"truncateToMinute",
|
||||
"truncateToMillisecond" -> ObjInstant.type
|
||||
"toDateTime",
|
||||
"today",
|
||||
"parseIso" -> ObjDate.type
|
||||
"daysUntil",
|
||||
"daysSince" -> ObjInt.type
|
||||
"toTimeZone",
|
||||
"toUTC",
|
||||
"parseRFC3339",
|
||||
"addYears",
|
||||
"addMonths",
|
||||
"addDays",
|
||||
"addHours",
|
||||
"addMinutes",
|
||||
"addSeconds" -> ObjDateTime.type
|
||||
@ -5486,6 +5525,20 @@ class Compiler(
|
||||
if (targetClass == ObjInstant.type && (name == "distantFuture" || name == "distantPast")) {
|
||||
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(
|
||||
"truncateToMinute",
|
||||
"truncateToSecond",
|
||||
@ -5538,6 +5591,7 @@ class Compiler(
|
||||
}
|
||||
if (targetClass == ObjDateTime.type) {
|
||||
return when (name) {
|
||||
"date" -> ObjDate.type
|
||||
"year",
|
||||
"month",
|
||||
"day",
|
||||
@ -9677,6 +9731,7 @@ class Compiler(
|
||||
"ChangeRejectionException" -> ObjChangeRejectionExceptionClass
|
||||
"Exception" -> ObjException.Root
|
||||
"Instant" -> ObjInstant.type
|
||||
"Date" -> ObjDate.type
|
||||
"DateTime" -> ObjDateTime.type
|
||||
"Duration" -> ObjDuration.type
|
||||
"Buffer" -> ObjBuffer.type
|
||||
|
||||
@ -952,6 +952,12 @@ class Script(
|
||||
doc = "Point in time (epoch-based).",
|
||||
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(
|
||||
name = "DateTime",
|
||||
value = ObjDateTime.type,
|
||||
|
||||
@ -1171,14 +1171,16 @@ class BytecodeCompiler(
|
||||
ObjMap.type,
|
||||
ObjBuffer.type,
|
||||
ObjInstant.type,
|
||||
ObjDateTime.type
|
||||
ObjDateTime.type,
|
||||
ObjDate.type
|
||||
)
|
||||
BinOp.MINUS -> receiverClass in setOf(
|
||||
ObjInt.type,
|
||||
ObjReal.type,
|
||||
ObjSet.type,
|
||||
ObjInstant.type,
|
||||
ObjDateTime.type
|
||||
ObjDateTime.type,
|
||||
ObjDate.type
|
||||
)
|
||||
BinOp.STAR -> receiverClass in setOf(ObjInt.type, ObjReal.type, ObjString.type)
|
||||
BinOp.SLASH, BinOp.PERCENT -> receiverClass in setOf(ObjInt.type, ObjReal.type)
|
||||
@ -7290,7 +7292,7 @@ class BytecodeCompiler(
|
||||
if (targetClass == ObjString.type && ref.name == "re" && ref.args.isEmpty() && !ref.isOptional) {
|
||||
ObjRegex.type
|
||||
} else {
|
||||
inferMethodCallReturnClass(ref.name)
|
||||
inferMethodCallReturnClass(targetClass, ref.name)
|
||||
}
|
||||
}
|
||||
is CallRef -> inferCallReturnClass(ref)
|
||||
@ -7463,7 +7465,7 @@ class BytecodeCompiler(
|
||||
if (targetClass == ObjString.type && ref.name == "re" && ref.args.isEmpty() && !ref.isOptional) {
|
||||
ObjRegex.type
|
||||
} else {
|
||||
inferMethodCallReturnClass(ref.name)
|
||||
inferMethodCallReturnClass(targetClass, ref.name)
|
||||
}
|
||||
}
|
||||
is CallRef -> inferCallReturnClass(ref)
|
||||
@ -7546,6 +7548,7 @@ class BytecodeCompiler(
|
||||
"ObservableList" -> ObjObservableList.type
|
||||
"ChangeRejectionException" -> ObjChangeRejectionExceptionClass
|
||||
"Instant" -> ObjInstant.type
|
||||
"Date" -> ObjDate.type
|
||||
"DateTime" -> ObjDateTime.type
|
||||
"Duration" -> ObjDuration.type
|
||||
"Exception" -> ObjException.Root
|
||||
@ -7584,6 +7587,36 @@ 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) {
|
||||
"map",
|
||||
"mapNotNull",
|
||||
@ -7616,13 +7649,13 @@ class BytecodeCompiler(
|
||||
"truncateToSecond",
|
||||
"truncateToMinute",
|
||||
"truncateToMillisecond" -> ObjInstant.type
|
||||
"toDateTime",
|
||||
"today",
|
||||
"parseIso" -> ObjDate.type
|
||||
"daysUntil",
|
||||
"daysSince" -> ObjInt.type
|
||||
"toTimeZone",
|
||||
"toUTC",
|
||||
"parseRFC3339",
|
||||
"addYears",
|
||||
"addMonths",
|
||||
"addDays",
|
||||
"addHours",
|
||||
"addMinutes",
|
||||
"addSeconds" -> ObjDateTime.type
|
||||
@ -7667,6 +7700,20 @@ class BytecodeCompiler(
|
||||
if (targetClass == ObjInstant.type && (name == "distantFuture" || name == "distantPast")) {
|
||||
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") {
|
||||
return ObjRegex.type
|
||||
}
|
||||
@ -7710,6 +7757,7 @@ class BytecodeCompiler(
|
||||
}
|
||||
if (targetClass == ObjDateTime.type) {
|
||||
return when (name) {
|
||||
"date" -> ObjDate.type
|
||||
"year",
|
||||
"month",
|
||||
"day",
|
||||
|
||||
@ -64,11 +64,13 @@ object StdlibDocsBootstrap {
|
||||
val _buffer = net.sergeych.lyng.obj.ObjBuffer.type
|
||||
|
||||
// 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, DateTime, Duration)
|
||||
// This enables completion/quick docs for symbols imported via `import lyng.time` (e.g., Instant, Date, DateTime, Duration)
|
||||
try {
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val _instant = net.sergeych.lyng.obj.ObjInstant.type
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val _date = net.sergeych.lyng.obj.ObjDate.type
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val _datetime = net.sergeych.lyng.obj.ObjDateTime.type
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val _duration = net.sergeych.lyng.obj.ObjDuration.type
|
||||
|
||||
303
lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjDate.kt
Normal file
303
lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjDate.kt
Normal file
@ -0,0 +1,303 @@
|
||||
/*
|
||||
* 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,10 +202,15 @@ class ObjDateTime(val instant: Instant, val timeZone: TimeZone) : Obj() {
|
||||
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",
|
||||
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") {
|
||||
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") {
|
||||
thisAs<ObjDateTime>().instant.epochSeconds.toObj()
|
||||
}
|
||||
@ -223,11 +228,7 @@ 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.",
|
||||
params = listOf(net.sergeych.lyng.miniast.ParamDoc("tz", type = type("lyng.Any"))),
|
||||
returns = type("lyng.DateTime"), moduleName = "lyng.time") {
|
||||
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")
|
||||
}
|
||||
val tz = parseTimeZoneArg(this, args.list.getOrNull(0), TimeZone.currentSystemDefault())
|
||||
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") {
|
||||
|
||||
@ -268,14 +268,19 @@ class ObjInstant(val instant: Instant,val truncateMode: LynonSettings.InstantTru
|
||||
returns = type("lyng.DateTime"),
|
||||
moduleName = "lyng.time"
|
||||
) {
|
||||
val tz = when (val a = args.list.getOrNull(0)) {
|
||||
null -> TimeZone.currentSystemDefault()
|
||||
is ObjString -> TimeZone.of(a.value)
|
||||
is ObjInt -> UtcOffset(seconds = a.value.toInt()).asTimeZone()
|
||||
else -> raiseIllegalArgument("invalid timezone: $a")
|
||||
}
|
||||
val tz = parseTimeZoneArg(this, args.list.getOrNull(0), TimeZone.currentSystemDefault())
|
||||
ObjDateTime(thisAs<ObjInstant>().instant, tz)
|
||||
}
|
||||
addFnDoc(
|
||||
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
|
||||
|
||||
|
||||
@ -36,7 +36,8 @@ enum class LynonType(val objClass: ObjClass, val defaultFrequency: Int = 1) {
|
||||
Buffer(ObjBuffer.type, 50),
|
||||
Instant(ObjInstant.type, 30),
|
||||
Duration(ObjDuration.type),
|
||||
Other(Obj.rootObjectType, 60);
|
||||
Other(Obj.rootObjectType, 60),
|
||||
Date(ObjDate.type, 20);
|
||||
|
||||
fun generalizeTo(other: LynonType): LynonType? {
|
||||
if (this == other) return this
|
||||
|
||||
@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@ -3479,6 +3480,68 @@ 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
|
||||
fun testDoubleImports() = runTest {
|
||||
val s = Scope.new()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user