Expand Gotlin language and tooling
This commit is contained in:
parent
8f4f858d86
commit
de1262b4cf
41 changed files with 6059 additions and 379 deletions
312
README.md
312
README.md
|
|
@ -14,13 +14,17 @@ This is the practical boundary of the prototype:
|
|||
- `val` and `var`
|
||||
- `Int`, `String`, `Boolean`, `Unit`
|
||||
- function types like `(String) -> Unit`
|
||||
- `if`, `else`, `while`
|
||||
- `if`, `else`, `while`, and `for (item in items)`
|
||||
- function calls
|
||||
- lambdas like `{ x: Int -> println(x) }` and `{ println(it) }`
|
||||
- `class` with primary-constructor fields and methods
|
||||
- `interface` with method signatures
|
||||
- Rust-style algebraic `enum` declarations with payload variants and exhaustive `match`
|
||||
- `println(...)`
|
||||
- arithmetic, comparison, and boolean operators
|
||||
- decimal literals, `defer`, and Go address-of expressions such as `&value`
|
||||
- named external Go struct construction, for example `http.Client(timeout = 3 * time.second)`
|
||||
- top-level embedded resources such as `@embed("assets/*") val assets: embed.FS`
|
||||
|
||||
## Example
|
||||
|
||||
|
|
@ -91,6 +95,85 @@ fun main() {
|
|||
}
|
||||
```
|
||||
|
||||
Rust-style enums:
|
||||
|
||||
```kotlin
|
||||
enum PaymentResult {
|
||||
Accepted(String)
|
||||
Rejected(String)
|
||||
Pending
|
||||
}
|
||||
|
||||
fun describe(result: PaymentResult): String {
|
||||
var description = ""
|
||||
match (result) {
|
||||
PaymentResult::Accepted(id) -> { description = id }
|
||||
PaymentResult::Rejected(reason) -> { description = reason }
|
||||
PaymentResult::Pending -> { description = "pending" }
|
||||
}
|
||||
return description
|
||||
}
|
||||
```
|
||||
|
||||
Enum matches must contain each variant exactly once. Variant payload arity is
|
||||
checked during Gotlin compilation.
|
||||
|
||||
Enums whose variants carry no payload are represented as string-backed values.
|
||||
The exact variant identifier is used for JSON and PostgreSQL text values:
|
||||
|
||||
```kotlin
|
||||
enum PaymentStatus {
|
||||
PENDING_RESERVATION
|
||||
INITIATED
|
||||
}
|
||||
```
|
||||
|
||||
This reads and writes `"PENDING_RESERVATION"` and `"INITIATED"` directly.
|
||||
Payload-carrying enums remain algebraic sum types.
|
||||
|
||||
## Structural mapping
|
||||
|
||||
Compatible classes and enums can be converted with `mapTo<T>()`:
|
||||
|
||||
```kotlin
|
||||
data class AddressEntity(var city: String)
|
||||
data class AddressResponse(var city: String)
|
||||
data class AccountEntity(var id: String, var address: *AddressEntity)
|
||||
data class AccountResponse(var address: *AddressResponse, var id: String)
|
||||
|
||||
val response = account.mapTo<AccountResponse>()
|
||||
```
|
||||
|
||||
When the surrounding expression provides a target type, the type argument is
|
||||
optional:
|
||||
|
||||
```kotlin
|
||||
fun response(account: *AccountEntity): *AccountResponse {
|
||||
return account.mapTo()
|
||||
}
|
||||
|
||||
val response: *AccountResponse = account.mapTo()
|
||||
val envelope = Envelope(account.mapTo())
|
||||
```
|
||||
|
||||
Use explicit `mapTo<T>()` when assigning to an untyped local or when no target
|
||||
type can be inferred.
|
||||
|
||||
Fields are matched by Gotlin name rather than declaration order. Mapping is
|
||||
recursive across nested classes, pointers, nullable values, lists, mutable
|
||||
lists, maps, and enum payloads. Enum variants are matched by name. Extra source
|
||||
fields and extra target enum variants are allowed; every target field and every
|
||||
source enum variant must be compatible.
|
||||
|
||||
Payloadless enums also map recursively to and from `String`. String-to-enum
|
||||
mapping validates the runtime value and panics for an unknown variant string.
|
||||
|
||||
Incompatible mappings fail compilation with a complete path, for example:
|
||||
|
||||
```text
|
||||
cannot map Account.address.zip: String is incompatible with Int
|
||||
```
|
||||
|
||||
```bash
|
||||
go run ./cmd/gotlinc build ./examples/hello.gt
|
||||
./hello
|
||||
|
|
@ -109,6 +192,231 @@ Run directly:
|
|||
go run ./cmd/gotlinc run ./examples/hello.gt
|
||||
```
|
||||
|
||||
## Type-checked SQL queries
|
||||
|
||||
Gotlin recognizes a PostgreSQL SQL DSL at compile time. SQL row mappings must be data classes annotated with `@table`. Fields map from lower-camel Gotlin names to `snake_case` columns by default and can override the SQL name with `@column`. Conflict keys use `@id`; database-generated or defaulted fields use `@generated`.
|
||||
|
||||
```kotlin
|
||||
import time
|
||||
|
||||
@table("accounts")
|
||||
data class AccountRow(
|
||||
@generated @id var id: String,
|
||||
var customerId: String,
|
||||
@column("kind") var accountType: String,
|
||||
var balance: Double,
|
||||
var closedAt: time.Time?
|
||||
)
|
||||
|
||||
fun accountsFor(customerId: String): GotlinSQLQuery {
|
||||
return sql.from<AccountRow>()
|
||||
.where { row -> row.customerId == customerId && row.closedAt == null }
|
||||
.orderByDescending { row -> row.balance }
|
||||
.limit(100)
|
||||
.build()
|
||||
}
|
||||
```
|
||||
|
||||
The compiler emits a Go value with this generated support type:
|
||||
|
||||
```go
|
||||
type GotlinSQLQuery struct {
|
||||
SQL string
|
||||
Args []any
|
||||
}
|
||||
```
|
||||
|
||||
The example selects every field, including `@generated` fields, and produces PostgreSQL `$n` placeholders. Arguments are emitted in SQL traversal order. A literal `limit(100)` is embedded after validation; a typed non-literal `Int` limit uses the next placeholder.
|
||||
|
||||
Nullable types use a `?` suffix, currently including forms such as `String?` and `time.Time?`; generated Go fields use pointers. Comparing a nullable field with `null` lowers to `IS NULL` or `IS NOT NULL`. Ordering comparisons support numeric values and `time.Time`; typed `now()` emits `CURRENT_TIMESTAMP` without an argument:
|
||||
|
||||
```kotlin
|
||||
sql.from<OutboxRow>()
|
||||
.where { it.publishedAt == null && (it.claimedUntil == null || it.claimedUntil < now()) }
|
||||
.orderBy { it.createdAt }
|
||||
.limit(batchSize)
|
||||
.forUpdate()
|
||||
.skipLocked()
|
||||
.build()
|
||||
```
|
||||
|
||||
The canonical select method order is:
|
||||
|
||||
```text
|
||||
sql.from<Row>()
|
||||
[.select { ... }]
|
||||
[.where { ... }]
|
||||
[.orderBy { ... } | .orderByDescending { ... }]
|
||||
[.limit(Int)]
|
||||
[.forUpdate()]
|
||||
[.skipLocked()]
|
||||
.build() | .fetch(pool, ctx) | .single(pool, ctx) | .iterator(pool, ctx)
|
||||
```
|
||||
|
||||
Each optional method may occur at most once. `select` must be first, `skipLocked` requires `forUpdate`, and clauses are emitted as `WHERE`, `ORDER BY`, `LIMIT`, `FOR UPDATE`, `SKIP LOCKED` in PostgreSQL order.
|
||||
|
||||
### Typed projections
|
||||
|
||||
Projection targets are local data classes. The constructor must contain one direct source-row field per target field, in target declaration order, with exact matching types:
|
||||
|
||||
```kotlin
|
||||
data class AccountSummary(var id: String, var balance: Double)
|
||||
|
||||
fun summaries(pool: *pgxpool.Pool, ctx: context.Context): List<*AccountSummary> {
|
||||
return sql.from<AccountRow>()
|
||||
.select { row -> AccountSummary(row.id, row.balance) }
|
||||
.orderBy { it.balance }
|
||||
.fetch(pool, ctx)
|
||||
}
|
||||
```
|
||||
|
||||
The compiler emits `SELECT id, balance`, scans in projection declaration order, and makes `fetch`, `single`, and `iterator` target `AccountSummary` rather than `AccountRow`. The row parameter in later `where` and ordering methods still represents `AccountRow`.
|
||||
|
||||
Select chains can execute directly against a pgx/v5 pool. `fetch(pool, ctx)` returns `List<*AccountRow>` and closes the pgx rows after reading and scanning every result in data-class field declaration order:
|
||||
|
||||
```kotlin
|
||||
import context
|
||||
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
fun accountsFor(
|
||||
pool: *pgxpool.Pool,
|
||||
ctx: context.Context,
|
||||
customerId: String
|
||||
): List<*AccountRow> {
|
||||
return sql.from<AccountRow>()
|
||||
.where { it.customerId == customerId }
|
||||
.orderBy { it.accountType }
|
||||
.fetch(pool, ctx)
|
||||
}
|
||||
```
|
||||
|
||||
`single(pool, ctx)` returns `*AccountRow`. It closes the rows and panics through Gotlin's auto-throw path unless the query produces exactly one row:
|
||||
|
||||
```kotlin
|
||||
fun account(pool: *pgxpool.Pool, ctx: context.Context, id: String): *AccountRow {
|
||||
return sql.from<AccountRow>()
|
||||
.where { it.id == id }
|
||||
.single(pool, ctx)
|
||||
}
|
||||
```
|
||||
|
||||
`iterator(pool, ctx)` streams pgx rows. Call `next()` before each `value()`, always arrange an explicit `close()` (normally with `defer`), and call `err()` after iteration. Assigning the error result invokes the existing auto-throw convention:
|
||||
|
||||
```kotlin
|
||||
fun printAccounts(pool: *pgxpool.Pool, ctx: context.Context) {
|
||||
val rows = sql.from<AccountRow>().iterator(pool, ctx)
|
||||
defer rows.close()
|
||||
|
||||
while (rows.next()) {
|
||||
val account: *AccountRow = rows.value()
|
||||
println(account.customerId)
|
||||
}
|
||||
|
||||
val checked = rows.err()
|
||||
}
|
||||
```
|
||||
|
||||
Query and scan failures panic through `gotlinAutoThrow`. `fetch` and `single` close rows internally; an iterator leaves lifecycle control with the caller, and `value()` panics unless the immediately preceding `next()` succeeded. None of the execution terminals require an intermediate `build()`, `query.sql`, or `query.args` access. `build()` remains available when a query value is needed for manual execution.
|
||||
|
||||
### Inserts and returning
|
||||
|
||||
Inserts omit every `@generated` field. If all fields are generated, the compiler emits `INSERT ... DEFAULT VALUES`. Conflict handling is optional; conflict fields must have `@id`:
|
||||
|
||||
```kotlin
|
||||
sql.insert<AccountRow>(row)
|
||||
.onConflict { it.id }
|
||||
.doNothing()
|
||||
.build()
|
||||
```
|
||||
|
||||
Composite conflict keys use `listOf`, for example `.onConflict { listOf(it.tenantId, it.id) }`.
|
||||
|
||||
Updates use a typed lower-camel `set` form because Gotlin does not currently implement Kotlin callable references (`AccountRow::balance`):
|
||||
|
||||
```kotlin
|
||||
sql.insert<AccountRow>(row)
|
||||
.onConflict { it.id }
|
||||
.doUpdate { excluded -> set(AccountRow.balance, excluded.balance) }
|
||||
.build()
|
||||
```
|
||||
|
||||
Multiple `set(...)` expressions may appear in one `doUpdate` lambda. Update targets must exist and be `var`; excluded fields must exist and have a compatible type.
|
||||
|
||||
An insert can return the full row or a typed projection. Write chains can use `fetch`, `single`, or `iterator` only after `returning`:
|
||||
|
||||
```kotlin
|
||||
sql.insert<AccountRow>(row)
|
||||
.returning { it }
|
||||
.single(pool, ctx)
|
||||
|
||||
sql.insert<AccountRow>(row)
|
||||
.onConflict { it.id }
|
||||
.doNothing()
|
||||
.returning { value -> AccountSummary(value.id, value.balance) }
|
||||
.single(pool, ctx)
|
||||
```
|
||||
|
||||
The exact insert forms are:
|
||||
|
||||
```text
|
||||
sql.insert<Row>(row)
|
||||
[.onConflict { field | listOf(fields...) }.doNothing() | .doUpdate { ... }]
|
||||
[.returning { it | Projection(it.field, ...) }]
|
||||
.build() | returning execution terminal
|
||||
```
|
||||
|
||||
### Updates and deletes
|
||||
|
||||
Typed updates use one `set` lambda. Targets are mutable source-row fields. Values may be typed names/selectors, literals, `null` for nullable targets, `now()` for timestamps, source-row fields, or numeric `+` and `-` expressions:
|
||||
|
||||
```kotlin
|
||||
sql.update<AccountRow>()
|
||||
.set { row ->
|
||||
set(row.balance, row.balance + amount)
|
||||
set(row.closedAt, now())
|
||||
}
|
||||
.where { it.id == accountId }
|
||||
.returning { row -> AccountSummary(row.id, row.balance) }
|
||||
.single(pool, ctx)
|
||||
```
|
||||
|
||||
Deletes support a typed predicate and the same returning forms:
|
||||
|
||||
```kotlin
|
||||
sql.delete<AccountRow>()
|
||||
.where { it.id == accountId }
|
||||
.returning { it }
|
||||
.single(pool, ctx)
|
||||
```
|
||||
|
||||
The exact write forms are:
|
||||
|
||||
```text
|
||||
sql.update<Row>()
|
||||
.set { row -> set(row.field, value); ... }
|
||||
[.where { predicate }]
|
||||
[.returning { it | Projection(it.field, ...) }]
|
||||
.build() | returning execution terminal
|
||||
|
||||
sql.delete<Row>()
|
||||
[.where { predicate }]
|
||||
[.returning { it | Projection(it.field, ...) }]
|
||||
.build() | returning execution terminal
|
||||
```
|
||||
|
||||
`where` is intentionally optional for updates and deletes, so omitting it affects the whole table. There is no write `execute` terminal yet; use `build()` for manual `Exec`, or add `returning` and use a query execution terminal.
|
||||
|
||||
At compile time the DSL checks that the generic row type exists, is a data class with `@table`, has unique mapped columns, and contains every referenced field. It checks known predicate operand types, nullable operations, insert row types, `@id` conflict metadata, update mutability and value compatibility, method cardinality/order, lock dependencies, and projection field counts/types.
|
||||
|
||||
Current SQL limitations:
|
||||
|
||||
- Predicates support `&&`, `||`, `!`, `==`, `!=`, and numeric/timestamp `<`, `<=`, `>`, `>=`. Values must have a compiler-known type; external values become `$n` arguments.
|
||||
- Projections and `returning` do not support scalar results, aliases, computed expressions, aggregates, external structs, or reordered/coerced target types. They accept a full row or direct fields passed to a local data-class constructor.
|
||||
- Standalone update values do not support SQL functions other than `now()`, string expressions, intervals, casts, subqueries, or arbitrary SQL fragments. Upsert `doUpdate` values still come only from the `excluded` row.
|
||||
- Inserts emit one row at a time. Bulk/multi-row inserts are not implemented.
|
||||
- Table and column annotations are validated unquoted SQL identifiers. Joins, aliases, grouping, aggregates, `OFFSET`, lock strengths other than `FOR UPDATE`, and conflict predicates are not implemented.
|
||||
- The compiler does not perform migrations, schema generation, database connections, or schema introspection.
|
||||
|
||||
Language server:
|
||||
|
||||
```bash
|
||||
|
|
@ -126,7 +434,7 @@ npm run build
|
|||
|
||||
## Notes
|
||||
|
||||
- `val` and `var` currently compile to the same local-variable semantics in Go.
|
||||
- `val` is immutable after initialization; `var` can be reassigned.
|
||||
- Type inference is local to declarations without an explicit type.
|
||||
- Top-level declarations currently support functions, classes, and interfaces.
|
||||
- `gotlinc build` produces an executable by default. If `-o` is omitted, the output name is derived from the input file name.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue