Pop — Patterns#

Concurrency patterns#

errgroup for parallel slice callbacks#

  • Usage: Used once in callbacks.go:46 to fan out AfterFind / AfterEagerFind calls across the elements of a result slice.
  • Example: callbacks.go:46 — after a bulk SELECT returns a slice of models, errgroup.Group is used to call AfterFind on every element in parallel, collecting the first error that occurs.
  • Assessment: Idiomatic and appropriate. The errgroup usage 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.Mutex fields: gil (for the in-process global interpreter lock required by mattn/go-sqlite3) and smGil (for schema-modification operations like CREATE/DROP TABLE). A helper locker(l *sync.Mutex, fn func() error) error wraps l.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 locker helper avoids repetitive lock/unlock boilerplate.

sync.RWMutex for column and columns cache#

  • Usage: sql_builder.go:244 and columns/columns.go:13.
  • Example: sql_builder.go:242–261 — a package-level columnCache map[string]columns.Columns stores pre-computed column lists per table name; reads take an RLock, writes take a full Lock. columns/columns.go:169 uses the same pattern for the column set itself.
  • Assessment: Standard read-heavy cache pattern. Correct use of RLock for the common read path to avoid serialising concurrent queries.

atomic.AddInt64 for elapsed time tracking#

  • Usage: connection.go:292atomic.AddInt64(&c.Elapsed, int64(time.Since(start))) accumulates total query time on the Connection without 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 errgroup usage 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 explicit Done() select loops in library code. The test connection_instrumented_test.go:59 is the only case <-ctx.Done() use and lives in test code.

Error handling#

  • Style: Mixed — errors.New for simple sentinel messages, fmt.Errorf with %w for wrapping, no third-party wrapping library.
  • Error types defined: None. Pop defines no custom type Err… error types. All errors are either errors.New string errors or wrapped chains from the database driver.
  • Wrapping approach: fmt.Errorf("…: %w", err) is used consistently throughout connection.go, file_migrator.go, commands.go, and connection_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.As calls.
  • Examples:
    • connection.go:74fmt.Errorf("could not create new connection: %w", err) — wraps the dialect factory error.
    • connection.go:190fmt.Errorf("database error on committing or rolling back transaction: %w", dberr) — wraps sqlx transaction errors.
    • file_migrator.go:35fmt.Errorf("error processing %s: %w", mf.Path, err) — adds migration file context.
    • genny/model/options.go:27errors.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: ConnectionDetails struct in connection_details.go carries all connection parameters (host, port, user, password, pool sizes, driver name). It is populated by LoadConfigFile() which reads database.yml, template-expands env vars via gobuffalo/envy, and YAML-unmarshals into a map[string]*ConnectionDetails. Callers can also construct a ConnectionDetails directly in code and pass it to NewConnection.
  • No functional options pattern for Connection: Pop does not use the “functional options” idiom for its primary Connection or Query types. ConnectionDetails acts as the config bag; Query accumulates clauses via chained methods (Where, Order, Limit) rather than constructor options.
  • Scopes as first-class closures: scopes.go defines type ScopeFunc func(q *Query) *Query. Users compose reusable query predicates as named ScopeFunc values and chain them with q.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, or fx).
  • Evidence:
    • connection.go:71–78NewConnection looks up a factory from the newConnection map, calls it, and directly assigns the returned dialect to c.Dialect and a dB wrapper to c.Store. All wiring is explicit and visible.
    • Connection.WithContext(ctx) returns a shallow copy of the connection with Store replaced by a contextStore wrapper — 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 → one dialect + one store).

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:292atomic.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.