// SPDX-FileCopyrightText: 2026 Stefan Majewsky // SPDX-License-Identifier: Apache-2.0 package oblast_test import ( "database/sql" "strconv" "testing" "go.xyrillian.de/oblast" "go.xyrillian.de/oblast/internal/assert" "go.xyrillian.de/oblast/internal/mock" "go.xyrillian.de/oblast/internal/must" ) func TestInsertBasicUsingLastInsertId(t *testing.T) { md := mock.NewDriver() db := sql.OpenDB(md) type basicRecord struct { ID int64 `db:"id,auto"` Name string `db:"name"` } store := oblast.MustNewStore[basicRecord]( oblast.SqliteDialect(), oblast.TableNameIs("basic_records"), oblast.PrimaryKeyIs("id"), ) for _, batchSize := range []int{1, oblast.PrepareThreshold - 1, oblast.PrepareThreshold + 1} { t.Run("N="+strconv.Itoa(batchSize), func(t *testing.T) { records := make([]basicRecord, batchSize) for idx := range batchSize { records[idx] = basicRecord{Name: "new"} md.ForQuery(`INSERT INTO "basic_records" ("name") VALUES (?)`). ExpectExecWithArgs("new"). AndReturnLastInsertId(int64(42 + idx)). AndReturnRowsAffected(1) } records = must.Return(store.Insert(db, records...))(t) for idx, r := range records { assert.Equal(t, r.ID, int64(42+idx)) } }) } } func TestInsertBasicUsingReturningClause(t *testing.T) { md := mock.NewDriver() db := sql.OpenDB(md) type basicRecord struct { ID int64 `db:"id,auto"` Name string `db:"name"` } store := oblast.MustNewStore[basicRecord]( oblast.PostgresDialect(), oblast.TableNameIs("basic_records"), oblast.PrimaryKeyIs("id"), ) for _, batchSize := range []int{1, oblast.PrepareThreshold - 1, oblast.PrepareThreshold + 1} { t.Run("N="+strconv.Itoa(batchSize), func(t *testing.T) { records := make([]basicRecord, batchSize) for idx := range batchSize { records[idx] = basicRecord{Name: "new"} md.ForQuery(`INSERT INTO "basic_records" ("name") VALUES ($1) RETURNING "id"`). ExpectQueryWithArgs("new"). AndReturnColumns("id"). WithRow(int64(42 + idx)) } records = must.Return(store.Insert(db, records...))(t) for idx, r := range records { assert.Equal(t, r.ID, int64(42+idx)) } }) } } func TestUpdateBasic(t *testing.T) { md := mock.NewDriver() db := sql.OpenDB(md) type basicRecord struct { ID int64 `db:"id,auto"` Name string `db:"name"` } store := oblast.MustNewStore[basicRecord]( oblast.SqliteDialect(), oblast.TableNameIs("basic_records"), oblast.PrimaryKeyIs("id"), ) for _, batchSize := range []int{1, oblast.PrepareThreshold - 1, oblast.PrepareThreshold + 1} { t.Run("N="+strconv.Itoa(batchSize), func(t *testing.T) { records := make([]basicRecord, batchSize) for idx := range batchSize { r := basicRecord{ID: int64(42 + idx), Name: "updated"} records[idx] = r md.ForQuery(`UPDATE "basic_records" SET "name" = ? WHERE "id" = ?`). ExpectExecWithArgs(r.Name, r.ID). AndReturnRowsAffected(1) } must.Succeed(t, store.Update(db, records...)) }) } } func TestDeleteBasic(t *testing.T) { md := mock.NewDriver() db := sql.OpenDB(md) type basicRecord struct { ID int64 `db:"id,auto"` Name string `db:"name"` } store := oblast.MustNewStore[basicRecord]( oblast.SqliteDialect(), oblast.TableNameIs("basic_records"), oblast.PrimaryKeyIs("id"), ) for _, batchSize := range []int{1, oblast.PrepareThreshold - 1, oblast.PrepareThreshold + 1} { t.Run("N="+strconv.Itoa(batchSize), func(t *testing.T) { records := make([]basicRecord, batchSize) for idx := range batchSize { r := basicRecord{ID: int64(42 + idx), Name: "removed"} records[idx] = r md.ForQuery(`DELETE FROM "basic_records" WHERE "id" = ?`). ExpectExecWithArgs(r.ID). AndReturnRowsAffected(1) } must.Succeed(t, store.Delete(db, records...)) }) } } func TestWriteQueriesNotPossible(t *testing.T) { md := mock.NewDriver() db := sql.OpenDB(md) type basicRecord struct { ID int64 `db:"id,auto"` Name string `db:"name"` } store := oblast.MustNewStore[basicRecord]( oblast.SqliteDialect(), // no TableNameIs() or PrimaryKeyIs() given ) r := basicRecord{Name: "foo"} _, err := store.Insert(db, r) assert.ErrEqual(t, err, "cannot execute Insert() because query could not be autogenerated") r.ID = 42 err = store.Update(db, r) assert.ErrEqual(t, err, "cannot execute Update() because query could not be autogenerated") err = store.Delete(db, r) assert.ErrEqual(t, err, "cannot execute Delete() because query could not be autogenerated") } func TestWriteQueriesFailDuringPrepare(t *testing.T) { md := mock.NewDriver() db := sql.OpenDB(md) type basicRecord struct { ID int64 `db:"id,auto"` Name string `db:"name"` } store := oblast.MustNewStore[basicRecord]( oblast.SqliteDialect(), oblast.TableNameIs("basic_records"), oblast.PrimaryKeyIs("id"), ) for _, batchSize := range []int{1, oblast.PrepareThreshold - 1, oblast.PrepareThreshold + 1} { records := make([]basicRecord, batchSize) for idx := range batchSize { records[idx] = basicRecord{Name: "foo"} } _, err := store.Insert(db, records...) baseError := `unexpected query: INSERT INTO "basic_records" ("name") VALUES (?)` if batchSize < oblast.PrepareThreshold { assert.ErrEqual(t, err, "during Exec() for record with idx = 0: "+baseError) } else { assert.ErrEqual(t, err, "during Prepare(): "+baseError) } for idx := range batchSize { records[idx].ID = int64(42 + idx) } err = store.Update(db, records...) baseError = `unexpected query: UPDATE "basic_records" SET "name" = ? WHERE "id" = ?` if batchSize < oblast.PrepareThreshold { assert.ErrEqual(t, err, "during Exec() for record with idx = 0: "+baseError) } else { assert.ErrEqual(t, err, "during Prepare(): "+baseError) } err = store.Delete(db, records...) baseError = `unexpected query: DELETE FROM "basic_records" WHERE "id" = ?` if batchSize < oblast.PrepareThreshold { assert.ErrEqual(t, err, "during Exec() for record with idx = 0: "+baseError) } else { assert.ErrEqual(t, err, "during Prepare(): "+baseError) } } store = oblast.MustNewStore[basicRecord]( oblast.PostgresDialect(), // for test coverage of insertUsingReturningClause() oblast.TableNameIs("basic_records"), oblast.PrimaryKeyIs("id"), ) for _, batchSize := range []int{1, oblast.PrepareThreshold - 1, oblast.PrepareThreshold + 1} { records := make([]basicRecord, batchSize) for idx := range batchSize { records[idx] = basicRecord{Name: "foo"} } _, err := store.Insert(db, records...) baseError := `unexpected query: INSERT INTO "basic_records" ("name") VALUES ($1) RETURNING "id"` if batchSize < oblast.PrepareThreshold { assert.ErrEqual(t, err, "during QueryRow() for record with idx = 0: "+baseError) } else { assert.ErrEqual(t, err, "during Prepare(): "+baseError) } } } func TestUpdateFailsOnMissingRecord(t *testing.T) { md := mock.NewDriver() db := sql.OpenDB(md) type basicRecord struct { ID int64 `db:"id,auto"` Name string `db:"name"` } store := oblast.MustNewStore[basicRecord]( oblast.SqliteDialect(), oblast.TableNameIs("basic_records"), oblast.PrimaryKeyIs("id"), ) md.ForQuery(`UPDATE "basic_records" SET "name" = ? WHERE "id" = ?`). ExpectExecWithArgs("changed", 42). AndReturnRowsAffected(0) err := store.Update(db, basicRecord{ID: 42, Name: "changed"}) assert.ErrEqual(t, err, "could not UPDATE record that does not exist in the database: id = 42") _, hasCorrectType := err.(oblast.MissingRecordError[basicRecord]) //nolint:errorlint // we explicitly do not want a wrapped error assert.Equal(t, hasCorrectType, true) } func TestInsertWithUnsignedIdField(t *testing.T) { md := mock.NewDriver() db := sql.OpenDB(md) type basicRecord struct { ID uint64 `db:"id,auto"` // not int64! Name string `db:"name"` } t.Run("using LastInsertID", func(t *testing.T) { store := oblast.MustNewStore[basicRecord]( oblast.SqliteDialect(), oblast.TableNameIs("basic_records"), oblast.PrimaryKeyIs("id"), ) // success case md.ForQuery(`INSERT INTO "basic_records" ("name") VALUES (?)`). ExpectExecWithArgs("first"). AndReturnLastInsertId(42). AndReturnRowsAffected(1) records := must.Return(store.Insert(db, basicRecord{Name: "first"}))(t) assert.SliceEqual(t, records, basicRecord{ID: 42, Name: "first"}) // error case: negative ID cannot be cast to uint64 md.ForQuery(`INSERT INTO "basic_records" ("name") VALUES (?)`). ExpectExecWithArgs("second"). AndReturnLastInsertId(-42). AndReturnRowsAffected(1) _, err := store.Insert(db, basicRecord{Name: "second"}) assert.ErrEqual(t, err, "LastInsertId() = -42 for record with idx = 0 cannot be converted to uint") // error case: cannot Insert() a record that already has its ID field filled md.ForQuery(`INSERT INTO "basic_records" ("name") VALUES (?)`). ExpectExecWithArgs("third"). AndReturnLastInsertId(42). AndReturnRowsAffected(1) _, err = store.Insert(db, basicRecord{ID: 23, Name: "third"}) assert.ErrEqual(t, err, `refusing to INSERT record with idx = 0 that already has non-zero values in its "auto" columns`) }) t.Run("using RETURNING clause", func(t *testing.T) { store := oblast.MustNewStore[basicRecord]( oblast.PostgresDialect(), oblast.TableNameIs("basic_records"), oblast.PrimaryKeyIs("id"), ) // success case md.ForQuery(`INSERT INTO "basic_records" ("name") VALUES ($1) RETURNING "id"`). ExpectQueryWithArgs("first"). AndReturnColumns("id"). WithRow(42) records := must.Return(store.Insert(db, basicRecord{Name: "first"}))(t) assert.SliceEqual(t, records, basicRecord{ID: 42, Name: "first"}) // error case: negative ID cannot be cast to uint64 md.ForQuery(`INSERT INTO "basic_records" ("name") VALUES ($1) RETURNING "id"`). ExpectQueryWithArgs("second"). AndReturnColumns("id"). WithRow(-42) _, err := store.Insert(db, basicRecord{Name: "second"}) assert.ErrEqual(t, err, `during QueryRow() for record with idx = 0: sql: Scan error on column index 0, name "id": converting driver.Value type int ("-42") to a uint64: invalid syntax`) // error case: cannot Insert() a record that already has its ID field filled md.ForQuery(`INSERT INTO "basic_records" ("name") VALUES ($1) RETURNING "id"`). ExpectQueryWithArgs("third"). AndReturnColumns("id"). WithRow(42) _, err = store.Insert(db, basicRecord{ID: 23, Name: "third"}) assert.ErrEqual(t, err, `refusing to INSERT record with idx = 0 that already has non-zero values in its "auto" columns`) }) } func TestInsertWithoutAutoColumns(t *testing.T) { md := mock.NewDriver() db := sql.OpenDB(md) type relation struct { FooID int64 `db:"foo_id"` BarID int64 `db:"bar_id"` } // Even in dialects using RETURNING clause, this uses Exec() because there is nothing to return. // Therefore, the test behavior with both dialects is identical except for the different placeholder syntax in the query. runTest := func(store oblast.Store[relation], query string) { md.ForQuery(query). ExpectExecWithArgs(1, 2). AndReturnRowsAffected(1) md.ForQuery(query). ExpectExecWithArgs(1, 3). AndReturnRowsAffected(1) relations := []relation{ {FooID: 1, BarID: 2}, {FooID: 1, BarID: 3}, } insertedRelations := must.Return(store.Insert(db, relations...))(t) assert.SliceEqual(t, insertedRelations, relations...) } t.Run("in dialect using LastInsertID", func(t *testing.T) { store := oblast.MustNewStore[relation]( oblast.SqliteDialect(), oblast.TableNameIs("foo_bar_relations"), oblast.PrimaryKeyIs("foo_id", "bar_id"), ) runTest(store, `INSERT INTO "foo_bar_relations" ("foo_id", "bar_id") VALUES (?, ?)`) }) t.Run("in dialect using RETURNING clause", func(t *testing.T) { store := oblast.MustNewStore[relation]( oblast.PostgresDialect(), oblast.TableNameIs("foo_bar_relations"), oblast.PrimaryKeyIs("foo_id", "bar_id"), ) runTest(store, `INSERT INTO "foo_bar_relations" ("foo_id", "bar_id") VALUES ($1, $2)`) }) }