aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2026-05-04 16:23:24 +0200
committerStefan Majewsky <majewsky@gmx.net>2026-05-04 16:23:24 +0200
commitf2d6d6f2f24e7e7d594296b77fa044ccd547321d (patch)
tree2772f58e535cd7e92a78aa8d7accce2462b29308
parent5e47f483cf08edd619012ba7762449954c81117d (diff)
downloadgo-oblast-f2d6d6f2f24e7e7d594296b77fa044ccd547321d.tar.gz
return None instead of ErrNoRows from SelectOne{,Where}
-rw-r--r--.golangci.yaml2
-rw-r--r--CHANGELOG.md6
-rw-r--r--REUSE.toml1
-rw-r--r--benchmark/benchmark_test.go4
-rw-r--r--go.mod2
-rw-r--r--go.sum2
-rw-r--r--select.go56
-rw-r--r--select_test.go26
8 files changed, 66 insertions, 33 deletions
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 <majewsky@gmx.net>
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))}}),
)
})
}