aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2026-09-15 17:29:51 +0200
committerStefan Majewsky <majewsky@gmx.net>2026-09-15 17:31:33 +0200
commite6b575dfd03f0aa953823a48520dc30556705dee (patch)
tree77af27c4cfb2170d597ce8c5826902cd6270fa2a
parentdd50131298675f0abdbd749cf39320c9f53fed38 (diff)
downloadgo-oblast-e6b575dfd03f0aa953823a48520dc30556705dee.tar.gz
add func TupleSelect/TupleSelectOne/TupleSelectOneOrNone
-rw-r--r--CHANGELOG.md10
-rw-r--r--oblast.go3
-rw-r--r--plan.go34
-rw-r--r--select.go78
-rw-r--r--select_test.go93
5 files changed, 203 insertions, 15 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aca0fe0..3874be9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,16 @@ SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
SPDX-License-Identifier: Apache-2.0
-->
+# v0.16.0 (TBD)
+
+API changes:
+
+- Add `oblast.{TupleSelect,TupleSelectOne,TupleSelectOneOrNone}` to help with selecting rows containing joined or aggregated columns.
+
+Changes:
+
+- Fix plan caching not considering the ReadOnly field correctly.
+
# v0.15.0 (2026-09-15)
API changes:
diff --git a/oblast.go b/oblast.go
index 837742d..57d0c63 100644
--- a/oblast.go
+++ b/oblast.go
@@ -102,9 +102,6 @@
// }
package oblast // import "go.xyrillian.de/oblast"
-// TODO: adapt selectOneValue() and selectSeveralValues() from gg/pgruntime/helpers.go into the public API here (reusing Selection[R] appropriately)
-// TODO: also consider if this pattern can be adapted to select pairs/triples/etc. of values in a convenient way, e.g. oblast.Select(ctx, db, `SELECT id, name FROM objects`).Foreach(func (id int64, name string) error { ... })
-
import (
"database/sql"
"database/sql/driver"
diff --git a/plan.go b/plan.go
index 423e339..6b5f002 100644
--- a/plan.go
+++ b/plan.go
@@ -34,14 +34,17 @@ func collectPlanOptions(popts []PlanOption) planOpts {
type planCacheKey struct {
Type reflect.Type
Dialect string
+ ReadOnly bool
StructTagKey string
TableName string
PrimaryKeyColumnNames string
}
var (
- generatedPlans = make(map[planCacheKey]plan)
- generatedPlansMutex sync.RWMutex
+ generatedPlans = make(map[planCacheKey]plan)
+ generatedPlansMutex sync.RWMutex
+ generatedTuplePlans = make(map[reflect.Type]plan)
+ generatedTuplePlansMutex sync.Mutex
)
// getOrBuildPlan is like [buildPlan], but caches generated plans and tries to reuse cached plans.
@@ -49,6 +52,7 @@ func getOrBuildPlan(t reflect.Type, dialect Dialect, opts planOpts) (plan, error
key := planCacheKey{
Type: t,
Dialect: dialect.String(),
+ ReadOnly: opts.ReadOnly,
StructTagKey: opts.StructTagKey,
TableName: opts.TableName,
PrimaryKeyColumnNames: strings.Join(opts.PrimaryKeyColumnNames, "\000"),
@@ -71,17 +75,39 @@ func getOrBuildPlan(t reflect.Type, dialect Dialect, opts planOpts) (plan, error
return p, nil
}
+// getOrBuildTuplePlan is like [getOrBuildPlan], but generates the plans used by TupleSelect() et al
+func getOrBuildTuplePlan(t reflect.Type) plan {
+ generatedTuplePlansMutex.Lock()
+ defer generatedTuplePlansMutex.Unlock()
+
+ p, ok := generatedTuplePlans[t]
+ if !ok {
+ indexes := make([][]int, t.NumField())
+ for idx := range indexes {
+ indexes[idx] = []int{idx}
+ }
+ p = plan{
+ TypeName: t.Name(),
+ StaticIndexes: indexes,
+ }
+ generatedTuplePlans[t] = p
+ }
+ return p
+}
+
// plan holds all information that we can derive from reflecting on a given type.
// The queries held within are only valid within the context of a given SQL dialect.
type plan struct {
TypeName string // for use in error messages
TableName string // from info.TableNameIs marker (if any)
- AllColumnNames []string // in order of struct fields
+ AllColumnNames []string // in order of struct fields (not set for TupleSelect() plans)
PrimaryKeyColumnNames []string // from info.PrimaryKeyIs marker (if any)
AutoColumnNames []string // subset of AllColumnNames where field has `,auto` marker
- // Field index (i.e. argument for reflect.Value.FieldByIndex()) for each column name.
+ // Field index (i.e. argument for reflect.Value.FieldByIndex()) for each column name. Not set for TupleSelect() plans.
IndexByColumnName map[string][]int
+ // Select indexes for TupleSelect() plans. Always of the form [[0], [1], [2], ..., [N]]. Not set for regular plans.
+ StaticIndexes [][]int
// Pointer-typed fields that need to be initialized before scanning into this type.
TransparentPointerStructFields []fieldInfo
diff --git a/select.go b/select.go
index 861c591..9b2150b 100644
--- a/select.go
+++ b/select.go
@@ -15,13 +15,6 @@ 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.
//
@@ -61,6 +54,15 @@ func startSelectQuery(ctx context.Context, db gsql.Handle, plan plan, query stri
return selection{Err: fmt.Errorf("during Query(): %w", err)}
}
+ // fast exit for TupleSelect()
+ if len(plan.IndexByColumnName) == 0 {
+ return selection{
+ Rows: rows,
+ Slots: make([]any, len(plan.StaticIndexes)),
+ Indexes: plan.StaticIndexes,
+ }
+ }
+
columnNames, err := rows.Columns()
if err != nil {
err = fmt.Errorf("during rows.Columns(): %w", err)
@@ -252,7 +254,67 @@ func (q PreparedSelectQuery[R]) SelectOneOrNone(ctx context.Context, db gsql.Han
return noRowsToNone(q.SelectOne(ctx, db, args...))
}
-///////////////////////////////////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////////////
+// tuple selections
+
+// TupleSelect executes the provided SQL query and fills an instance of the record type R for each row in the result set.
+// Unlike [Store.Select], struct fields are matched to the result columns not based on names or struct tags, but purely based on order:
+// Values from the first column are stored in the first result field, and so on.
+//
+// This is usually more convenient when defining an ad-hoc record type for a single query. Compare:
+//
+// const query = `SELECT given_name, COUNT(*) AS user_count FROM users WHERE family_name = $1 GROUP BY first_name`
+// type record struct {
+// GivenName string `db:"given_name"`
+// UserCount uint64 `db:"user_count"`
+// }
+// err = oblast.MustNewStore[record](config.DB.Dialect).Select(ctx, db, query, lastName).Foreach(func(r record) error {
+// return doSomethingWith(r.GivenName, r.UserCount)
+// })
+//
+// With:
+//
+// const query = `SELECT given_name, COUNT(*) FROM users WHERE family_name = $1 GROUP BY first_name`
+// type record struct {
+// GivenName string
+// UserCount uint64
+// }
+// err = oblast.TupleSelect[record](ctx, db, query, lastName).Foreach(func(r record) error {
+// return doSomethingWith(r.GivenName, r.UserCount)
+// })
+//
+// Do not use this function with queries of the form `SELECT * FROM ...`,
+// where the order of columns is not well-defined and may vary between otherwise compatible DB schemas.
+func TupleSelect[R any](ctx context.Context, db gsql.Handle, query string, args ...any) Selection[R] {
+ // 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.
+
+ plan := getOrBuildTuplePlan(reflect.TypeFor[R]())
+ return Selection[R]{startSelectQuery(ctx, db, plan, query, args...)}
+}
+
+// TupleSelectOne executes the provided SQL query and fills an instance of the record type R if there is exactly one row in the result set,
+// following the same behavior as [TupleSelect] for mapping a row into a record.
+//
+// If there are no rows in the result set, [sql.ErrNoRows] is returned.
+func TupleSelectOne[R any](ctx context.Context, db gsql.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.
+
+ return TupleSelect[R](ctx, db, query, args...).First()
+}
+
+// TupleSelectOneOrNone is like [TupleSelectOne], but returns [None] instead of [sql.ErrNoRows].
+//
+// [None]: https://pkg.go.dev/go.xyrillian.de/gg/option#None
+func TupleSelectOneOrNone[R any](ctx context.Context, db gsql.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.
+
+ return TupleSelect[R](ctx, db, query, args...).FirstOrNone()
+}
+
+////////////////////////////////////////////////////////////////////////////////
// non-record selections
// Select executes the provided SQL query that returns rows that each contain exactly one value.
diff --git a/select_test.go b/select_test.go
index 5520948..6e99463 100644
--- a/select_test.go
+++ b/select_test.go
@@ -160,6 +160,39 @@ func TestSelectReturningSomeRecords(t *testing.T) {
name := must.Return(oblast.SelectOneOrNone[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3))(t)
assert.Equal(t, name, Some("foo"))
})
+
+ type tupleRecord struct {
+ ID int64
+ Name string
+ }
+ commonSetupForTupleSelect := func() {
+ md.ForQuery(`SELECT id, name FROM basic_records WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("id", "name").
+ WithRow(1, "foo").
+ WithRow(2, "bar")
+ }
+
+ t.Run("using TupleSelect", func(t *testing.T) {
+ commonSetupForTupleSelect()
+ records := must.Return(oblast.TupleSelect[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3).Collect())(t)
+ assert.Equal(t, records, []tupleRecord{
+ {1, "foo"},
+ {2, "bar"},
+ })
+ })
+
+ t.Run("using TupleSelectOne", func(t *testing.T) {
+ commonSetupForTupleSelect()
+ record := must.Return(oblast.TupleSelectOne[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3))(t)
+ assert.Equal(t, record, tupleRecord{1, "foo"})
+ })
+
+ t.Run("using TupleSelectOneOrNone", func(t *testing.T) {
+ commonSetupForTupleSelect()
+ record := must.Return(oblast.TupleSelectOneOrNone[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3))(t)
+ assert.Equal(t, record, Some(tupleRecord{1, "foo"}))
+ })
}
func TestSelectReturningNoRecords(t *testing.T) {
@@ -275,6 +308,34 @@ func TestSelectReturningNoRecords(t *testing.T) {
name := must.Return(oblast.SelectOneOrNone[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3))(t)
assert.Equal(t, name, None[string]())
})
+
+ type tupleRecord struct {
+ ID int64
+ Name string
+ }
+ commonSetupForTupleSelect := func() {
+ md.ForQuery(`SELECT id, name FROM basic_records WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("id", "name")
+ }
+
+ t.Run("using TupleSelect", func(t *testing.T) {
+ commonSetupForTupleSelect()
+ records := must.Return(oblast.TupleSelect[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3).Collect())(t)
+ assert.Equal(t, records, nil)
+ })
+
+ t.Run("using TupleSelectOne", func(t *testing.T) {
+ commonSetupForTupleSelect()
+ _, err := oblast.TupleSelectOne[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3)
+ assert.ErrEqual(t, err, sql.ErrNoRows.Error())
+ })
+
+ t.Run("using TupleSelectOneOrNone", func(t *testing.T) {
+ commonSetupForTupleSelect()
+ record := must.Return(oblast.TupleSelectOneOrNone[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3))(t)
+ assert.Equal(t, record, None[tupleRecord]())
+ })
}
func TestSelectIntoUnexpectedField(t *testing.T) {
@@ -546,6 +607,21 @@ func TestSelectCapturingQueryError(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 < ?")
})
+
+ type tupleRecord struct {
+ ID int64
+ Name string
+ }
+
+ t.Run("using oblast.TupleSelect", func(t *testing.T) {
+ _, err := oblast.TupleSelect[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3).Collect()
+ assert.ErrEqual(t, err, "during Query(): unexpected query: SELECT id, name FROM basic_records WHERE id < ?")
+ })
+
+ t.Run("using oblast.TupleSelectOne", func(t *testing.T) {
+ _, err := oblast.TupleSelectOne[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3)
+ assert.ErrEqual(t, err, "during Query(): unexpected query: SELECT id, name FROM basic_records WHERE id < ?")
+ })
}
func TestSelectCapturingCloseError(t *testing.T) {
@@ -610,6 +686,23 @@ func TestSelectCapturingCloseError(t *testing.T) {
assert.ErrEqual(t, err, "datacenter on fire")
})
+ type tupleRecord struct {
+ ID int64
+ Name string
+ }
+
+ t.Run("using oblast.TupleSelect", func(t *testing.T) {
+ commonSetup(`SELECT id, name FROM basic_records WHERE id < ?`)
+ _, err := oblast.TupleSelect[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3).Collect()
+ assert.ErrEqual(t, err, "datacenter on fire")
+ })
+
+ t.Run("using oblast.TupleSelectOne", func(t *testing.T) {
+ commonSetup(`SELECT id, name FROM basic_records WHERE id < ?`)
+ _, err := oblast.TupleSelectOne[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3)
+ assert.ErrEqual(t, err, "datacenter on fire")
+ })
+
commonSetup = func(query string) {
md.ForQuery(query).
ExpectQueryWithArgs(3).