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() .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() .select { row -> EventProjection(row.id, row.payload) } .orderBy { it.createdAt } .fetch(pool, ctx) } fun eventStream(pool: *pgxpool.Pool, ctx: context.Context): GotlinSQLIterator { return sql.from() .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(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(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() .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() .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().skipLocked().build() }`, want: "requires a preceding forUpdate", }, { name: "lock before limit", src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from().forUpdate().limit(1).build() }`, want: "limit() may appear once", }, { name: "non nullable null comparison", src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from().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().limit(limit).build() }`, want: "argument must have type Int", }, { name: "negative literal limit", src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from().limit(-1).build() }`, want: "requires a non-negative Int", }, { name: "projection type mismatch", src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from().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().select { EventProjection(it.id) }.build() }`, want: "expects 2 fields, got 1", }, { name: "projection after where", src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from().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().where { it.id == "x" }.build() }`, want: "requires set() as its first method", }, { name: "update incompatible value", src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update().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().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().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().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().single(pool, ctx) }`, want: "requires returning()", }, { name: "returning out of order", src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.delete().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) } }