aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md7
-rw-r--r--benchmark/benchmark_test.go4
-rw-r--r--select.go68
-rw-r--r--select_test.go87
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)
}
diff --git a/select.go b/select.go
index d6fd56c..fc98fe7 100644
--- a/select.go
+++ b/select.go
@@ -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))}}),
)
})