diff options
| author | Stefan Majewsky <majewsky@gmx.net> | 2026-04-19 14:25:13 +0200 |
|---|---|---|
| committer | Stefan Majewsky <majewsky@gmx.net> | 2026-04-19 14:25:13 +0200 |
| commit | e4c744843e740e74b824c43950b1961736ccb8ad (patch) | |
| tree | 83ee79a50bf788a5e9f7f62ddf6a9ce62514936b | |
| parent | 6a089f6d65da3ef5391314e1ab5907083bf577fc (diff) | |
| download | go-oblast-e4c744843e740e74b824c43950b1961736ccb8ad.tar.gz | |
change Store.Insert() to take arguments by pointer
This increases memory overhead slightly, but when converting Gorp uses
into Oblast, I saw that there is a legitimate risk of just ignoring the
result value and continuing with the unupdated records. So according to
the design priorities that I myself laid down, I'm sacrificing some
performance for safety of use.
| -rw-r--r-- | TODO.md | 6 | ||||
| -rw-r--r-- | benchmark/benchmark_test.go | 8 | ||||
| -rw-r--r-- | query.go | 23 | ||||
| -rw-r--r-- | query_test.go | 57 |
4 files changed, 52 insertions, 42 deletions
@@ -1,2 +1,6 @@ -- TODO: Store.Insert should take its arguments as pointers; the risk of continuing to use the un-updated records is too great +<!-- +SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +SPDX-License-Identifier: Apache-2.0 +--> + - TODO: consider adding an upsert, e.g. `func (Store[R]) InsertOrUpdate(db Handle, records ...*R) error`, that chooses based on whether any auto fields is non-zero diff --git a/benchmark/benchmark_test.go b/benchmark/benchmark_test.go index ada238e..d18c0cb 100644 --- a/benchmark/benchmark_test.go +++ b/benchmark/benchmark_test.go @@ -268,10 +268,12 @@ func BenchmarkInsertAndDelete(b *testing.B) { // prepare the functions that will be benched insertAndDeleteWithOblast := func(b *testing.B) { records := make([]OblastEntry, batchSize) + recordsForInsert := make([]*OblastEntry, batchSize) for idx := range records { records[idx] = OblastEntry{Message: "hello"} + recordsForInsert[idx] = &records[idx] } - records = must.Return(store.Insert(db, records...))(b) + must.Succeed(b, store.Insert(db, recordsForInsert...)) for _, r := range records { if r.ID == 0 { b.Errorf("ID was not filled!") @@ -388,10 +390,12 @@ func BenchmarkUpdate(b *testing.B) { // prepare a bunch of records that we can update, in a reproducible way _ = must.Return(db.Exec(`DELETE FROM entries`)) recordsForOblast := make([]OblastEntry, batchSize) + recordsForOblastForInsert := make([]*OblastEntry, batchSize) for idx := range recordsForOblast { recordsForOblast[idx] = OblastEntry{Message: "hello"} + recordsForOblastForInsert[idx] = &recordsForOblast[idx] } - recordsForOblast = must.Return(store.Insert(db, recordsForOblast...))(b) + must.Succeed(b, store.Insert(db, recordsForOblastForInsert...)) recordsForGorp := make([]any, batchSize) for idx, r := range recordsForOblast { recordsForGorp[idx] = new(GorpEntry(r)) @@ -72,13 +72,12 @@ func (s preparedStatement) QueryRow(args ...any) *sql.Row { // // Fields that are declared with the "auto" tag will not be written into the DB, // and instead their value (as auto-generated by the DB on insert) will be placed in the record. -// On success, returns the original set of records, updated thusly. // // Returns an error if [NewStore] was called without the [TableNameIs] option, which is required to generate a query for this method. // // Returns an error if any of the `records` has a non-zero value in any column marked as `db:",auto"`. // Records that already exist in the database should be handled with [Store.Update] instead. -func (s Store[R]) Insert(db Handle, records ...R) ([]R, error) { +func (s Store[R]) Insert(db Handle, records ...*R) error { // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization. // Any expression that does not depend on type R should be factored out into a reusable function. if s.dialect.UsesLastInsertID() || len(s.plan.Insert.ScanIndexes) == 0 { @@ -88,13 +87,13 @@ func (s Store[R]) Insert(db Handle, records ...R) ([]R, error) { } } -func (s Store[R]) insertUsingLastInsertID(db Handle, records []R) (returnedRecords []R, returnedError error) { +func (s Store[R]) insertUsingLastInsertID(db Handle, records []*R) (returnedError error) { // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization. // Any expression that does not depend on type R should be factored out into a reusable function. stmt, err := prepare(db, s.plan.Insert.Query, "Insert", len(records)) if err != nil { - return nil, err + return err } defer func() { returnedError = newIOError(returnedError, "Stmt.Close", stmt.Close()) @@ -109,14 +108,14 @@ func (s Store[R]) insertUsingLastInsertID(db Handle, records []R) (returnedRecor scanIndex = s.plan.Insert.ScanIndexes[0] } for idx := range records { - v := reflect.ValueOf(&records[idx]).Elem() + v := reflect.ValueOf(records[idx]).Elem() err := insertRecordUsingLastInsertID(v, idx, stmt, argumentIndexes, argumentSlots, scanIndex, s.plan) if err != nil { - return nil, newIOError(err, "Stmt.Close", stmt.Close()) + return newIOError(err, "Stmt.Close", stmt.Close()) } } - return records, newIOError(nil, "Stmt.Close", stmt.Close()) + return newIOError(nil, "Stmt.Close", stmt.Close()) } func insertRecordUsingLastInsertID(v reflect.Value, recordIndex int, stmt preparedStatement, argumentIndexes [][]int, argumentSlots []any, scanIndex []int, plan plan) error { @@ -152,13 +151,13 @@ func insertRecordUsingLastInsertID(v reflect.Value, recordIndex int, stmt prepar return nil } -func (s Store[R]) insertUsingReturningClause(db Handle, records []R) (returnedRecords []R, returnedError error) { +func (s Store[R]) insertUsingReturningClause(db Handle, records []*R) (returnedError error) { // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization. // Any expression that does not depend on type R should be factored out into a reusable function. stmt, err := prepare(db, s.plan.Insert.Query, "Insert", len(records)) if err != nil { - return nil, err + return err } var ( @@ -169,14 +168,14 @@ func (s Store[R]) insertUsingReturningClause(db Handle, records []R) (returnedRe ) for idx := range records { - v := reflect.ValueOf(&records[idx]).Elem() + v := reflect.ValueOf(records[idx]).Elem() err := insertRecordUsingReturningClause(v, idx, stmt, argumentIndexes, argumentSlots, scanIndexes, scanSlots) if err != nil { - return nil, newIOError(err, "Stmt.Close", stmt.Close()) + return newIOError(err, "Stmt.Close", stmt.Close()) } } - return records, newIOError(nil, "Stmt.Close", stmt.Close()) + return newIOError(nil, "Stmt.Close", stmt.Close()) } func insertRecordUsingReturningClause(v reflect.Value, recordIndex int, stmt preparedStatement, argumentIndexes [][]int, argumentSlots []any, scanIndexes [][]int, scanSlots []any) error { diff --git a/query_test.go b/query_test.go index 2d14a88..ae60db9 100644 --- a/query_test.go +++ b/query_test.go @@ -31,15 +31,15 @@ func TestInsertBasicUsingLastInsertId(t *testing.T) { 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) + records := make([]*basicRecord, batchSize) for idx := range batchSize { - records[idx] = basicRecord{Name: "new"} + 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) + must.Succeed(t, store.Insert(db, records...)) for idx, r := range records { assert.Equal(t, r.ID, int64(42+idx)) } @@ -63,15 +63,15 @@ func TestInsertBasicUsingReturningClause(t *testing.T) { 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) + records := make([]*basicRecord, batchSize) for idx := range batchSize { - records[idx] = basicRecord{Name: "new"} + 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) + must.Succeed(t, store.Insert(db, records...)) for idx, r := range records { assert.Equal(t, r.ID, int64(42+idx)) } @@ -151,7 +151,7 @@ func TestWriteQueriesNotPossible(t *testing.T) { ) r := basicRecord{Name: "foo"} - _, err := store.Insert(db, r) + err := store.Insert(db, &r) assert.ErrEqual(t, err, "cannot execute Insert() because query could not be autogenerated") r.ID = 42 @@ -178,11 +178,13 @@ func TestWriteQueriesFailDuringPrepare(t *testing.T) { for _, batchSize := range []int{1, oblast.PrepareThreshold - 1, oblast.PrepareThreshold + 1} { records := make([]basicRecord, batchSize) + recordsForInsert := make([]*basicRecord, batchSize) for idx := range batchSize { - records[idx] = basicRecord{Name: "foo"} + records[idx] = basicRecord{ID: int64(42 + idx), Name: "foo"} + recordsForInsert[idx] = &basicRecord{Name: "foo"} } - _, err := store.Insert(db, records...) + err := store.Insert(db, recordsForInsert...) 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) @@ -190,10 +192,6 @@ func TestWriteQueriesFailDuringPrepare(t *testing.T) { 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 { @@ -218,12 +216,12 @@ func TestWriteQueriesFailDuringPrepare(t *testing.T) { ) for _, batchSize := range []int{1, oblast.PrepareThreshold - 1, oblast.PrepareThreshold + 1} { - records := make([]basicRecord, batchSize) + recordsForInsert := make([]*basicRecord, batchSize) for idx := range batchSize { - records[idx] = basicRecord{Name: "foo"} + recordsForInsert[idx] = &basicRecord{Name: "foo"} } - _, err := store.Insert(db, records...) + err := store.Insert(db, recordsForInsert...) 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) @@ -277,15 +275,16 @@ func TestInsertWithUnsignedIdField(t *testing.T) { 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"}) + record := basicRecord{Name: "first"} + must.Succeed(t, store.Insert(db, &record)) + assert.Equal(t, record, 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"}) + 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 @@ -293,7 +292,7 @@ func TestInsertWithUnsignedIdField(t *testing.T) { ExpectExecWithArgs("third"). AndReturnLastInsertId(42). AndReturnRowsAffected(1) - _, err = store.Insert(db, basicRecord{ID: 23, Name: "third"}) + 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`) }) @@ -309,15 +308,16 @@ func TestInsertWithUnsignedIdField(t *testing.T) { 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"}) + record := basicRecord{Name: "first"} + must.Succeed(t, store.Insert(db, &record)) + assert.Equal(t, record, 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"}) + 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 @@ -325,7 +325,7 @@ func TestInsertWithUnsignedIdField(t *testing.T) { ExpectQueryWithArgs("third"). AndReturnColumns("id"). WithRow(42) - _, err = store.Insert(db, basicRecord{ID: 23, Name: "third"}) + 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`) }) } @@ -348,12 +348,15 @@ func TestInsertWithoutAutoColumns(t *testing.T) { md.ForQuery(query). ExpectExecWithArgs(1, 3). AndReturnRowsAffected(1) - relations := []relation{ + relations := []*relation{ {FooID: 1, BarID: 2}, {FooID: 1, BarID: 3}, } - insertedRelations := must.Return(store.Insert(db, relations...))(t) - assert.SliceEqual(t, insertedRelations, relations...) + must.Succeed(t, store.Insert(db, relations...)) + assert.SliceDeepEqual(t, relations, + &relation{FooID: 1, BarID: 2}, + &relation{FooID: 1, BarID: 3}, + ) } t.Run("in dialect using LastInsertID", func(t *testing.T) { |
