Expand typed SQL DSL
This commit is contained in:
parent
72606e7818
commit
314c8fb207
17 changed files with 1082 additions and 498 deletions
|
|
@ -137,6 +137,13 @@ type MultiAssignStmt struct {
|
|||
|
||||
func (MultiAssignStmt) stmtNode() {}
|
||||
|
||||
type FieldAssignStmt struct {
|
||||
Target SelectorExpr
|
||||
Value Expr
|
||||
}
|
||||
|
||||
func (FieldAssignStmt) stmtNode() {}
|
||||
|
||||
type ReturnStmt struct {
|
||||
Value Expr
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,9 @@ func collectFunctionEffects(statements []Stmt, node *effectNode) {
|
|||
collectExpressionEffects(value.Value, node)
|
||||
case MultiAssignStmt:
|
||||
collectExpressionEffects(value.Value, node)
|
||||
case FieldAssignStmt:
|
||||
collectExpressionEffects(value.Target, node)
|
||||
collectExpressionEffects(value.Value, node)
|
||||
case ReturnStmt:
|
||||
if value.Value != nil {
|
||||
collectExpressionEffects(value.Value, node)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ type goGenerator struct {
|
|||
needsTime bool
|
||||
needsJSONDecode bool
|
||||
needsCoroutines bool
|
||||
needsSQLBulk bool
|
||||
sqlContextAlias string
|
||||
sqlPGXAlias string
|
||||
currentFunc FunctionDecl
|
||||
|
|
@ -67,6 +68,7 @@ type goGenerator struct {
|
|||
func (g *goGenerator) program(program *Program, packageOverride string) error {
|
||||
containsSQL := programContainsSQL(program)
|
||||
containsSQLExecution := programContainsSQLExecution(program)
|
||||
g.needsSQLBulk = programContainsSQLBulk(program)
|
||||
g.needsCoroutines = programUsesCoroutines(program)
|
||||
if g.needsCoroutines {
|
||||
g.needsTime = true
|
||||
|
|
@ -145,6 +147,10 @@ func (g *goGenerator) program(program *Program, packageOverride string) error {
|
|||
}
|
||||
}
|
||||
runtimeImports := map[string]string{}
|
||||
if g.needsSQLBulk {
|
||||
runtimeImports["strings"] = "strings"
|
||||
runtimeImports["strconv"] = "strconv"
|
||||
}
|
||||
if containsSQLExecution {
|
||||
runtimeImports["context"] = g.sqlContextAlias
|
||||
runtimeImports["github.com/jackc/pgx/v5"] = g.sqlPGXAlias
|
||||
|
|
@ -607,6 +613,16 @@ func (g *goGenerator) stmt(stmt Stmt, tail []Stmt) error {
|
|||
names = append(names, g.assignTarget(name))
|
||||
}
|
||||
g.line(fmt.Sprintf("%s = %s", strings.Join(names, ", "), value))
|
||||
case FieldAssignStmt:
|
||||
target, err := g.expr(s.Target, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := g.expr(s.Value, g.exprType(s.Target))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.line(target + " = " + value)
|
||||
case ReturnStmt:
|
||||
if s.Value == nil {
|
||||
g.line("return")
|
||||
|
|
@ -1528,6 +1544,28 @@ func (g *goGenerator) emitSQLSupport(program *Program, execution bool) {
|
|||
g.line("Args []any")
|
||||
g.indentLevel--
|
||||
g.line("}")
|
||||
if g.needsSQLBulk {
|
||||
g.line("")
|
||||
g.line("func gotlinSQLBulkInsert[T any](rows []*T, table string, columns []string, values func(*T) []any) GotlinSQLQuery {")
|
||||
g.indentLevel++
|
||||
g.line("if len(rows) == 0 { panic(\"bulk insert requires at least one row\") }")
|
||||
g.line("var query strings.Builder")
|
||||
g.line("query.WriteString(\"INSERT INTO \" + table + \" (\" + strings.Join(columns, \", \") + \") VALUES \")")
|
||||
g.line("args := make([]any, 0, len(rows)*len(columns))")
|
||||
g.line("placeholder := 1")
|
||||
g.line("for rowIndex, row := range rows {")
|
||||
g.indentLevel++
|
||||
g.line("if rowIndex > 0 { query.WriteString(\", \") }")
|
||||
g.line("query.WriteString(\"(\")")
|
||||
g.line("for columnIndex := range columns { if columnIndex > 0 { query.WriteString(\", \") }; query.WriteString(\"$\" + strconv.Itoa(placeholder)); placeholder++ }")
|
||||
g.line("query.WriteString(\")\")")
|
||||
g.line("args = append(args, values(row)...)")
|
||||
g.indentLevel--
|
||||
g.line("}")
|
||||
g.line("return GotlinSQLQuery{SQL: query.String(), Args: args}")
|
||||
g.indentLevel--
|
||||
g.line("}")
|
||||
}
|
||||
if !execution {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -406,6 +406,11 @@ func (resolver *semanticResolver) resolveStmts(stmts []Stmt, scope *Scope, class
|
|||
}
|
||||
value.Value, _ = resolver.resolveExpr(value.Value, scope, class, UnknownType{})
|
||||
stmts[index] = value
|
||||
case FieldAssignStmt:
|
||||
target, targetType := resolver.resolveExpr(value.Target, scope, class, UnknownType{})
|
||||
value.Target = target.(SelectorExpr)
|
||||
value.Value, _ = resolver.resolveExpr(value.Value, scope, class, targetType)
|
||||
stmts[index] = value
|
||||
case ReturnStmt:
|
||||
if value.Value != nil {
|
||||
value.Value, _ = resolver.resolveExpr(value.Value, scope, class, returnType)
|
||||
|
|
@ -932,7 +937,8 @@ var semanticBuiltins = map[string]bool{
|
|||
"append": true, "keys": true, "goAssert": true, "len": true, "cap": true,
|
||||
"make": true, "new": true, "copy": true, "delete": true, "close": true,
|
||||
"panic": true, "recover": true, "string": true, "int": true, "float64": true, "bool": true,
|
||||
"sql": true, "set": true, "now": true, "Result": true, "ByteSlice": true,
|
||||
"sql": true, "now": true, "Result": true, "ByteSlice": true,
|
||||
"count": true, "countDistinct": true, "sum": true, "avg": true, "min": true, "max": true,
|
||||
"runBlocking": true, "withContext": true, "coroutineScope": true, "launch": true, "async": true,
|
||||
"delay": true, "withTimeout": true, "isActive": true, "coroutineContext": true,
|
||||
"continue": true, "break": true,
|
||||
|
|
|
|||
|
|
@ -698,6 +698,17 @@ func (p *parser) parseStmt() (Stmt, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.match(tokenAssign) {
|
||||
target, ok := expr.(SelectorExpr)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("assignment target must be a variable or field selector")
|
||||
}
|
||||
value, err := p.parseExpr(0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return FieldAssignStmt{Target: target, Value: value}, nil
|
||||
}
|
||||
return ExprStmt{Value: expr}, nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,13 @@ func (checker *mutabilityChecker) checkStmts(statements []Stmt) error {
|
|||
if err := checker.checkExpr(value.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
case FieldAssignStmt:
|
||||
if err := checker.checkExpr(value.Target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checker.checkExpr(value.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
case ReturnStmt:
|
||||
if value.Value != nil {
|
||||
if err := checker.checkExpr(value.Value); err != nil {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ type sqlLowered struct {
|
|||
hasResult bool
|
||||
}
|
||||
|
||||
type sqlSourceRef struct {
|
||||
class ClassDecl
|
||||
alias string
|
||||
}
|
||||
|
||||
func splitSQLChain(expr Expr) (CallExpr, string, []sqlCallStep, bool) {
|
||||
var reversed []sqlCallStep
|
||||
current := expr
|
||||
|
|
@ -31,7 +36,7 @@ func splitSQLChain(expr Expr) (CallExpr, string, []sqlCallStep, bool) {
|
|||
}
|
||||
if receiver, ok := selector.Receiver.(IdentExpr); ok && receiver.Name == "sql" {
|
||||
switch selector.Name {
|
||||
case "from", "insert", "update", "delete":
|
||||
case "from", "insert", "insertAll", "update", "delete":
|
||||
default:
|
||||
return CallExpr{}, "", nil, false
|
||||
}
|
||||
|
|
@ -78,9 +83,11 @@ func (g *goGenerator) lowerSQLQuery(expr Expr) (string, bool, error) {
|
|||
var err error
|
||||
switch operation {
|
||||
case "from":
|
||||
lowered, err = g.lowerSQLSelect(root, steps)
|
||||
lowered, err = g.lowerSQLSelectExpanded(root, steps)
|
||||
case "insert":
|
||||
lowered, err = g.lowerSQLInsert(root, steps)
|
||||
case "insertAll":
|
||||
lowered, err = g.lowerSQLBulkInsert(root, steps)
|
||||
case "update":
|
||||
lowered, err = g.lowerSQLUpdate(root, steps)
|
||||
case "delete":
|
||||
|
|
@ -188,7 +195,7 @@ func sqlProjectionType(call CallExpr, rowType string) (string, bool) {
|
|||
}
|
||||
rowName := "it"
|
||||
if !lambda.ImplicitIt {
|
||||
if len(lambda.Params) != 1 {
|
||||
if len(lambda.Params) == 0 {
|
||||
return "", false
|
||||
}
|
||||
rowName = lambda.Params[0].Name
|
||||
|
|
@ -337,6 +344,170 @@ func (g *goGenerator) lowerSQLSelect(root CallExpr, steps []sqlCallStep) (sqlLow
|
|||
return sqlLowered{value: sqlQueryValue(query, args), result: result, hasResult: true}, nil
|
||||
}
|
||||
|
||||
func (g *goGenerator) lowerSQLSelectExpanded(root CallExpr, steps []sqlCallStep) (sqlLowered, error) {
|
||||
if len(root.Args) != 0 || len(root.NamedArgs) != 0 {
|
||||
return sqlLowered{}, fmt.Errorf("sql.from does not accept value arguments")
|
||||
}
|
||||
class, err := g.sqlClass(root, "from")
|
||||
if err != nil {
|
||||
return sqlLowered{}, err
|
||||
}
|
||||
sources := []sqlSourceRef{{class: class}}
|
||||
result := class
|
||||
columns := sqlScopedAllColumns(sources[0])
|
||||
var joins []string
|
||||
var where, group, having, order, limit, offset string
|
||||
var args []string
|
||||
seenAlias, seenSelect, seenWhere, seenGroup, seenHaving, seenOrder, seenLimit, seenOffset := false, false, false, false, false, false, false, false
|
||||
seenForUpdate, seenSkipLocked := false, false
|
||||
stage := 0
|
||||
for _, step := range steps {
|
||||
switch step.name {
|
||||
case "alias":
|
||||
if seenAlias || stage != 0 {
|
||||
return sqlLowered{}, fmt.Errorf("alias() may appear once before joins")
|
||||
}
|
||||
alias, err := sqlAliasArgument(step.call, "alias")
|
||||
if err != nil {
|
||||
return sqlLowered{}, err
|
||||
}
|
||||
sources[0].alias = alias
|
||||
columns = sqlScopedAllColumns(sources[0])
|
||||
seenAlias = true
|
||||
case "join", "leftJoin", "rightJoin":
|
||||
if stage > 0 {
|
||||
return sqlLowered{}, fmt.Errorf("joins must appear before select() and where()")
|
||||
}
|
||||
joined, clause, joinArgs, err := g.sqlJoin(step, sources, len(args)+1)
|
||||
if err != nil {
|
||||
return sqlLowered{}, err
|
||||
}
|
||||
sources = append(sources, joined)
|
||||
joins = append(joins, clause)
|
||||
args = append(args, joinArgs...)
|
||||
columns = sqlScopedAllColumns(sources[0])
|
||||
case "select":
|
||||
if seenSelect || stage > 0 {
|
||||
return sqlLowered{}, fmt.Errorf("select() may appear once after joins and before filtering")
|
||||
}
|
||||
result, columns, err = g.sqlProjectionScoped(step.call, sources, "select")
|
||||
if err != nil {
|
||||
return sqlLowered{}, err
|
||||
}
|
||||
seenSelect, stage = true, 1
|
||||
case "where":
|
||||
if seenWhere || stage > 2 {
|
||||
return sqlLowered{}, fmt.Errorf("where() may appear once before grouping")
|
||||
}
|
||||
where, args, err = g.sqlWhereScoped(step.call, sources, len(args)+1, args)
|
||||
if err != nil {
|
||||
return sqlLowered{}, err
|
||||
}
|
||||
seenWhere, stage = true, 2
|
||||
case "groupBy":
|
||||
if seenGroup || stage > 3 {
|
||||
return sqlLowered{}, fmt.Errorf("groupBy() may appear once before having() and ordering")
|
||||
}
|
||||
group, err = g.sqlGroupBy(step.call, sources)
|
||||
if err != nil {
|
||||
return sqlLowered{}, err
|
||||
}
|
||||
seenGroup, stage = true, 3
|
||||
case "having":
|
||||
if seenHaving || !seenGroup || stage > 4 {
|
||||
return sqlLowered{}, fmt.Errorf("having() may appear once after groupBy()")
|
||||
}
|
||||
var havingArgs []string
|
||||
having, havingArgs, err = g.sqlPredicateLambda(step.call, sources, "having", len(args)+1)
|
||||
if err != nil {
|
||||
return sqlLowered{}, err
|
||||
}
|
||||
args = append(args, havingArgs...)
|
||||
seenHaving, stage = true, 4
|
||||
case "orderBy", "orderByDescending":
|
||||
if seenOrder || stage > 5 {
|
||||
return sqlLowered{}, fmt.Errorf("ordering may appear once before limit() and offset()")
|
||||
}
|
||||
order, err = g.sqlOrderScoped(step, sources)
|
||||
if err != nil {
|
||||
return sqlLowered{}, err
|
||||
}
|
||||
seenOrder, stage = true, 5
|
||||
case "limit", "offset":
|
||||
if seenForUpdate || seenSkipLocked {
|
||||
return sqlLowered{}, fmt.Errorf("%s() may appear once before forUpdate()", step.name)
|
||||
}
|
||||
if step.name == "limit" && seenLimit {
|
||||
return sqlLowered{}, fmt.Errorf("limit() may appear once")
|
||||
}
|
||||
if step.name == "offset" && seenOffset {
|
||||
return sqlLowered{}, fmt.Errorf("offset() may appear once")
|
||||
}
|
||||
value, valueArgs, err := g.sqlNonNegativeInt(step.call, step.name)
|
||||
if err != nil {
|
||||
return sqlLowered{}, err
|
||||
}
|
||||
if value == "?" {
|
||||
value = "$" + strconv.Itoa(len(args)+1)
|
||||
}
|
||||
args = append(args, valueArgs...)
|
||||
if step.name == "limit" {
|
||||
limit, seenLimit = value, true
|
||||
} else {
|
||||
offset, seenOffset = value, true
|
||||
}
|
||||
stage = 6
|
||||
case "forUpdate":
|
||||
if seenForUpdate || !sqlNoArgs(step.call) {
|
||||
return sqlLowered{}, fmt.Errorf("forUpdate() accepts no arguments and may appear once after offset()")
|
||||
}
|
||||
seenForUpdate = true
|
||||
stage = 7
|
||||
case "skipLocked":
|
||||
if seenSkipLocked || !seenForUpdate || !sqlNoArgs(step.call) {
|
||||
return sqlLowered{}, fmt.Errorf("skipLocked() accepts no arguments and requires a preceding forUpdate()")
|
||||
}
|
||||
seenSkipLocked = true
|
||||
stage = 8
|
||||
default:
|
||||
return sqlLowered{}, fmt.Errorf("unsupported sql.from method %q", step.name)
|
||||
}
|
||||
}
|
||||
from := class.Table
|
||||
if sources[0].alias != "" {
|
||||
from += " AS " + sources[0].alias
|
||||
}
|
||||
query := "SELECT " + strings.Join(columns, ", ") + " FROM " + from
|
||||
if len(joins) > 0 {
|
||||
query += " " + strings.Join(joins, " ")
|
||||
}
|
||||
if where != "" {
|
||||
query += " WHERE " + where
|
||||
}
|
||||
if group != "" {
|
||||
query += " GROUP BY " + group
|
||||
}
|
||||
if having != "" {
|
||||
query += " HAVING " + having
|
||||
}
|
||||
if order != "" {
|
||||
query += " ORDER BY " + order
|
||||
}
|
||||
if limit != "" {
|
||||
query += " LIMIT " + limit
|
||||
}
|
||||
if offset != "" {
|
||||
query += " OFFSET " + offset
|
||||
}
|
||||
if seenForUpdate {
|
||||
query += " FOR UPDATE"
|
||||
}
|
||||
if seenSkipLocked {
|
||||
query += " SKIP LOCKED"
|
||||
}
|
||||
return sqlLowered{value: sqlQueryValue(query, args), result: result, hasResult: true}, nil
|
||||
}
|
||||
|
||||
func (g *goGenerator) lowerSQLInsert(root CallExpr, steps []sqlCallStep) (sqlLowered, error) {
|
||||
if len(root.Args) != 1 || len(root.NamedArgs) != 0 {
|
||||
return sqlLowered{}, fmt.Errorf("sql.insert expects exactly one row argument")
|
||||
|
|
@ -437,6 +608,41 @@ func (g *goGenerator) lowerSQLInsert(root CallExpr, steps []sqlCallStep) (sqlLow
|
|||
return lowered, nil
|
||||
}
|
||||
|
||||
func (g *goGenerator) lowerSQLBulkInsert(root CallExpr, steps []sqlCallStep) (sqlLowered, error) {
|
||||
if len(root.Args) != 1 || len(root.NamedArgs) != 0 {
|
||||
return sqlLowered{}, fmt.Errorf("sql.insertAll expects exactly one row list")
|
||||
}
|
||||
if len(steps) != 0 {
|
||||
return sqlLowered{}, fmt.Errorf("sql.insertAll currently ends directly with build()")
|
||||
}
|
||||
class, err := g.sqlClass(root, "insertAll")
|
||||
if err != nil {
|
||||
return sqlLowered{}, err
|
||||
}
|
||||
typ := g.exprType(root.Args[0])
|
||||
base, args, ok := parseGenericType(typ)
|
||||
if !ok || (base != "List" && base != "MutableList") || len(args) != 1 || strings.TrimPrefix(args[0], "*") != class.Name {
|
||||
return sqlLowered{}, fmt.Errorf("sql.insertAll<%s> requires List<%s>, got %s", class.Name, class.Name, typ)
|
||||
}
|
||||
rows, err := g.expr(root.Args[0], "List<"+class.Name+">")
|
||||
if err != nil {
|
||||
return sqlLowered{}, err
|
||||
}
|
||||
var columns, values []string
|
||||
for _, field := range class.Fields {
|
||||
if field.Generated {
|
||||
continue
|
||||
}
|
||||
columns = append(columns, strconv.Quote(sqlColumn(field)))
|
||||
values = append(values, "row."+mappingFieldName(class, field))
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
return sqlLowered{}, fmt.Errorf("sql.insertAll does not support rows containing only generated fields")
|
||||
}
|
||||
value := fmt.Sprintf("gotlinSQLBulkInsert[%s](%s, %q, []string{%s}, func(row *%s) []any { return []any{%s} })", class.Name, rows, class.Table, strings.Join(columns, ", "), class.Name, strings.Join(values, ", "))
|
||||
return sqlLowered{value: value}, nil
|
||||
}
|
||||
|
||||
func (g *goGenerator) lowerSQLUpdate(root CallExpr, steps []sqlCallStep) (sqlLowered, error) {
|
||||
if len(root.Args) != 0 || len(root.NamedArgs) != 0 {
|
||||
return sqlLowered{}, fmt.Errorf("sql.update does not accept value arguments")
|
||||
|
|
@ -541,6 +747,370 @@ func sqlAllColumns(class ClassDecl) []string {
|
|||
return columns
|
||||
}
|
||||
|
||||
func sqlScopedAllColumns(source sqlSourceRef) []string {
|
||||
columns := make([]string, 0, len(source.class.Fields))
|
||||
for _, field := range source.class.Fields {
|
||||
columns = append(columns, sqlQualifiedColumn(source, field))
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
func sqlQualifiedColumn(source sqlSourceRef, field FieldDecl) string {
|
||||
column := sqlColumn(field)
|
||||
if source.alias != "" {
|
||||
return source.alias + "." + column
|
||||
}
|
||||
return column
|
||||
}
|
||||
|
||||
func sqlAliasArgument(call CallExpr, method string) (string, error) {
|
||||
if len(call.Args) != 1 || len(call.TypeArgs) != 0 || len(call.NamedArgs) != 0 {
|
||||
return "", fmt.Errorf("%s() expects one alias string", method)
|
||||
}
|
||||
literal, ok := call.Args[0].(StringExpr)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s() alias must be a string literal", method)
|
||||
}
|
||||
value, err := strconv.Unquote(literal.Value)
|
||||
if err != nil || !validSQLName(value) {
|
||||
return "", fmt.Errorf("invalid SQL alias %q", value)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (g *goGenerator) sqlJoin(step sqlCallStep, existing []sqlSourceRef, placeholderStart int) (sqlSourceRef, string, []string, error) {
|
||||
if len(step.call.TypeArgs) != 1 || len(step.call.NamedArgs) != 0 {
|
||||
return sqlSourceRef{}, "", nil, fmt.Errorf("%s expects one joined row type", step.name)
|
||||
}
|
||||
joined, ok := g.semantic.Classes[step.call.TypeArgs[0]]
|
||||
if !ok || !joined.Data || joined.Table == "" {
|
||||
return sqlSourceRef{}, "", nil, fmt.Errorf("joined row %s must be a @table data class", step.call.TypeArgs[0])
|
||||
}
|
||||
if len(step.call.Args) < 1 || len(step.call.Args) > 2 {
|
||||
return sqlSourceRef{}, "", nil, fmt.Errorf("%s expects an optional alias and a predicate lambda", step.name)
|
||||
}
|
||||
lambda, ok := step.call.Args[len(step.call.Args)-1].(LambdaExpr)
|
||||
if !ok {
|
||||
return sqlSourceRef{}, "", nil, fmt.Errorf("%s expects a predicate lambda", step.name)
|
||||
}
|
||||
alias := joined.Table
|
||||
if len(step.call.Args) == 2 {
|
||||
literal, ok := step.call.Args[0].(StringExpr)
|
||||
if !ok {
|
||||
return sqlSourceRef{}, "", nil, fmt.Errorf("%s alias must be a string literal", step.name)
|
||||
}
|
||||
var err error
|
||||
alias, err = strconv.Unquote(literal.Value)
|
||||
if err != nil || !validSQLName(alias) {
|
||||
return sqlSourceRef{}, "", nil, fmt.Errorf("invalid SQL alias %q", alias)
|
||||
}
|
||||
}
|
||||
for _, current := range existing {
|
||||
currentAlias := current.alias
|
||||
if currentAlias == "" {
|
||||
currentAlias = current.class.Table
|
||||
}
|
||||
if currentAlias == alias {
|
||||
return sqlSourceRef{}, "", nil, fmt.Errorf("duplicate SQL alias %q", alias)
|
||||
}
|
||||
}
|
||||
source := sqlSourceRef{class: joined, alias: alias}
|
||||
sources := append(append([]sqlSourceRef{}, existing...), source)
|
||||
scope, body, err := sqlScopedLambda(lambda, sources, step.name)
|
||||
if err != nil {
|
||||
return sqlSourceRef{}, "", nil, err
|
||||
}
|
||||
predicate, args, err := g.sqlPredicateScoped(body, scope)
|
||||
if err != nil {
|
||||
return sqlSourceRef{}, "", nil, err
|
||||
}
|
||||
joinType := map[string]string{"join": "INNER JOIN", "leftJoin": "LEFT JOIN", "rightJoin": "RIGHT JOIN"}[step.name]
|
||||
clause := joinType + " " + joined.Table
|
||||
if alias != joined.Table {
|
||||
clause += " AS " + alias
|
||||
}
|
||||
clause += " ON " + postgresPlaceholders(predicate, placeholderStart)
|
||||
return source, clause, args, nil
|
||||
}
|
||||
|
||||
func sqlScopedLambda(lambda LambdaExpr, sources []sqlSourceRef, method string) (map[string]sqlSourceRef, Expr, error) {
|
||||
scope := map[string]sqlSourceRef{}
|
||||
if lambda.ImplicitIt {
|
||||
if len(sources) != 1 {
|
||||
return nil, nil, fmt.Errorf("%s lambda requires %d row parameters", method, len(sources))
|
||||
}
|
||||
scope["it"] = sources[0]
|
||||
} else {
|
||||
if len(lambda.Params) != len(sources) {
|
||||
return nil, nil, fmt.Errorf("%s lambda requires %d row parameters", method, len(sources))
|
||||
}
|
||||
for index, param := range lambda.Params {
|
||||
if param.Type != "" && param.Type != sources[index].class.Name {
|
||||
return nil, nil, fmt.Errorf("%s parameter %s must have type %s", method, param.Name, sources[index].class.Name)
|
||||
}
|
||||
scope[param.Name] = sources[index]
|
||||
}
|
||||
}
|
||||
if len(lambda.Body) != 1 {
|
||||
return nil, nil, fmt.Errorf("%s lambda must contain one expression", method)
|
||||
}
|
||||
statement, ok := lambda.Body[0].(ExprStmt)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("%s lambda must contain one expression", method)
|
||||
}
|
||||
return scope, statement.Value, nil
|
||||
}
|
||||
|
||||
func (g *goGenerator) sqlProjectionScoped(call CallExpr, sources []sqlSourceRef, method string) (ClassDecl, []string, error) {
|
||||
lambda, err := sqlLambdaArg(call, method)
|
||||
if err != nil {
|
||||
return ClassDecl{}, nil, err
|
||||
}
|
||||
scope, body, err := sqlScopedLambda(lambda, sources, method)
|
||||
if err != nil {
|
||||
return ClassDecl{}, nil, err
|
||||
}
|
||||
if ident, ok := body.(IdentExpr); ok {
|
||||
if source, found := scope[ident.Name]; found {
|
||||
return source.class, sqlScopedAllColumns(source), nil
|
||||
}
|
||||
}
|
||||
constructor, ok := body.(CallExpr)
|
||||
if !ok || len(constructor.TypeArgs) != 0 || len(constructor.NamedArgs) != 0 {
|
||||
return ClassDecl{}, nil, fmt.Errorf("%s expects Projection(row.field, aggregate(...))", method)
|
||||
}
|
||||
callee, ok := constructor.Callee.(IdentExpr)
|
||||
if !ok {
|
||||
return ClassDecl{}, nil, fmt.Errorf("%s projection must construct a local data class", method)
|
||||
}
|
||||
projection, ok := g.semantic.Classes[callee.Name]
|
||||
if !ok || !projection.Data {
|
||||
return ClassDecl{}, nil, fmt.Errorf("%s projection type %s must be a data class", method, callee.Name)
|
||||
}
|
||||
if len(constructor.Args) != len(projection.Fields) {
|
||||
return ClassDecl{}, nil, fmt.Errorf("%s projection %s expects %d fields, got %d", method, projection.Name, len(projection.Fields), len(constructor.Args))
|
||||
}
|
||||
columns := make([]string, len(constructor.Args))
|
||||
for index, argument := range constructor.Args {
|
||||
operand, err := g.sqlOperandScoped(argument, scope)
|
||||
if err != nil {
|
||||
return ClassDecl{}, nil, fmt.Errorf("%s projection argument %d: %w", method, index+1, err)
|
||||
}
|
||||
if len(operand.args) != 0 {
|
||||
return ClassDecl{}, nil, fmt.Errorf("%s projection arguments must be fields or aggregates", method)
|
||||
}
|
||||
if !sqlProjectionTypesCompatible(projection.Fields[index].Type, operand.typ) {
|
||||
return ClassDecl{}, nil, fmt.Errorf("%s projection field %s has type %s but expression has type %s", method, projection.Fields[index].Name, projection.Fields[index].Type, operand.typ)
|
||||
}
|
||||
columns[index] = operand.sql
|
||||
}
|
||||
return projection, columns, nil
|
||||
}
|
||||
|
||||
func (g *goGenerator) sqlWhereScoped(call CallExpr, sources []sqlSourceRef, placeholderStart int, existing []string) (string, []string, error) {
|
||||
predicate, args, err := g.sqlPredicateLambda(call, sources, "where", placeholderStart)
|
||||
return predicate, append(existing, args...), err
|
||||
}
|
||||
|
||||
func (g *goGenerator) sqlPredicateLambda(call CallExpr, sources []sqlSourceRef, method string, placeholderStart int) (string, []string, error) {
|
||||
lambda, err := sqlLambdaArg(call, method)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
scope, body, err := sqlScopedLambda(lambda, sources, method)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
predicate, args, err := g.sqlPredicateScoped(body, scope)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return postgresPlaceholders(predicate, placeholderStart), args, nil
|
||||
}
|
||||
|
||||
func (g *goGenerator) sqlPredicateScoped(expr Expr, scope map[string]sqlSourceRef) (string, []string, error) {
|
||||
switch value := expr.(type) {
|
||||
case BinaryExpr:
|
||||
if value.Op == "&&" || value.Op == "||" {
|
||||
left, leftArgs, err := g.sqlPredicateScoped(value.Left, scope)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
right, rightArgs, err := g.sqlPredicateScoped(value.Right, scope)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
op := "AND"
|
||||
if value.Op == "||" {
|
||||
op = "OR"
|
||||
}
|
||||
return "(" + left + " " + op + " " + right + ")", append(leftArgs, rightArgs...), nil
|
||||
}
|
||||
if !isSQLComparison(value.Op) {
|
||||
return "", nil, fmt.Errorf("unsupported SQL predicate operator %q", value.Op)
|
||||
}
|
||||
left, err := g.sqlOperandScoped(value.Left, scope)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
right, err := g.sqlOperandScoped(value.Right, scope)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if left.typ == "Null" || right.typ == "Null" {
|
||||
if value.Op != "==" && value.Op != "!=" {
|
||||
return "", nil, fmt.Errorf("null only supports == and != in SQL predicates")
|
||||
}
|
||||
operand := left
|
||||
if operand.typ == "Null" {
|
||||
operand = right
|
||||
}
|
||||
if !sqlNullableType(operand.typ) {
|
||||
return "", nil, fmt.Errorf("SQL null comparison requires a nullable operand, got %s", operand.typ)
|
||||
}
|
||||
op := "IS NULL"
|
||||
if value.Op == "!=" {
|
||||
op = "IS NOT NULL"
|
||||
}
|
||||
return operand.sql + " " + op, operand.args, nil
|
||||
}
|
||||
if !sqlTypesCompatible(left.typ, right.typ) {
|
||||
return "", nil, fmt.Errorf("SQL predicate compares incompatible types %s and %s", left.typ, right.typ)
|
||||
}
|
||||
if value.Op == ">" || value.Op == ">=" || value.Op == "<" || value.Op == "<=" {
|
||||
if !(sqlNumericType(left.typ) && sqlNumericType(right.typ)) && !(sqlTimestampType(left.typ) && sqlTimestampType(right.typ)) {
|
||||
return "", nil, fmt.Errorf("SQL ordering comparison requires numeric operands or timestamp operands, got %s and %s", left.typ, right.typ)
|
||||
}
|
||||
}
|
||||
op := map[string]string{"==": "=", "!=": "<>", ">": ">", ">=": ">=", "<": "<", "<=": "<="}[value.Op]
|
||||
return left.sql + " " + op + " " + right.sql, append(left.args, right.args...), nil
|
||||
case UnaryExpr:
|
||||
if value.Op != "!" {
|
||||
return "", nil, fmt.Errorf("unsupported SQL predicate unary operator %q", value.Op)
|
||||
}
|
||||
inner, args, err := g.sqlPredicateScoped(value.Value, scope)
|
||||
return "(NOT " + inner + ")", args, err
|
||||
case SelectorExpr:
|
||||
operand, err := g.sqlOperandScoped(value, scope)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if operand.typ != "Boolean" {
|
||||
return "", nil, fmt.Errorf("SQL predicate field has type %s, not Boolean", operand.typ)
|
||||
}
|
||||
return operand.sql, nil, nil
|
||||
default:
|
||||
return "", nil, fmt.Errorf("SQL %s lambda must produce a Boolean predicate", "where/having")
|
||||
}
|
||||
}
|
||||
|
||||
func (g *goGenerator) sqlOperandScoped(expr Expr, scope map[string]sqlSourceRef) (sqlOperandValue, error) {
|
||||
if selector, ok := expr.(SelectorExpr); ok {
|
||||
if receiver, ok := selector.Receiver.(IdentExpr); ok {
|
||||
if source, found := scope[receiver.Name]; found {
|
||||
field, err := sqlField(source.class, selector.Name)
|
||||
if err != nil {
|
||||
return sqlOperandValue{}, err
|
||||
}
|
||||
return sqlOperandValue{sql: sqlQualifiedColumn(source, field), typ: field.Type}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if call, ok := expr.(CallExpr); ok {
|
||||
if ident, ok := call.Callee.(IdentExpr); ok {
|
||||
if ident.Name == "count" && len(call.Args) == 0 {
|
||||
return sqlOperandValue{sql: "COUNT(*)", typ: "Long"}, nil
|
||||
}
|
||||
if (ident.Name == "sum" || ident.Name == "avg" || ident.Name == "min" || ident.Name == "max" || ident.Name == "countDistinct") && len(call.Args) == 1 {
|
||||
inner, err := g.sqlOperandScoped(call.Args[0], scope)
|
||||
if err != nil {
|
||||
return sqlOperandValue{}, err
|
||||
}
|
||||
if len(inner.args) != 0 {
|
||||
return sqlOperandValue{}, fmt.Errorf("%s() requires a row field", ident.Name)
|
||||
}
|
||||
name := map[string]string{"sum": "SUM", "avg": "AVG", "min": "MIN", "max": "MAX", "countDistinct": "COUNT"}[ident.Name]
|
||||
typ := inner.typ
|
||||
sql := name + "(" + inner.sql + ")"
|
||||
if ident.Name == "avg" {
|
||||
typ = "Double"
|
||||
}
|
||||
if ident.Name == "countDistinct" {
|
||||
typ = "Long"
|
||||
sql = "COUNT(DISTINCT " + inner.sql + ")"
|
||||
}
|
||||
return sqlOperandValue{sql: sql, typ: typ}, nil
|
||||
}
|
||||
if ident.Name == "now" && sqlNoArgs(call) {
|
||||
return sqlOperandValue{sql: "CURRENT_TIMESTAMP", typ: "time.Time"}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if binary, ok := expr.(BinaryExpr); ok && (binary.Op == "+" || binary.Op == "-") {
|
||||
left, err := g.sqlOperandScoped(binary.Left, scope)
|
||||
if err != nil {
|
||||
return sqlOperandValue{}, err
|
||||
}
|
||||
right, err := g.sqlOperandScoped(binary.Right, scope)
|
||||
if err != nil {
|
||||
return sqlOperandValue{}, err
|
||||
}
|
||||
return sqlOperandValue{sql: "(" + left.sql + " " + binary.Op + " " + right.sql + ")", typ: left.typ, args: append(left.args, right.args...)}, nil
|
||||
}
|
||||
return g.sqlOperand(expr, ClassDecl{}, "")
|
||||
}
|
||||
|
||||
func (g *goGenerator) sqlGroupBy(call CallExpr, sources []sqlSourceRef) (string, error) {
|
||||
lambda, err := sqlLambdaArg(call, "groupBy")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
scope, body, err := sqlScopedLambda(lambda, sources, "groupBy")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
expressions := []Expr{body}
|
||||
if list, ok := body.(CallExpr); ok {
|
||||
if ident, ok := list.Callee.(IdentExpr); ok && (ident.Name == "listOf" || ident.Name == "mutableListOf") {
|
||||
expressions = list.Args
|
||||
}
|
||||
}
|
||||
columns := make([]string, len(expressions))
|
||||
for index, expr := range expressions {
|
||||
operand, err := g.sqlOperandScoped(expr, scope)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(operand.args) != 0 {
|
||||
return "", fmt.Errorf("groupBy() requires row fields")
|
||||
}
|
||||
columns[index] = operand.sql
|
||||
}
|
||||
return strings.Join(columns, ", "), nil
|
||||
}
|
||||
|
||||
func (g *goGenerator) sqlOrderScoped(step sqlCallStep, sources []sqlSourceRef) (string, error) {
|
||||
lambda, err := sqlLambdaArg(step.call, step.name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
scope, body, err := sqlScopedLambda(lambda, sources, step.name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
operand, err := g.sqlOperandScoped(body, scope)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(operand.args) != 0 {
|
||||
return "", fmt.Errorf("ordering requires a row field or aggregate")
|
||||
}
|
||||
if step.name == "orderByDescending" {
|
||||
return operand.sql + " DESC", nil
|
||||
}
|
||||
return operand.sql, nil
|
||||
}
|
||||
|
||||
func (g *goGenerator) sqlProjection(call CallExpr, rowClass ClassDecl, method string) (ClassDecl, []string, error) {
|
||||
lambda, err := sqlLambdaArg(call, method)
|
||||
if err != nil {
|
||||
|
|
@ -620,23 +1190,27 @@ func sqlOrder(step sqlCallStep, class ClassDecl) (string, error) {
|
|||
}
|
||||
|
||||
func (g *goGenerator) sqlLimit(call CallExpr) (string, []string, error) {
|
||||
return g.sqlNonNegativeInt(call, "limit")
|
||||
}
|
||||
|
||||
func (g *goGenerator) sqlNonNegativeInt(call CallExpr, method string) (string, []string, error) {
|
||||
if len(call.Args) != 1 || len(call.NamedArgs) != 0 || len(call.TypeArgs) != 0 {
|
||||
return "", nil, fmt.Errorf("limit() expects exactly one Int argument")
|
||||
return "", nil, fmt.Errorf("%s() expects exactly one Int argument", method)
|
||||
}
|
||||
if literal, ok := call.Args[0].(IntExpr); ok {
|
||||
value, err := strconv.Atoi(literal.Value)
|
||||
if err != nil || value < 0 {
|
||||
return "", nil, fmt.Errorf("limit() requires a non-negative Int")
|
||||
return "", nil, fmt.Errorf("%s() requires a non-negative Int", method)
|
||||
}
|
||||
return literal.Value, nil, nil
|
||||
}
|
||||
if unary, ok := call.Args[0].(UnaryExpr); ok && unary.Op == "-" {
|
||||
if _, ok := unary.Value.(IntExpr); ok {
|
||||
return "", nil, fmt.Errorf("limit() requires a non-negative Int")
|
||||
return "", nil, fmt.Errorf("%s() requires a non-negative Int", method)
|
||||
}
|
||||
}
|
||||
if typ := g.exprType(call.Args[0]); typ != "Int" {
|
||||
return "", nil, fmt.Errorf("limit() argument must have type Int, got %s", typ)
|
||||
return "", nil, fmt.Errorf("%s() argument must have type Int, got %s", method, typ)
|
||||
}
|
||||
value, err := g.expr(call.Args[0], "Int")
|
||||
if err != nil {
|
||||
|
|
@ -869,24 +1443,16 @@ func (g *goGenerator) sqlTypedUpdateAssignments(call CallExpr, class ClassDecl)
|
|||
rowName = lambda.Params[0].Name
|
||||
}
|
||||
if len(lambda.Body) == 0 {
|
||||
return nil, nil, fmt.Errorf("set lambda requires at least one set(row.field, value) expression")
|
||||
return nil, nil, fmt.Errorf("set lambda requires at least one row.field = value assignment")
|
||||
}
|
||||
var assignments, args []string
|
||||
seen := map[string]bool{}
|
||||
for _, stmt := range lambda.Body {
|
||||
exprStmt, ok := stmt.(ExprStmt)
|
||||
assignment, ok := stmt.(FieldAssignStmt)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("set lambda only supports set(row.field, value) expressions")
|
||||
return nil, nil, fmt.Errorf("set lambda only supports %s.field = value assignments", rowName)
|
||||
}
|
||||
setCall, ok := exprStmt.Value.(CallExpr)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("set lambda only supports set(row.field, value) expressions")
|
||||
}
|
||||
callee, ok := setCall.Callee.(IdentExpr)
|
||||
if !ok || callee.Name != "set" || len(setCall.Args) != 2 || len(setCall.NamedArgs) != 0 || len(setCall.TypeArgs) != 0 {
|
||||
return nil, nil, fmt.Errorf("set lambda expects set(%s.field, value)", rowName)
|
||||
}
|
||||
target, err := sqlRowField(setCall.Args[0], class, rowName)
|
||||
target, err := sqlRowField(assignment.Target, class, rowName)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("set target: %w", err)
|
||||
}
|
||||
|
|
@ -896,7 +1462,7 @@ func (g *goGenerator) sqlTypedUpdateAssignments(call CallExpr, class ClassDecl)
|
|||
if seen[target.Name] {
|
||||
return nil, nil, fmt.Errorf("duplicate set target %s", target.Name)
|
||||
}
|
||||
value, err := g.sqlOperand(setCall.Args[1], class, rowName)
|
||||
value, err := g.sqlOperand(assignment.Value, class, rowName)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("set value for %s: %w", target.Name, err)
|
||||
}
|
||||
|
|
@ -926,27 +1492,16 @@ func sqlConflictUpdateAssignments(lambda LambdaExpr, class ClassDecl) ([]string,
|
|||
excludedName = lambda.Params[0].Name
|
||||
}
|
||||
if len(lambda.Body) == 0 {
|
||||
return nil, fmt.Errorf("doUpdate lambda requires at least one set() expression")
|
||||
return nil, fmt.Errorf("doUpdate lambda requires at least one Row.field = excluded.field assignment")
|
||||
}
|
||||
assignments := make([]string, 0, len(lambda.Body))
|
||||
seen := map[string]bool{}
|
||||
for _, stmt := range lambda.Body {
|
||||
exprStmt, ok := stmt.(ExprStmt)
|
||||
assignment, ok := stmt.(FieldAssignStmt)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("doUpdate only supports set() expressions")
|
||||
}
|
||||
call, ok := exprStmt.Value.(CallExpr)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("doUpdate only supports set() expressions")
|
||||
}
|
||||
callee, ok := call.Callee.(IdentExpr)
|
||||
if !ok || callee.Name != "set" || len(call.Args) != 2 || len(call.NamedArgs) != 0 || len(call.TypeArgs) != 0 {
|
||||
return nil, fmt.Errorf("doUpdate expects set(%s.field, %s.field)", class.Name, excludedName)
|
||||
}
|
||||
targetRef, ok := call.Args[0].(SelectorExpr)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("set target must be %s.field", class.Name)
|
||||
return nil, fmt.Errorf("doUpdate only supports %s.field = %s.field assignments", class.Name, excludedName)
|
||||
}
|
||||
targetRef := assignment.Target
|
||||
targetType, ok := targetRef.Receiver.(IdentExpr)
|
||||
if !ok || targetType.Name != class.Name {
|
||||
return nil, fmt.Errorf("set target must be %s.field", class.Name)
|
||||
|
|
@ -961,7 +1516,7 @@ func sqlConflictUpdateAssignments(lambda LambdaExpr, class ClassDecl) ([]string,
|
|||
if seen[target.Name] {
|
||||
return nil, fmt.Errorf("duplicate set target %s", target.Name)
|
||||
}
|
||||
sourceRef, ok := call.Args[1].(SelectorExpr)
|
||||
sourceRef, ok := assignment.Value.(SelectorExpr)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("set value must be %s.field", excludedName)
|
||||
}
|
||||
|
|
@ -1117,6 +1672,13 @@ func programContainsSQLExecution(program *Program) bool {
|
|||
})
|
||||
}
|
||||
|
||||
func programContainsSQLBulk(program *Program) bool {
|
||||
return programExprMatches(program, func(expr Expr) bool {
|
||||
_, operation, _, ok := splitSQLChain(expr)
|
||||
return ok && operation == "insertAll"
|
||||
})
|
||||
}
|
||||
|
||||
func exprMatches(expr Expr, match func(Expr) bool) bool {
|
||||
if match(expr) {
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -133,9 +133,9 @@ import pgxpool "github.com/jackc/pgx/v5/pgxpool"
|
|||
fun claim(id: String, payload: String, pool: *pgxpool.Pool, ctx: context.Context): EventProjection {
|
||||
return sql.update<EventRow>()
|
||||
.set { row ->
|
||||
set(row.payload, payload)
|
||||
set(row.attempts, row.attempts + 1)
|
||||
set(row.claimedUntil, now())
|
||||
row.payload = payload
|
||||
row.attempts = row.attempts + 1
|
||||
row.claimedUntil = now()
|
||||
}
|
||||
.where { it.id == id && it.publishedAt == null }
|
||||
.returning { row -> EventProjection(row.id, row.payload) }
|
||||
|
|
@ -210,7 +210,7 @@ func TestRejectExpandedInvalidSQLQueries(t *testing.T) {
|
|||
{
|
||||
name: "projection type mismatch",
|
||||
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<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",
|
||||
|
|
@ -220,7 +220,7 @@ func TestRejectExpandedInvalidSQLQueries(t *testing.T) {
|
|||
{
|
||||
name: "projection after where",
|
||||
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",
|
||||
|
|
@ -229,22 +229,22 @@ func TestRejectExpandedInvalidSQLQueries(t *testing.T) {
|
|||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
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?",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ func TestGenerateSQLInsertDoUpdate(t *testing.T) {
|
|||
fun upsertAccount(row: AccountRow): GotlinSQLQuery {
|
||||
return sql.insert<AccountRow>(row)
|
||||
.onConflict { it.id }
|
||||
.doUpdate { excluded -> set(AccountRow.balance, excluded.balance) }
|
||||
.doUpdate { excluded -> AccountRow.balance = excluded.balance }
|
||||
.build()
|
||||
}
|
||||
`)
|
||||
|
|
@ -223,8 +223,8 @@ fun upsert(row: BalanceRow): GotlinSQLQuery {
|
|||
return sql.insert<BalanceRow>(row)
|
||||
.onConflict { listOf(it.tenantId, it.id) }
|
||||
.doUpdate { excluded ->
|
||||
set(BalanceRow.amount, excluded.amount)
|
||||
set(BalanceRow.pending, excluded.pending)
|
||||
BalanceRow.amount = excluded.amount
|
||||
BalanceRow.pending = excluded.pending
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
|
@ -234,6 +234,50 @@ fun upsert(row: BalanceRow): GotlinSQLQuery {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenerateSQLJoinsAliasesGroupingOffsetAndAggregates(t *testing.T) {
|
||||
code := compileSQL(t, accountRowSource+`
|
||||
@table("customers")
|
||||
data class CustomerRow(@id var id: String, var name: String)
|
||||
data class AccountSummary(var customerId: String, var customerName: String, var total: Double, var entries: Long)
|
||||
|
||||
fun summaries(minimum: Double): GotlinSQLQuery {
|
||||
return sql.from<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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -282,12 +326,12 @@ func TestRejectInvalidSQLQueries(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "unknown update target",
|
||||
src: accountRowSource + `fun query(row: AccountRow): GotlinSQLQuery { return sql.insert<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",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
|
|
@ -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() }`,
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -119,6 +119,13 @@ func hydrateStmtTypeRefs(statements []Stmt) error {
|
|||
if err := hydrateExprTypeRefs(value.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
case FieldAssignStmt:
|
||||
if err := hydrateExprTypeRefs(value.Target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := hydrateExprTypeRefs(value.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
case ReturnStmt:
|
||||
if value.Value != nil {
|
||||
if err := hydrateExprTypeRefs(value.Value); err != nil {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue