From e6b575dfd03f0aa953823a48520dc30556705dee Mon Sep 17 00:00:00 2001 From: Stefan Majewsky Date: Tue, 15 Sep 2026 17:29:51 +0200 Subject: add func TupleSelect/TupleSelectOne/TupleSelectOneOrNone --- CHANGELOG.md | 10 +++++++ oblast.go | 3 -- plan.go | 34 ++++++++++++++++++--- select.go | 78 +++++++++++++++++++++++++++++++++++++++++++----- select_test.go | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 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 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). -- cgit v1.3.1