diff options
| -rw-r--r-- | CHANGELOG.md | 6 | ||||
| -rw-r--r-- | benchmark/benchmark_test.go | 71 | ||||
| -rw-r--r-- | select.go | 109 | ||||
| -rw-r--r-- | select_test.go | 81 |
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) @@ -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) { |
