Expand typed SQL DSL
This commit is contained in:
parent
72606e7818
commit
314c8fb207
17 changed files with 1082 additions and 498 deletions
778
README.md
778
README.md
|
|
@ -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.Accepted(id) -> "accepted " + id
|
||||||
|
PaymentResult.Rejected(reason) -> "rejected " + reason
|
||||||
PaymentResult.Pending -> "pending"
|
PaymentResult.Pending -> "pending"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Enum matches must contain each variant exactly once. Variant payload arity is
|
Payloadless enums are represented as exact string-backed values, making them
|
||||||
checked during Gotlin compilation. A match used as an expression also requires
|
suitable for JSON and PostgreSQL columns.
|
||||||
every arm to return the same type. Block-style statement matches remain
|
|
||||||
available for side effects.
|
|
||||||
|
|
||||||
## User generics
|
## Result error handling
|
||||||
|
|
||||||
Functions and classes may declare type parameters. Calls infer straightforward
|
```kotlin
|
||||||
type bindings from arguments or accept explicit type arguments:
|
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
|
```kotlin
|
||||||
data class Box<T>(var value: T) {
|
data class Box<T>(var value: T) {
|
||||||
fun get(): T { return value }
|
fun get(): T = value
|
||||||
}
|
}
|
||||||
|
|
||||||
fun identity<T>(value: T): T { return value }
|
fun identity<T>(value: T): T = value
|
||||||
|
|
||||||
val number = identity(42)
|
val inferred = identity(42)
|
||||||
val text = identity<String>("value")
|
val explicit = identity<String>("value")
|
||||||
val box = Box("boxed")
|
val boxed = Box("text")
|
||||||
```
|
```
|
||||||
|
|
||||||
Type parameters currently use an implicit `Any` constraint. Generic methods
|
Higher-order functions use Kotlin-style function types and trailing lambdas:
|
||||||
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
|
```kotlin
|
||||||
fun email(user: User?): String? {
|
fun transform<T, R>(value: T, block: (T) -> R): R = block(value)
|
||||||
return user?.email
|
|
||||||
|
val answer = transform(21) { value ->
|
||||||
|
value * 2
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The compiler rejects `null` in non-nullable arguments, fields, local variables,
|
The final expression is returned automatically for value lambdas. Unit lambdas
|
||||||
and return values. Nullable receivers cannot be dereferenced directly. Gotlin
|
execute their final expression as a statement. Explicit `return` is also
|
||||||
smart-casts values after `value != null` branches and guard clauses such as
|
supported.
|
||||||
`if (value == null) { return }`. Use `!!` only when an invariant cannot be
|
|
||||||
expressed through control flow:
|
## Structured concurrency and context
|
||||||
|
|
||||||
|
Coroutine effects are inferred from direct and transitive calls:
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
val required: User = optionalUser!!
|
fun poll() {
|
||||||
```
|
while (isActive()) {
|
||||||
|
receiveEvents()
|
||||||
## Error handling
|
delay(1000)
|
||||||
|
}
|
||||||
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> {
|
fun main() {
|
||||||
return db.ping()
|
runBlocking {
|
||||||
|
launch { poll() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `?` to propagate a Go error or chain a Gotlin function returning `Result`.
|
`launch` creates a structured Unit child. `async<T>` creates a typed deferred
|
||||||
Explicit panic and fallback operations are available when appropriate:
|
child. Scopes wait for children, propagate failures, and cancel siblings.
|
||||||
|
|
||||||
```kotlin
|
Available operations include `runBlocking`, `withContext`, `coroutineScope`,
|
||||||
val required = parse("42").unwrap()
|
`launch`, `async`, `await`, `delay`, `withTimeout`, `isActive`, and
|
||||||
val fallback = parse("invalid").unwrapOr(0)
|
`coroutineContext`.
|
||||||
```
|
|
||||||
|
|
||||||
Gotlin never inserts implicit panic wrappers. A Go call returning `(T, error)`
|
HTTP request contexts can establish an ambient scope with an expression-bodied
|
||||||
must have an explicit `Result` context or use `?`, explicit `value, error`
|
function:
|
||||||
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 {
|
### Queries, aliases, joins, groups, and aggregates
|
||||||
return sql.from<AccountRow>()
|
|
||||||
.where { row -> row.customerId == customerId && row.closedAt == null }
|
```kotlin
|
||||||
.orderByDescending { row -> row.balance }
|
@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 = 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(100)
|
.limit(100)
|
||||||
.build()
|
.offset(20)
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The compiler emits a Go value with this generated support type:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type GotlinSQLQuery struct {
|
|
||||||
SQL string
|
|
||||||
Args []any
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The example selects every field, including `@generated` fields, and produces PostgreSQL `$n` placeholders. Arguments are emitted in SQL traversal order. A literal `limit(100)` is embedded after validation; a typed non-literal `Int` limit uses the next placeholder.
|
|
||||||
|
|
||||||
Nullable types use a `?` suffix, currently including forms such as `String?` and `time.Time?`; generated Go fields use pointers. Comparing a nullable field with `null` lowers to `IS NULL` or `IS NOT NULL`. Ordering comparisons support numeric values and `time.Time`; typed `now()` emits `CURRENT_TIMESTAMP` without an argument:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
sql.from<OutboxRow>()
|
|
||||||
.where { it.publishedAt == null && (it.claimedUntil == null || it.claimedUntil < now()) }
|
|
||||||
.orderBy { it.createdAt }
|
|
||||||
.limit(batchSize)
|
|
||||||
.forUpdate()
|
|
||||||
.skipLocked()
|
|
||||||
.build()
|
.build()
|
||||||
```
|
```
|
||||||
|
|
||||||
The canonical select method order is:
|
Join methods include `join`, `leftJoin`, and `rightJoin`. Aggregates include
|
||||||
|
`count`, `countDistinct`, `sum`, `avg`, `min`, and `max`.
|
||||||
|
|
||||||
```text
|
### Inserts and bulk inserts
|
||||||
sql.from<Row>()
|
|
||||||
[.select { ... }]
|
|
||||||
[.where { ... }]
|
|
||||||
[.orderBy { ... } | .orderByDescending { ... }]
|
|
||||||
[.limit(Int)]
|
|
||||||
[.forUpdate()]
|
|
||||||
[.skipLocked()]
|
|
||||||
.build() | .fetch(pool, ctx) | .single(pool, ctx) | .iterator(pool, ctx)
|
|
||||||
```
|
|
||||||
|
|
||||||
Each optional method may occur at most once. `select` must be first, `skipLocked` requires `forUpdate`, and clauses are emitted as `WHERE`, `ORDER BY`, `LIMIT`, `FOR UPDATE`, `SKIP LOCKED` in PostgreSQL order.
|
|
||||||
|
|
||||||
### Typed projections
|
|
||||||
|
|
||||||
Projection targets are local data classes. The constructor must contain one direct source-row field per target field, in target declaration order, with exact matching types:
|
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
data class AccountSummary(var id: String, var balance: Double)
|
val insert = sql.insert<AccountRow>(row).build()
|
||||||
|
val bulk = sql.insertAll<AccountRow>(rows).build()
|
||||||
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`.
|
Bulk inserts create runtime-sized PostgreSQL placeholder lists and reject empty
|
||||||
|
input.
|
||||||
|
|
||||||
Select chains can execute directly against a pgx/v5 pool. `fetch(pool, ctx)`
|
### Conflicts and updates
|
||||||
returns `Result<List<AccountRow>, Error>` and closes pgx rows after reading and
|
|
||||||
scanning every result in data-class field declaration order:
|
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
import context
|
val upsert = sql.insert<AccountRow>(row)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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<AccountRow>(row)
|
|
||||||
.onConflict { it.id }
|
.onConflict { it.id }
|
||||||
.doNothing()
|
.doUpdate { excluded ->
|
||||||
|
AccountRow.balance = excluded.balance
|
||||||
|
}
|
||||||
.build()
|
.build()
|
||||||
```
|
|
||||||
|
|
||||||
Composite conflict keys use `listOf`, for example `.onConflict { listOf(it.tenantId, it.id) }`.
|
val update = sql.update<AccountRow>()
|
||||||
|
|
||||||
Updates use a typed lower-camel `set` form because Gotlin does not currently implement Kotlin callable references (`AccountRow::balance`):
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
sql.insert<AccountRow>(row)
|
|
||||||
.onConflict { it.id }
|
|
||||||
.doUpdate { excluded -> set(AccountRow.balance, excluded.balance) }
|
|
||||||
.build()
|
|
||||||
```
|
|
||||||
|
|
||||||
Multiple `set(...)` expressions may appear in one `doUpdate` lambda. Update targets must exist and be `var`; excluded fields must exist and have a compatible type.
|
|
||||||
|
|
||||||
An insert can return the full row or a typed projection. Write chains can use `fetch`, `single`, or `iterator` only after `returning`:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
sql.insert<AccountRow>(row)
|
|
||||||
.returning { it }
|
|
||||||
.single(pool, ctx)
|
|
||||||
|
|
||||||
sql.insert<AccountRow>(row)
|
|
||||||
.onConflict { it.id }
|
|
||||||
.doNothing()
|
|
||||||
.returning { value -> AccountSummary(value.id, value.balance) }
|
|
||||||
.single(pool, ctx)
|
|
||||||
```
|
|
||||||
|
|
||||||
The exact insert forms are:
|
|
||||||
|
|
||||||
```text
|
|
||||||
sql.insert<Row>(row)
|
|
||||||
[.onConflict { field | listOf(fields...) }.doNothing() | .doUpdate { ... }]
|
|
||||||
[.returning { it | Projection(it.field, ...) }]
|
|
||||||
.build() | returning execution terminal
|
|
||||||
```
|
|
||||||
|
|
||||||
### Updates and deletes
|
|
||||||
|
|
||||||
Typed updates use one `set` lambda. Targets are mutable source-row fields. Values may be typed names/selectors, literals, `null` for nullable targets, `now()` for timestamps, source-row fields, or numeric `+` and `-` expressions:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
sql.update<AccountRow>()
|
|
||||||
.set { row ->
|
.set { row ->
|
||||||
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.
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
24
examples/sql_expanded.gt
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue