diff --git a/README.md b/README.md index 1af4c00..bfcf2c2 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,143 @@ # Gotlin -`Gotlin` is a small Kotlin-like frontend implemented in Go that targets the Go toolchain. - -This is the practical boundary of the prototype: - -- 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 +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 +interop. ```kotlin -package demo +package main -fun fib(n: Int): Int { - if (n < 2) { - return n - } - return fib(n - 1) + fib(n - 2) +fun fibonacci(value: Int): Int { + if (value < 2) { return value } + return fibonacci(value - 1) + fibonacci(value - 2) } fun main() { - println(fib(8)) + println(fibonacci(10)) } ``` -Imports from Go packages are supported: +## Compiler pipeline -```kotlin -package demo - -import strings - -fun main() { - println(strings.ToUpper("gotlin")) -} +```text +Lexer + -> parser and syntax AST + -> package resolver and structural TypeRef resolution + -> lexical symbols, type checking, effects, and diagnostics + -> typed HIR + -> 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 -package demo.web - -import fmt -import net.http - -fun helloHandler(w: http.ResponseWriter, r: *http.Request) { - fmt.Fprintln(w, "hello from gotlin") +fun greet(name: String): String { + val message = "Hello " + name + return message } -fun main() { - http.HandleFunc("/", helloHandler) - fmt.Println("serving http://localhost:8080") - http.ListenAndServe(":8080", http.DefaultServeMux) -} +fun doubled(value: Int) = value * 2 ``` -Classes and interfaces: +`val` is immutable and `var` is mutable: ```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 { fun greet(name: String): String } -class ConsoleGreeter(val prefix: String) { - fun greet(name: String): String { - return prefix + name - } +class PrefixGreeter(val prefix: String): Greeter { + fun greet(name: String) = prefix + name } -fun main() { - val greeter: Greeter = ConsoleGreeter("hello, ") - println(greeter.greet("gotlin")) +fun create(): PrefixGreeter = PrefixGreeter("Hello ") +``` + +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(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 enum PaymentResult { @@ -117,196 +145,92 @@ enum PaymentResult { Rejected(String) Pending } +``` -fun describe(result: PaymentResult): String { - return match (result) { - PaymentResult.Accepted(id) -> id - PaymentResult.Rejected(reason) -> reason - PaymentResult.Pending -> "pending" +Matches are exhaustive and may return values: + +```kotlin +fun description(result: PaymentResult) = match (result) { + 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 = strconv.atoi(value) + +fun doubled(value: String): Result { + 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(var value: T) { + fun get(): T = value +} + +fun identity(value: T): T = value + +val inferred = identity(42) +val explicit = identity("value") +val boxed = Box("text") +``` + +Higher-order functions use Kotlin-style function types and trailing lambdas: + +```kotlin +fun transform(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 -checked during Gotlin compilation. A match used as an expression also requires -every arm to return the same type. Block-style statement matches remain -available for side effects. +`launch` creates a structured Unit child. `async` creates a typed deferred +child. Scopes wait for children, propagate failures, and cancel siblings. -## 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 -type bindings from arguments or accept explicit type arguments: - -```kotlin -data class Box(var value: T) { - fun get(): T { return value } -} - -fun identity(value: T): T { return value } - -val number = identity(42) -val text = identity("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` flows. -An explicit `Result` return or variable type converts the Go return directly: - -```kotlin -fun parse(value: String): Result { - return strconv.atoi(value) -} - -fun ping(db: *sql.DB): Result { - 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`. 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()`: - -```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() -``` - -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()` 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: +HTTP request contexts can establish an ambient scope with an expression-bodied +function: ```kotlin 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 -failures, and, when nested, combines cancellation from the parent coroutine and -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: +Go calls whose first parameter is `context.Context` receive the ambient context +automatically when that argument is omitted: ```kotlin -fun command(): *exec.Cmd { - return exec.commandContext("date") -} +fun command(): *exec.Cmd = exec.commandContext("date") ``` -The function becomes contextually effectful and lowers to -`exec.CommandContext(gotlinScope.Context(), "date")`. Passing an explicit -context remains supported and suppresses injection, which is important for HTTP -request contexts and deliberately detached work. +Passing an explicit context suppresses automatic injection. + +## Collections and channels ```kotlin -fun load(): Int { - delay(10) - return 42 -} +val names = listOf("Ada", "Linus") +val scores = mapOf("Ada", 10, "Linus", 8) +val channel = Channel(1) -fun main() { - runBlocking { - val value = async { return load() } - launch { println("loading") } - println(value.await()) - } -} +channel.send("ready") +val message = channel.read() ``` -## Type-checked SQL queries +Collection types include `List`, `MutableList`, `Map`, and +`MutableMap`. -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 -import time +fun response(account: AccountEntity): AccountResponse = account.mapTo() +val response = account.mapTo() +``` + +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") 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() - .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: +### Queries, aliases, joins, groups, and aggregates ```kotlin -sql.from() - .where { it.publishedAt == null && (it.claimedUntil == null || it.claimedUntil < now()) } - .orderBy { it.createdAt } - .limit(batchSize) - .forUpdate() - .skipLocked() - .build() -``` +@table("customers") +data class CustomerRow(@id var id: String, var name: String) -The canonical select method order is: +data class AccountSummary( + var customerId: String, + var customerName: String, + var total: Double, + var entries: Long +) -```text -sql.from() - [.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 { - return sql.from() - .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, 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 { - return sql.from() - .where { it.customerId == customerId } - .orderBy { it.accountType } - .fetch(pool, ctx) - .unwrap() -} -``` - -`single(pool, ctx)` returns `Result` 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() - .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().iterator(pool, ctx).unwrap() - defer rows.close() - - while (rows.next()) { - val account: AccountRow = rows.value() - println(account.customerId) +fun summaries(minimum: Double): GotlinSQLQuery = sql.from() + .alias("account") + .leftJoin("customer") { account, customer -> + account.customerId == customer.id } - - val checked = rows.err() -} -``` - -Query and scan failures are returned as `Result` errors. `fetch` and `single` -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 -`query.args` access. `build()` remains available 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(row) - .onConflict { it.id } - .doNothing() + .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(100) + .offset(20) .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 -sql.insert(row) +val insert = sql.insert(row).build() +val bulk = sql.insertAll(rows).build() +``` + +Bulk inserts create runtime-sized PostgreSQL placeholder lists and reject empty +input. + +### Conflicts and updates + +```kotlin +val upsert = sql.insert(row) .onConflict { it.id } - .doUpdate { excluded -> set(AccountRow.balance, excluded.balance) } + .doUpdate { excluded -> + 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(row) - .returning { it } - .single(pool, ctx) - -sql.insert(row) - .onConflict { it.id } - .doNothing() - .returning { value -> AccountSummary(value.id, value.balance) } - .single(pool, ctx) -``` - -The exact insert forms are: - -```text -sql.insert(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() +val update = sql.update() .set { row -> - set(row.balance, row.balance + amount) - set(row.closedAt, now()) + row.balance = row.balance + amount + row.closedAt = now() } .where { it.id == accountId } - .returning { row -> AccountSummary(row.id, row.balance) } - .single(pool, ctx) + .returning { it } + .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 -sql.delete() +val accounts = sql.from() + .where { it.customerId == customerId } + .fetch(pool, ctx) + .unwrap() + +val account = sql.from() .where { it.id == accountId } - .returning { it } .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 -sql.update() - .set { row -> set(row.field, value); ... } - [.where { predicate }] - [.returning { it | Projection(it.field, ...) }] - .build() | returning execution terminal +## Go interop -sql.delete() - [.where { predicate }] - [.returning { it | Projection(it.field, ...) }] - .build() | returning execution terminal +Go packages are imported directly: + +```kotlin +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. -- 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. +## Build and run -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 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 -cd ./tools/vscode-gotlin +cd tools/vscode-gotlin 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. diff --git a/cmd/gotlin-lsp/main.go b/cmd/gotlin-lsp/main.go index 152de49..328c188 100644 --- a/cmd/gotlin-lsp/main.go +++ b/cmd/gotlin-lsp/main.go @@ -1644,8 +1644,13 @@ var builtinDetails = map[string]string{ "float64": "fun float64(value: Any): Double", "bool": "fun bool(value: Any): Boolean", "sql": "typed PostgreSQL query DSL", - "set": "fun set(target: Any, value: Any): Unit", "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", "withContext": "fun withContext(ctx: context.Context, block: () -> Unit): Unit", "coroutineScope": "contextual fun coroutineScope(block: () -> Unit): Unit", diff --git a/cmd/gotlin-lsp/main_test.go b/cmd/gotlin-lsp/main_test.go index 6508fe0..e195cc5 100644 --- a/cmd/gotlin-lsp/main_test.go +++ b/cmd/gotlin-lsp/main_test.go @@ -92,7 +92,7 @@ fun main() { 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) { t.Fatalf("%s is not registered as builtin", name) } diff --git a/examples/sql_expanded.gt b/examples/sql_expanded.gt new file mode 100644 index 0000000..cc75a7b --- /dev/null +++ b/examples/sql_expanded.gt @@ -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() + .alias("account") + .leftJoin("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): GotlinSQLQuery = sql.insertAll(rows).build() + +fun main() { + println(summaries().sql) + println(bulk(listOf(AccountRow("a-1", "c-1", 10.0))).sql) +} diff --git a/internal/lang/ast.go b/internal/lang/ast.go index 3d2df48..fc0dd90 100644 --- a/internal/lang/ast.go +++ b/internal/lang/ast.go @@ -137,6 +137,13 @@ type MultiAssignStmt struct { func (MultiAssignStmt) stmtNode() {} +type FieldAssignStmt struct { + Target SelectorExpr + Value Expr +} + +func (FieldAssignStmt) stmtNode() {} + type ReturnStmt struct { Value Expr } diff --git a/internal/lang/effects.go b/internal/lang/effects.go index 5bf4d95..54dc5f8 100644 --- a/internal/lang/effects.go +++ b/internal/lang/effects.go @@ -81,6 +81,9 @@ func collectFunctionEffects(statements []Stmt, node *effectNode) { collectExpressionEffects(value.Value, node) case MultiAssignStmt: collectExpressionEffects(value.Value, node) + case FieldAssignStmt: + collectExpressionEffects(value.Target, node) + collectExpressionEffects(value.Value, node) case ReturnStmt: if value.Value != nil { collectExpressionEffects(value.Value, node) diff --git a/internal/lang/generate_go.go b/internal/lang/generate_go.go index bc87b1c..6fe7a53 100644 --- a/internal/lang/generate_go.go +++ b/internal/lang/generate_go.go @@ -53,6 +53,7 @@ type goGenerator struct { needsTime bool needsJSONDecode bool needsCoroutines bool + needsSQLBulk bool sqlContextAlias string sqlPGXAlias string currentFunc FunctionDecl @@ -67,6 +68,7 @@ type goGenerator struct { func (g *goGenerator) program(program *Program, packageOverride string) error { containsSQL := programContainsSQL(program) containsSQLExecution := programContainsSQLExecution(program) + g.needsSQLBulk = programContainsSQLBulk(program) g.needsCoroutines = programUsesCoroutines(program) if g.needsCoroutines { g.needsTime = true @@ -145,6 +147,10 @@ func (g *goGenerator) program(program *Program, packageOverride string) error { } } runtimeImports := map[string]string{} + if g.needsSQLBulk { + runtimeImports["strings"] = "strings" + runtimeImports["strconv"] = "strconv" + } if containsSQLExecution { runtimeImports["context"] = g.sqlContextAlias 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)) } 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: if s.Value == nil { g.line("return") @@ -1528,6 +1544,28 @@ func (g *goGenerator) emitSQLSupport(program *Program, execution bool) { g.line("Args []any") g.indentLevel-- 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 { return } diff --git a/internal/lang/hir.go b/internal/lang/hir.go index fe9e64b..f1faa1d 100644 --- a/internal/lang/hir.go +++ b/internal/lang/hir.go @@ -406,6 +406,11 @@ func (resolver *semanticResolver) resolveStmts(stmts []Stmt, scope *Scope, class } value.Value, _ = resolver.resolveExpr(value.Value, scope, class, UnknownType{}) 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: if value.Value != nil { 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, "make": true, "new": true, "copy": true, "delete": true, "close": 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, "delay": true, "withTimeout": true, "isActive": true, "coroutineContext": true, "continue": true, "break": true, diff --git a/internal/lang/parser.go b/internal/lang/parser.go index 904913f..2458206 100644 --- a/internal/lang/parser.go +++ b/internal/lang/parser.go @@ -698,6 +698,17 @@ func (p *parser) parseStmt() (Stmt, error) { if err != nil { 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 } } diff --git a/internal/lang/semantics.go b/internal/lang/semantics.go index 37b9812..ff97a3f 100644 --- a/internal/lang/semantics.go +++ b/internal/lang/semantics.go @@ -89,6 +89,13 @@ func (checker *mutabilityChecker) checkStmts(statements []Stmt) error { if err := checker.checkExpr(value.Value); err != nil { 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: if value.Value != nil { if err := checker.checkExpr(value.Value); err != nil { diff --git a/internal/lang/sql.go b/internal/lang/sql.go index 449cd46..ae58301 100644 --- a/internal/lang/sql.go +++ b/internal/lang/sql.go @@ -17,6 +17,11 @@ type sqlLowered struct { hasResult bool } +type sqlSourceRef struct { + class ClassDecl + alias string +} + func splitSQLChain(expr Expr) (CallExpr, string, []sqlCallStep, bool) { var reversed []sqlCallStep current := expr @@ -31,7 +36,7 @@ func splitSQLChain(expr Expr) (CallExpr, string, []sqlCallStep, bool) { } if receiver, ok := selector.Receiver.(IdentExpr); ok && receiver.Name == "sql" { switch selector.Name { - case "from", "insert", "update", "delete": + case "from", "insert", "insertAll", "update", "delete": default: return CallExpr{}, "", nil, false } @@ -78,9 +83,11 @@ func (g *goGenerator) lowerSQLQuery(expr Expr) (string, bool, error) { var err error switch operation { case "from": - lowered, err = g.lowerSQLSelect(root, steps) + lowered, err = g.lowerSQLSelectExpanded(root, steps) case "insert": lowered, err = g.lowerSQLInsert(root, steps) + case "insertAll": + lowered, err = g.lowerSQLBulkInsert(root, steps) case "update": lowered, err = g.lowerSQLUpdate(root, steps) case "delete": @@ -188,7 +195,7 @@ func sqlProjectionType(call CallExpr, rowType string) (string, bool) { } rowName := "it" if !lambda.ImplicitIt { - if len(lambda.Params) != 1 { + if len(lambda.Params) == 0 { return "", false } 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 } +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) { if len(root.Args) != 1 || len(root.NamedArgs) != 0 { 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 } +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) { if len(root.Args) != 0 || len(root.NamedArgs) != 0 { return sqlLowered{}, fmt.Errorf("sql.update does not accept value arguments") @@ -541,6 +747,370 @@ func sqlAllColumns(class ClassDecl) []string { 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) { lambda, err := sqlLambdaArg(call, method) if err != nil { @@ -620,23 +1190,27 @@ func sqlOrder(step sqlCallStep, class ClassDecl) (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 { - 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 { value, err := strconv.Atoi(literal.Value) 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 } if unary, ok := call.Args[0].(UnaryExpr); ok && unary.Op == "-" { 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" { - 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") if err != nil { @@ -869,24 +1443,16 @@ func (g *goGenerator) sqlTypedUpdateAssignments(call CallExpr, class ClassDecl) rowName = lambda.Params[0].Name } 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 seen := map[string]bool{} for _, stmt := range lambda.Body { - exprStmt, ok := stmt.(ExprStmt) + assignment, ok := stmt.(FieldAssignStmt) 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) - 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) + target, err := sqlRowField(assignment.Target, class, rowName) if err != nil { 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] { 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 { 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 } 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)) seen := map[string]bool{} for _, stmt := range lambda.Body { - exprStmt, ok := stmt.(ExprStmt) + assignment, ok := stmt.(FieldAssignStmt) if !ok { - return nil, fmt.Errorf("doUpdate only supports set() expressions") - } - 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) + return nil, fmt.Errorf("doUpdate only supports %s.field = %s.field assignments", class.Name, excludedName) } + targetRef := assignment.Target targetType, ok := targetRef.Receiver.(IdentExpr) if !ok || targetType.Name != 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] { return nil, fmt.Errorf("duplicate set target %s", target.Name) } - sourceRef, ok := call.Args[1].(SelectorExpr) + sourceRef, ok := assignment.Value.(SelectorExpr) if !ok { 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 { if match(expr) { return true diff --git a/internal/lang/sql_expansion_test.go b/internal/lang/sql_expansion_test.go index 66eed46..28adb39 100644 --- a/internal/lang/sql_expansion_test.go +++ b/internal/lang/sql_expansion_test.go @@ -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 { return sql.update() .set { row -> - set(row.payload, payload) - set(row.attempts, row.attempts + 1) - set(row.claimedUntil, now()) + row.payload = payload + row.attempts = row.attempts + 1 + row.claimedUntil = now() } .where { it.id == id && it.publishedAt == null } .returning { row -> EventProjection(row.id, row.payload) } @@ -210,7 +210,7 @@ func TestRejectExpandedInvalidSQLQueries(t *testing.T) { { name: "projection type mismatch", src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from().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", @@ -220,7 +220,7 @@ func TestRejectExpandedInvalidSQLQueries(t *testing.T) { { name: "projection after where", src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from().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", @@ -229,22 +229,22 @@ func TestRejectExpandedInvalidSQLQueries(t *testing.T) { }, { name: "update incompatible value", - src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update().set { set(it.attempts, "bad") }.build() }`, + src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update().set { it.attempts = "bad" }.build() }`, want: "has type Int but value has type String", }, { name: "update null non nullable", - src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update().set { set(it.payload, null) }.build() }`, + src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update().set { it.payload = null }.build() }`, want: "has non-nullable type String", }, { name: "update nullable into non nullable", - src: eventSQLSource + `fun query(value: String?): GotlinSQLQuery { return sql.update().set { set(it.payload, value) }.build() }`, + src: eventSQLSource + `fun query(value: String?): GotlinSQLQuery { return sql.update().set { it.payload = value }.build() }`, want: "has type String but value has type String?", }, { name: "duplicate update target", - src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update().set { set(it.payload, "a"); set(it.payload, "b") }.build() }`, + src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update().set { it.payload = "a"; it.payload = "b" }.build() }`, want: "duplicate set target payload", }, { diff --git a/internal/lang/sql_test.go b/internal/lang/sql_test.go index 8c083a5..131467c 100644 --- a/internal/lang/sql_test.go +++ b/internal/lang/sql_test.go @@ -205,7 +205,7 @@ func TestGenerateSQLInsertDoUpdate(t *testing.T) { fun upsertAccount(row: AccountRow): GotlinSQLQuery { return sql.insert(row) .onConflict { it.id } - .doUpdate { excluded -> set(AccountRow.balance, excluded.balance) } + .doUpdate { excluded -> AccountRow.balance = excluded.balance } .build() } `) @@ -223,8 +223,8 @@ fun upsert(row: BalanceRow): GotlinSQLQuery { return sql.insert(row) .onConflict { listOf(it.tenantId, it.id) } .doUpdate { excluded -> - set(BalanceRow.amount, excluded.amount) - set(BalanceRow.pending, excluded.pending) + BalanceRow.amount = excluded.amount + BalanceRow.pending = excluded.pending } .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() + .alias("account") + .leftJoin("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): GotlinSQLQuery = sql.insertAll(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) { tests := []struct { name string @@ -282,12 +326,12 @@ func TestRejectInvalidSQLQueries(t *testing.T) { }, { name: "unknown update target", - src: accountRowSource + `fun query(row: AccountRow): GotlinSQLQuery { return sql.insert(row).onConflict { it.id }.doUpdate { excluded -> set(AccountRow.missing, excluded.balance) }.build() }`, + src: accountRowSource + `fun query(row: AccountRow): GotlinSQLQuery { return sql.insert(row).onConflict { it.id }.doUpdate { excluded -> AccountRow.missing = excluded.balance }.build() }`, want: "has no field missing", }, { name: "incompatible update fields", - src: accountRowSource + `fun query(row: AccountRow): GotlinSQLQuery { return sql.insert(row).onConflict { it.id }.doUpdate { excluded -> set(AccountRow.balance, excluded.accountType) }.build() }`, + src: accountRowSource + `fun query(row: AccountRow): GotlinSQLQuery { return sql.insert(row).onConflict { it.id }.doUpdate { excluded -> AccountRow.balance = excluded.accountType }.build() }`, want: "has type Double", }, { @@ -338,6 +382,32 @@ fun query(row: OtherRow): GotlinSQLQuery { return sql.insert(row).on src: accountRowSource + `fun query(row: AccountRow, pool: Any, ctx: Any): List { return sql.insert(row).fetch(pool, ctx).unwrap() }`, 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().alias("row").join("row") { account, customer -> account.customerId == customer.id }.build() +`, + want: `duplicate SQL alias "row"`, + }, + { + name: "having without grouping", + src: accountRowSource + `fun query(): GotlinSQLQuery = sql.from().having { count() > 0 }.build()`, + want: "after groupBy", + }, + { + name: "negative offset", + src: accountRowSource + `fun query(): GotlinSQLQuery = sql.from().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): GotlinSQLQuery = sql.insertAll(rows).build() +`, + want: "requires List", + }, } for _, test := range tests { diff --git a/internal/lang/type_refs.go b/internal/lang/type_refs.go index 2f44a71..45c0b54 100644 --- a/internal/lang/type_refs.go +++ b/internal/lang/type_refs.go @@ -119,6 +119,13 @@ func hydrateStmtTypeRefs(statements []Stmt) error { if err := hydrateExprTypeRefs(value.Value); err != nil { return err } + case FieldAssignStmt: + if err := hydrateExprTypeRefs(value.Target); err != nil { + return err + } + if err := hydrateExprTypeRefs(value.Value); err != nil { + return err + } case ReturnStmt: if value.Value != nil { if err := hydrateExprTypeRefs(value.Value); err != nil { diff --git a/tools/vscode-gotlin/scripts/validate.js b/tools/vscode-gotlin/scripts/validate.js index 93c1c2b..0c940cb 100644 --- a/tools/vscode-gotlin/scripts/validate.js +++ b/tools/vscode-gotlin/scripts/validate.js @@ -47,10 +47,10 @@ const expectedTokens = [ "throw", "enum", "match", "package", "import", "jsonNaming", "embed", "table", "column", "id", "generated", "Int", "Long", "String", "Boolean", "Unit", "Double", "Float", "Any", "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", - "forUpdate", "skipLocked", "insert", "update", "delete", "onConflict", "doNothing", - "doUpdate", "returning", "build", "fetch", "single", "iterator", "set", "now", "mapTo" + "forUpdate", "skipLocked", "insert", "insertAll", "update", "delete", "onConflict", "doNothing", + "doUpdate", "returning", "build", "fetch", "single", "iterator", "count", "sum", "avg", "min", "max", "now", "mapTo" ]; for (const token of expectedTokens) { 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); for (const method of [ - "from", "where", "select", "orderBy", "orderByDescending", "limit", "forUpdate", - "skipLocked", "insert", "update", "delete", "onConflict", "doNothing", "doUpdate", - "returning", "build", "fetch", "single", "iterator", "set", "now" + "from", "alias", "join", "leftJoin", "rightJoin", "where", "select", "groupBy", "having", "orderBy", "orderByDescending", "limit", "offset", "forUpdate", + "skipLocked", "insert", "insertAll", "update", "delete", "onConflict", "doNothing", "doUpdate", + "returning", "build", "fetch", "single", "iterator", "count", "sum", "avg", "min", "max", "now" ]) { 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)); 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", - "sqlsingle", "sqliterator", "sqlinsertnothing", "sqlinsertupdate", "sqlupdatereturning" + "sqlsingle", "sqliterator", "sqlinsertnothing", "sqlinsertupdate", "sqlupdatereturning", "sqlbulk", "sqljoin" ]) { assert(prefixes.has(prefix), `snippets are missing prefix ${prefix}`); } diff --git a/tools/vscode-gotlin/snippets/gotlin.code-snippets b/tools/vscode-gotlin/snippets/gotlin.code-snippets index 9272ee9..afbf1b9 100644 --- a/tools/vscode-gotlin/snippets/gotlin.code-snippets +++ b/tools/vscode-gotlin/snippets/gotlin.code-snippets @@ -224,7 +224,7 @@ "sql.insert<${1:Row}>(${2:row})", " .onConflict { ${3:it.id} }", " .doUpdate { ${4:excluded} ->", - " set(${1:Row}.${5:value}, ${4:excluded}.${5:value})", + " ${1:Row}.${5:value} = ${4:excluded}.${5:value}", " }", " .build()" ], @@ -233,9 +233,9 @@ "SQL Update Returning": { "prefix": "sqlupdatereturning", "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}.${4:value}, ${5:newValue})", + " ${3:row}.${4:value} = ${5:newValue}", " }", " .where { ${6:it.id == id} }", " .returning { it }", @@ -243,6 +243,22 @@ ], "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": { "prefix": "if", "body": [ diff --git a/tools/vscode-gotlin/syntaxes/gotlin.tmLanguage.json b/tools/vscode-gotlin/syntaxes/gotlin.tmLanguage.json index 8c52ff5..d80d0cc 100644 --- a/tools/vscode-gotlin/syntaxes/gotlin.tmLanguage.json +++ b/tools/vscode-gotlin/syntaxes/gotlin.tmLanguage.json @@ -202,11 +202,11 @@ }, { "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", - "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", @@ -214,7 +214,7 @@ }, { "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",