Expand typed SQL DSL

This commit is contained in:
pavel 2026-08-28 00:02:32 +02:00
commit 314c8fb207
17 changed files with 1082 additions and 498 deletions

822
README.md
View file

@ -1,115 +1,143 @@
# Gotlin # Gotlin
`Gotlin` is a small Kotlin-like frontend implemented in Go that targets the Go toolchain. Gotlin is a Kotlin-flavored language implemented in Go. It compiles typed
Gotlin source to Go and uses the Go toolchain for binaries, packages, and
This is the practical boundary of the prototype: interop.
- It is a Kotlin-flavored language frontend.
- It targets the Go toolchain by generating valid Go source and building through `go build`.
- It is not a direct integration into Go's internal `cmd/compile` backend APIs.
The compiler keeps source spelling in its syntax AST, then builds lexical
symbols, structural semantic types, resolved expression meanings, and typed
HIR before Go emission. Class reference semantics live in `ClassType`; only
the semantic Type-to-Go mapping turns a class such as `User` into `*User`.
Separately compiled Gotlin packages publish a versioned `.gti.json` interface;
imports load that interface before falling back to `go/types`, preserving class
reference semantics, generics, enums, function signatures, and inferred effects
across package boundaries.
## Supported language slice
- `fun` declarations
- `val` and `var`
- `Int`, `Long`, `String`, `Boolean`, `Unit`
- function types like `(String) -> Unit`
- user generic functions and classes with inferred or explicit type arguments
- `if`, `else`, `while`, and `for (item in items)`
- function calls
- lambdas like `{ x: Int -> println(x) }` and `{ println(it) }`
- Kotlin-style final-expression returns in value lambdas, for example `{ value -> value * 2 }`
- `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`
- explicit nullable types (`Type?`), safe access (`?.`), and non-null assertions (`!!`)
- Gotlin classes are reference types by default; `*` is only needed for external Go pointer types
- 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`
- inferred structured coroutine effects with `runBlocking`, `coroutineScope`, `launch`, `async`, `await`, `delay`, `withTimeout`, `isActive`, and `coroutineContext`
## Example
```kotlin ```kotlin
package demo package main
fun fib(n: Int): Int { fun fibonacci(value: Int): Int {
if (n < 2) { if (value < 2) { return value }
return n return fibonacci(value - 1) + fibonacci(value - 2)
}
return fib(n - 1) + fib(n - 2)
} }
fun main() { fun main() {
println(fib(8)) println(fibonacci(10))
} }
``` ```
Imports from Go packages are supported: ## Compiler pipeline
```kotlin ```text
package demo Lexer
-> parser and syntax AST
import strings -> package resolver and structural TypeRef resolution
-> lexical symbols, type checking, effects, and diagnostics
fun main() { -> typed HIR
println(strings.ToUpper("gotlin")) -> Go emission
} -> Go compiler
``` ```
HTTP server example: Semantic types distinguish named values, Gotlin classes, nullable values, Go
pointers, functions, generics, tuples, and imported Gotlin classes. A Gotlin
class remains `User` throughout semantic analysis; only Type-to-Go lowering
chooses the `*User` representation.
Separately compiled Gotlin packages expose a versioned `.gti.json` interface
containing classes, enums, signatures, generics, and inferred effects. The
compiler and LSP load package interfaces before using `go/types` for ordinary
Go dependencies.
## Functions and values
```kotlin ```kotlin
package demo.web fun greet(name: String): String {
val message = "Hello " + name
import fmt return message
import net.http
fun helloHandler(w: http.ResponseWriter, r: *http.Request) {
fmt.Fprintln(w, "hello from gotlin")
} }
fun main() { fun doubled(value: Int) = value * 2
http.HandleFunc("/", helloHandler)
fmt.Println("serving http://localhost:8080")
http.ListenAndServe(":8080", http.DefaultServeMux)
}
``` ```
Classes and interfaces: `val` is immutable and `var` is mutable:
```kotlin ```kotlin
package demo val accountId = "account-1"
var attempts = 0
attempts += 1
```
Supported control flow includes `if`, `else`, `while`, `for`, `try`, `catch`,
`throw`, exhaustive `match`, and `defer`.
## Classes and interfaces
Gotlin classes are reference-valued by default:
```kotlin
interface Greeter { interface Greeter {
fun greet(name: String): String fun greet(name: String): String
} }
class ConsoleGreeter(val prefix: String) { class PrefixGreeter(val prefix: String): Greeter {
fun greet(name: String): String { fun greet(name: String) = prefix + name
return prefix + name
}
} }
fun main() { fun create(): PrefixGreeter = PrefixGreeter("Hello ")
val greeter: Greeter = ConsoleGreeter("hello, ") ```
println(greeter.greet("gotlin"))
Use `*` only for Go pointer types:
```kotlin
fun handle(request: *http.Request, pool: *pgxpool.Pool) { }
```
Imported Gotlin classes retain reference semantics across package boundaries:
```kotlin
fun publish(lifecycle: platform.Lifecycle) { }
```
## Null safety
Types are non-nullable unless marked with `?`:
```kotlin
fun email(user: User?): String {
if (user == null) { return "missing" }
return user.email
} }
``` ```
Rust-style enums: Safe access and non-null assertions are available:
```kotlin
val city: String? = user?.address?.city
val required: User = optionalUser!!
```
The semantic analyzer smart-casts values after null checks and guard clauses.
## Data classes and JSON
```kotlin
@jsonNaming(camelCase)
data class AccountReply(
var accountId: String,
var availableBalance: Double
)
```
Supported JSON policies are `snakeCase`, `camelCase`, `pascalCase`, and
`kebabCase`.
```kotlin
val body = json.marshal(reply).unwrap()
val decoded = json.decode<AccountReply>(body).unwrap()
```
Resources can be embedded at package scope:
```kotlin
import embed
@embed("static/*") val assets: embed.FS
```
## Enums and match
Enums support payloadless and payload variants:
```kotlin ```kotlin
enum PaymentResult { enum PaymentResult {
@ -117,196 +145,92 @@ enum PaymentResult {
Rejected(String) Rejected(String)
Pending Pending
} }
```
fun describe(result: PaymentResult): String { Matches are exhaustive and may return values:
return match (result) {
PaymentResult.Accepted(id) -> id ```kotlin
PaymentResult.Rejected(reason) -> reason fun description(result: PaymentResult) = match (result) {
PaymentResult.Pending -> "pending" PaymentResult.Accepted(id) -> "accepted " + id
PaymentResult.Rejected(reason) -> "rejected " + reason
PaymentResult.Pending -> "pending"
}
```
Payloadless enums are represented as exact string-backed values, making them
suitable for JSON and PostgreSQL columns.
## Result error handling
```kotlin
fun parse(value: String): Result<Int, Error> = strconv.atoi(value)
fun doubled(value: String): Result<Int, Error> {
val parsed = strconv.atoi(value)?
return Result.Ok(parsed * 2)
}
```
Available operations include `?`, `unwrap()`, `unwrapOr(value)`, explicit
destructuring, and exhaustive `Result` matching. Go `(T, error)` and error-only
returns adapt to `Result` when required by context.
## Generics and lambdas
```kotlin
data class Box<T>(var value: T) {
fun get(): T = value
}
fun identity<T>(value: T): T = value
val inferred = identity(42)
val explicit = identity<String>("value")
val boxed = Box("text")
```
Higher-order functions use Kotlin-style function types and trailing lambdas:
```kotlin
fun transform<T, R>(value: T, block: (T) -> R): R = block(value)
val answer = transform(21) { value ->
value * 2
}
```
The final expression is returned automatically for value lambdas. Unit lambdas
execute their final expression as a statement. Explicit `return` is also
supported.
## Structured concurrency and context
Coroutine effects are inferred from direct and transitive calls:
```kotlin
fun poll() {
while (isActive()) {
receiveEvents()
delay(1000)
}
}
fun main() {
runBlocking {
launch { poll() }
} }
} }
``` ```
Enum matches must contain each variant exactly once. Variant payload arity is `launch` creates a structured Unit child. `async<T>` creates a typed deferred
checked during Gotlin compilation. A match used as an expression also requires child. Scopes wait for children, propagate failures, and cancel siblings.
every arm to return the same type. Block-style statement matches remain
available for side effects.
## User generics Available operations include `runBlocking`, `withContext`, `coroutineScope`,
`launch`, `async`, `await`, `delay`, `withTimeout`, `isActive`, and
`coroutineContext`.
Functions and classes may declare type parameters. Calls infer straightforward HTTP request contexts can establish an ambient scope with an expression-bodied
type bindings from arguments or accept explicit type arguments: function:
```kotlin
data class Box<T>(var value: T) {
fun get(): T { return value }
}
fun identity<T>(value: T): T { return value }
val number = identity(42)
val text = identity<String>("value")
val box = Box("boxed")
```
Type parameters currently use an implicit `Any` constraint. Generic methods
with their own type parameters are intentionally deferred; place parameters on
the enclosing class or a top-level function.
## Null safety
Types are non-nullable by default. Add `?` explicitly when `null` is valid:
```kotlin
fun email(user: User?): String? {
return user?.email
}
```
The compiler rejects `null` in non-nullable arguments, fields, local variables,
and return values. Nullable receivers cannot be dereferenced directly. Gotlin
smart-casts values after `value != null` branches and guard clauses such as
`if (value == null) { return }`. Use `!!` only when an invariant cannot be
expressed through control flow:
```kotlin
val required: User = optionalUser!!
```
## Error handling
Gotlin adapts Go `(T, error)` returns into Rust-style `Result<T, Error>` flows.
An explicit `Result` return or variable type converts the Go return directly:
```kotlin
fun parse(value: String): Result<Int, Error> {
return strconv.atoi(value)
}
fun ping(db: *sql.DB): Result<Unit, Error> {
return db.ping()
}
```
Use `?` to propagate a Go error or chain a Gotlin function returning `Result`.
Explicit panic and fallback operations are available when appropriate:
```kotlin
val required = parse("42").unwrap()
val fallback = parse("invalid").unwrapOr(0)
```
Gotlin never inserts implicit panic wrappers. A Go call returning `(T, error)`
must have an explicit `Result` context or use `?`, explicit `value, error`
destructuring, or `.unwrap()`.
Gotlin-defined `class` and `data class` values are references automatically,
including nested generic types such as `List<User>`. Explicit pointer syntax is
reserved for Go interop, for example `*http.Request` and `*pgxpool.Pool`;
applying `*` to a Gotlin class is a compile error.
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
```
Emit Go source instead:
```bash
go run ./cmd/gotlinc build -src ./examples/hello.gt -o /tmp/hello.go
go run /tmp/hello.go
```
Emit or consume package interfaces with repeatable metadata flags:
```bash
gotlinc build -src -metadata-output platform.gti.json \
-metadata-package example/platform platform.gt -o platform.go
gotlinc build -src -metadata platform.gti.json service.gt -o service.go
```
Run directly:
```bash
go run ./cmd/gotlinc run ./examples/hello.gt
```
## Structured coroutines
Gotlin coroutines use Go goroutines underneath, but expose only structured
scopes. A scope waits for its children, propagates child failures, and cancels
sibling coroutine contexts. The removed `worker`, bare `go`, and channel
`select` forms are not valid Gotlin syntax; use coroutine scopes, `delay`, and
explicit channel `read()`/`send()` operations.
Coroutine effects are inferred through the call graph. Functions that directly
or transitively use coroutine operations receive a hidden scope parameter and
can only be called from an ambient coroutine scope. `runBlocking` establishes a
scope boundary, so neither a `suspend` modifier nor manually threaded context is
needed.
Use `coroutineContext()` only at Go interop boundaries that require a
`context.Context`; it returns the ambient scope context without exposing it in
the Gotlin function signature.
HTTP handlers can establish a request-scoped ambient context with an
expression-bodied function:
```kotlin ```kotlin
fun handle(request: *http.Request) = withContext(request.context()) { fun handle(request: *http.Request) = withContext(request.context()) {
@ -314,301 +238,205 @@ fun handle(request: *http.Request) = withContext(request.context()) {
} }
``` ```
`withContext` is a structured boundary. It waits for children, propagates Go calls whose first parameter is `context.Context` receive the ambient context
failures, and, when nested, combines cancellation from the parent coroutine and automatically when that argument is omitted:
the supplied Go context.
For conventional Go APIs whose first parameter is `context.Context`, Gotlin
normally injects that ambient context automatically when the argument is
omitted:
```kotlin ```kotlin
fun command(): *exec.Cmd { fun command(): *exec.Cmd = exec.commandContext("date")
return exec.commandContext("date")
}
``` ```
The function becomes contextually effectful and lowers to Passing an explicit context suppresses automatic injection.
`exec.CommandContext(gotlinScope.Context(), "date")`. Passing an explicit
context remains supported and suppresses injection, which is important for HTTP ## Collections and channels
request contexts and deliberately detached work.
```kotlin ```kotlin
fun load(): Int { val names = listOf<String>("Ada", "Linus")
delay(10) val scores = mapOf<String, Int>("Ada", 10, "Linus", 8)
return 42 val channel = Channel<String>(1)
}
fun main() { channel.send("ready")
runBlocking { val message = channel.read()
val value = async<Int> { return load() }
launch { println("loading") }
println(value.await())
}
}
``` ```
## Type-checked SQL queries Collection types include `List<T>`, `MutableList<T>`, `Map<K, V>`, and
`MutableMap<K, V>`.
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`. ## Structural mapping
`mapTo` maps compatible classes, enums, collections, and nullable values:
```kotlin ```kotlin
import time fun response(account: AccountEntity): AccountResponse = account.mapTo()
val response = account.mapTo<AccountResponse>()
```
Fields are matched by name and mappings are validated recursively with
path-specific compile errors.
## Typed PostgreSQL DSL
SQL rows are data classes with table metadata:
```kotlin
@table("accounts") @table("accounts")
data class AccountRow( data class AccountRow(
@generated @id var id: String, @generated @id var id: String,
var customerId: String, var customerId: String,
@column("kind") var accountType: String,
var balance: Double, var balance: Double,
var closedAt: time.Time? 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: ### Queries, aliases, joins, groups, and aggregates
```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 ```kotlin
sql.from<OutboxRow>() @table("customers")
.where { it.publishedAt == null && (it.claimedUntil == null || it.claimedUntil < now()) } data class CustomerRow(@id var id: String, var name: String)
.orderBy { it.createdAt }
.limit(batchSize)
.forUpdate()
.skipLocked()
.build()
```
The canonical select method order is: data class AccountSummary(
var customerId: String,
var customerName: String,
var total: Double,
var entries: Long
)
```text fun summaries(minimum: Double): GotlinSQLQuery = sql.from<AccountRow>()
sql.from<Row>() .alias("account")
[.select { ... }] .leftJoin<CustomerRow>("customer") { account, customer ->
[.where { ... }] account.customerId == customer.id
[.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)
.unwrap()
}
```
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 `Result<List<AccountRow>, Error>` and closes 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)
.unwrap()
}
```
`single(pool, ctx)` returns `Result<AccountRow, Error>` and closes the rows. Use
`?` to propagate zero/multiple-row errors or call `.unwrap()` explicitly:
```kotlin
fun account(pool: *pgxpool.Pool, ctx: context.Context, id: String): AccountRow {
return sql.from<AccountRow>()
.where { it.id == id }
.single(pool, ctx)
.unwrap()
}
```
`iterator(pool, ctx)` returns a `Result` around a streaming pgx iterator. Call
`next()` before each `value()`, arrange an explicit `close()`, and inspect
`err()` after iteration:
```kotlin
fun printAccounts(pool: *pgxpool.Pool, ctx: context.Context) {
val rows = sql.from<AccountRow>().iterator(pool, ctx).unwrap()
defer rows.close()
while (rows.next()) {
val account: AccountRow = rows.value()
println(account.customerId)
} }
.select { account, customer ->
val checked = rows.err() AccountSummary(
} account.customerId,
``` customer.name,
sum(account.balance),
Query and scan failures are returned as `Result` errors. `fetch` and `single` count()
close rows internally; an iterator leaves lifecycle control with the caller, )
and `value()` panics only when called without a successful `next()`. None of the }
execution terminals require an intermediate `build()`, `query.sql`, or .where { account, customer -> account.balance > minimum }
`query.args` access. `build()` remains available for manual execution. .groupBy { account, customer -> listOf(account.customerId, customer.name) }
.having { account, customer -> sum(account.balance) > minimum }
### Inserts and returning .orderByDescending { account, customer -> sum(account.balance) }
.limit(100)
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`: .offset(20)
```kotlin
sql.insert<AccountRow>(row)
.onConflict { it.id }
.doNothing()
.build() .build()
``` ```
Composite conflict keys use `listOf`, for example `.onConflict { listOf(it.tenantId, it.id) }`. Join methods include `join`, `leftJoin`, and `rightJoin`. Aggregates include
`count`, `countDistinct`, `sum`, `avg`, `min`, and `max`.
Updates use a typed lower-camel `set` form because Gotlin does not currently implement Kotlin callable references (`AccountRow::balance`): ### Inserts and bulk inserts
```kotlin ```kotlin
sql.insert<AccountRow>(row) val insert = sql.insert<AccountRow>(row).build()
val bulk = sql.insertAll<AccountRow>(rows).build()
```
Bulk inserts create runtime-sized PostgreSQL placeholder lists and reject empty
input.
### Conflicts and updates
```kotlin
val upsert = sql.insert<AccountRow>(row)
.onConflict { it.id } .onConflict { it.id }
.doUpdate { excluded -> set(AccountRow.balance, excluded.balance) } .doUpdate { excluded ->
AccountRow.balance = excluded.balance
}
.build() .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. val update = sql.update<AccountRow>()
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 ->
set(row.balance, row.balance + amount) row.balance = row.balance + amount
set(row.closedAt, now()) row.closedAt = now()
} }
.where { it.id == accountId } .where { it.id == accountId }
.returning { row -> AccountSummary(row.id, row.balance) } .returning { it }
.single(pool, ctx) .build()
``` ```
Deletes support a typed predicate and the same returning forms: Write targets are checked for existence, mutability, nullability, duplicate
assignment, and value compatibility.
### Execution
```kotlin ```kotlin
sql.delete<AccountRow>() val accounts = sql.from<AccountRow>()
.where { it.customerId == customerId }
.fetch(pool, ctx)
.unwrap()
val account = sql.from<AccountRow>()
.where { it.id == accountId } .where { it.id == accountId }
.returning { it }
.single(pool, ctx) .single(pool, ctx)
.unwrap()
``` ```
The exact write forms are: Execution terminals are `fetch`, `single`, and `iterator`. They return typed
`Result` values and use generated row scanners.
```text ## Go interop
sql.update<Row>()
.set { row -> set(row.field, value); ... }
[.where { predicate }]
[.returning { it | Projection(it.field, ...) }]
.build() | returning execution terminal
sql.delete<Row>() Go packages are imported directly:
[.where { predicate }]
[.returning { it | Projection(it.field, ...) }] ```kotlin
.build() | returning execution terminal import http net.http
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
``` ```
`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. Go selectors are written in lower camel case and emitted with exported Go
names. Named arguments construct external Go structs:
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. ```kotlin
val client = http.Client(timeout = 3 * time.second)
```
Current SQL limitations: `go/types` and `go/packages` provide function, method, field, alias, tuple,
variadic, and error-return signatures to semantic analysis and the LSP.
- Predicates support `&&`, `||`, `!`, `==`, `!=`, and numeric/timestamp `<`, `<=`, `>`, `>=`. Values must have a compiler-known type; external values become `$n` arguments. ## Build and run
- 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
go run ./cmd/gotlinc build ./examples/hello.gt
./hello
go run ./cmd/gotlinc run ./examples/hello.gt
```
Emit Go source:
```bash
go run ./cmd/gotlinc build -src ./examples/hello.gt -o /tmp/hello.go
```
Emit and consume package interfaces:
```bash
gotlinc build -src \
-metadata-output platform.gti.json \
-metadata-package example/platform \
platform.gt -o platform.go
gotlinc build -src \
-metadata platform.gti.json \
service.gt -o service.go
```
## Language server and editor
Build the language server:
```bash ```bash
go build -o ./bin/gotlin-lsp ./cmd/gotlin-lsp go build -o ./bin/gotlin-lsp ./cmd/gotlin-lsp
./bin/gotlin-lsp
``` ```
VS Code extension: The VS Code extension under `tools/vscode-gotlin` provides syntax highlighting,
snippets, diagnostics, hover, document symbols, definitions, references, and a
`gopls` bridge for imported Go APIs.
```bash ```bash
cd ./tools/vscode-gotlin cd tools/vscode-gotlin
npm install npm install
npm run build npm run check
``` ```
## Notes
- `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.
- `gotlinc build -src` emits Go source instead of a binary.
- `gotlinc` supports `build` and `run`, and defaults to `build` if no subcommand is given.
- Gotlin source files use the `.gt` extension.
- `gotlin-lsp` provides diagnostics, hover, and go-to-definition over stdio.
- `gotlin-lsp` can optionally use `gopls` for hover and definition on Go-imported symbols.
- the VS Code extension adds syntax highlighting, snippets, and launches the LSP for `.gt` files.

View file

@ -1644,8 +1644,13 @@ var builtinDetails = map[string]string{
"float64": "fun float64(value: Any): Double", "float64": "fun float64(value: Any): Double",
"bool": "fun bool(value: Any): Boolean", "bool": "fun bool(value: Any): Boolean",
"sql": "typed PostgreSQL query DSL", "sql": "typed PostgreSQL query DSL",
"set": "fun set(target: Any, value: Any): Unit",
"now": "fun now(): time.Time", "now": "fun now(): time.Time",
"count": "fun count(): Long",
"countDistinct": "fun countDistinct(value: Any): Long",
"sum": "fun sum(value: Any): Any",
"avg": "fun avg(value: Any): Double",
"min": "fun min(value: Any): Any",
"max": "fun max(value: Any): Any",
"runBlocking": "fun runBlocking(block: () -> Unit): Unit", "runBlocking": "fun runBlocking(block: () -> Unit): Unit",
"withContext": "fun withContext(ctx: context.Context, block: () -> Unit): Unit", "withContext": "fun withContext(ctx: context.Context, block: () -> Unit): Unit",
"coroutineScope": "contextual fun coroutineScope(block: () -> Unit): Unit", "coroutineScope": "contextual fun coroutineScope(block: () -> Unit): Unit",

View file

@ -92,7 +92,7 @@ fun main() {
t.Fatalf("unexpected builtin diagnostic: %+v", diagnostic) t.Fatalf("unexpected builtin diagnostic: %+v", diagnostic)
} }
} }
for _, name := range []string{"ByteSlice", "append", "keys", "goAssert", "len", "sql", "set", "now"} { for _, name := range []string{"ByteSlice", "append", "keys", "goAssert", "len", "sql", "count", "sum", "avg", "now"} {
if !isBuiltin(name) { if !isBuiltin(name) {
t.Fatalf("%s is not registered as builtin", name) t.Fatalf("%s is not registered as builtin", name)
} }

24
examples/sql_expanded.gt Normal file
View file

@ -0,0 +1,24 @@
package main
@table("accounts")
data class AccountRow(@id var id: String, var customerId: String, var balance: Double)
@table("customers")
data class CustomerRow(@id var id: String, var name: String)
data class AccountSummary(var customerId: String, var customerName: String, var total: Double, var entries: Long)
fun summaries(): GotlinSQLQuery = sql.from<AccountRow>()
.alias("account")
.leftJoin<CustomerRow>("customer") { account, customer -> account.customerId == customer.id }
.select { account, customer -> AccountSummary(account.customerId, customer.name, sum(account.balance), count()) }
.groupBy { account, customer -> listOf(account.customerId, customer.name) }
.offset(20)
.build()
fun bulk(rows: List<AccountRow>): GotlinSQLQuery = sql.insertAll<AccountRow>(rows).build()
fun main() {
println(summaries().sql)
println(bulk(listOf(AccountRow("a-1", "c-1", 10.0))).sql)
}

View file

@ -137,6 +137,13 @@ type MultiAssignStmt struct {
func (MultiAssignStmt) stmtNode() {} func (MultiAssignStmt) stmtNode() {}
type FieldAssignStmt struct {
Target SelectorExpr
Value Expr
}
func (FieldAssignStmt) stmtNode() {}
type ReturnStmt struct { type ReturnStmt struct {
Value Expr Value Expr
} }

View file

@ -81,6 +81,9 @@ func collectFunctionEffects(statements []Stmt, node *effectNode) {
collectExpressionEffects(value.Value, node) collectExpressionEffects(value.Value, node)
case MultiAssignStmt: case MultiAssignStmt:
collectExpressionEffects(value.Value, node) collectExpressionEffects(value.Value, node)
case FieldAssignStmt:
collectExpressionEffects(value.Target, node)
collectExpressionEffects(value.Value, node)
case ReturnStmt: case ReturnStmt:
if value.Value != nil { if value.Value != nil {
collectExpressionEffects(value.Value, node) collectExpressionEffects(value.Value, node)

View file

@ -53,6 +53,7 @@ type goGenerator struct {
needsTime bool needsTime bool
needsJSONDecode bool needsJSONDecode bool
needsCoroutines bool needsCoroutines bool
needsSQLBulk bool
sqlContextAlias string sqlContextAlias string
sqlPGXAlias string sqlPGXAlias string
currentFunc FunctionDecl currentFunc FunctionDecl
@ -67,6 +68,7 @@ type goGenerator struct {
func (g *goGenerator) program(program *Program, packageOverride string) error { func (g *goGenerator) program(program *Program, packageOverride string) error {
containsSQL := programContainsSQL(program) containsSQL := programContainsSQL(program)
containsSQLExecution := programContainsSQLExecution(program) containsSQLExecution := programContainsSQLExecution(program)
g.needsSQLBulk = programContainsSQLBulk(program)
g.needsCoroutines = programUsesCoroutines(program) g.needsCoroutines = programUsesCoroutines(program)
if g.needsCoroutines { if g.needsCoroutines {
g.needsTime = true g.needsTime = true
@ -145,6 +147,10 @@ func (g *goGenerator) program(program *Program, packageOverride string) error {
} }
} }
runtimeImports := map[string]string{} runtimeImports := map[string]string{}
if g.needsSQLBulk {
runtimeImports["strings"] = "strings"
runtimeImports["strconv"] = "strconv"
}
if containsSQLExecution { if containsSQLExecution {
runtimeImports["context"] = g.sqlContextAlias runtimeImports["context"] = g.sqlContextAlias
runtimeImports["github.com/jackc/pgx/v5"] = g.sqlPGXAlias runtimeImports["github.com/jackc/pgx/v5"] = g.sqlPGXAlias
@ -607,6 +613,16 @@ func (g *goGenerator) stmt(stmt Stmt, tail []Stmt) error {
names = append(names, g.assignTarget(name)) names = append(names, g.assignTarget(name))
} }
g.line(fmt.Sprintf("%s = %s", strings.Join(names, ", "), value)) g.line(fmt.Sprintf("%s = %s", strings.Join(names, ", "), value))
case FieldAssignStmt:
target, err := g.expr(s.Target, "")
if err != nil {
return err
}
value, err := g.expr(s.Value, g.exprType(s.Target))
if err != nil {
return err
}
g.line(target + " = " + value)
case ReturnStmt: case ReturnStmt:
if s.Value == nil { if s.Value == nil {
g.line("return") g.line("return")
@ -1528,6 +1544,28 @@ func (g *goGenerator) emitSQLSupport(program *Program, execution bool) {
g.line("Args []any") g.line("Args []any")
g.indentLevel-- g.indentLevel--
g.line("}") g.line("}")
if g.needsSQLBulk {
g.line("")
g.line("func gotlinSQLBulkInsert[T any](rows []*T, table string, columns []string, values func(*T) []any) GotlinSQLQuery {")
g.indentLevel++
g.line("if len(rows) == 0 { panic(\"bulk insert requires at least one row\") }")
g.line("var query strings.Builder")
g.line("query.WriteString(\"INSERT INTO \" + table + \" (\" + strings.Join(columns, \", \") + \") VALUES \")")
g.line("args := make([]any, 0, len(rows)*len(columns))")
g.line("placeholder := 1")
g.line("for rowIndex, row := range rows {")
g.indentLevel++
g.line("if rowIndex > 0 { query.WriteString(\", \") }")
g.line("query.WriteString(\"(\")")
g.line("for columnIndex := range columns { if columnIndex > 0 { query.WriteString(\", \") }; query.WriteString(\"$\" + strconv.Itoa(placeholder)); placeholder++ }")
g.line("query.WriteString(\")\")")
g.line("args = append(args, values(row)...)")
g.indentLevel--
g.line("}")
g.line("return GotlinSQLQuery{SQL: query.String(), Args: args}")
g.indentLevel--
g.line("}")
}
if !execution { if !execution {
return return
} }

View file

@ -406,6 +406,11 @@ func (resolver *semanticResolver) resolveStmts(stmts []Stmt, scope *Scope, class
} }
value.Value, _ = resolver.resolveExpr(value.Value, scope, class, UnknownType{}) value.Value, _ = resolver.resolveExpr(value.Value, scope, class, UnknownType{})
stmts[index] = value stmts[index] = value
case FieldAssignStmt:
target, targetType := resolver.resolveExpr(value.Target, scope, class, UnknownType{})
value.Target = target.(SelectorExpr)
value.Value, _ = resolver.resolveExpr(value.Value, scope, class, targetType)
stmts[index] = value
case ReturnStmt: case ReturnStmt:
if value.Value != nil { if value.Value != nil {
value.Value, _ = resolver.resolveExpr(value.Value, scope, class, returnType) value.Value, _ = resolver.resolveExpr(value.Value, scope, class, returnType)
@ -932,7 +937,8 @@ var semanticBuiltins = map[string]bool{
"append": true, "keys": true, "goAssert": true, "len": true, "cap": true, "append": true, "keys": true, "goAssert": true, "len": true, "cap": true,
"make": true, "new": true, "copy": true, "delete": true, "close": true, "make": true, "new": true, "copy": true, "delete": true, "close": true,
"panic": true, "recover": true, "string": true, "int": true, "float64": true, "bool": true, "panic": true, "recover": true, "string": true, "int": true, "float64": true, "bool": true,
"sql": true, "set": true, "now": true, "Result": true, "ByteSlice": true, "sql": true, "now": true, "Result": true, "ByteSlice": true,
"count": true, "countDistinct": true, "sum": true, "avg": true, "min": true, "max": true,
"runBlocking": true, "withContext": true, "coroutineScope": true, "launch": true, "async": true, "runBlocking": true, "withContext": true, "coroutineScope": true, "launch": true, "async": true,
"delay": true, "withTimeout": true, "isActive": true, "coroutineContext": true, "delay": true, "withTimeout": true, "isActive": true, "coroutineContext": true,
"continue": true, "break": true, "continue": true, "break": true,

View file

@ -698,6 +698,17 @@ func (p *parser) parseStmt() (Stmt, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if p.match(tokenAssign) {
target, ok := expr.(SelectorExpr)
if !ok {
return nil, fmt.Errorf("assignment target must be a variable or field selector")
}
value, err := p.parseExpr(0)
if err != nil {
return nil, err
}
return FieldAssignStmt{Target: target, Value: value}, nil
}
return ExprStmt{Value: expr}, nil return ExprStmt{Value: expr}, nil
} }
} }

View file

@ -89,6 +89,13 @@ func (checker *mutabilityChecker) checkStmts(statements []Stmt) error {
if err := checker.checkExpr(value.Value); err != nil { if err := checker.checkExpr(value.Value); err != nil {
return err return err
} }
case FieldAssignStmt:
if err := checker.checkExpr(value.Target); err != nil {
return err
}
if err := checker.checkExpr(value.Value); err != nil {
return err
}
case ReturnStmt: case ReturnStmt:
if value.Value != nil { if value.Value != nil {
if err := checker.checkExpr(value.Value); err != nil { if err := checker.checkExpr(value.Value); err != nil {

View file

@ -17,6 +17,11 @@ type sqlLowered struct {
hasResult bool hasResult bool
} }
type sqlSourceRef struct {
class ClassDecl
alias string
}
func splitSQLChain(expr Expr) (CallExpr, string, []sqlCallStep, bool) { func splitSQLChain(expr Expr) (CallExpr, string, []sqlCallStep, bool) {
var reversed []sqlCallStep var reversed []sqlCallStep
current := expr current := expr
@ -31,7 +36,7 @@ func splitSQLChain(expr Expr) (CallExpr, string, []sqlCallStep, bool) {
} }
if receiver, ok := selector.Receiver.(IdentExpr); ok && receiver.Name == "sql" { if receiver, ok := selector.Receiver.(IdentExpr); ok && receiver.Name == "sql" {
switch selector.Name { switch selector.Name {
case "from", "insert", "update", "delete": case "from", "insert", "insertAll", "update", "delete":
default: default:
return CallExpr{}, "", nil, false return CallExpr{}, "", nil, false
} }
@ -78,9 +83,11 @@ func (g *goGenerator) lowerSQLQuery(expr Expr) (string, bool, error) {
var err error var err error
switch operation { switch operation {
case "from": case "from":
lowered, err = g.lowerSQLSelect(root, steps) lowered, err = g.lowerSQLSelectExpanded(root, steps)
case "insert": case "insert":
lowered, err = g.lowerSQLInsert(root, steps) lowered, err = g.lowerSQLInsert(root, steps)
case "insertAll":
lowered, err = g.lowerSQLBulkInsert(root, steps)
case "update": case "update":
lowered, err = g.lowerSQLUpdate(root, steps) lowered, err = g.lowerSQLUpdate(root, steps)
case "delete": case "delete":
@ -188,7 +195,7 @@ func sqlProjectionType(call CallExpr, rowType string) (string, bool) {
} }
rowName := "it" rowName := "it"
if !lambda.ImplicitIt { if !lambda.ImplicitIt {
if len(lambda.Params) != 1 { if len(lambda.Params) == 0 {
return "", false return "", false
} }
rowName = lambda.Params[0].Name rowName = lambda.Params[0].Name
@ -337,6 +344,170 @@ func (g *goGenerator) lowerSQLSelect(root CallExpr, steps []sqlCallStep) (sqlLow
return sqlLowered{value: sqlQueryValue(query, args), result: result, hasResult: true}, nil return sqlLowered{value: sqlQueryValue(query, args), result: result, hasResult: true}, nil
} }
func (g *goGenerator) lowerSQLSelectExpanded(root CallExpr, steps []sqlCallStep) (sqlLowered, error) {
if len(root.Args) != 0 || len(root.NamedArgs) != 0 {
return sqlLowered{}, fmt.Errorf("sql.from does not accept value arguments")
}
class, err := g.sqlClass(root, "from")
if err != nil {
return sqlLowered{}, err
}
sources := []sqlSourceRef{{class: class}}
result := class
columns := sqlScopedAllColumns(sources[0])
var joins []string
var where, group, having, order, limit, offset string
var args []string
seenAlias, seenSelect, seenWhere, seenGroup, seenHaving, seenOrder, seenLimit, seenOffset := false, false, false, false, false, false, false, false
seenForUpdate, seenSkipLocked := false, false
stage := 0
for _, step := range steps {
switch step.name {
case "alias":
if seenAlias || stage != 0 {
return sqlLowered{}, fmt.Errorf("alias() may appear once before joins")
}
alias, err := sqlAliasArgument(step.call, "alias")
if err != nil {
return sqlLowered{}, err
}
sources[0].alias = alias
columns = sqlScopedAllColumns(sources[0])
seenAlias = true
case "join", "leftJoin", "rightJoin":
if stage > 0 {
return sqlLowered{}, fmt.Errorf("joins must appear before select() and where()")
}
joined, clause, joinArgs, err := g.sqlJoin(step, sources, len(args)+1)
if err != nil {
return sqlLowered{}, err
}
sources = append(sources, joined)
joins = append(joins, clause)
args = append(args, joinArgs...)
columns = sqlScopedAllColumns(sources[0])
case "select":
if seenSelect || stage > 0 {
return sqlLowered{}, fmt.Errorf("select() may appear once after joins and before filtering")
}
result, columns, err = g.sqlProjectionScoped(step.call, sources, "select")
if err != nil {
return sqlLowered{}, err
}
seenSelect, stage = true, 1
case "where":
if seenWhere || stage > 2 {
return sqlLowered{}, fmt.Errorf("where() may appear once before grouping")
}
where, args, err = g.sqlWhereScoped(step.call, sources, len(args)+1, args)
if err != nil {
return sqlLowered{}, err
}
seenWhere, stage = true, 2
case "groupBy":
if seenGroup || stage > 3 {
return sqlLowered{}, fmt.Errorf("groupBy() may appear once before having() and ordering")
}
group, err = g.sqlGroupBy(step.call, sources)
if err != nil {
return sqlLowered{}, err
}
seenGroup, stage = true, 3
case "having":
if seenHaving || !seenGroup || stage > 4 {
return sqlLowered{}, fmt.Errorf("having() may appear once after groupBy()")
}
var havingArgs []string
having, havingArgs, err = g.sqlPredicateLambda(step.call, sources, "having", len(args)+1)
if err != nil {
return sqlLowered{}, err
}
args = append(args, havingArgs...)
seenHaving, stage = true, 4
case "orderBy", "orderByDescending":
if seenOrder || stage > 5 {
return sqlLowered{}, fmt.Errorf("ordering may appear once before limit() and offset()")
}
order, err = g.sqlOrderScoped(step, sources)
if err != nil {
return sqlLowered{}, err
}
seenOrder, stage = true, 5
case "limit", "offset":
if seenForUpdate || seenSkipLocked {
return sqlLowered{}, fmt.Errorf("%s() may appear once before forUpdate()", step.name)
}
if step.name == "limit" && seenLimit {
return sqlLowered{}, fmt.Errorf("limit() may appear once")
}
if step.name == "offset" && seenOffset {
return sqlLowered{}, fmt.Errorf("offset() may appear once")
}
value, valueArgs, err := g.sqlNonNegativeInt(step.call, step.name)
if err != nil {
return sqlLowered{}, err
}
if value == "?" {
value = "$" + strconv.Itoa(len(args)+1)
}
args = append(args, valueArgs...)
if step.name == "limit" {
limit, seenLimit = value, true
} else {
offset, seenOffset = value, true
}
stage = 6
case "forUpdate":
if seenForUpdate || !sqlNoArgs(step.call) {
return sqlLowered{}, fmt.Errorf("forUpdate() accepts no arguments and may appear once after offset()")
}
seenForUpdate = true
stage = 7
case "skipLocked":
if seenSkipLocked || !seenForUpdate || !sqlNoArgs(step.call) {
return sqlLowered{}, fmt.Errorf("skipLocked() accepts no arguments and requires a preceding forUpdate()")
}
seenSkipLocked = true
stage = 8
default:
return sqlLowered{}, fmt.Errorf("unsupported sql.from method %q", step.name)
}
}
from := class.Table
if sources[0].alias != "" {
from += " AS " + sources[0].alias
}
query := "SELECT " + strings.Join(columns, ", ") + " FROM " + from
if len(joins) > 0 {
query += " " + strings.Join(joins, " ")
}
if where != "" {
query += " WHERE " + where
}
if group != "" {
query += " GROUP BY " + group
}
if having != "" {
query += " HAVING " + having
}
if order != "" {
query += " ORDER BY " + order
}
if limit != "" {
query += " LIMIT " + limit
}
if offset != "" {
query += " OFFSET " + offset
}
if seenForUpdate {
query += " FOR UPDATE"
}
if seenSkipLocked {
query += " SKIP LOCKED"
}
return sqlLowered{value: sqlQueryValue(query, args), result: result, hasResult: true}, nil
}
func (g *goGenerator) lowerSQLInsert(root CallExpr, steps []sqlCallStep) (sqlLowered, error) { func (g *goGenerator) lowerSQLInsert(root CallExpr, steps []sqlCallStep) (sqlLowered, error) {
if len(root.Args) != 1 || len(root.NamedArgs) != 0 { if len(root.Args) != 1 || len(root.NamedArgs) != 0 {
return sqlLowered{}, fmt.Errorf("sql.insert expects exactly one row argument") return sqlLowered{}, fmt.Errorf("sql.insert expects exactly one row argument")
@ -437,6 +608,41 @@ func (g *goGenerator) lowerSQLInsert(root CallExpr, steps []sqlCallStep) (sqlLow
return lowered, nil return lowered, nil
} }
func (g *goGenerator) lowerSQLBulkInsert(root CallExpr, steps []sqlCallStep) (sqlLowered, error) {
if len(root.Args) != 1 || len(root.NamedArgs) != 0 {
return sqlLowered{}, fmt.Errorf("sql.insertAll expects exactly one row list")
}
if len(steps) != 0 {
return sqlLowered{}, fmt.Errorf("sql.insertAll currently ends directly with build()")
}
class, err := g.sqlClass(root, "insertAll")
if err != nil {
return sqlLowered{}, err
}
typ := g.exprType(root.Args[0])
base, args, ok := parseGenericType(typ)
if !ok || (base != "List" && base != "MutableList") || len(args) != 1 || strings.TrimPrefix(args[0], "*") != class.Name {
return sqlLowered{}, fmt.Errorf("sql.insertAll<%s> requires List<%s>, got %s", class.Name, class.Name, typ)
}
rows, err := g.expr(root.Args[0], "List<"+class.Name+">")
if err != nil {
return sqlLowered{}, err
}
var columns, values []string
for _, field := range class.Fields {
if field.Generated {
continue
}
columns = append(columns, strconv.Quote(sqlColumn(field)))
values = append(values, "row."+mappingFieldName(class, field))
}
if len(columns) == 0 {
return sqlLowered{}, fmt.Errorf("sql.insertAll does not support rows containing only generated fields")
}
value := fmt.Sprintf("gotlinSQLBulkInsert[%s](%s, %q, []string{%s}, func(row *%s) []any { return []any{%s} })", class.Name, rows, class.Table, strings.Join(columns, ", "), class.Name, strings.Join(values, ", "))
return sqlLowered{value: value}, nil
}
func (g *goGenerator) lowerSQLUpdate(root CallExpr, steps []sqlCallStep) (sqlLowered, error) { func (g *goGenerator) lowerSQLUpdate(root CallExpr, steps []sqlCallStep) (sqlLowered, error) {
if len(root.Args) != 0 || len(root.NamedArgs) != 0 { if len(root.Args) != 0 || len(root.NamedArgs) != 0 {
return sqlLowered{}, fmt.Errorf("sql.update does not accept value arguments") return sqlLowered{}, fmt.Errorf("sql.update does not accept value arguments")
@ -541,6 +747,370 @@ func sqlAllColumns(class ClassDecl) []string {
return columns return columns
} }
func sqlScopedAllColumns(source sqlSourceRef) []string {
columns := make([]string, 0, len(source.class.Fields))
for _, field := range source.class.Fields {
columns = append(columns, sqlQualifiedColumn(source, field))
}
return columns
}
func sqlQualifiedColumn(source sqlSourceRef, field FieldDecl) string {
column := sqlColumn(field)
if source.alias != "" {
return source.alias + "." + column
}
return column
}
func sqlAliasArgument(call CallExpr, method string) (string, error) {
if len(call.Args) != 1 || len(call.TypeArgs) != 0 || len(call.NamedArgs) != 0 {
return "", fmt.Errorf("%s() expects one alias string", method)
}
literal, ok := call.Args[0].(StringExpr)
if !ok {
return "", fmt.Errorf("%s() alias must be a string literal", method)
}
value, err := strconv.Unquote(literal.Value)
if err != nil || !validSQLName(value) {
return "", fmt.Errorf("invalid SQL alias %q", value)
}
return value, nil
}
func (g *goGenerator) sqlJoin(step sqlCallStep, existing []sqlSourceRef, placeholderStart int) (sqlSourceRef, string, []string, error) {
if len(step.call.TypeArgs) != 1 || len(step.call.NamedArgs) != 0 {
return sqlSourceRef{}, "", nil, fmt.Errorf("%s expects one joined row type", step.name)
}
joined, ok := g.semantic.Classes[step.call.TypeArgs[0]]
if !ok || !joined.Data || joined.Table == "" {
return sqlSourceRef{}, "", nil, fmt.Errorf("joined row %s must be a @table data class", step.call.TypeArgs[0])
}
if len(step.call.Args) < 1 || len(step.call.Args) > 2 {
return sqlSourceRef{}, "", nil, fmt.Errorf("%s expects an optional alias and a predicate lambda", step.name)
}
lambda, ok := step.call.Args[len(step.call.Args)-1].(LambdaExpr)
if !ok {
return sqlSourceRef{}, "", nil, fmt.Errorf("%s expects a predicate lambda", step.name)
}
alias := joined.Table
if len(step.call.Args) == 2 {
literal, ok := step.call.Args[0].(StringExpr)
if !ok {
return sqlSourceRef{}, "", nil, fmt.Errorf("%s alias must be a string literal", step.name)
}
var err error
alias, err = strconv.Unquote(literal.Value)
if err != nil || !validSQLName(alias) {
return sqlSourceRef{}, "", nil, fmt.Errorf("invalid SQL alias %q", alias)
}
}
for _, current := range existing {
currentAlias := current.alias
if currentAlias == "" {
currentAlias = current.class.Table
}
if currentAlias == alias {
return sqlSourceRef{}, "", nil, fmt.Errorf("duplicate SQL alias %q", alias)
}
}
source := sqlSourceRef{class: joined, alias: alias}
sources := append(append([]sqlSourceRef{}, existing...), source)
scope, body, err := sqlScopedLambda(lambda, sources, step.name)
if err != nil {
return sqlSourceRef{}, "", nil, err
}
predicate, args, err := g.sqlPredicateScoped(body, scope)
if err != nil {
return sqlSourceRef{}, "", nil, err
}
joinType := map[string]string{"join": "INNER JOIN", "leftJoin": "LEFT JOIN", "rightJoin": "RIGHT JOIN"}[step.name]
clause := joinType + " " + joined.Table
if alias != joined.Table {
clause += " AS " + alias
}
clause += " ON " + postgresPlaceholders(predicate, placeholderStart)
return source, clause, args, nil
}
func sqlScopedLambda(lambda LambdaExpr, sources []sqlSourceRef, method string) (map[string]sqlSourceRef, Expr, error) {
scope := map[string]sqlSourceRef{}
if lambda.ImplicitIt {
if len(sources) != 1 {
return nil, nil, fmt.Errorf("%s lambda requires %d row parameters", method, len(sources))
}
scope["it"] = sources[0]
} else {
if len(lambda.Params) != len(sources) {
return nil, nil, fmt.Errorf("%s lambda requires %d row parameters", method, len(sources))
}
for index, param := range lambda.Params {
if param.Type != "" && param.Type != sources[index].class.Name {
return nil, nil, fmt.Errorf("%s parameter %s must have type %s", method, param.Name, sources[index].class.Name)
}
scope[param.Name] = sources[index]
}
}
if len(lambda.Body) != 1 {
return nil, nil, fmt.Errorf("%s lambda must contain one expression", method)
}
statement, ok := lambda.Body[0].(ExprStmt)
if !ok {
return nil, nil, fmt.Errorf("%s lambda must contain one expression", method)
}
return scope, statement.Value, nil
}
func (g *goGenerator) sqlProjectionScoped(call CallExpr, sources []sqlSourceRef, method string) (ClassDecl, []string, error) {
lambda, err := sqlLambdaArg(call, method)
if err != nil {
return ClassDecl{}, nil, err
}
scope, body, err := sqlScopedLambda(lambda, sources, method)
if err != nil {
return ClassDecl{}, nil, err
}
if ident, ok := body.(IdentExpr); ok {
if source, found := scope[ident.Name]; found {
return source.class, sqlScopedAllColumns(source), nil
}
}
constructor, ok := body.(CallExpr)
if !ok || len(constructor.TypeArgs) != 0 || len(constructor.NamedArgs) != 0 {
return ClassDecl{}, nil, fmt.Errorf("%s expects Projection(row.field, aggregate(...))", method)
}
callee, ok := constructor.Callee.(IdentExpr)
if !ok {
return ClassDecl{}, nil, fmt.Errorf("%s projection must construct a local data class", method)
}
projection, ok := g.semantic.Classes[callee.Name]
if !ok || !projection.Data {
return ClassDecl{}, nil, fmt.Errorf("%s projection type %s must be a data class", method, callee.Name)
}
if len(constructor.Args) != len(projection.Fields) {
return ClassDecl{}, nil, fmt.Errorf("%s projection %s expects %d fields, got %d", method, projection.Name, len(projection.Fields), len(constructor.Args))
}
columns := make([]string, len(constructor.Args))
for index, argument := range constructor.Args {
operand, err := g.sqlOperandScoped(argument, scope)
if err != nil {
return ClassDecl{}, nil, fmt.Errorf("%s projection argument %d: %w", method, index+1, err)
}
if len(operand.args) != 0 {
return ClassDecl{}, nil, fmt.Errorf("%s projection arguments must be fields or aggregates", method)
}
if !sqlProjectionTypesCompatible(projection.Fields[index].Type, operand.typ) {
return ClassDecl{}, nil, fmt.Errorf("%s projection field %s has type %s but expression has type %s", method, projection.Fields[index].Name, projection.Fields[index].Type, operand.typ)
}
columns[index] = operand.sql
}
return projection, columns, nil
}
func (g *goGenerator) sqlWhereScoped(call CallExpr, sources []sqlSourceRef, placeholderStart int, existing []string) (string, []string, error) {
predicate, args, err := g.sqlPredicateLambda(call, sources, "where", placeholderStart)
return predicate, append(existing, args...), err
}
func (g *goGenerator) sqlPredicateLambda(call CallExpr, sources []sqlSourceRef, method string, placeholderStart int) (string, []string, error) {
lambda, err := sqlLambdaArg(call, method)
if err != nil {
return "", nil, err
}
scope, body, err := sqlScopedLambda(lambda, sources, method)
if err != nil {
return "", nil, err
}
predicate, args, err := g.sqlPredicateScoped(body, scope)
if err != nil {
return "", nil, err
}
return postgresPlaceholders(predicate, placeholderStart), args, nil
}
func (g *goGenerator) sqlPredicateScoped(expr Expr, scope map[string]sqlSourceRef) (string, []string, error) {
switch value := expr.(type) {
case BinaryExpr:
if value.Op == "&&" || value.Op == "||" {
left, leftArgs, err := g.sqlPredicateScoped(value.Left, scope)
if err != nil {
return "", nil, err
}
right, rightArgs, err := g.sqlPredicateScoped(value.Right, scope)
if err != nil {
return "", nil, err
}
op := "AND"
if value.Op == "||" {
op = "OR"
}
return "(" + left + " " + op + " " + right + ")", append(leftArgs, rightArgs...), nil
}
if !isSQLComparison(value.Op) {
return "", nil, fmt.Errorf("unsupported SQL predicate operator %q", value.Op)
}
left, err := g.sqlOperandScoped(value.Left, scope)
if err != nil {
return "", nil, err
}
right, err := g.sqlOperandScoped(value.Right, scope)
if err != nil {
return "", nil, err
}
if left.typ == "Null" || right.typ == "Null" {
if value.Op != "==" && value.Op != "!=" {
return "", nil, fmt.Errorf("null only supports == and != in SQL predicates")
}
operand := left
if operand.typ == "Null" {
operand = right
}
if !sqlNullableType(operand.typ) {
return "", nil, fmt.Errorf("SQL null comparison requires a nullable operand, got %s", operand.typ)
}
op := "IS NULL"
if value.Op == "!=" {
op = "IS NOT NULL"
}
return operand.sql + " " + op, operand.args, nil
}
if !sqlTypesCompatible(left.typ, right.typ) {
return "", nil, fmt.Errorf("SQL predicate compares incompatible types %s and %s", left.typ, right.typ)
}
if value.Op == ">" || value.Op == ">=" || value.Op == "<" || value.Op == "<=" {
if !(sqlNumericType(left.typ) && sqlNumericType(right.typ)) && !(sqlTimestampType(left.typ) && sqlTimestampType(right.typ)) {
return "", nil, fmt.Errorf("SQL ordering comparison requires numeric operands or timestamp operands, got %s and %s", left.typ, right.typ)
}
}
op := map[string]string{"==": "=", "!=": "<>", ">": ">", ">=": ">=", "<": "<", "<=": "<="}[value.Op]
return left.sql + " " + op + " " + right.sql, append(left.args, right.args...), nil
case UnaryExpr:
if value.Op != "!" {
return "", nil, fmt.Errorf("unsupported SQL predicate unary operator %q", value.Op)
}
inner, args, err := g.sqlPredicateScoped(value.Value, scope)
return "(NOT " + inner + ")", args, err
case SelectorExpr:
operand, err := g.sqlOperandScoped(value, scope)
if err != nil {
return "", nil, err
}
if operand.typ != "Boolean" {
return "", nil, fmt.Errorf("SQL predicate field has type %s, not Boolean", operand.typ)
}
return operand.sql, nil, nil
default:
return "", nil, fmt.Errorf("SQL %s lambda must produce a Boolean predicate", "where/having")
}
}
func (g *goGenerator) sqlOperandScoped(expr Expr, scope map[string]sqlSourceRef) (sqlOperandValue, error) {
if selector, ok := expr.(SelectorExpr); ok {
if receiver, ok := selector.Receiver.(IdentExpr); ok {
if source, found := scope[receiver.Name]; found {
field, err := sqlField(source.class, selector.Name)
if err != nil {
return sqlOperandValue{}, err
}
return sqlOperandValue{sql: sqlQualifiedColumn(source, field), typ: field.Type}, nil
}
}
}
if call, ok := expr.(CallExpr); ok {
if ident, ok := call.Callee.(IdentExpr); ok {
if ident.Name == "count" && len(call.Args) == 0 {
return sqlOperandValue{sql: "COUNT(*)", typ: "Long"}, nil
}
if (ident.Name == "sum" || ident.Name == "avg" || ident.Name == "min" || ident.Name == "max" || ident.Name == "countDistinct") && len(call.Args) == 1 {
inner, err := g.sqlOperandScoped(call.Args[0], scope)
if err != nil {
return sqlOperandValue{}, err
}
if len(inner.args) != 0 {
return sqlOperandValue{}, fmt.Errorf("%s() requires a row field", ident.Name)
}
name := map[string]string{"sum": "SUM", "avg": "AVG", "min": "MIN", "max": "MAX", "countDistinct": "COUNT"}[ident.Name]
typ := inner.typ
sql := name + "(" + inner.sql + ")"
if ident.Name == "avg" {
typ = "Double"
}
if ident.Name == "countDistinct" {
typ = "Long"
sql = "COUNT(DISTINCT " + inner.sql + ")"
}
return sqlOperandValue{sql: sql, typ: typ}, nil
}
if ident.Name == "now" && sqlNoArgs(call) {
return sqlOperandValue{sql: "CURRENT_TIMESTAMP", typ: "time.Time"}, nil
}
}
}
if binary, ok := expr.(BinaryExpr); ok && (binary.Op == "+" || binary.Op == "-") {
left, err := g.sqlOperandScoped(binary.Left, scope)
if err != nil {
return sqlOperandValue{}, err
}
right, err := g.sqlOperandScoped(binary.Right, scope)
if err != nil {
return sqlOperandValue{}, err
}
return sqlOperandValue{sql: "(" + left.sql + " " + binary.Op + " " + right.sql + ")", typ: left.typ, args: append(left.args, right.args...)}, nil
}
return g.sqlOperand(expr, ClassDecl{}, "")
}
func (g *goGenerator) sqlGroupBy(call CallExpr, sources []sqlSourceRef) (string, error) {
lambda, err := sqlLambdaArg(call, "groupBy")
if err != nil {
return "", err
}
scope, body, err := sqlScopedLambda(lambda, sources, "groupBy")
if err != nil {
return "", err
}
expressions := []Expr{body}
if list, ok := body.(CallExpr); ok {
if ident, ok := list.Callee.(IdentExpr); ok && (ident.Name == "listOf" || ident.Name == "mutableListOf") {
expressions = list.Args
}
}
columns := make([]string, len(expressions))
for index, expr := range expressions {
operand, err := g.sqlOperandScoped(expr, scope)
if err != nil {
return "", err
}
if len(operand.args) != 0 {
return "", fmt.Errorf("groupBy() requires row fields")
}
columns[index] = operand.sql
}
return strings.Join(columns, ", "), nil
}
func (g *goGenerator) sqlOrderScoped(step sqlCallStep, sources []sqlSourceRef) (string, error) {
lambda, err := sqlLambdaArg(step.call, step.name)
if err != nil {
return "", err
}
scope, body, err := sqlScopedLambda(lambda, sources, step.name)
if err != nil {
return "", err
}
operand, err := g.sqlOperandScoped(body, scope)
if err != nil {
return "", err
}
if len(operand.args) != 0 {
return "", fmt.Errorf("ordering requires a row field or aggregate")
}
if step.name == "orderByDescending" {
return operand.sql + " DESC", nil
}
return operand.sql, nil
}
func (g *goGenerator) sqlProjection(call CallExpr, rowClass ClassDecl, method string) (ClassDecl, []string, error) { func (g *goGenerator) sqlProjection(call CallExpr, rowClass ClassDecl, method string) (ClassDecl, []string, error) {
lambda, err := sqlLambdaArg(call, method) lambda, err := sqlLambdaArg(call, method)
if err != nil { if err != nil {
@ -620,23 +1190,27 @@ func sqlOrder(step sqlCallStep, class ClassDecl) (string, error) {
} }
func (g *goGenerator) sqlLimit(call CallExpr) (string, []string, error) { func (g *goGenerator) sqlLimit(call CallExpr) (string, []string, error) {
return g.sqlNonNegativeInt(call, "limit")
}
func (g *goGenerator) sqlNonNegativeInt(call CallExpr, method string) (string, []string, error) {
if len(call.Args) != 1 || len(call.NamedArgs) != 0 || len(call.TypeArgs) != 0 { if len(call.Args) != 1 || len(call.NamedArgs) != 0 || len(call.TypeArgs) != 0 {
return "", nil, fmt.Errorf("limit() expects exactly one Int argument") return "", nil, fmt.Errorf("%s() expects exactly one Int argument", method)
} }
if literal, ok := call.Args[0].(IntExpr); ok { if literal, ok := call.Args[0].(IntExpr); ok {
value, err := strconv.Atoi(literal.Value) value, err := strconv.Atoi(literal.Value)
if err != nil || value < 0 { if err != nil || value < 0 {
return "", nil, fmt.Errorf("limit() requires a non-negative Int") return "", nil, fmt.Errorf("%s() requires a non-negative Int", method)
} }
return literal.Value, nil, nil return literal.Value, nil, nil
} }
if unary, ok := call.Args[0].(UnaryExpr); ok && unary.Op == "-" { if unary, ok := call.Args[0].(UnaryExpr); ok && unary.Op == "-" {
if _, ok := unary.Value.(IntExpr); ok { if _, ok := unary.Value.(IntExpr); ok {
return "", nil, fmt.Errorf("limit() requires a non-negative Int") return "", nil, fmt.Errorf("%s() requires a non-negative Int", method)
} }
} }
if typ := g.exprType(call.Args[0]); typ != "Int" { if typ := g.exprType(call.Args[0]); typ != "Int" {
return "", nil, fmt.Errorf("limit() argument must have type Int, got %s", typ) return "", nil, fmt.Errorf("%s() argument must have type Int, got %s", method, typ)
} }
value, err := g.expr(call.Args[0], "Int") value, err := g.expr(call.Args[0], "Int")
if err != nil { if err != nil {
@ -869,24 +1443,16 @@ func (g *goGenerator) sqlTypedUpdateAssignments(call CallExpr, class ClassDecl)
rowName = lambda.Params[0].Name rowName = lambda.Params[0].Name
} }
if len(lambda.Body) == 0 { if len(lambda.Body) == 0 {
return nil, nil, fmt.Errorf("set lambda requires at least one set(row.field, value) expression") return nil, nil, fmt.Errorf("set lambda requires at least one row.field = value assignment")
} }
var assignments, args []string var assignments, args []string
seen := map[string]bool{} seen := map[string]bool{}
for _, stmt := range lambda.Body { for _, stmt := range lambda.Body {
exprStmt, ok := stmt.(ExprStmt) assignment, ok := stmt.(FieldAssignStmt)
if !ok { if !ok {
return nil, nil, fmt.Errorf("set lambda only supports set(row.field, value) expressions") return nil, nil, fmt.Errorf("set lambda only supports %s.field = value assignments", rowName)
} }
setCall, ok := exprStmt.Value.(CallExpr) target, err := sqlRowField(assignment.Target, class, rowName)
if !ok {
return nil, nil, fmt.Errorf("set lambda only supports set(row.field, value) expressions")
}
callee, ok := setCall.Callee.(IdentExpr)
if !ok || callee.Name != "set" || len(setCall.Args) != 2 || len(setCall.NamedArgs) != 0 || len(setCall.TypeArgs) != 0 {
return nil, nil, fmt.Errorf("set lambda expects set(%s.field, value)", rowName)
}
target, err := sqlRowField(setCall.Args[0], class, rowName)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("set target: %w", err) return nil, nil, fmt.Errorf("set target: %w", err)
} }
@ -896,7 +1462,7 @@ func (g *goGenerator) sqlTypedUpdateAssignments(call CallExpr, class ClassDecl)
if seen[target.Name] { if seen[target.Name] {
return nil, nil, fmt.Errorf("duplicate set target %s", target.Name) return nil, nil, fmt.Errorf("duplicate set target %s", target.Name)
} }
value, err := g.sqlOperand(setCall.Args[1], class, rowName) value, err := g.sqlOperand(assignment.Value, class, rowName)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("set value for %s: %w", target.Name, err) return nil, nil, fmt.Errorf("set value for %s: %w", target.Name, err)
} }
@ -926,27 +1492,16 @@ func sqlConflictUpdateAssignments(lambda LambdaExpr, class ClassDecl) ([]string,
excludedName = lambda.Params[0].Name excludedName = lambda.Params[0].Name
} }
if len(lambda.Body) == 0 { if len(lambda.Body) == 0 {
return nil, fmt.Errorf("doUpdate lambda requires at least one set() expression") return nil, fmt.Errorf("doUpdate lambda requires at least one Row.field = excluded.field assignment")
} }
assignments := make([]string, 0, len(lambda.Body)) assignments := make([]string, 0, len(lambda.Body))
seen := map[string]bool{} seen := map[string]bool{}
for _, stmt := range lambda.Body { for _, stmt := range lambda.Body {
exprStmt, ok := stmt.(ExprStmt) assignment, ok := stmt.(FieldAssignStmt)
if !ok { if !ok {
return nil, fmt.Errorf("doUpdate only supports set() expressions") return nil, fmt.Errorf("doUpdate only supports %s.field = %s.field assignments", class.Name, excludedName)
}
call, ok := exprStmt.Value.(CallExpr)
if !ok {
return nil, fmt.Errorf("doUpdate only supports set() expressions")
}
callee, ok := call.Callee.(IdentExpr)
if !ok || callee.Name != "set" || len(call.Args) != 2 || len(call.NamedArgs) != 0 || len(call.TypeArgs) != 0 {
return nil, fmt.Errorf("doUpdate expects set(%s.field, %s.field)", class.Name, excludedName)
}
targetRef, ok := call.Args[0].(SelectorExpr)
if !ok {
return nil, fmt.Errorf("set target must be %s.field", class.Name)
} }
targetRef := assignment.Target
targetType, ok := targetRef.Receiver.(IdentExpr) targetType, ok := targetRef.Receiver.(IdentExpr)
if !ok || targetType.Name != class.Name { if !ok || targetType.Name != class.Name {
return nil, fmt.Errorf("set target must be %s.field", class.Name) return nil, fmt.Errorf("set target must be %s.field", class.Name)
@ -961,7 +1516,7 @@ func sqlConflictUpdateAssignments(lambda LambdaExpr, class ClassDecl) ([]string,
if seen[target.Name] { if seen[target.Name] {
return nil, fmt.Errorf("duplicate set target %s", target.Name) return nil, fmt.Errorf("duplicate set target %s", target.Name)
} }
sourceRef, ok := call.Args[1].(SelectorExpr) sourceRef, ok := assignment.Value.(SelectorExpr)
if !ok { if !ok {
return nil, fmt.Errorf("set value must be %s.field", excludedName) return nil, fmt.Errorf("set value must be %s.field", excludedName)
} }
@ -1117,6 +1672,13 @@ func programContainsSQLExecution(program *Program) bool {
}) })
} }
func programContainsSQLBulk(program *Program) bool {
return programExprMatches(program, func(expr Expr) bool {
_, operation, _, ok := splitSQLChain(expr)
return ok && operation == "insertAll"
})
}
func exprMatches(expr Expr, match func(Expr) bool) bool { func exprMatches(expr Expr, match func(Expr) bool) bool {
if match(expr) { if match(expr) {
return true return true

View file

@ -133,9 +133,9 @@ import pgxpool "github.com/jackc/pgx/v5/pgxpool"
fun claim(id: String, payload: String, pool: *pgxpool.Pool, ctx: context.Context): EventProjection { fun claim(id: String, payload: String, pool: *pgxpool.Pool, ctx: context.Context): EventProjection {
return sql.update<EventRow>() return sql.update<EventRow>()
.set { row -> .set { row ->
set(row.payload, payload) row.payload = payload
set(row.attempts, row.attempts + 1) row.attempts = row.attempts + 1
set(row.claimedUntil, now()) row.claimedUntil = now()
} }
.where { it.id == id && it.publishedAt == null } .where { it.id == id && it.publishedAt == null }
.returning { row -> EventProjection(row.id, row.payload) } .returning { row -> EventProjection(row.id, row.payload) }
@ -210,7 +210,7 @@ func TestRejectExpandedInvalidSQLQueries(t *testing.T) {
{ {
name: "projection type mismatch", name: "projection type mismatch",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().select { WrongProjection(it.id) }.build() }`, src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().select { WrongProjection(it.id) }.build() }`,
want: "has type Int but row field id has type String", want: "has type Int but expression has type String",
}, },
{ {
name: "projection arity mismatch", name: "projection arity mismatch",
@ -220,7 +220,7 @@ func TestRejectExpandedInvalidSQLQueries(t *testing.T) {
{ {
name: "projection after where", name: "projection after where",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().where { it.id == "x" }.select { EventProjection(it.id, it.payload) }.build() }`, src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().where { it.id == "x" }.select { EventProjection(it.id, it.payload) }.build() }`,
want: "must be the first sql.from method", want: "after joins and before filtering",
}, },
{ {
name: "update missing set", name: "update missing set",
@ -229,22 +229,22 @@ func TestRejectExpandedInvalidSQLQueries(t *testing.T) {
}, },
{ {
name: "update incompatible value", name: "update incompatible value",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update<EventRow>().set { set(it.attempts, "bad") }.build() }`, src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update<EventRow>().set { it.attempts = "bad" }.build() }`,
want: "has type Int but value has type String", want: "has type Int but value has type String",
}, },
{ {
name: "update null non nullable", name: "update null non nullable",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update<EventRow>().set { set(it.payload, null) }.build() }`, src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update<EventRow>().set { it.payload = null }.build() }`,
want: "has non-nullable type String", want: "has non-nullable type String",
}, },
{ {
name: "update nullable into non nullable", name: "update nullable into non nullable",
src: eventSQLSource + `fun query(value: String?): GotlinSQLQuery { return sql.update<EventRow>().set { set(it.payload, value) }.build() }`, src: eventSQLSource + `fun query(value: String?): GotlinSQLQuery { return sql.update<EventRow>().set { it.payload = value }.build() }`,
want: "has type String but value has type String?", want: "has type String but value has type String?",
}, },
{ {
name: "duplicate update target", name: "duplicate update target",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update<EventRow>().set { set(it.payload, "a"); set(it.payload, "b") }.build() }`, src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update<EventRow>().set { it.payload = "a"; it.payload = "b" }.build() }`,
want: "duplicate set target payload", want: "duplicate set target payload",
}, },
{ {

View file

@ -205,7 +205,7 @@ func TestGenerateSQLInsertDoUpdate(t *testing.T) {
fun upsertAccount(row: AccountRow): GotlinSQLQuery { fun upsertAccount(row: AccountRow): GotlinSQLQuery {
return sql.insert<AccountRow>(row) return sql.insert<AccountRow>(row)
.onConflict { it.id } .onConflict { it.id }
.doUpdate { excluded -> set(AccountRow.balance, excluded.balance) } .doUpdate { excluded -> AccountRow.balance = excluded.balance }
.build() .build()
} }
`) `)
@ -223,8 +223,8 @@ fun upsert(row: BalanceRow): GotlinSQLQuery {
return sql.insert<BalanceRow>(row) return sql.insert<BalanceRow>(row)
.onConflict { listOf(it.tenantId, it.id) } .onConflict { listOf(it.tenantId, it.id) }
.doUpdate { excluded -> .doUpdate { excluded ->
set(BalanceRow.amount, excluded.amount) BalanceRow.amount = excluded.amount
set(BalanceRow.pending, excluded.pending) BalanceRow.pending = excluded.pending
} }
.build() .build()
} }
@ -234,6 +234,50 @@ fun upsert(row: BalanceRow): GotlinSQLQuery {
} }
} }
func TestGenerateSQLJoinsAliasesGroupingOffsetAndAggregates(t *testing.T) {
code := compileSQL(t, accountRowSource+`
@table("customers")
data class CustomerRow(@id var id: String, var name: String)
data class AccountSummary(var customerId: String, var customerName: String, var total: Double, var entries: Long)
fun summaries(minimum: Double): GotlinSQLQuery {
return sql.from<AccountRow>()
.alias("account")
.leftJoin<CustomerRow>("customer") { account, customer -> account.customerId == customer.id }
.select { account, customer -> AccountSummary(account.customerId, customer.name, sum(account.balance), count()) }
.where { account, customer -> account.balance > minimum }
.groupBy { account, customer -> listOf(account.customerId, customer.name) }
.having { account, customer -> sum(account.balance) > minimum }
.orderByDescending { account, customer -> sum(account.balance) }
.limit(10)
.offset(20)
.build()
}
`)
want := `SQL: "SELECT account.customer_id, customer.name, SUM(account.balance), COUNT(*) FROM accounts AS account LEFT JOIN customers AS customer ON account.customer_id = customer.id WHERE account.balance > $1 GROUP BY account.customer_id, customer.name HAVING SUM(account.balance) > $2 ORDER BY SUM(account.balance) DESC LIMIT 10 OFFSET 20"`
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
if !strings.Contains(code, `Args: []any{minimum, minimum}`) {
t.Fatalf("aggregate arguments missing:\n%s", code)
}
}
func TestGenerateSQLBulkInsert(t *testing.T) {
code := compileSQL(t, accountRowSource+`
fun insertAccounts(rows: List<AccountRow>): GotlinSQLQuery = sql.insertAll<AccountRow>(rows).build()
`)
for _, want := range []string{
`gotlinSQLBulkInsert[AccountRow](rows, "accounts", []string{"id", "customer_id", "kind", "balance"}`,
`func(row *AccountRow) []any { return []any{row.Id, row.CustomerId, row.AccountType, row.Balance} }`,
`func gotlinSQLBulkInsert[T any]`,
`panic("bulk insert requires at least one row")`,
} {
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
}
func TestRejectInvalidSQLQueries(t *testing.T) { func TestRejectInvalidSQLQueries(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -282,12 +326,12 @@ func TestRejectInvalidSQLQueries(t *testing.T) {
}, },
{ {
name: "unknown update target", name: "unknown update target",
src: accountRowSource + `fun query(row: AccountRow): GotlinSQLQuery { return sql.insert<AccountRow>(row).onConflict { it.id }.doUpdate { excluded -> set(AccountRow.missing, excluded.balance) }.build() }`, src: accountRowSource + `fun query(row: AccountRow): GotlinSQLQuery { return sql.insert<AccountRow>(row).onConflict { it.id }.doUpdate { excluded -> AccountRow.missing = excluded.balance }.build() }`,
want: "has no field missing", want: "has no field missing",
}, },
{ {
name: "incompatible update fields", name: "incompatible update fields",
src: accountRowSource + `fun query(row: AccountRow): GotlinSQLQuery { return sql.insert<AccountRow>(row).onConflict { it.id }.doUpdate { excluded -> set(AccountRow.balance, excluded.accountType) }.build() }`, src: accountRowSource + `fun query(row: AccountRow): GotlinSQLQuery { return sql.insert<AccountRow>(row).onConflict { it.id }.doUpdate { excluded -> AccountRow.balance = excluded.accountType }.build() }`,
want: "has type Double", want: "has type Double",
}, },
{ {
@ -338,6 +382,32 @@ fun query(row: OtherRow): GotlinSQLQuery { return sql.insert<AccountRow>(row).on
src: accountRowSource + `fun query(row: AccountRow, pool: Any, ctx: Any): List<AccountRow> { return sql.insert<AccountRow>(row).fetch(pool, ctx).unwrap() }`, src: accountRowSource + `fun query(row: AccountRow, pool: Any, ctx: Any): List<AccountRow> { return sql.insert<AccountRow>(row).fetch(pool, ctx).unwrap() }`,
want: "fetch() is only supported for sql.from", want: "fetch() is only supported for sql.from",
}, },
{
name: "duplicate join alias",
src: accountRowSource + `
@table("customers") data class CustomerRow(@id var id: String)
fun query(): GotlinSQLQuery = sql.from<AccountRow>().alias("row").join<CustomerRow>("row") { account, customer -> account.customerId == customer.id }.build()
`,
want: `duplicate SQL alias "row"`,
},
{
name: "having without grouping",
src: accountRowSource + `fun query(): GotlinSQLQuery = sql.from<AccountRow>().having { count() > 0 }.build()`,
want: "after groupBy",
},
{
name: "negative offset",
src: accountRowSource + `fun query(): GotlinSQLQuery = sql.from<AccountRow>().offset(-1).build()`,
want: "offset() requires a non-negative Int",
},
{
name: "bulk insert wrong element type",
src: accountRowSource + `
@table("other") data class OtherRow(@id var id: String)
fun query(rows: List<OtherRow>): GotlinSQLQuery = sql.insertAll<AccountRow>(rows).build()
`,
want: "requires List<AccountRow>",
},
} }
for _, test := range tests { for _, test := range tests {

View file

@ -119,6 +119,13 @@ func hydrateStmtTypeRefs(statements []Stmt) error {
if err := hydrateExprTypeRefs(value.Value); err != nil { if err := hydrateExprTypeRefs(value.Value); err != nil {
return err return err
} }
case FieldAssignStmt:
if err := hydrateExprTypeRefs(value.Target); err != nil {
return err
}
if err := hydrateExprTypeRefs(value.Value); err != nil {
return err
}
case ReturnStmt: case ReturnStmt:
if value.Value != nil { if value.Value != nil {
if err := hydrateExprTypeRefs(value.Value); err != nil { if err := hydrateExprTypeRefs(value.Value); err != nil {

View file

@ -47,10 +47,10 @@ const expectedTokens = [
"throw", "enum", "match", "package", "import", "jsonNaming", "embed", "table", "column", "id", "throw", "enum", "match", "package", "import", "jsonNaming", "embed", "table", "column", "id",
"generated", "Int", "Long", "String", "Boolean", "Unit", "Double", "Float", "Any", "generated", "Int", "Long", "String", "Boolean", "Unit", "Double", "Float", "Any",
"ByteSlice", "Error", "Result", "List", "MutableList", "Map", "MutableMap", "Channel", "GotlinSQLQuery", "ByteSlice", "Error", "Result", "List", "MutableList", "Map", "MutableMap", "Channel", "GotlinSQLQuery",
"GotlinSQLIterator", "from", "where", "orderBy", "orderByDescending", "limit", "GotlinSQLIterator", "from", "alias", "join", "leftJoin", "rightJoin", "where", "groupBy", "having", "orderBy", "orderByDescending", "limit", "offset",
"runBlocking", "coroutineScope", "launch", "async", "await", "delay", "withTimeout", "isActive", "runBlocking", "coroutineScope", "launch", "async", "await", "delay", "withTimeout", "isActive",
"forUpdate", "skipLocked", "insert", "update", "delete", "onConflict", "doNothing", "forUpdate", "skipLocked", "insert", "insertAll", "update", "delete", "onConflict", "doNothing",
"doUpdate", "returning", "build", "fetch", "single", "iterator", "set", "now", "mapTo" "doUpdate", "returning", "build", "fetch", "single", "iterator", "count", "sum", "avg", "min", "max", "now", "mapTo"
]; ];
for (const token of expectedTokens) { for (const token of expectedTokens) {
assert(grammarSource.includes(token), `grammar is missing ${token}`); assert(grammarSource.includes(token), `grammar is missing ${token}`);
@ -63,9 +63,9 @@ for (const annotation of ["jsonNaming", "embed", "table", "column", "id", "gener
const sqlSource = JSON.stringify(grammar.repository.sql); const sqlSource = JSON.stringify(grammar.repository.sql);
for (const method of [ for (const method of [
"from", "where", "select", "orderBy", "orderByDescending", "limit", "forUpdate", "from", "alias", "join", "leftJoin", "rightJoin", "where", "select", "groupBy", "having", "orderBy", "orderByDescending", "limit", "offset", "forUpdate",
"skipLocked", "insert", "update", "delete", "onConflict", "doNothing", "doUpdate", "skipLocked", "insert", "insertAll", "update", "delete", "onConflict", "doNothing", "doUpdate",
"returning", "build", "fetch", "single", "iterator", "set", "now" "returning", "build", "fetch", "single", "iterator", "count", "sum", "avg", "min", "max", "now"
]) { ]) {
assert(sqlSource.includes(method), `SQL grammar is missing ${method}`); assert(sqlSource.includes(method), `SQL grammar is missing ${method}`);
} }
@ -83,7 +83,7 @@ for (const declaration of [
const prefixes = new Set(Object.values(snippets).map((snippet) => snippet.prefix)); const prefixes = new Set(Object.values(snippets).map((snippet) => snippet.prefix));
for (const prefix of [ for (const prefix of [
"dataclass", "exprfun", "withcontext", "genericfun", "genericclass", "tablerow", "embed", "coroutinescope", "launch", "async", "enum", "match", "matchvalue", "mapto", "safe", "nonnull", "resultfun", "resultmatch", "defer", "foreach", "sqlfetch", "dataclass", "exprfun", "withcontext", "genericfun", "genericclass", "tablerow", "embed", "coroutinescope", "launch", "async", "enum", "match", "matchvalue", "mapto", "safe", "nonnull", "resultfun", "resultmatch", "defer", "foreach", "sqlfetch",
"sqlsingle", "sqliterator", "sqlinsertnothing", "sqlinsertupdate", "sqlupdatereturning" "sqlsingle", "sqliterator", "sqlinsertnothing", "sqlinsertupdate", "sqlupdatereturning", "sqlbulk", "sqljoin"
]) { ]) {
assert(prefixes.has(prefix), `snippets are missing prefix ${prefix}`); assert(prefixes.has(prefix), `snippets are missing prefix ${prefix}`);
} }

View file

@ -224,7 +224,7 @@
"sql.insert<${1:Row}>(${2:row})", "sql.insert<${1:Row}>(${2:row})",
" .onConflict { ${3:it.id} }", " .onConflict { ${3:it.id} }",
" .doUpdate { ${4:excluded} ->", " .doUpdate { ${4:excluded} ->",
" set(${1:Row}.${5:value}, ${4:excluded}.${5:value})", " ${1:Row}.${5:value} = ${4:excluded}.${5:value}",
" }", " }",
" .build()" " .build()"
], ],
@ -233,9 +233,9 @@
"SQL Update Returning": { "SQL Update Returning": {
"prefix": "sqlupdatereturning", "prefix": "sqlupdatereturning",
"body": [ "body": [
"val ${1:updated}: *${2:Row} = sql.update<${2:Row}>()", "val ${1:updated}: ${2:Row} = sql.update<${2:Row}>()",
" .set { ${3:row} ->", " .set { ${3:row} ->",
" set(${3:row}.${4:value}, ${5:newValue})", " ${3:row}.${4:value} = ${5:newValue}",
" }", " }",
" .where { ${6:it.id == id} }", " .where { ${6:it.id == id} }",
" .returning { it }", " .returning { it }",
@ -243,6 +243,22 @@
], ],
"description": "Update and return a typed SQL row" "description": "Update and return a typed SQL row"
}, },
"SQL Bulk Insert": {
"prefix": "sqlbulk",
"body": ["val ${1:query} = sql.insertAll<${2:Row}>(${3:rows}).build()"],
"description": "Build a typed bulk insert"
},
"SQL Join And Aggregate": {
"prefix": "sqljoin",
"body": [
"val ${1:query} = sql.from<${2:LeftRow}>()",
" .alias(\"${3:left}\")",
" .leftJoin<${4:RightRow}>(\"${5:right}\") { ${3:left}, ${5:right} -> ${3:left}.${6:id} == ${5:right}.${7:leftId} }",
" .groupBy { ${3:left}, ${5:right} -> ${3:left}.${6:id} }",
" .build()"
],
"description": "Build a typed joined and grouped query"
},
"If": { "If": {
"prefix": "if", "prefix": "if",
"body": [ "body": [

View file

@ -202,11 +202,11 @@
}, },
{ {
"name": "support.function.sql.query.gotlin", "name": "support.function.sql.query.gotlin",
"match": "(?<=\\.)\\b(from|where|select|orderBy|orderByDescending|limit|forUpdate|skipLocked)\\b" "match": "(?<=\\.)\\b(from|alias|join|leftJoin|rightJoin|where|select|groupBy|having|orderBy|orderByDescending|limit|offset|forUpdate|skipLocked)\\b"
}, },
{ {
"name": "support.function.sql.mutation.gotlin", "name": "support.function.sql.mutation.gotlin",
"match": "(?<=\\.)\\b(insert|update|delete|onConflict|doNothing|doUpdate|returning)\\b" "match": "(?<=\\.)\\b(insert|insertAll|update|delete|onConflict|doNothing|doUpdate|returning)\\b"
}, },
{ {
"name": "support.function.sql.execution.gotlin", "name": "support.function.sql.execution.gotlin",
@ -214,7 +214,7 @@
}, },
{ {
"name": "support.function.sql.helper.gotlin", "name": "support.function.sql.helper.gotlin",
"match": "\\b(set|now)\\b(?=\\s*\\()" "match": "\\b(count|countDistinct|sum|avg|min|max|now)\\b(?=\\s*\\()"
}, },
{ {
"name": "support.function.mapping.gotlin", "name": "support.function.mapping.gotlin",