aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2026-09-15 13:26:02 +0200
committerStefan Majewsky <majewsky@gmx.net>2026-09-15 13:35:09 +0200
commit8d85366bee9c59486b445a9cc21a6271e7244e0a (patch)
tree20d804823e93f048ef150882cf7412d664efa927
parent92551b9a7b2825b76cb62ad7874ebef8e0c1e9c9 (diff)
downloadgo-oblast-8d85366bee9c59486b445a9cc21a6271e7244e0a.tar.gz
add func Select/SelectOne/SelectOneOrNone
-rw-r--r--CHANGELOG.md6
-rw-r--r--benchmark/benchmark_test.go71
-rw-r--r--select.go109
-rw-r--r--select_test.go81
4 files changed, 257 insertions, 10 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42c91be..84fa87e 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.15.0 (TBD)
+
+API changes:
+
+- Add `oblast.{Select,SelectOne,SelectOneOrNone}` to help with selecting rows containing exactly one value.
+
# v0.14.0 (2026-09-07)
API changes:
diff --git a/benchmark/benchmark_test.go b/benchmark/benchmark_test.go
index 4549962..487b9c5 100644
--- a/benchmark/benchmark_test.go
+++ b/benchmark/benchmark_test.go
@@ -174,6 +174,46 @@ func BenchmarkORMSelectMany(b *testing.B) {
}
}
+func BenchmarkORMSelectManyValues(b *testing.B) {
+ db, _ := makeSqliteTestDB(b, totalRecordCountForSelect)
+
+ // 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 _, batchSize := range batchSizesForSelect {
+ b.Run("N="+strconv.Itoa(batchSize), func(b *testing.B) {
+ // prepare the functions that will be benched
+ query := `SELECT message FROM entries WHERE id < ` + strconv.Itoa(batchSize)
+ selectWithOblast := func(b *testing.B) {
+ messages := must.Return(oblast.Select[string](noctx, db, query).Collect())(b)
+ assert.Equal(b, len(messages), batchSize)
+ }
+ selectWithSqlite := func(b *testing.B) {
+ var count int
+ rows := must.Return(db.Query(query))(b) //nolint:rowserrcheck // false positive
+ var message string
+ for rows.Next() {
+ must.Succeed(b, rows.Scan(&message))
+ count++
+ }
+ must.Succeed(b, rows.Close())
+ assert.Equal(b, count, batchSize)
+ }
+
+ // run actual benchmark
+ b.Run("via Oblast", func(b *testing.B) {
+ for b.Loop() {
+ selectWithOblast(b)
+ }
+ })
+ b.Run("just SQLite", func(b *testing.B) {
+ for b.Loop() {
+ selectWithSqlite(b)
+ }
+ })
+ })
+ }
+}
+
func BenchmarkORMSelectOne(b *testing.B) {
db, dsn := makeSqliteTestDB(b, totalRecordCountForSelect)
@@ -258,6 +298,37 @@ func BenchmarkORMSelectOne(b *testing.B) {
})
}
+func BenchmarkORMSelectOneValue(b *testing.B) {
+ db, _ := makeSqliteTestDB(b, totalRecordCountForSelect)
+
+ // grab a "random" record from the DB, not just the first or the last
+ recordID := min(totalRecordCountForSelect*2/3, totalRecordCountForSelect)
+
+ // prepare the functions that will be benched
+ query := `SELECT message FROM entries WHERE id = ` + strconv.Itoa(recordID)
+ selectWithOblast := func(b *testing.B) {
+ message := must.Return(oblast.SelectOne[string](noctx, db, query))(b)
+ assert.Equal(b, len(message), 71)
+ }
+ selectWithSqlite := func(b *testing.B) {
+ var message string
+ must.Succeed(b, db.QueryRow(query).Scan(&message))
+ assert.Equal(b, len(message), 71)
+ }
+
+ // run actual benchmark
+ b.Run("via Oblast", func(b *testing.B) {
+ for b.Loop() {
+ selectWithOblast(b)
+ }
+ })
+ b.Run("just SQLite", func(b *testing.B) {
+ for b.Loop() {
+ selectWithSqlite(b)
+ }
+ })
+}
+
func BenchmarkORMInsertAndDelete(b *testing.B) {
db, dsn := makeSqliteTestDB(b, 0)
diff --git a/select.go b/select.go
index 985abf9..861c591 100644
--- a/select.go
+++ b/select.go
@@ -15,6 +15,13 @@ import (
. "go.xyrillian.de/gg/option"
)
+// NOTE: I had the idea to add types Tuple2[A, B], Tuple3[A, B, C] and so on for ad-hoc selections without declaring a new record type each time.
+// I'm not fully sold on whether that actually makes the API more ergonomic. But if this desired, we can do so in a backwards-compatible way,
+// by having those types implement interface { sealed(seal); cardinality() int; splatPointers([]any) }.
+// When buildPlan() sees this interface being implemented, it can skip all the work, generate no queries at all, and instead instruct type selection
+// to allocate scanArgs with length t.cardinality() and use t.splatPointers(scanArgs) to have Tuple put pointers to its fields in there, thus bypassing reflection.
+// Since this does not involve picking a dialect at all, we could also have the Select() method on the Tuple type itself.
+
// 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.
//
@@ -245,10 +252,63 @@ func (q PreparedSelectQuery[R]) SelectOneOrNone(ctx context.Context, db gsql.Han
return noRowsToNone(q.SelectOne(ctx, db, args...))
}
+///////////////////////////////////////////////////////////////////////////////////////////
+// non-record selections
+
+// Select executes the provided SQL query that returns rows that each contain exactly one value.
+func Select[T any](ctx context.Context, db gsql.Handle, query string, args ...any) Selection[T] {
+ // 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 Selection[T]{startSelectValueQuery(ctx, db, query, args)}
+}
+
+func startSelectValueQuery(ctx context.Context, db gsql.Handle, query string, args []any) selection {
+ rows, err := db.GSQLQuery(ctx, query, args)
+ if err != nil {
+ return selection{Err: fmt.Errorf("during Query(): %w", err)}
+ }
+ return selection{Rows: rows} // all other members are nil because this is a non-record selection
+}
+
+// SelectOne executes the provided SQL query that returns exactly one row containing exactly one value.
+//
+// This is the same as declaring a value of type T and then saying db.QueryRow(query, args...).Scan(&value)
+// or whatever the equivalent for the DB handle in question is.
+//
+// If there are no rows in the result set, [sql.ErrNoRows] is returned.
+func SelectOne[T any](ctx context.Context, db gsql.Handle, query string, args ...any) (T, 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 T
+ err := selectOneValue(ctx, db, &result, query, args)
+ return result, err
+}
+
+// SelectOneOrNone is like SelectOne, but returns [None] instead of [sql.ErrNoRows].
+//
+// [None]: https://pkg.go.dev/go.xyrillian.de/gg/option#None
+func SelectOneOrNone[T any](ctx context.Context, db gsql.Handle, query string, args ...any) (Option[T], 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(SelectOne[T](ctx, db, query, args...))
+}
+
+func selectOneValue(ctx context.Context, db gsql.Handle, target any, query string, args []any) error {
+ stmt, err := db.GSQLPrepare(ctx, query, false)
+ if err != nil {
+ return err
+ }
+ err = stmt.QueryRow(ctx, args, []any{target})
+ return errext.WithCleanup(err, "Stmt.Close", stmt.Close())
+}
+
////////////////////////////////////////////////////////////////////////////////
// type Selection
-// Selection provides access to the result set from a [Store.Select], [Store.SelectWhere] or [PreparedSelectQuery.Select] call.
+// Selection provides access to the result set from a [Select], [Store.Select], [Store.SelectWhere] or [PreparedSelectQuery.Select] call.
//
// Instances of this type are not meant to be held in variables.
// Instead, chain one of its method calls directly after the Select or SelectWhere call to choose how to process the result set.
@@ -261,13 +321,21 @@ type Selection[R any] struct {
type selection struct {
// from startSelectQuery()
Rows gsql.Rows
- Slots []any // NOTE: len(s.Slots) == len(s.Indexes)
+ Slots []any // NOTE: len(s.Slots) == len(s.Indexes); will be empty for non-record selections (created by Select[T])
Err error // NOTE: if this field is set, all other fields will be unset
- // from plan
+ // from plan; will all be empty for non-record selections (created by Select[T])
Indexes [][]int
TransparentPointerStructFields []fieldInfo
}
+func (s selection) collectRowOrValue(pointerToTarget any) error {
+ if len(s.Slots) > 0 {
+ return s.collectRow(reflect.ValueOf(pointerToTarget).Elem(), s.Slots)
+ } else {
+ return s.collectValue(pointerToTarget)
+ }
+}
+
func (s selection) collectRow(v reflect.Value, slots []any) error {
for _, field := range s.TransparentPointerStructFields {
f := v.FieldByIndex(field.Index)
@@ -283,6 +351,14 @@ func (s selection) collectRow(v reflect.Value, slots []any) error {
return nil
}
+func (s selection) collectValue(pointerToTarget any) error {
+ err := s.Rows.Scan(pointerToTarget)
+ if err != nil {
+ return errext.WithCleanup(err, "Rows.Close", s.Rows.Close())
+ }
+ return nil
+}
+
// Collect returns all of the selected records as a slice.
// This is the most versatile output format for type [Selection], but may cause a spike in memory usage for big result sets.
func (s Selection[R]) Collect() ([]R, error) {
@@ -297,7 +373,7 @@ func (s Selection[R]) Collect() ([]R, error) {
for s.Rows.Next() {
var target *R
result, target = growRecordSlice(result)
- err := s.collectRow(reflect.ValueOf(target).Elem(), s.Slots)
+ err := s.collectRowOrValue(target)
if err != nil {
return nil, err
}
@@ -346,12 +422,25 @@ func (s Selection[R]) Foreach(action func(R) error) error {
// NOTE: `record` will escape to the heap because of the reflect.ValueOf() call.
// By reusing the same `record` throughout the loop, this function will only allocate at most one instance of R on the heap.
- var record R
- v := reflect.ValueOf(&record).Elem()
+ var (
+ record R
+ v reflect.Value
+ isRecord = len(s.Slots) > 0
+ )
+ if isRecord {
+ v = reflect.ValueOf(&record).Elem()
+ }
for s.Rows.Next() {
- var zero R
+ var (
+ zero R
+ err error
+ )
record = zero
- err := s.collectRow(v, s.Slots)
+ if isRecord {
+ err = s.collectRow(v, s.Slots)
+ } else {
+ err = s.collectValue(&record)
+ }
if err != nil {
return err
}
@@ -377,7 +466,7 @@ func (s Selection[R]) First() (R, error) {
if !s.Rows.Next() {
return record, sql.ErrNoRows
}
- err := s.collectRow(reflect.ValueOf(&record).Elem(), s.Slots)
+ err := s.collectRowOrValue(&record)
if err == nil {
err = s.Rows.Close()
}
@@ -396,7 +485,7 @@ func (s Selection[R]) FirstOrNone() (Option[R], error) {
return None[R](), nil
}
var record R
- err := s.collectRow(reflect.ValueOf(&record).Elem(), s.Slots)
+ err := s.collectRowOrValue(&record)
if err == nil {
err = s.Rows.Close()
}
diff --git a/select_test.go b/select_test.go
index c2b319e..5520948 100644
--- a/select_test.go
+++ b/select_test.go
@@ -134,6 +134,32 @@ func TestSelectReturningSomeRecords(t *testing.T) {
record := must.Return(query.SelectOneOrNone(ctx, db, 3))(t)
assert.Equal(t, record, Some(basicRecord{1, "ffffffoo"}))
})
+
+ commonSetupForValueSelect := func() {
+ md.ForQuery(`SELECT name FROM basic_records WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("name").
+ WithRow("foo").
+ WithRow("bar")
+ }
+
+ t.Run("using oblast.Select", func(t *testing.T) {
+ commonSetupForValueSelect()
+ names := must.Return(oblast.Select[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3).Collect())(t)
+ assert.Equal(t, names, []string{"foo", "bar"})
+ })
+
+ t.Run("using oblast.SelectOne", func(t *testing.T) {
+ commonSetupForValueSelect()
+ name := must.Return(oblast.SelectOne[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3))(t)
+ assert.Equal(t, name, "foo")
+ })
+
+ t.Run("using oblast.SelectOneOrNone", func(t *testing.T) {
+ commonSetupForValueSelect()
+ name := must.Return(oblast.SelectOneOrNone[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3))(t)
+ assert.Equal(t, name, Some("foo"))
+ })
}
func TestSelectReturningNoRecords(t *testing.T) {
@@ -225,6 +251,30 @@ func TestSelectReturningNoRecords(t *testing.T) {
record := must.Return(query.SelectOneOrNone(ctx, db, 3))(t)
assert.Equal(t, record, None[basicRecord]())
})
+
+ commonSetupForValueSelect := func() {
+ md.ForQuery(`SELECT name FROM basic_records WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("name")
+ }
+
+ t.Run("using oblast.Select", func(t *testing.T) {
+ commonSetupForValueSelect()
+ names := must.Return(oblast.Select[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3).Collect())(t)
+ assert.Equal(t, names, nil)
+ })
+
+ t.Run("using oblast.SelectOne", func(t *testing.T) {
+ commonSetupForValueSelect()
+ _, err := oblast.SelectOne[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3)
+ assert.ErrEqual(t, err, sql.ErrNoRows.Error())
+ })
+
+ t.Run("using oblast.SelectOneOrNone", func(t *testing.T) {
+ commonSetupForValueSelect()
+ name := must.Return(oblast.SelectOneOrNone[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3))(t)
+ assert.Equal(t, name, None[string]())
+ })
}
func TestSelectIntoUnexpectedField(t *testing.T) {
@@ -486,6 +536,16 @@ func TestSelectCapturingQueryError(t *testing.T) {
_, err := query.SelectOne(ctx, db, 3)
assert.ErrEqual(t, err, `unexpected query: SELECT "id", "name" FROM "basic_records" WHERE id < ?`)
})
+
+ t.Run("using oblast.Select", func(t *testing.T) {
+ _, err := oblast.Select[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3).Collect()
+ assert.ErrEqual(t, err, "during Query(): unexpected query: SELECT name FROM basic_records WHERE id < ?")
+ })
+
+ t.Run("using oblast.SelectOne", func(t *testing.T) {
+ _, err := oblast.SelectOne[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3)
+ assert.ErrEqual(t, err, "unexpected query: SELECT name FROM basic_records WHERE id < ?")
+ })
}
func TestSelectCapturingCloseError(t *testing.T) {
@@ -549,6 +609,27 @@ func TestSelectCapturingCloseError(t *testing.T) {
_, err := query.SelectOne(ctx, db, 3)
assert.ErrEqual(t, err, "datacenter on fire")
})
+
+ commonSetup = func(query string) {
+ md.ForQuery(query).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("name").
+ WithRow("foo").
+ WithRow("bar").
+ AndCloseFailsWith(errors.New("datacenter on fire"))
+ }
+
+ t.Run("using oblast.Select", func(t *testing.T) {
+ commonSetup(`SELECT name FROM basic_records WHERE id < ?`)
+ _, err := oblast.Select[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3).Collect()
+ assert.ErrEqual(t, err, "datacenter on fire")
+ })
+
+ t.Run("using oblast.SelectOne", func(t *testing.T) {
+ commonSetup(`SELECT name FROM basic_records WHERE id < ?`)
+ _, err := oblast.SelectOne[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3)
+ assert.ErrEqual(t, err, "datacenter on fire")
+ })
}
func TestSelectNotPossibleWithoutTableName(t *testing.T) {