diff options
| author | Stefan Majewsky <majewsky@gmx.net> | 2026-04-12 17:18:43 +0200 |
|---|---|---|
| committer | Stefan Majewsky <majewsky@gmx.net> | 2026-04-12 17:19:34 +0200 |
| commit | 5e30087db4a06c24c103737d4cb7dcdf06da5b24 (patch) | |
| tree | dc606d3575b6287b80b4558da3d00df083358d9c | |
| parent | 8d60f626d819f8bdb038ce619d00946442cc2594 (diff) | |
| download | go-oblast-5e30087db4a06c24c103737d4cb7dcdf06da5b24.tar.gz | |
add Store.SelectOne
| -rw-r--r-- | .golangci.yaml | 8 | ||||
| -rw-r--r-- | benchmark/benchmark_test.go | 115 | ||||
| -rw-r--r-- | internal/assert/assert.go | 4 | ||||
| -rw-r--r-- | oblast.go | 4 | ||||
| -rw-r--r-- | select.go (renamed from query.go) | 30 |
5 files changed, 141 insertions, 20 deletions
diff --git a/.golangci.yaml b/.golangci.yaml index 995ee5a..286a9a5 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -32,6 +32,8 @@ linters: - prealloc - predeclared - reassign + - revive + - rowserrcheck - unconvert - usestdlibvars - usetesting @@ -71,6 +73,12 @@ linters: go-version-pattern: 1\.\d+(\.0)?$ nolintlint: require-specific: true + revive: + rules: + - name: exported + arguments: + - checkPrivateReceivers + - disableChecksOnConstants staticcheck: dot-import-whitelist: - github.com/majewsky/gg/option diff --git a/benchmark/benchmark_test.go b/benchmark/benchmark_test.go index e0822f2..b60951a 100644 --- a/benchmark/benchmark_test.go +++ b/benchmark/benchmark_test.go @@ -13,40 +13,50 @@ import ( "github.com/go-gorp/gorp/v3" _ "github.com/mattn/go-sqlite3" "go.xyrillian.de/oblast" + "go.xyrillian.de/oblast/internal/assert" ) -func BenchmarkSelect(b *testing.B) { - const totalRecordCount = 1000 +const totalRecordCount = 1000 - db, err := sql.Open("sqlite3", "file:foobar?mode=memory&cache=shared") +func makeTestDB(t testing.TB) (*sql.DB, error) { + db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())) if err != nil { - b.Fatal(err) + return nil, err } // fill in some random-looking, but deterministic data _, err = db.Exec(`CREATE TABLE entries (id INTEGER, message TEXT)`) if err != nil { - b.Fatal(err) + return nil, err } stmt, err := db.Prepare(`INSERT INTO entries (id, message) VALUES (?, ?)`) if err != nil { - b.Fatal(err) + return nil, err } for idx := range totalRecordCount { buf := sha256.Sum256([]byte(strconv.Itoa(idx))) _, err = stmt.Exec(idx, fmt.Sprintf("sha256:%x", buf[:])) if err != nil { - b.Fatal(err) + return nil, err } } err = stmt.Close() if err != nil { + return nil, err + } + + return db, nil +} + +func BenchmarkSelect(b *testing.B) { + db, err := makeTestDB(b) + if err != nil { b.Fatal(err) } // test with different sizes of resultsets (N=1 is an OLTP-like workload, // then the larger N lean more towards the OLAP side of things) - for _, selectedRecordCount := range []int{1, 10, 100, 1000} { + for selectedRecordCount := 1; selectedRecordCount < totalRecordCount; selectedRecordCount *= 10 { b.Run("N="+strconv.Itoa(selectedRecordCount), func(b *testing.B) { // prepare the functions that will be benched type record struct { @@ -65,9 +75,7 @@ func BenchmarkSelect(b *testing.B) { if err != nil { b.Error(err) } - if len(records) != selectedRecordCount { - b.Errorf("expected %d, but got %d records", selectedRecordCount, len(records)) - } + assert.Equal(b, len(records), selectedRecordCount) } selectWithGorp := func(b *testing.B) { @@ -76,14 +84,12 @@ func BenchmarkSelect(b *testing.B) { if err != nil { b.Error(err) } - if len(records) != selectedRecordCount { - b.Errorf("expected %d, but got %d records", selectedRecordCount, len(records)) - } + assert.Equal(b, len(records), selectedRecordCount) } selectWithSqlite := func(b *testing.B) { var count int - rows, err := db.Query(query) + rows, err := db.Query(query) //nolint:rowserrcheck // false positive if err != nil { b.Error(err) } @@ -104,9 +110,7 @@ func BenchmarkSelect(b *testing.B) { if err != nil { b.Error(err) } - if count != selectedRecordCount { - b.Errorf("expected %d, but got %d records", selectedRecordCount, count) - } + assert.Equal(b, count, selectedRecordCount) } // run once to prewarm caches @@ -135,3 +139,78 @@ func BenchmarkSelect(b *testing.B) { }) } } + +func BenchmarkSelectOne(b *testing.B) { + db, err := makeTestDB(b) + if err != nil { + b.Fatal(err) + } + + // grab a "random" record from the DB, not just the first or the last + recordID := min(totalRecordCount*2/3, totalRecordCount) + + // prepare the functions that will be benched + type record struct { + ID int `db:"id"` + Message string `db:"message"` + } + store, err := oblast.NewStore[record](oblast.SqliteDialect()) + if err != nil { + b.Fatal(err) + } + gdb := gorp.DbMap{Db: db, Dialect: gorp.SqliteDialect{}} + query := `SELECT * FROM entries WHERE id = ` + strconv.Itoa(recordID) + + selectWithOblast := func(b *testing.B) { + r, err := store.SelectOne(db, query) + if err != nil { + b.Error(err) + } + assert.Equal(b, r.ID, recordID) + } + + selectWithGorp := func(b *testing.B) { + var r record + err := gdb.SelectOne(&r, query) + if err != nil { + b.Error(err) + } + assert.Equal(b, r.ID, recordID) + } + + selectWithSqlite := func(b *testing.B) { + var ( + id int64 + message string + ) + err := db.QueryRow(query).Scan(&id, &message) + if err != nil { + b.Error(err) + } + assert.Equal(b, id, int64(recordID)) + } + + // run once to prewarm caches + selectWithOblast(b) + selectWithGorp(b) + if b.Failed() { + b.FailNow() + } + + // run actual benchmark + b.Run("via Gorp", func(b *testing.B) { + for range b.N { + selectWithGorp(b) + } + }) + b.Run("via Oblast", func(b *testing.B) { + for range b.N { + selectWithOblast(b) + } + }) + b.Run("just SQLite", func(b *testing.B) { + for range b.N { + selectWithSqlite(b) + } + }) +} diff --git a/internal/assert/assert.go b/internal/assert/assert.go index c4e7b50..c82d35c 100644 --- a/internal/assert/assert.go +++ b/internal/assert/assert.go @@ -9,7 +9,7 @@ import ( ) // Equal is a test assertion. -func Equal[V comparable](t *testing.T, actual, expected V) { +func Equal[V comparable](t testing.TB, actual, expected V) { t.Helper() if actual != expected { t.Errorf("expected %#v, but got %#v", expected, actual) @@ -17,7 +17,7 @@ func Equal[V comparable](t *testing.T, actual, expected V) { } // DeepEqual is a test assertion. -func DeepEqual[V any](t *testing.T, actual, expected V) { +func DeepEqual[V any](t testing.TB, actual, expected V) { t.Helper() if !reflect.DeepEqual(actual, expected) { t.Errorf("expected %#v, but got %#v", expected, actual) @@ -42,6 +42,7 @@ package oblast // import "go.xyrillian.de/oblast" import ( "database/sql" + "errors" "go.xyrillian.de/oblast/internal" ) @@ -75,3 +76,6 @@ var ( _ Handle = &sql.DB{} _ Handle = &sql.Tx{} ) + +// ErrMultipleRows is returned by [Store.SelectOne] if the query returned multiple rows. +var ErrMultipleRows = errors.New("sql: multiple rows in result set") @@ -11,6 +11,10 @@ import ( "go.xyrillian.de/oblast/internal" ) +// Select executes the provided SQL query and fills an instance of the record type R for each row in the result set, +// according to the column names reported by the database as part of the result set. +// +// An error is returned if any column name in the result set does not correspond to an addressable field in R. func (s Store[R]) Select(db Handle, query string, args ...any) (result []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. @@ -90,3 +94,29 @@ func mergeRowsCloseError(err, closeErr error) error { return fmt.Errorf("%w (additional error during rows.Close(): %s)", err, closeErr.Error()) } } + +// SelectOne executes the provided SQL query and fills an instance of the record type R if there is exactly one row in the result set, +// according to the column names reported by the database as part of the result set. +// +// If there are no rows in the result set, [sql.ErrNoRows] is returned. +// If there are multiple rows in the result set, [ErrMultipleRows] is returned. +// +// Warning: Because of limitations in the interface of database/sql, this function is built on [Store.Select] and cannot be any faster than it. +// For maximum performance, use [Store.SelectOneWhere] which avoids the overhead of potentially having to read multiple rows. +func (s Store[R]) SelectOne(db Handle, query string, args ...any) (result R, err 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. + var results []R + results, err = s.Select(db, query, args...) + if err == nil { + switch len(results) { + case 0: + err = sql.ErrNoRows + case 1: + result = results[0] + default: + err = ErrMultipleRows + } + } + return +} |
