aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2026-04-19 14:25:13 +0200
committerStefan Majewsky <majewsky@gmx.net>2026-04-19 14:25:13 +0200
commite4c744843e740e74b824c43950b1961736ccb8ad (patch)
tree83ee79a50bf788a5e9f7f62ddf6a9ce62514936b
parent6a089f6d65da3ef5391314e1ab5907083bf577fc (diff)
downloadgo-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.md6
-rw-r--r--benchmark/benchmark_test.go8
-rw-r--r--query.go23
-rw-r--r--query_test.go57
4 files changed, 52 insertions, 42 deletions
diff --git a/TODO.md b/TODO.md
index 57b81a1..f427337 100644
--- a/TODO.md
+++ b/TODO.md
@@ -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))
diff --git a/query.go b/query.go
index 1392dfb..0a366ab 100644
--- a/query.go
+++ b/query.go
@@ -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) {