aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md7
-rw-r--r--benchmark/benchmark_test.go8
-rw-r--r--select.go75
-rw-r--r--select_test.go139
4 files changed, 205 insertions, 24 deletions
diff --git a/README.md b/README.md
index 5d614cd..d2e9660 100644
--- a/README.md
+++ b/README.md
@@ -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)
}
diff --git a/select.go b/select.go
index 5e4ab42..2cd1e44 100644
--- a/select.go
+++ b/select.go
@@ -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")
+ })
}