277 lines
9.7 KiB
Go
277 lines
9.7 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)
|
|
}
|
|
if got := mapGoType(row.Fields[3].Type); 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)
|
|
}
|
|
|
|
fun eventStream(pool: *pgxpool.Pool, ctx: context.Context): GotlinSQLIterator<EventProjection> {
|
|
return sql.from<EventRow>()
|
|
.select { row -> EventProjection(row.id, row.payload) }
|
|
.iterator(pool, ctx)
|
|
}
|
|
`)
|
|
for _, want := range []string{
|
|
`return gotlinSQLFetch[EventProjection](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, payload FROM outbox_events ORDER BY created_at", Args: []any{}}, gotlinSQLScanEventProjection)`,
|
|
`return 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)
|
|
}
|
|
`)
|
|
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)
|
|
}
|
|
`)
|
|
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)
|
|
}
|
|
`)
|
|
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) }`,
|
|
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)
|
|
}
|
|
}
|