aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md7
-rw-r--r--dialect.go17
-rw-r--r--oblast.go19
-rw-r--r--plan.go70
-rw-r--r--plan_test.go12
5 files changed, 103 insertions, 22 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b75ae4..2f7727b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,13 @@ SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
SPDX-License-Identifier: Apache-2.0
-->
+# v0.12.0 (TBD)
+
+Changes:
+
+- Computations performed during `NewStore` are now cached, thus improving performance for repeated calls with the same arguments,
+ at the extra cost of one mutex read lock (amortized) per call.
+
# v0.11.0 (2026-07-17)
API changes:
diff --git a/dialect.go b/dialect.go
index d057c8d..11842eb 100644
--- a/dialect.go
+++ b/dialect.go
@@ -43,6 +43,11 @@ type Dialect interface {
// behave like UPDATE if a record with the same primary key already exists.
// This is only used for record types that have a primary key.
UpsertClause(pkColumns, otherColumns []string) string
+
+ // String returns a unique identifier for this Dialect instance.
+ // Different instances shall return the same string only if all their methods behave identically.
+ // This information is used to cache generated query plans.
+ String() string
}
// MariaDBDialect is the dialect of MariaDB 10.5+ databases.
@@ -81,6 +86,10 @@ func (d mariadbDialect) UpsertClause(pkColumns, otherColumns []string) string {
return ` ON DUPLICATE KEY UPDATE ` + strings.Join(clauses, ", ")
}
+func (mariadbDialect) String() string {
+ return "mariadb"
+}
+
// PostgresDialect is the dialect of PostgreSQL databases.
func PostgresDialect() Dialect {
return postgresDialect{}
@@ -117,6 +126,10 @@ func (d postgresDialect) UpsertClause(pkColumns, otherColumns []string) string {
}
}
+func (postgresDialect) String() string {
+ return "postgres"
+}
+
// SqliteDialect is the dialect of SQLite 3.35.0+ databases.
//
// This dialect does NOT support ancient SQLite versions (3.35.0 was released 2021-03-12)
@@ -142,3 +155,7 @@ func (sqliteDialect) CanUseLastInsertId() bool {
func (sqliteDialect) UpsertClause(pkColumns, otherColumns []string) string {
return postgresDialect{}.UpsertClause(pkColumns, otherColumns)
}
+
+func (sqliteDialect) String() string {
+ return "sqliteDialect"
+}
diff --git a/oblast.go b/oblast.go
index 00aaa3f..68e6db8 100644
--- a/oblast.go
+++ b/oblast.go
@@ -138,23 +138,26 @@ func StructTagKeyIs(key string) PlanOption {
// Store holds information on how to read and write data into record type R,
// and can also be used to execute autogenerated queries if the respective [PlanOption] values were provided during [NewStore].
type Store[R any] struct {
- dialect Dialect
- plan plan
+ plan plan
}
// NewStore initializes a store for record type R.
// Returns an error if R is not a struct type.
+//
+// In most situations, the intended usage pattern is to call NewStore (or [MustNewStore]) once per record type,
+// and hold the result in a global variable.
+//
+// When dealing with private one-off record types that are declared within the function or method using them,
+// NewStore (or [MustNewStore]) may also be called once per function call.
+// NewStore will internally cache its results and return a cheap copy on subsequent calls with the same arguments,
+// only incurring the cost of a read lock on a mutex.
func NewStore[R any](dialect Dialect, opts ...PlanOption) (Store[R], error) {
- var popts planOpts
- for _, opt := range opts {
- opt(&popts)
- }
- plan, err := buildPlan(reflect.TypeFor[R](), dialect, popts)
+ plan, err := getOrBuildPlan(reflect.TypeFor[R](), dialect, collectPlanOptions(opts))
if err != nil {
var zero R
return Store[R]{}, fmt.Errorf("cannot use type %T for queries: %w", zero, err)
}
- return Store[R]{dialect, plan}, err
+ return Store[R]{plan}, err
}
// MustNewStore is like [NewStore], but panics on error.
diff --git a/plan.go b/plan.go
index dbcc012..010569d 100644
--- a/plan.go
+++ b/plan.go
@@ -10,8 +10,66 @@ import (
"reflect"
"slices"
"strings"
+ "sync"
)
+// planOpts holds additional arguments to buildPlan().
+type planOpts struct {
+ StructTagKey string // defaults to "db"
+ TableName string
+ PrimaryKeyColumnNames []string
+}
+
+func collectPlanOptions(popts []PlanOption) planOpts {
+ opts := planOpts{
+ StructTagKey: "db",
+ }
+ for _, popt := range popts {
+ popt(&opts)
+ }
+ return opts
+}
+
+type planCacheKey struct {
+ Type reflect.Type
+ Dialect string
+ StructTagKey string
+ TableName string
+ PrimaryKeyColumnNames string
+}
+
+var (
+ generatedPlans = make(map[planCacheKey]plan)
+ generatedPlansMutex sync.RWMutex
+)
+
+// getOrBuildPlan is like [buildPlan], but caches generated plans and tries to reuse cached plans.
+func getOrBuildPlan(t reflect.Type, dialect Dialect, opts planOpts) (plan, error) {
+ key := planCacheKey{
+ Type: t,
+ Dialect: dialect.String(),
+ StructTagKey: opts.StructTagKey,
+ TableName: opts.TableName,
+ PrimaryKeyColumnNames: strings.Join(opts.PrimaryKeyColumnNames, "\000"),
+ }
+
+ generatedPlansMutex.RLock()
+ p, ok := generatedPlans[key]
+ generatedPlansMutex.RUnlock()
+ if ok {
+ return p, nil
+ }
+
+ p, err := buildPlan(t, dialect, opts)
+ if err != nil {
+ return plan{}, err
+ }
+ generatedPlansMutex.Lock()
+ generatedPlans[key] = p
+ generatedPlansMutex.Unlock()
+ return p, nil
+}
+
// 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 {
@@ -61,24 +119,12 @@ type plannedQuery struct {
ScanIndexes [][]int
}
-// planOpts holds additional arguments to buildPlan().
-type planOpts struct {
- StructTagKey string // defaults to "db"
- TableName string
- PrimaryKeyColumnNames []string
-}
-
// buildPlan creates a new plan for the given struct type.
func buildPlan(t reflect.Type, dialect Dialect, opts planOpts) (plan, error) {
if t.Kind() != reflect.Struct {
return plan{}, fmt.Errorf("expected struct type, but got kind %q", t.Kind().String())
}
- // apply defaults to planOpts fields
- if opts.StructTagKey == "" {
- opts.StructTagKey = "db"
- }
-
var p = plan{
TypeName: t.Name(),
TableName: opts.TableName,
diff --git a/plan_test.go b/plan_test.go
index ae9e2cc..d6d6e65 100644
--- a/plan_test.go
+++ b/plan_test.go
@@ -39,6 +39,7 @@ func TestPlanFieldTraversal(t *testing.T) {
// 6. traverses into "yetMoreTimestamps" as well (despite the extra pointer and the type being private)
// 7. recognizes "id" as an autofilled column
plan, err := buildPlan(reflect.TypeFor[Log](), PostgresDialect(), planOpts{
+ StructTagKey: "db",
TableName: "log_entries",
PrimaryKeyColumnNames: []string{"id"},
})
@@ -65,6 +66,7 @@ func TestQueryConstructionBasic(t *testing.T) {
CreatedAt time.Time `db:"CreatedAt"`
}
opts := planOpts{
+ StructTagKey: "db",
TableName: "basic_records",
PrimaryKeyColumnNames: []string{"ID"},
}
@@ -147,6 +149,7 @@ func TestQueryConstructionWithOnlyPrimaryKey(t *testing.T) {
BarID int64 `db:"bar_id"`
}
opts := planOpts{
+ StructTagKey: "db",
TableName: "foo_bar_relations",
PrimaryKeyColumnNames: []string{"foo_id", "bar_id"},
}
@@ -227,7 +230,8 @@ func TestQueryConstructionWithoutPrimaryKey(t *testing.T) {
BarID int64 `db:"bar_id"`
}
opts := planOpts{
- TableName: "foo_bar_relations",
+ StructTagKey: "db",
+ TableName: "foo_bar_relations",
}
t.Run("MariaDBDialect", func(t *testing.T) {
@@ -305,7 +309,9 @@ func TestQueryConstructionImpossible(t *testing.T) {
Foo int
Bar *string
}
- opts := planOpts{}
+ opts := planOpts{
+ StructTagKey: "db",
+ }
testWith := func(dialect Dialect) func(*testing.T) {
return func(t *testing.T) {
@@ -344,6 +350,7 @@ func TestQueryConstructionWithMultiplePrimaryKeyColumns(t *testing.T) {
CreatedAt time.Time `db:"created_at"`
}
opts := planOpts{
+ StructTagKey: "db",
TableName: "complex_records",
PrimaryKeyColumnNames: []string{"group_id", "name"},
}
@@ -425,6 +432,7 @@ func TestQueryConstructionWithMultipleAutoColumns(t *testing.T) {
CreatedAt time.Time `db:"created_at,auto"`
}
opts := planOpts{
+ StructTagKey: "db",
TableName: "autogenerated_records",
PrimaryKeyColumnNames: []string{"id"},
}