Pop — Patterns#
Concurrency patterns#
errgroup for parallel slice callbacks#
- Usage: Used once in
callbacks.go:46to fan outAfterFind/AfterEagerFindcalls across the elements of a result slice. - Example:
callbacks.go:46— after a bulk SELECT returns a slice of models,errgroup.Groupis used to callAfterFindon every element in parallel, collecting the first error that occurs. - Assessment: Idiomatic and appropriate. The
errgroupusage here is limited and purposeful: it parallelises post-load work that is otherwise independent per element. The rest of the codebase is entirely synchronous, so this is the only concurrency touchpoint in the library core.
sync.Mutex for serialised access#
- Usage: 20 occurrences across the codebase.
- Example:
dialect_sqlite.go:39–169— SQLite’s dialect holds two separate*sync.Mutexfields:gil(for the in-process global interpreter lock required bymattn/go-sqlite3) andsmGil(for schema-modification operations like CREATE/DROP TABLE). A helperlocker(l *sync.Mutex, fn func() error) errorwrapsl.Lock() / defer l.Unlock()around any callable, giving a clean higher-order locking idiom. - Assessment: The two-tier lock separation (DDL vs DML) is deliberate and correct for SQLite’s threading model. The
lockerhelper avoids repetitive lock/unlock boilerplate.
sync.RWMutex for column and columns cache#
- Usage:
sql_builder.go:244andcolumns/columns.go:13. - Example:
sql_builder.go:242–261— a package-levelcolumnCache map[string]columns.Columnsstores pre-computed column lists per table name; reads take anRLock, writes take a fullLock.columns/columns.go:169uses the same pattern for the column set itself. - Assessment: Standard read-heavy cache pattern. Correct use of
RLockfor the common read path to avoid serialising concurrent queries.
atomic.AddInt64 for elapsed time tracking#
- Usage:
connection.go:292—atomic.AddInt64(&c.Elapsed, int64(time.Since(start)))accumulates total query time on theConnectionwithout a mutex. - Assessment: Appropriate single-value atomic; avoids lock overhead for a hot path metric.
Categories not found#
- Worker pools: Not present.
- Fan-out/fan-in pipelines: Not present (the
errgroupusage is the closest, but it is a simple parallel map, not a pipeline). - Rate limiting: Not present.
- Context cancellation / graceful shutdown: Minimal; context propagation exists but is routed through
contextStore(see below) rather than via explicitDone()select loops in library code. The testconnection_instrumented_test.go:59is the onlycase <-ctx.Done()use and lives in test code.
Error handling#
- Style: Mixed —
errors.Newfor simple sentinel messages,fmt.Errorfwith%wfor wrapping, no third-party wrapping library. - Error types defined: None. Pop defines no custom
type Err…error types. All errors are eithererrors.Newstring errors or wrapped chains from the database driver. - Wrapping approach:
fmt.Errorf("…: %w", err)is used consistently throughoutconnection.go,file_migrator.go,commands.go, andconnection_instrumented.go(10+ call sites). The pattern adds context about what operation failed before the wrapped driver or stdlib error. - Total error-producing sites: ~147
errors.New/fmt.Errorf/errors.Is/errors.Ascalls. - Examples:
connection.go:74—fmt.Errorf("could not create new connection: %w", err)— wraps the dialect factory error.connection.go:190—fmt.Errorf("database error on committing or rolling back transaction: %w", dberr)— wraps sqlx transaction errors.file_migrator.go:35—fmt.Errorf("error processing %s: %w", mf.Path, err)— adds migration file context.genny/model/options.go:27—errors.New("you must set a name for your model")— sentinel for validation failures in code generation options.
errors.Is/errors.As: Used sparingly, mainly in dialect implementations to check for driver-specific error types.
Configuration pattern#
- Approach: Config struct populated from YAML file; no functional options.
- Example:
ConnectionDetailsstruct inconnection_details.gocarries all connection parameters (host, port, user, password, pool sizes, driver name). It is populated byLoadConfigFile()which readsdatabase.yml, template-expands env vars viagobuffalo/envy, and YAML-unmarshals into amap[string]*ConnectionDetails. Callers can also construct aConnectionDetailsdirectly in code and pass it toNewConnection. - No functional options pattern for
Connection: Pop does not use the “functional options” idiom for its primaryConnectionorQuerytypes.ConnectionDetailsacts as the config bag;Queryaccumulates clauses via chained methods (Where,Order,Limit) rather than constructor options. - Scopes as first-class closures:
scopes.godefinestype ScopeFunc func(q *Query) *Query. Users compose reusable query predicates as namedScopeFuncvalues and chain them withq.Scope(fn). This is the idiomatic “functional option applied to queries” pattern without using the term.
Dependency injection#
- Approach: Manual wiring; no DI framework (no
wire,dig, orfx). - Evidence:
connection.go:71–78—NewConnectionlooks up a factory from thenewConnectionmap, calls it, and directly assigns the returneddialecttoc.Dialectand adBwrapper toc.Store. All wiring is explicit and visible.Connection.WithContext(ctx)returns a shallow copy of the connection withStorereplaced by acontextStorewrapper — again, direct struct mutation, not DI.
- Assessment: Appropriate for a library. DI frameworks add overhead and magic that is unnecessary when the dependency graph is shallow and stable (one
Connection→ onedialect+ onestore).
Other notable patterns#
Registry pattern (init-based dialect registration)#
Each dialect_*.go file calls init() to register its factory in the package-level newConnection map:
// dialect_postgresql.go:24–31
func init() {
newConnection[namePostgreSQL] = newPostgreSQL
}Five dialects self-register (postgresql, mysql, mariadb, sqlite, cockroach). NewConnection in connection.go looks up the factory by dialect name string. This is a clean open/closed extension point: adding a new dialect requires only a new file — no switch to modify.
Interface-based callback system (lifecycle hooks)#
callbacks.go defines twelve narrow lifecycle interfaces (BeforeSaveable, AfterCreateable, BeforeDestroyable, etc.). Each has exactly one method. Model.beforeSave, Model.afterCreate, etc. use value.(InterfaceName) type assertions to check whether the user’s struct implements the hook before calling it. This is the Go equivalent of Rails/ActiveRecord callbacks — opt-in via interface satisfaction rather than reflection tag scanning.
contextStore embedding for transparent context injection#
store.go:43–69 defines contextStore as:
type contextStore struct {
store // embedded interface
ctx context.Context
}Each method override calls the embedded store’s Context variant, injecting ctx. Connection.WithContext(ctx) shallow-copies the connection and swaps Store for a contextStore. This achieves context propagation across the entire query path without changing any public method signatures — an elegant zero-API-breakage solution.
Type switch for database scanning (slices sub-package)#
slices/float.go, slices/int.go, slices/map.go, slices/uuid.go each implement sql.Scanner via a type switch over the source value (src interface{}):
switch t := src.(type) {
case []byte: ...
case string: ...
case nil: ...
}This is idiomatic Go for implementing database/sql scanner interfaces over multiple wire types.
Type switch in finders for flexible primary key#
finders.go:33 uses a type switch on the id interface{} argument to Find, allowing callers to pass int, string, uuid.UUID, etc. without overloading.
Fluent builder for queries#
Query accumulates SQL clauses via chained method calls — q.Where(...).Order(...).Limit(...) — each returning *Query. Final SQL is generated on demand by q.ToSQL(model). This is a classic builder pattern: the object accumulates state until an explicit “build” step is triggered.
sql_builder internal builder#
sql_builder.go is a private, non-exported builder used by Query.ToSQL. It holds references to the query and model, calls compile() to assemble fragments, and exposes String() and Args(). It separates SQL construction logic from the public Query API.
Reflection-based ORM mapping#
model.go uses reflect.TypeOf / reflect.ValueOf to derive table names (gobuffalo/flect for pluralization), map struct fields to column names via db:"" tags, and read/write the ID field. No code generation is needed; the trade-off is deferred error detection and runtime overhead.
Elapsed time on Connection struct#
connection.go:292 — atomic.AddInt64(&c.Elapsed, ...) accumulates total database time per connection. This is a simple observability mechanism without requiring a logger or metrics sink — callers can inspect c.Elapsed after a sequence of operations.
Generics#
Not used. The project predates Go 1.18 adoption and relies on interface{} / reflection instead.