diff options
| -rw-r--r-- | README.md | 7 | ||||
| -rw-r--r-- | benchmark/benchmark_test.go | 8 | ||||
| -rw-r--r-- | select.go | 75 | ||||
| -rw-r--r-- | select_test.go | 139 |
4 files changed, 205 insertions, 24 deletions
@@ -20,12 +20,13 @@ The design goals, ordered by priority (most important comes first), are: - An intuitive API that encodes type safety through the use of generics. - A minimal amount of memory allocations in hot paths. - A minimal amount of CPU usage. +- As few library dependencies as possible. -As a surprising consequence, this meant having to eschew `context.Context` arguments. +As a surprising consequence, this set of priorities forced this library to eschew `context.Context` arguments. Early benchmarking showed that replacing `QueryRow` with `QueryRowContext` increased allocations by up to 50% and memory allocated by up to 100%. -The author of this library is still a fan of `context.Context` for things like HTTP requests to external services. +The author of this library is still a fan of `context.Context` for things like HTTP requests to external services, where unpredictable delays make a structured cancellation facility vital. But this library optimizes for blazing fast OLTP workloads (with maybe a few OLAP queries every once in a while), where not being able to back out of a running query is not that big of a deal because query runtimes should always be short anyway. -If in doubt, the lack of `context.Context` arguments can be counteracted by setting timeouts on your DB transactions. +If in doubt, the lack of `context.Context` arguments can be counteracted by setting timeouts on your DB transactions during OLAP workloads. Explicit non-goals include: diff --git a/benchmark/benchmark_test.go b/benchmark/benchmark_test.go index 2b0545a..ada238e 100644 --- a/benchmark/benchmark_test.go +++ b/benchmark/benchmark_test.go @@ -23,7 +23,7 @@ import ( // This is not a real benchmark (obviously). // Its purpose is to be the first line that is printed, while having one of the longest names, // so that all other results are aligned with it and the table looks nice. -func BenchmarkHeadingHeadingHeadingHeadingHeadingHeading(b *testing.B) { +func BenchmarkHeadingHeadingHeadingHeadingHeadingHeadingHeading(b *testing.B) { for b.Loop() { time.Sleep(time.Microsecond) } @@ -88,6 +88,7 @@ func BenchmarkSelectMany(b *testing.B) { gormDB := must.Return(gorm.Open(sqlite.Open(dsn), &gorm.Config{}))(b) partialQuery := `id < ` + strconv.Itoa(batchSize) query := `SELECT * FROM entries WHERE ` + partialQuery + precomputedQuery := store.MustPrepareSelectQueryWhere(partialQuery) selectWithOblast := func(b *testing.B) { records := must.Return(store.Select(db, query))(b) @@ -95,7 +96,7 @@ func BenchmarkSelectMany(b *testing.B) { } selectWithOblastWhere := func(b *testing.B) { - records := must.Return(store.SelectWhere(db, partialQuery))(b) + records := must.Return(precomputedQuery.Select(db))(b) assert.Equal(b, len(records), batchSize) } @@ -181,6 +182,7 @@ func BenchmarkSelectOne(b *testing.B) { gormDB := must.Return(gorm.Open(sqlite.Open(dsn), &gorm.Config{}))(b) partialQuery := `id = ` + strconv.Itoa(recordID) query := `SELECT * FROM entries WHERE ` + partialQuery + precomputedQuery := store.MustPrepareSelectQueryWhere(partialQuery) selectWithOblast := func(b *testing.B) { r := must.Return(store.SelectOne(db, query))(b) @@ -188,7 +190,7 @@ func BenchmarkSelectOne(b *testing.B) { } selectWithOblastWhere := func(b *testing.B) { - r := must.Return(store.SelectOneWhere(db, partialQuery))(b) + r := must.Return(precomputedQuery.SelectOne(db))(b) assert.Equal(b, r.ID, recordID) } @@ -153,7 +153,8 @@ func (s Store[R]) SelectOne(db Handle, query string, args ...any) (result R, err // SelectOneWhere is like [Store.SelectOne], but you only provide the part of the SELECT query that comes after the WHERE. // See [Store.SelectWhere] for an explanation of how the full query is constructed from this partial query. // -// This method is more efficient than [Store.SelectOne] on CPU runtime, but has a slight memory allocation overhead. +// 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(db Handle, partialQuery string, args ...any) (result R, err 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. @@ -167,6 +168,10 @@ func selectOneWhere(db Handle, plan plan, v reflect.Value, partialQuery string, return errors.New("cannot execute SelectOneWhere() because query could not be autogenerated") } query := plan.Select.Query + partialQuery + return selectOne(db, plan, v, query, args) +} + +func selectOne(db Handle, plan plan, v reflect.Value, query string, args []any) error { for _, index := range plan.IndexesOfTransparentPointerStructs { f := v.FieldByIndex(index) f.Set(reflect.New(f.Type().Elem())) @@ -178,4 +183,70 @@ func selectOneWhere(db Handle, plan plan, v reflect.Value, partialQuery string, return db.QueryRow(query, args...).Scan(slots...) } -// TODO: variant of SelectWhere/SelectOneWhere that has the full query precomputed +// PrepareSelectQueryWhere performs the same query string preparation as [Store.SelectWhere] or [Store.SelectOneWhere]. +// The resulting query can then be executed multiple times without incurring repeated memory allocation overhead from this preparation step. +func (s Store[R]) PrepareSelectQueryWhere(partialQuery string) (PreparedSelectQuery[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. + + query, err := prepareSelectQueryWhere(s.plan, partialQuery) + return PreparedSelectQuery[R]{s, query}, err +} + +// MustPrepareSelectQueryWhere is like [Store.PrepareSelectQueryWhere], but panics on error. +func (s Store[R]) MustPrepareSelectQueryWhere(partialQuery string) PreparedSelectQuery[R] { + q, err := s.PrepareSelectQueryWhere(partialQuery) + if err != nil { + panic(err.Error()) + } + return q +} + +func prepareSelectQueryWhere(plan plan, partialQuery string) (string, error) { + if plan.Select.Query == "" { + return "", errors.New("cannot execute PrepareSelectQueryWhere() because query could not be autogenerated") + } + return plan.Select.Query + partialQuery, nil +} + +// PreparedSelectQuery holds a pre-computed SELECT query that was customized by the user. +// This type is an optimization to avoid performing the same query string manipulations over and over again in hot paths. +// +// It is returned by [Store.PrepareSelectQueryWhere]. +type PreparedSelectQuery[R any] struct { + store Store[R] + query string +} + +// Select behaves the same as [Store.SelectWhere], but uses the query that was precomputed when q was constructed. +func (q PreparedSelectQuery[R]) Select(db Handle, 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. + + rows, indexes, err := startSelectQuery(db, q.store.plan, q.query, args...) + if err != nil { + return nil, err + } + + var result []R + slots := make([]any, len(indexes)) + for rows.Next() { + var target R + err = collectRow(rows, q.store.plan, reflect.ValueOf(&target).Elem(), slots, indexes) + if err != nil { + return nil, err + } + result = append(result, target) + } + + return result, newIOError(err, "Rows.Err", rows.Err()) +} + +// SelectOne behaves the same as [Store.SelectOneWhere], but uses the query that was precomputed when q was constructed. +func (q PreparedSelectQuery[R]) SelectOne(db Handle, args ...any) (result R, err 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(db, q.store.plan, reflect.ValueOf(&result).Elem(), q.query, args) + return +} diff --git a/select_test.go b/select_test.go index e985548..51b0912 100644 --- a/select_test.go +++ b/select_test.go @@ -55,24 +55,49 @@ func TestSelectReturningSomeRecords(t *testing.T) { ) }) + t.Run("using PreparedSelectQuery.Select", func(t *testing.T) { + md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`). + ExpectQueryWithArgs(3). + AndReturnColumns("id", "name"). + WithRow(1, "fffoo"). + WithRow(2, "bbbar") + query := store.MustPrepareSelectQueryWhere(`id < ?`) + records := must.Return(query.Select(db, 3))(t) + assert.SliceEqual(t, records, + basicRecord{1, "fffoo"}, + basicRecord{2, "bbbar"}, + ) + }) + t.Run("using Store.SelectOne", func(t *testing.T) { md.ForQuery(`SELECT * FROM basic_records WHERE id < ?`). ExpectQueryWithArgs(3). AndReturnColumns("name", "id"). - WithRow("fffoo", 1). - WithRow("bbbar", 2) + WithRow("ffffoo", 1). + WithRow("bbbbar", 2) record := must.Return(store.SelectOne(db, `SELECT * FROM basic_records WHERE id < ?`, 3))(t) - assert.Equal(t, record, basicRecord{1, "fffoo"}) + assert.Equal(t, record, basicRecord{1, "ffffoo"}) }) t.Run("using Store.SelectOneWhere", func(t *testing.T) { md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`). ExpectQueryWithArgs(3). AndReturnColumns("id", "name"). - WithRow(1, "ffffoo"). - WithRow(2, "bbbbar") + WithRow(1, "fffffoo"). + WithRow(2, "bbbbbar") record := must.Return(store.SelectOneWhere(db, `id < ?`, 3))(t) - assert.Equal(t, record, basicRecord{1, "ffffoo"}) + assert.Equal(t, record, basicRecord{1, "fffffoo"}) + }) + + t.Run("using PreparedSelectQuery.SelectOne", func(t *testing.T) { + md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`). + ExpectQueryWithArgs(3). + AndReturnColumns("id", "name"). + WithRow(1, "ffffffoo"). + WithRow(2, "bbbbbbar") + query := store.MustPrepareSelectQueryWhere(`id < ?`) + record := must.Return(query.SelectOne(db, 3))(t) + assert.Equal(t, record, basicRecord{1, "ffffffoo"}) }) } @@ -106,6 +131,15 @@ func TestSelectReturningNoRecords(t *testing.T) { assert.SliceEqual(t, records, nil...) }) + t.Run("using PreparedSelectQuery.Select", func(t *testing.T) { + md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`). + ExpectQueryWithArgs(3). + AndReturnColumns("id", "name") + query := store.MustPrepareSelectQueryWhere(`id < ?`) + records := must.Return(query.Select(db, 3))(t) + assert.SliceEqual(t, records, nil...) + }) + t.Run("using Store.SelectOne", func(t *testing.T) { md.ForQuery(`SELECT * FROM basic_records WHERE id < ?`). ExpectQueryWithArgs(3). @@ -121,6 +155,15 @@ func TestSelectReturningNoRecords(t *testing.T) { _, err := store.SelectOneWhere(db, `id < ?`, 3) assert.ErrEqual(t, err, sql.ErrNoRows.Error()) }) + + t.Run("using PreparedSelectQuery.SelectOne", func(t *testing.T) { + md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`). + ExpectQueryWithArgs(3). + AndReturnColumns("id", "name") + query := store.MustPrepareSelectQueryWhere(`id < ?`) + _, err := query.SelectOne(db, 3) + assert.ErrEqual(t, err, sql.ErrNoRows.Error()) + }) } func TestSelectIntoUnexpectedField(t *testing.T) { @@ -196,6 +239,13 @@ func TestSelectWithScanError(t *testing.T) { assert.ErrEqual(t, err, expectedError) }) + t.Run("using PreparedSelectQuery.Select", func(t *testing.T) { + commonSetup(`SELECT "id", "created_at" FROM "basic_records" WHERE id < ?`) + query := store.MustPrepareSelectQueryWhere(`id < ?`) + _, err := query.Select(db, 3) + assert.ErrEqual(t, err, expectedError) + }) + t.Run("using Store.SelectOne", func(t *testing.T) { commonSetup(`SELECT * FROM basic_records WHERE id < ?`) _, err := store.SelectOne(db, `SELECT * FROM basic_records WHERE id < ?`, 3) @@ -207,6 +257,13 @@ func TestSelectWithScanError(t *testing.T) { _, err := store.SelectOneWhere(db, `id < ?`, 3) assert.ErrEqual(t, err, expectedError) }) + + t.Run("using PreparedSelectQuery.SelectOne", func(t *testing.T) { + commonSetup(`SELECT "id", "created_at" FROM "basic_records" WHERE id < ?`) + query := store.MustPrepareSelectQueryWhere(`id < ?`) + _, err := query.SelectOne(db, 3) + assert.ErrEqual(t, err, expectedError) + }) } func TestSelectIntoEmbeddedTypes(t *testing.T) { @@ -258,6 +315,16 @@ func TestSelectIntoEmbeddedTypes(t *testing.T) { ) }) + t.Run("using PreparedSelectQuery.Select", func(t *testing.T) { + commonSetup(`SELECT "id", "created_at", "updated_at" FROM "composite_records" WHERE TRUE`) + query := store.MustPrepareSelectQueryWhere(`TRUE`) + records := must.Return(query.Select(db))(t) + assert.SliceDeepEqual(t, records, + compositeRecord{1, HasCreatedAt{time.Unix(1, 0)}, &HasUpdatedAt{new(time.Unix(3, 0))}}, + compositeRecord{2, HasCreatedAt{time.Unix(2, 0)}, &HasUpdatedAt{nil}}, + ) + }) + t.Run("using Store.SelectOne", func(t *testing.T) { commonSetup(`SELECT * FROM composite_records`) record := must.Return(store.SelectOne(db, `SELECT * FROM composite_records`))(t) @@ -273,6 +340,15 @@ func TestSelectIntoEmbeddedTypes(t *testing.T) { compositeRecord{1, HasCreatedAt{time.Unix(1, 0)}, &HasUpdatedAt{new(time.Unix(3, 0))}}, ) }) + + t.Run("using PreparedSelectQuery.SelectOne", func(t *testing.T) { + commonSetup(`SELECT "id", "created_at", "updated_at" FROM "composite_records" WHERE TRUE`) + query := store.MustPrepareSelectQueryWhere(`TRUE`) + record := must.Return(query.SelectOne(db))(t) + assert.DeepEqual(t, record, + compositeRecord{1, HasCreatedAt{time.Unix(1, 0)}, &HasUpdatedAt{new(time.Unix(3, 0))}}, + ) + }) } func TestSelectCapturingQueryError(t *testing.T) { @@ -294,20 +370,32 @@ func TestSelectCapturingQueryError(t *testing.T) { assert.ErrEqual(t, err, "during Query(): unexpected query: SELECT * FROM basic_records WHERE id < ?") }) - t.Run("using Store.SelectOne", func(t *testing.T) { - _, err := store.SelectOne(db, `SELECT * FROM basic_records WHERE id < ?`, 3) - assert.ErrEqual(t, err, "during Query(): unexpected query: SELECT * FROM basic_records WHERE id < ?") - }) - t.Run("using Store.SelectWhere", func(t *testing.T) { _, err := store.SelectWhere(db, `id < ?`, 3) assert.ErrEqual(t, err, `during Query(): unexpected query: SELECT "id", "name" FROM "basic_records" WHERE id < ?`) }) + t.Run("using PreparedSelectQuery.Select", func(t *testing.T) { + query := store.MustPrepareSelectQueryWhere(`id < ?`) + _, err := query.Select(db, 3) + assert.ErrEqual(t, err, `during Query(): unexpected query: SELECT "id", "name" FROM "basic_records" WHERE id < ?`) + }) + + t.Run("using Store.SelectOne", func(t *testing.T) { + _, err := store.SelectOne(db, `SELECT * FROM basic_records WHERE id < ?`, 3) + assert.ErrEqual(t, err, "during Query(): unexpected query: SELECT * FROM basic_records WHERE id < ?") + }) + t.Run("using Store.SelectOneWhere", func(t *testing.T) { _, err := store.SelectOneWhere(db, `id < ?`, 3) assert.ErrEqual(t, err, `unexpected query: SELECT "id", "name" FROM "basic_records" WHERE id < ?`) }) + + t.Run("using PreparedSelectQuery.SelectOne", func(t *testing.T) { + query := store.MustPrepareSelectQueryWhere(`id < ?`) + _, err := query.SelectOne(db, 3) + assert.ErrEqual(t, err, `unexpected query: SELECT "id", "name" FROM "basic_records" WHERE id < ?`) + }) } func TestSelectCapturingCloseError(t *testing.T) { @@ -339,15 +427,22 @@ func TestSelectCapturingCloseError(t *testing.T) { assert.ErrEqual(t, err, "during Rows.Err(): datacenter on fire") }) - t.Run("using Store.SelectOne", func(t *testing.T) { - commonSetup(`SELECT * FROM basic_records WHERE id < ?`) - _, err := store.SelectOne(db, `SELECT * FROM basic_records WHERE id < ?`, 3) + t.Run("using Store.SelectWhere", func(t *testing.T) { + commonSetup(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`) + _, err := store.SelectWhere(db, `id < ?`, 3) assert.ErrEqual(t, err, "during Rows.Err(): datacenter on fire") }) - t.Run("using Store.SelectWhere", func(t *testing.T) { + t.Run("using PreparedSelectQuery.Select", func(t *testing.T) { commonSetup(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`) - _, err := store.SelectWhere(db, `id < ?`, 3) + query := store.MustPrepareSelectQueryWhere(`id < ?`) + _, err := query.Select(db, 3) + assert.ErrEqual(t, err, "during Rows.Err(): datacenter on fire") + }) + + t.Run("using Store.SelectOne", func(t *testing.T) { + commonSetup(`SELECT * FROM basic_records WHERE id < ?`) + _, err := store.SelectOne(db, `SELECT * FROM basic_records WHERE id < ?`, 3) assert.ErrEqual(t, err, "during Rows.Err(): datacenter on fire") }) @@ -356,6 +451,13 @@ func TestSelectCapturingCloseError(t *testing.T) { _, err := store.SelectOneWhere(db, `id < ?`, 3) assert.ErrEqual(t, err, "datacenter on fire") }) + + t.Run("using PreparedSelectQuery.SelectOne", func(t *testing.T) { + commonSetup(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`) + query := store.MustPrepareSelectQueryWhere(`id < ?`) + _, err := query.SelectOne(db, 3) + assert.ErrEqual(t, err, "datacenter on fire") + }) } func TestSelectNotPossibleWithoutTableName(t *testing.T) { @@ -377,4 +479,9 @@ func TestSelectNotPossibleWithoutTableName(t *testing.T) { _, err := store.SelectOneWhere(db, `id < ?`, 3) assert.ErrEqual(t, err, "cannot execute SelectOneWhere() because query could not be autogenerated") }) + + t.Run("using PreparedSelectQuery", func(t *testing.T) { + _, err := store.PrepareSelectQueryWhere(`id < ?`) + assert.ErrEqual(t, err, "cannot execute PrepareSelectQueryWhere() because query could not be autogenerated") + }) } |
