diff options
| -rw-r--r-- | CHANGELOG.md | 7 | ||||
| -rw-r--r-- | dialect.go | 17 | ||||
| -rw-r--r-- | oblast.go | 19 | ||||
| -rw-r--r-- | plan.go | 70 | ||||
| -rw-r--r-- | plan_test.go | 12 |
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: @@ -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" +} @@ -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. @@ -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"}, } |
