gotlin/internal/lang/sql_expansion_test.go

281 lines
9.9 KiB
Go

package lang
import (
"strings"
"testing"
)
const eventSQLSource = `
import time
@table("outbox_events")
data class EventRow(
@generated @id var id: String,
var payload: String,
var attempts: Int,
var publishedAt: time.Time?,
var claimedUntil: time.Time?,
var createdAt: time.Time
)
data class EventProjection(var id: String, var payload: String)
data class WrongProjection(var id: Int)
`
func TestSQLGeneratedNullableAndLockingMetadata(t *testing.T) {
program, err := Parse(eventSQLSource)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
row := program.Classes[0]
if !row.Fields[0].Generated {
t.Fatal("id field is missing @generated metadata")
}
if row.Fields[3].Type != "time.Time?" {
t.Fatalf("publishedAt type = %q, want time.Time?", row.Fields[3].Type)
}
typ, err := ParseType(row.Fields[3].Type)
if err != nil {
t.Fatal(err)
}
if got := renderGoType(typ); got != "*time.Time" {
t.Fatalf("mapped nullable timestamp = %q, want *time.Time", got)
}
code := compileSQL(t, eventSQLSource+`
fun claimQuery(nowLimit: Int): GotlinSQLQuery {
return sql.from<EventRow>()
.where { it.publishedAt == null && (it.claimedUntil == null || it.claimedUntil < now()) }
.orderByDescending { it.createdAt }
.limit(nowLimit)
.forUpdate()
.skipLocked()
.build()
}
`)
for _, want := range []string{
`SQL: "SELECT id, payload, attempts, published_at, claimed_until, created_at FROM outbox_events WHERE (published_at IS NULL AND (claimed_until IS NULL OR claimed_until < CURRENT_TIMESTAMP)) ORDER BY created_at DESC LIMIT $1 FOR UPDATE SKIP LOCKED"`,
`Args: []any{nowLimit}`,
} {
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
}
func TestSQLTypedProjectionExecution(t *testing.T) {
code := compileSQL(t, eventSQLSource+`
import context
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
fun events(pool: *pgxpool.Pool, ctx: context.Context): List<EventProjection> {
return sql.from<EventRow>()
.select { row -> EventProjection(row.id, row.payload) }
.orderBy { it.createdAt }
.fetch(pool, ctx).unwrap()
}
fun eventStream(pool: *pgxpool.Pool, ctx: context.Context): GotlinSQLIterator<EventProjection> {
return sql.from<EventRow>()
.select { row -> EventProjection(row.id, row.payload) }
.iterator(pool, ctx).unwrap()
}
`)
for _, want := range []string{
`return gotlinResultUnwrap(gotlinSQLFetch[EventProjection](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, payload FROM outbox_events ORDER BY created_at", Args: []any{}}, gotlinSQLScanEventProjection))`,
`return gotlinResultUnwrap(gotlinSQLIterate[EventProjection](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, payload FROM outbox_events", Args: []any{}}, gotlinSQLScanEventProjection))`,
`func gotlinSQLScanEventProjection(row gotlinSQLRow) (*EventProjection, error)`,
`err := row.Scan(&value.Id, &value.Payload)`,
} {
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
}
func TestSQLInsertOmitsGeneratedAndReturnsProjection(t *testing.T) {
code := compileSQL(t, eventSQLSource+`
import context
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
fun create(row: EventRow, pool: *pgxpool.Pool, ctx: context.Context): EventProjection {
return sql.insert<EventRow>(row)
.returning { value -> EventProjection(value.id, value.payload) }
.single(pool, ctx).unwrap()
}
`)
for _, want := range []string{
`SQL: "INSERT INTO outbox_events (payload, attempts, published_at, claimed_until, created_at) VALUES ($1, $2, $3, $4, $5) RETURNING id, payload"`,
`Args: []any{row.Payload, row.Attempts, row.PublishedAt, row.ClaimedUntil, row.CreatedAt}`,
`gotlinSQLSingle[EventProjection]`,
} {
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
}
func TestSQLGeneratedOnlyInsertUsesDefaultValues(t *testing.T) {
code := compileSQL(t, `
@table("tokens") data class TokenRow(@generated @id var id: String)
fun insert(row: TokenRow): GotlinSQLQuery { return sql.insert<TokenRow>(row).build() }
`)
if want := `SQL: "INSERT INTO tokens DEFAULT VALUES"`; !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
func TestSQLTypedUpdateAndReturning(t *testing.T) {
code := compileSQL(t, eventSQLSource+`
import context
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())
}
.where { it.id == id && it.publishedAt == null }
.returning { row -> EventProjection(row.id, row.payload) }
.single(pool, ctx).unwrap()
}
`)
for _, want := range []string{
`SQL: "UPDATE outbox_events SET payload = $1, attempts = (attempts + $2), claimed_until = CURRENT_TIMESTAMP WHERE (id = $3 AND published_at IS NULL) RETURNING id, payload"`,
`Args: []any{payload, 1, id}`,
`gotlinSQLSingle[EventProjection]`,
} {
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
}
func TestSQLDeleteReturningFullRow(t *testing.T) {
code := compileSQL(t, eventSQLSource+`
import context
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
fun remove(id: String, pool: *pgxpool.Pool, ctx: context.Context): EventRow {
return sql.delete<EventRow>()
.where { it.id == id }
.returning { it }
.single(pool, ctx).unwrap()
}
`)
for _, want := range []string{
`SQL: "DELETE FROM outbox_events WHERE id = $1 RETURNING id, payload, attempts, published_at, claimed_until, created_at"`,
`gotlinSQLSingle[EventRow]`,
`err := row.Scan(&value.Id, &value.Payload, &value.Attempts, &value.PublishedAt, &value.ClaimedUntil, &value.CreatedAt)`,
} {
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
}
func TestRejectExpandedInvalidSQLQueries(t *testing.T) {
tests := []struct {
name string
src string
want string
}{
{
name: "skip locked without for update",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().skipLocked().build() }`,
want: "requires a preceding forUpdate",
},
{
name: "lock before limit",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().forUpdate().limit(1).build() }`,
want: "limit() may appear once",
},
{
name: "non nullable null comparison",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().where { it.payload == null }.build() }`,
want: "null comparison requires a nullable operand",
},
{
name: "wrong limit type",
src: eventSQLSource + `fun query(limit: String): GotlinSQLQuery { return sql.from<EventRow>().limit(limit).build() }`,
want: "argument must have type Int",
},
{
name: "negative literal limit",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().limit(-1).build() }`,
want: "requires a non-negative Int",
},
{
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",
},
{
name: "projection arity mismatch",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().select { EventProjection(it.id) }.build() }`,
want: "expects 2 fields, got 1",
},
{
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",
},
{
name: "update missing set",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update<EventRow>().where { it.id == "x" }.build() }`,
want: "requires set() as its first method",
},
{
name: "update incompatible value",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update<EventRow>().set { 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() }`,
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() }`,
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() }`,
want: "duplicate set target payload",
},
{
name: "write execution without returning",
src: eventSQLSource + `fun query(pool: Any, ctx: Any): EventRow { return sql.delete<EventRow>().single(pool, ctx).unwrap() }`,
want: "requires returning()",
},
{
name: "returning out of order",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.delete<EventRow>().returning { it }.where { it.id == "x" }.build() }`,
want: "invalid method order",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
program, err := Parse(test.src)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
_, err = GenerateGo(program)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want substring %q", err, test.want)
}
})
}
}
func TestRejectDuplicateGeneratedAnnotation(t *testing.T) {
_, err := Parse(`data class Row(@generated @generated var id: String)`)
if err == nil || !strings.Contains(err.Error(), "duplicate generated annotation") {
t.Fatalf("Parse() error = %v", err)
}
}