diff options
| -rw-r--r-- | CHANGELOG.md | 7 | ||||
| -rw-r--r-- | benchmark/benchmark_test.go | 4 | ||||
| -rw-r--r-- | select.go | 68 | ||||
| -rw-r--r-- | select_test.go | 87 |
4 files changed, 141 insertions, 25 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 4495d62..af2aa55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> SPDX-License-Identifier: Apache-2.0 --> +# v0.5.0 + +API changes: + +- Offer both `None`-returning and `sql.ErrNoRows`-returning variants for `Store.SelectOne...` functions. + Testing in real-world application code indicates that both return signatures have their uses. + # v0.4.0 (2026-05-04) API changes: diff --git a/benchmark/benchmark_test.go b/benchmark/benchmark_test.go index b389d38..721d3a2 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).UnwrapOrPanic("missing record") + r := must.Return(store.SelectOne(noctx, db, query))(b) assert.Equal(b, r.ID, recordID) } selectWithOblastWhere := func(b *testing.B) { - r := must.Return(precomputedQuery.SelectOne(noctx, db))(b).UnwrapOrPanic("missing record") + r := must.Return(precomputedQuery.SelectOne(noctx, db))(b) assert.Equal(b, r.ID, recordID) } @@ -160,11 +160,11 @@ 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, [None] is returned. +// If there are no rows in the result set, [sql.ErrNoRows] 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) (Option[R], error) { +func (s Store[R]) SelectOne(ctx context.Context, db Handle, query string, args ...any) (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. // @@ -174,6 +174,24 @@ func (s Store[R]) SelectOne(ctx context.Context, db Handle, query string, args . results, err := s.Select(ctx, db, query, args...) switch { case err != nil: + var zero R + return zero, err + case len(results) == 0: + var zero R + return zero, sql.ErrNoRows + default: + return results[0], nil + } +} + +// SelectOneOrNone is like SelectOne, but returns [None] instead of [sql.ErrNoRows]. +func (s Store[R]) SelectOneOrNone(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. + + results, err := s.Select(ctx, db, query, args...) + switch { + case err != nil: return None[R](), err case len(results) == 0: return None[R](), nil @@ -187,20 +205,21 @@ 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) (Option[R], error) { +func (s Store[R]) SelectOneWhere(ctx context.Context, db Handle, partialQuery string, args ...any) (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 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 - } + return result, err +} + +// SelectOneOrNoneWhere is like SelectOneWhere, but returns [None] instead of [sql.ErrNoRows]. +func (s Store[R]) SelectOneOrNoneWhere(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. + + return noRowsToNone(s.SelectOneWhere(ctx, db, partialQuery, args...)) } func selectOneWhere(ctx context.Context, db Handle, plan plan, v reflect.Value, partialQuery string, args []any) error { @@ -223,6 +242,17 @@ func selectOne(ctx context.Context, db Handle, plan plan, v reflect.Value, query return db.QueryRowContext(ctx, query, args...).Scan(slots...) } +func noRowsToNone[R any](record R, err error) (Option[R], error) { + switch { + case err == nil: + return Some(record), nil + case errors.Is(err, sql.ErrNoRows): + return None[R](), nil + default: + return None[R](), err + } +} + // PrepareSelectQueryWhere performs the same query string preparation as [Store.SelectWhere] or [Store.SelectOneWhere]. // The resulting query can then be executed multiple times without incurring repeated memory allocation overhead from this preparation step. func (s Store[R]) PrepareSelectQueryWhere(partialQuery string) (PreparedSelectQuery[R], error) { @@ -283,18 +313,16 @@ 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) (Option[R], error) { +func (q PreparedSelectQuery[R]) SelectOne(ctx context.Context, db Handle, args ...any) (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 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 - } + return result, err +} + +// SelectOneOrNone is like SelectOne, but returns [None] instead of [sql.ErrNoRows]. +func (q PreparedSelectQuery[R]) SelectOneOrNone(ctx context.Context, db Handle, args ...any) (Option[R], error) { + return noRowsToNone(q.SelectOne(ctx, db, args...)) } diff --git a/select_test.go b/select_test.go index a31614d..bd56f7d 100644 --- a/select_test.go +++ b/select_test.go @@ -79,6 +79,16 @@ 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"}) + }) + + t.Run("using Store.SelectOneOrNone", func(t *testing.T) { + md.ForQuery(`SELECT * FROM basic_records WHERE id < ?`). + ExpectQueryWithArgs(3). + AndReturnColumns("name", "id"). + WithRow("ffffoo", 1). + WithRow("bbbbar", 2) + record := must.Return(store.SelectOneOrNone(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3))(t) assert.Equal(t, record, Some(basicRecord{1, "ffffoo"})) }) @@ -89,6 +99,16 @@ 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"}) + }) + + t.Run("using Store.SelectOneOrNoneWhere", func(t *testing.T) { + md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`). + ExpectQueryWithArgs(3). + AndReturnColumns("id", "name"). + WithRow(1, "fffffoo"). + WithRow(2, "bbbbbar") + record := must.Return(store.SelectOneOrNoneWhere(ctx, db, `id < ?`, 3))(t) assert.Equal(t, record, Some(basicRecord{1, "fffffoo"})) }) @@ -100,6 +120,17 @@ 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"}) + }) + + t.Run("using PreparedSelectQuery.SelectOneOrNone", func(t *testing.T) { + md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`). + ExpectQueryWithArgs(3). + AndReturnColumns("id", "name"). + WithRow(1, "ffffffoo"). + WithRow(2, "bbbbbbar") + query := store.MustPrepareSelectQueryWhere(`id < ?`) + record := must.Return(query.SelectOneOrNone(ctx, db, 3))(t) assert.Equal(t, record, Some(basicRecord{1, "ffffffoo"})) }) } @@ -148,7 +179,15 @@ func TestSelectReturningNoRecords(t *testing.T) { md.ForQuery(`SELECT * FROM basic_records WHERE id < ?`). ExpectQueryWithArgs(3). AndReturnColumns("name", "id") - record := must.Return(store.SelectOne(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3))(t) + _, err := store.SelectOne(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3) + assert.ErrEqual(t, err, sql.ErrNoRows.Error()) + }) + + t.Run("using Store.SelectOneOrNone", func(t *testing.T) { + md.ForQuery(`SELECT * FROM basic_records WHERE id < ?`). + ExpectQueryWithArgs(3). + AndReturnColumns("name", "id") + record := must.Return(store.SelectOneOrNone(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3))(t) assert.Equal(t, record, None[basicRecord]()) }) @@ -156,7 +195,15 @@ func TestSelectReturningNoRecords(t *testing.T) { md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`). ExpectQueryWithArgs(3). AndReturnColumns("id", "name") - record := must.Return(store.SelectOneWhere(ctx, db, `id < ?`, 3))(t) + _, err := store.SelectOneWhere(ctx, db, `id < ?`, 3) + assert.ErrEqual(t, err, sql.ErrNoRows.Error()) + }) + + t.Run("using Store.SelectOneOrNoneWhere", func(t *testing.T) { + md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`). + ExpectQueryWithArgs(3). + AndReturnColumns("id", "name") + record := must.Return(store.SelectOneOrNoneWhere(ctx, db, `id < ?`, 3))(t) assert.Equal(t, record, None[basicRecord]()) }) @@ -165,7 +212,16 @@ func TestSelectReturningNoRecords(t *testing.T) { ExpectQueryWithArgs(3). AndReturnColumns("id", "name") query := store.MustPrepareSelectQueryWhere(`id < ?`) - record := must.Return(query.SelectOne(ctx, db, 3))(t) + _, err := query.SelectOne(ctx, db, 3) + assert.ErrEqual(t, err, sql.ErrNoRows.Error()) + }) + + t.Run("using PreparedSelectQuery.SelectOneOrNone", func(t *testing.T) { + md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`). + ExpectQueryWithArgs(3). + AndReturnColumns("id", "name") + query := store.MustPrepareSelectQueryWhere(`id < ?`) + record := must.Return(query.SelectOneOrNone(ctx, db, 3))(t) assert.Equal(t, record, None[basicRecord]()) }) } @@ -336,6 +392,14 @@ 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))}}, + ) + }) + + t.Run("using Store.SelectOneOrNone", func(t *testing.T) { + commonSetup(`SELECT * FROM composite_records`) + record := must.Return(store.SelectOneOrNone(ctx, db, `SELECT * FROM composite_records`))(t) + assert.DeepEqual(t, record, Some(compositeRecord{1, HasCreatedAt{time.Unix(1, 0)}, &HasUpdatedAt{new(time.Unix(3, 0))}}), ) }) @@ -344,6 +408,14 @@ 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))}}, + ) + }) + + t.Run("using Store.SelectOneOrNoneWhere", func(t *testing.T) { + commonSetup(`SELECT "id", "created_at", "updated_at" FROM "composite_records" WHERE TRUE`) + record := must.Return(store.SelectOneOrNoneWhere(ctx, db, `TRUE`))(t) + assert.DeepEqual(t, record, Some(compositeRecord{1, HasCreatedAt{time.Unix(1, 0)}, &HasUpdatedAt{new(time.Unix(3, 0))}}), ) }) @@ -353,6 +425,15 @@ 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))}}, + ) + }) + + t.Run("using PreparedSelectQuery.SelectOneOrNone", func(t *testing.T) { + commonSetup(`SELECT "id", "created_at", "updated_at" FROM "composite_records" WHERE TRUE`) + query := store.MustPrepareSelectQueryWhere(`TRUE`) + record := must.Return(query.SelectOneOrNone(ctx, db))(t) + assert.DeepEqual(t, record, Some(compositeRecord{1, HasCreatedAt{time.Unix(1, 0)}, &HasUpdatedAt{new(time.Unix(3, 0))}}), ) }) |
