From b3be9082425d1acdae2118056a63db331ad59106 Mon Sep 17 00:00:00 2001 From: sergeych Date: Wed, 15 Apr 2026 23:20:23 +0300 Subject: [PATCH] Add Date type and database release docs --- CHANGELOG.md | 21 ++ README.md | 2 + docs/ai_stdlib_reference.md | 2 +- docs/lyng.io.db.md | 240 ++++++++++++++ docs/lyngio.md | 30 +- docs/time.md | 185 +++++++---- docs/whats_new.md | 5 +- lyng/src/commonMain/kotlin/Common.kt | 5 + .../kotlin/net/sergeych/lyng/Compiler.kt | 71 +++- .../kotlin/net/sergeych/lyng/Script.kt | 6 + .../lyng/bytecode/BytecodeCompiler.kt | 64 +++- .../lyng/miniast/StdlibDocsBootstrap.kt | 4 +- .../kotlin/net/sergeych/lyng/obj/ObjDate.kt | 303 ++++++++++++++++++ .../net/sergeych/lyng/obj/ObjDateTime.kt | 11 +- .../net/sergeych/lyng/obj/ObjInstant.kt | 17 +- .../kotlin/net/sergeych/lynon/LynonEncoder.kt | 5 +- lynglib/src/commonTest/kotlin/ScriptTest.kt | 63 ++++ proposals/db_interface.md | 0 proposals/lyngdb.lyng | 0 19 files changed, 928 insertions(+), 106 deletions(-) create mode 100644 docs/lyng.io.db.md create mode 100644 lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjDate.kt delete mode 100644 proposals/db_interface.md delete mode 100644 proposals/lyngdb.lyng diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ca4ac8..7f0ff36 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/README.md b/README.md index d30e94b..4eaca2b 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/ai_stdlib_reference.md b/docs/ai_stdlib_reference.md index 5abf619..8508336 100644 --- a/docs/ai_stdlib_reference.md +++ b/docs/ai_stdlib_reference.md @@ -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. diff --git a/docs/lyng.io.db.md b/docs/lyng.io.db.md new file mode 100644 index 0000000..8a6778d --- /dev/null +++ b/docs/lyng.io.db.md @@ -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). diff --git a/docs/lyngio.md b/docs/lyngio.md index 9cb19cb..dc35936 100644 --- a/docs/lyngio.md +++ b/docs/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 diff --git a/docs/time.md b/docs/time.md index aba9860..41a922e 100644 --- a/docs/time.md +++ b/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") - - // to localized DateTime (uses system default TZ if not specified): + + val t4 = t3.truncateToMinute() + assertEquals("2024-01-01T12:00:00Z", t4.toRFC3339()) + 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) | +| member | description | +|--------------------------------|------------------------------------------------------| +| 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. diff --git a/docs/whats_new.md b/docs/whats_new.md index 8c72d5c..45a4e1c 100644 --- a/docs/whats_new.md +++ b/docs/whats_new.md @@ -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. diff --git a/lyng/src/commonMain/kotlin/Common.kt b/lyng/src/commonMain/kotlin/Common.kt index 9014806..61ecf51 100644 --- a/lyng/src/commonMain/kotlin/Common.kt +++ b/lyng/src/commonMain/kotlin/Common.kt @@ -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) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index 05ed355..7ab2011 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -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 diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt index 1e1846f..8f6ffa2 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt @@ -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, diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt index 72415d5..705f64f 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt @@ -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", diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/StdlibDocsBootstrap.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/StdlibDocsBootstrap.kt index 313299b..b84408d 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/StdlibDocsBootstrap.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/StdlibDocsBootstrap.kt @@ -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 diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjDate.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjDate.kt new file mode 100644 index 0000000..5d58100 --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjDate.kt @@ -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().date.year.toObj() }) + addPropertyDoc("month", "The month component (1..12).", type("lyng.Int"), moduleName = "lyng.time", + getter = { thisAs().date.month.number.toObj() }) + addPropertyDoc("dayOfMonth", "The day of month component.", type("lyng.Int"), moduleName = "lyng.time", + getter = { thisAs().date.day.toObj() }) + addPropertyDoc("day", "Alias to dayOfMonth.", type("lyng.Int"), moduleName = "lyng.time", + getter = { thisAs().date.day.toObj() }) + addPropertyDoc("dayOfWeek", "The day of week (1=Monday, 7=Sunday).", type("lyng.Int"), moduleName = "lyng.time", + getter = { thisAs().date.dayOfWeek.isoDayNumber.toObj() }) + addPropertyDoc("dayOfYear", "The day of year (1..365/366).", type("lyng.Int"), moduleName = "lyng.time", + getter = { thisAs().date.dayOfYear.toObj() }) + addPropertyDoc("isLeapYear", "Whether this date is in a leap year.", type("lyng.Bool"), moduleName = "lyng.time", + getter = { isLeapYear(thisAs().date.year).toObj() }) + addPropertyDoc("lengthOfMonth", "Number of days in this date's month.", type("lyng.Int"), moduleName = "lyng.time", + getter = { monthLength(thisAs().date.year, thisAs().date.month.number).toObj() }) + addPropertyDoc("lengthOfYear", "Number of days in this date's year.", type("lyng.Int"), moduleName = "lyng.time", + getter = { (if (isLeapYear(thisAs().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().date.toString().toObj() + } + addFnDoc("toSortableString", "Alias to toIsoString.", returns = type("lyng.String"), moduleName = "lyng.time") { + thisAs().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().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().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().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().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().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(0) + daysBetween(thisAs().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(0) + daysBetween(other.date, thisAs().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(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})""") diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjDateTime.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjDateTime.kt index ad553b3..6d4de6a 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjDateTime.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjDateTime.kt @@ -202,10 +202,15 @@ class ObjDateTime(val instant: Instant, val timeZone: TimeZone) : Obj() { getter = { thisAs().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().timeZone.id.toObj() }) + addPropertyDoc("date", "The calendar date component of this DateTime.", type("lyng.Date"), moduleName = "lyng.time", + getter = { ObjDate(toDate(thisAs().instant, thisAs().timeZone)) }) addFnDoc("toInstant", "Convert this localized date time back to an absolute Instant.", returns = type("lyng.Instant"), moduleName = "lyng.time") { ObjInstant(thisAs().instant) } + addFnDoc("toDate", "Return the calendar date component of this DateTime.", returns = type("lyng.Date"), moduleName = "lyng.time") { + ObjDate(toDate(thisAs().instant, thisAs().timeZone)) + } addFnDoc("toEpochSeconds", "Return the number of full seconds since the Unix epoch (UTC).", returns = type("lyng.Int"), moduleName = "lyng.time") { thisAs().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().instant, tz) } addFnDoc("toUTC", "Shortcut to convert this date time to the UTC time zone.", returns = type("lyng.DateTime"), moduleName = "lyng.time") { diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstant.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstant.kt index b2dcb9a..c2f4ebe 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstant.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstant.kt @@ -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().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().instant, tz)) + } // class members diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lynon/LynonEncoder.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lynon/LynonEncoder.kt index 6ad18be..16b601e 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lynon/LynonEncoder.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lynon/LynonEncoder.kt @@ -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 @@ -211,4 +212,4 @@ open class LynonEncoder(val bout: BitOutput, val settings: LynonSettings = Lynon bout.putBit(bit) } -} \ No newline at end of file +} diff --git a/lynglib/src/commonTest/kotlin/ScriptTest.kt b/lynglib/src/commonTest/kotlin/ScriptTest.kt index 5833a15..45f95ec 100644 --- a/lynglib/src/commonTest/kotlin/ScriptTest.kt +++ b/lynglib/src/commonTest/kotlin/ScriptTest.kt @@ -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() + + assertEquals(LocalDate(2026, 4, 15), decoded) + } + @Test fun testDoubleImports() = runTest { val s = Scope.new() diff --git a/proposals/db_interface.md b/proposals/db_interface.md deleted file mode 100644 index e69de29..0000000 diff --git a/proposals/lyngdb.lyng b/proposals/lyngdb.lyng deleted file mode 100644 index e69de29..0000000