281 lines
9.9 KiB
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 ->
|
|
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) }
|
|
.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 expression 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: "after joins and before filtering",
|
|
},
|
|
{
|
|
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 { 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 { 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 { 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 { it.payload = "a"; 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)
|
|
}
|
|
}
|