From f2d6d6f2f24e7e7d594296b77fa044ccd547321d Mon Sep 17 00:00:00 2001 From: Stefan Majewsky Date: Mon, 4 May 2026 16:23:24 +0200 Subject: return None instead of ErrNoRows from SelectOne{,Where} --- .golangci.yaml | 2 +- CHANGELOG.md | 6 +++++ REUSE.toml | 1 + benchmark/benchmark_test.go | 4 ++-- go.mod | 2 ++ go.sum | 2 ++ select.go | 56 ++++++++++++++++++++++++++++++--------------- select_test.go | 26 +++++++++++---------- 8 files changed, 66 insertions(+), 33 deletions(-) create mode 100644 go.sum diff --git a/.golangci.yaml b/.golangci.yaml index 286a9a5..189dfac 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -81,7 +81,7 @@ linters: - disableChecksOnConstants staticcheck: dot-import-whitelist: - - github.com/majewsky/gg/option + - go.xyrillian.de/gg/option exclusions: generated: lax presets: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ecb77d..98099f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ SPDX-FileCopyrightText: 2026 Stefan Majewsky SPDX-License-Identifier: Apache-2.0 --> +# v0.4.0 (TBD) + +API changes: + +- Return `None` instead of `sql.ErrNoRows` from `Store.SelectOne...` functions. + # v0.3.0 (2026-04-30) API changes: diff --git a/REUSE.toml b/REUSE.toml index fbb08c7..68fa0f8 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -11,6 +11,7 @@ path = [ "benchmark/go.sum", "description", "go.mod", + "go.sum", "go.work", "go.work.sum", ] diff --git a/benchmark/benchmark_test.go b/benchmark/benchmark_test.go index 721d3a2..b389d38 100644 --- a/benchmark/benchmark_test.go +++ b/benchmark/benchmark_test.go @@ -189,12 +189,12 @@ func BenchmarkSelectOne(b *testing.B) { precomputedQuery := store.MustPrepareSelectQueryWhere(partialQuery) selectWithOblast := func(b *testing.B) { - r := must.Return(store.SelectOne(noctx, db, query))(b) + r := must.Return(store.SelectOne(noctx, db, query))(b).UnwrapOrPanic("missing record") assert.Equal(b, r.ID, recordID) } selectWithOblastWhere := func(b *testing.B) { - r := must.Return(precomputedQuery.SelectOne(noctx, db))(b) + r := must.Return(precomputedQuery.SelectOne(noctx, db))(b).UnwrapOrPanic("missing record") assert.Equal(b, r.ID, recordID) } diff --git a/go.mod b/go.mod index 344456f..795b71b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module go.xyrillian.de/oblast go 1.26.0 + +require go.xyrillian.de/gg v1.7.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1e5a029 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +go.xyrillian.de/gg v1.7.0 h1:IA0BJaX9TtBD7crH+CSoK4lYmBk5zi7nUQd0YRzPNf0= +go.xyrillian.de/gg v1.7.0/go.mod h1:dj+ZhCwC6JKWyFvImhVNXQAErrRcYMUkXu6vwWYNrzQ= diff --git a/select.go b/select.go index 0b86ab5..d6fd56c 100644 --- a/select.go +++ b/select.go @@ -9,6 +9,8 @@ import ( "errors" "fmt" "reflect" + + . "go.xyrillian.de/gg/option" ) // Select executes the provided SQL query and fills an instance of the record type R for each row in the result set, @@ -158,24 +160,26 @@ func collectRow(rows *sql.Rows, plan plan, v reflect.Value, slots []any, indexes // 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 no rows in the result set, [None] 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(ctx context.Context, db Handle, query string, args ...any) (result R, err error) { +func (s Store[R]) SelectOne(ctx context.Context, db Handle, query string, args ...any) (Option[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. - - var results []R - results, err = s.Select(ctx, db, query, args...) - if err == nil { - if len(results) == 0 { - err = sql.ErrNoRows - } else { - result = results[0] - } + // + // NOTE: The "limitation in the interface of database/sql" is that type sql.Row does not have the Columns() method, + // which we need when mapping result columns to struct fields for user-provided queries. + + results, err := s.Select(ctx, db, query, args...) + switch { + case err != nil: + return None[R](), err + case len(results) == 0: + return None[R](), nil + default: + return Some(results[0]), nil } - return } // SelectOneWhere is like [Store.SelectOne], but you only provide the part of the SELECT query that comes after the WHERE. @@ -183,12 +187,20 @@ func (s Store[R]) SelectOne(ctx context.Context, db Handle, query string, args . // // This method is more efficient than [Store.SelectOne] on CPU runtime, but has a slight memory allocation overhead per call from query preparation. // This can be avoided by using [Store.PrepareSelectQueryWhere] instead. -func (s Store[R]) SelectOneWhere(ctx context.Context, db Handle, partialQuery string, args ...any) (result R, err error) { +func (s Store[R]) SelectOneWhere(ctx context.Context, db Handle, partialQuery string, args ...any) (Option[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. - err = selectOneWhere(ctx, db, s.plan, reflect.ValueOf(&result).Elem(), partialQuery, args) - return + var result R + err := selectOneWhere(ctx, db, s.plan, reflect.ValueOf(&result).Elem(), partialQuery, args) + switch { + case err == nil: + return Some(result), nil + case errors.Is(err, sql.ErrNoRows): + return None[R](), nil + default: + return None[R](), err + } } func selectOneWhere(ctx context.Context, db Handle, plan plan, v reflect.Value, partialQuery string, args []any) error { @@ -271,10 +283,18 @@ func (q PreparedSelectQuery[R]) Select(ctx context.Context, db Handle, args ...a } // SelectOne behaves the same as [Store.SelectOneWhere], but uses the query that was precomputed when q was constructed. -func (q PreparedSelectQuery[R]) SelectOne(ctx context.Context, db Handle, args ...any) (result R, err error) { +func (q PreparedSelectQuery[R]) SelectOne(ctx context.Context, db Handle, args ...any) (Option[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. - err = selectOne(ctx, db, q.store.plan, reflect.ValueOf(&result).Elem(), q.query, args) - return + var result R + err := selectOne(ctx, db, q.store.plan, reflect.ValueOf(&result).Elem(), q.query, args) + switch { + case err == nil: + return Some(result), nil + case errors.Is(err, sql.ErrNoRows): + return None[R](), nil + default: + return None[R](), err + } } diff --git a/select_test.go b/select_test.go index d4b1970..a31614d 100644 --- a/select_test.go +++ b/select_test.go @@ -9,6 +9,8 @@ import ( "testing" "time" + . "go.xyrillian.de/gg/option" + "go.xyrillian.de/oblast" "go.xyrillian.de/oblast/internal/testhelpers/assert" "go.xyrillian.de/oblast/internal/testhelpers/mock" @@ -77,7 +79,7 @@ func TestSelectReturningSomeRecords(t *testing.T) { WithRow("ffffoo", 1). WithRow("bbbbar", 2) record := must.Return(store.SelectOne(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3))(t) - assert.Equal(t, record, basicRecord{1, "ffffoo"}) + assert.Equal(t, record, Some(basicRecord{1, "ffffoo"})) }) t.Run("using Store.SelectOneWhere", func(t *testing.T) { @@ -87,7 +89,7 @@ func TestSelectReturningSomeRecords(t *testing.T) { WithRow(1, "fffffoo"). WithRow(2, "bbbbbar") record := must.Return(store.SelectOneWhere(ctx, db, `id < ?`, 3))(t) - assert.Equal(t, record, basicRecord{1, "fffffoo"}) + assert.Equal(t, record, Some(basicRecord{1, "fffffoo"})) }) t.Run("using PreparedSelectQuery.SelectOne", func(t *testing.T) { @@ -98,7 +100,7 @@ func TestSelectReturningSomeRecords(t *testing.T) { WithRow(2, "bbbbbbar") query := store.MustPrepareSelectQueryWhere(`id < ?`) record := must.Return(query.SelectOne(ctx, db, 3))(t) - assert.Equal(t, record, basicRecord{1, "ffffffoo"}) + assert.Equal(t, record, Some(basicRecord{1, "ffffffoo"})) }) } @@ -146,16 +148,16 @@ func TestSelectReturningNoRecords(t *testing.T) { md.ForQuery(`SELECT * FROM basic_records WHERE id < ?`). ExpectQueryWithArgs(3). AndReturnColumns("name", "id") - _, err := store.SelectOne(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3) - assert.ErrEqual(t, err, sql.ErrNoRows.Error()) + record := must.Return(store.SelectOne(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3))(t) + assert.Equal(t, record, None[basicRecord]()) }) t.Run("using Store.SelectOneWhere", func(t *testing.T) { md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`). ExpectQueryWithArgs(3). AndReturnColumns("id", "name") - _, err := store.SelectOneWhere(ctx, db, `id < ?`, 3) - assert.ErrEqual(t, err, sql.ErrNoRows.Error()) + record := must.Return(store.SelectOneWhere(ctx, db, `id < ?`, 3))(t) + assert.Equal(t, record, None[basicRecord]()) }) t.Run("using PreparedSelectQuery.SelectOne", func(t *testing.T) { @@ -163,8 +165,8 @@ func TestSelectReturningNoRecords(t *testing.T) { ExpectQueryWithArgs(3). AndReturnColumns("id", "name") query := store.MustPrepareSelectQueryWhere(`id < ?`) - _, err := query.SelectOne(ctx, db, 3) - assert.ErrEqual(t, err, sql.ErrNoRows.Error()) + record := must.Return(query.SelectOne(ctx, db, 3))(t) + assert.Equal(t, record, None[basicRecord]()) }) } @@ -334,7 +336,7 @@ func TestSelectIntoEmbeddedTypes(t *testing.T) { commonSetup(`SELECT * FROM composite_records`) record := must.Return(store.SelectOne(ctx, db, `SELECT * FROM composite_records`))(t) assert.DeepEqual(t, record, - compositeRecord{1, HasCreatedAt{time.Unix(1, 0)}, &HasUpdatedAt{new(time.Unix(3, 0))}}, + Some(compositeRecord{1, HasCreatedAt{time.Unix(1, 0)}, &HasUpdatedAt{new(time.Unix(3, 0))}}), ) }) @@ -342,7 +344,7 @@ func TestSelectIntoEmbeddedTypes(t *testing.T) { commonSetup(`SELECT "id", "created_at", "updated_at" FROM "composite_records" WHERE TRUE`) record := must.Return(store.SelectOneWhere(ctx, db, `TRUE`))(t) assert.DeepEqual(t, record, - compositeRecord{1, HasCreatedAt{time.Unix(1, 0)}, &HasUpdatedAt{new(time.Unix(3, 0))}}, + Some(compositeRecord{1, HasCreatedAt{time.Unix(1, 0)}, &HasUpdatedAt{new(time.Unix(3, 0))}}), ) }) @@ -351,7 +353,7 @@ func TestSelectIntoEmbeddedTypes(t *testing.T) { query := store.MustPrepareSelectQueryWhere(`TRUE`) record := must.Return(query.SelectOne(ctx, db))(t) assert.DeepEqual(t, record, - compositeRecord{1, HasCreatedAt{time.Unix(1, 0)}, &HasUpdatedAt{new(time.Unix(3, 0))}}, + Some(compositeRecord{1, HasCreatedAt{time.Unix(1, 0)}, &HasUpdatedAt{new(time.Unix(3, 0))}}), ) }) } -- cgit v1.2.3