SQL Adapter — adapters/sql¶
See also:
adapters/sqlon pkg.go.dev · SQL Examples guide · Observer Pattern · Error HandlingRunnable demo:
examples/sensor-service— sensor readings service combiningadapters/nethttp,adapters/mqtt, andadapters/sqlwith a sharedstats.NewFanoutobserver; shows sqlc-generated structs, goose migrations,Validate[T]pre-insert and post-read, and field factory functions reusingRefinerules acrossdb.Readinganddb.InsertReadingParams.
adapters/sql brings go-codex's codec-based validation to SQL databases by
pairing three single-purpose tools, each doing exactly the job it is designed
for:
| Tool | Owns | Does NOT own |
|---|---|---|
pressly/goose |
Schema migrations — versioned .sql files, goose_db_version table |
Struct generation, query execution, validation |
sqlc |
Typed struct + query method generation from the schema and SQL query files | Business-rule validation, cross-field rules |
go-codex (Codec[T] + Refine) |
Refinement validation on top of sqlc's generated structs — rules SQL cannot express, testable in pure Go | Schema DDL, SQL generation/execution, row scanning |
The adapter adds no query builder, no ORM, no row scanner. Its sole job is
the T → validate → T boundary: ensuring a struct passes all its codec
constraints before it reaches the database or after it comes back from one.
Validate¶
Validate[T] is the core function. It runs a value through its codec's
encode→decode round trip, applying every Refine and RefineFunc constraint.
The returned T is the normalized value — the result of Decode after
Encode. This may differ from v when a Refine step normalizes data (e.g.
trimming whitespace). This is the same round-trip semantics used by
format.JSON.Read throughout go-codex.
Two usage modes¶
Pre-query validation — reject invalid data before it reaches the database:
validated, err := sqladapter.Validate(insertParamsCodec, params,
sqladapter.ValidateOptions{Table: "users", Op: "insert_user", Observer: obs})
if err != nil {
return fmt.Errorf("invalid input: %w", err) // never calls queries.InsertUser
}
_ = queries.InsertUser(ctx, validated)
Post-query validation — defence in depth against rows written by other clients that bypassed the codec:
u, err := queries.GetUser(ctx, id)
if err != nil { return nil, err } // sql.ErrNoRows etc — not go-codex's concern
u, err = sqladapter.Validate(userCodec, u,
sqladapter.ValidateOptions{Table: "users", Op: "get_user", Observer: obs})
ValidateOptions¶
type ValidateOptions struct {
Table string // sqlc table name — for error context and observer
Op string // sqlc query name — e.g. "get_user", "insert_user"
Observer stats.Observer // nil → stats.NoopObserver
}
Declare once — DecorateInput/DecorateOutput¶
Calling Validate by hand around every sqlc call (above) means repeating
Table/Op — and remembering to call it at all — at every call site.
DecorateInput/DecorateOutput wrap an sqlc-generated function once
and return a drop-in replacement with the identical signature, callable in
place of the sqlc method everywhere:
// Pre-query validation, declared once — mirrors the manual pattern above.
insertUser := sqladapter.DecorateInput(queries.InsertUser, insertParamsCodec,
sqladapter.ValidateOptions{Table: "users", Op: "insert_user"})
err := insertUser(ctx, params) // validated automatically; sqlc never called on invalid input
// Post-query validation, declared once.
getUser := sqladapter.DecorateOutput(queries.GetUser, userCodec,
sqladapter.ValidateOptions{Table: "users", Op: "get_user"})
u, err := getUser(ctx, id) // validated automatically before the caller sees it
Both reuse ValidateOptions (no new option type) and call Validate
internally (no duplicated logic). Unlike bare Validate — which has no
ctx parameter — the returned closures DO have ctx in scope (they wrap
func(ctx, ...)), so they resolve stats.ObserverFromContext(ctx) when
Observer is left nil. DecorateOutput passes fn's own error (e.g.
sql.ErrNoRows) through unchanged — validation only runs on success.
This is the SQL equivalent of declaring a ports.Cache/ports.File once
and reusing it — see Design pattern: declarative descriptor + plain
function
for the full picture across file/sql/rest/events/cache. SQL has no
templated key/path to attach a per-var codec to (sqlc's typed parameters
already fill that role, validated the same way as the row itself) — the
decorator's reusable value is the codec + Table/Op bundle, not a key
template.
Migrator¶
Migrator wraps pressly/goose/v3 with go-codex structured errors and
observer hooks. It never inspects row data — schema evolution and row
validation are separate concerns by design.
// NewMigrator validates the dialect and migrations directory at construction time.
func NewMigrator(db *sql.DB, migrations fs.FS, dir string, dialect string) (*Migrator, error)
func (m *Migrator) Up(ctx context.Context, opts MigrateOptions) error
func (m *Migrator) Down(ctx context.Context, opts MigrateOptions) error
func (m *Migrator) Status(ctx context.Context) ([]MigrationStatus, error)
Supported dialect strings: "postgres", "mysql", "sqlite3", "mssql",
"redshift", "tidb", "clickhouse", "vertica", "ydb", "spanner",
"turso".
Embedding migrations¶
//go:embed migrations/*.sql
var migrationsFS embed.FS
migrator, err := sqladapter.NewMigrator(db, migrationsFS, "migrations", "postgres")
if err != nil { log.Fatal(err) }
if err := migrator.Up(ctx, sqladapter.MigrateOptions{Observer: obs}); err != nil {
log.Fatal(err)
}
MigrationStatus¶
type MigrationStatus struct {
Version int64 // numeric prefix of the migration file
Name string // file path
AppliedAt time.Time // zero value = pending
}
MigrateOptions¶
Error types¶
All error types implement error, Unwrap(), and slog.LogValuer.
| Type | Returned by | Key fields |
|---|---|---|
RowValidationError |
Validate — codec Refine/decode failure |
Table string, Op string, Err error |
MigrationError |
NewMigrator, Up, Down, Status — goose failure |
Op string ("init"/"up"/"down"/"status"), Version int64, Err error |
RowValidationError.Err is typically codex.ValidationErrors — use
errors.As to reach per-field detail:
var rve sqladapter.RowValidationError
if errors.As(err, &rve) {
slog.Error("row invalid", "error", rve) // structured: {table, op, err}
var ve codex.ValidationErrors
if errors.As(err, &ve) {
for _, fe := range ve {
fmt.Printf("field %q: %v\n", fe.Field, fe.Err)
}
}
}
sql.ErrNoRows and driver errors from sqlc query methods are not wrapped
by go-codex — they pass through unchanged so errors.Is(err, sql.ErrNoRows)
continues to work.
Error-path ergonomics — handle/log by default¶
SQL is an internal store boundary with no caller to respond to.
DrainInsertAdapter's OnError func(error) IS the handle action (fully
owns the error); leaving it nil is the log default (observer-only). For a
respond-equivalent — publishing a typed error payload — compose OnError
with a declared
events.ErrorChannel:
sql.DrainInsertAdapter(db, "orders", format.JSON(orderCodec), sql.DrainInsertOptions{
OnError: func(err error) {
if resp, matched, mapErr := errHandle.ErrorResponseFor(err); matched && mapErr == nil &&
resp.Action == events.ErrorRespond {
_ = mqttClient.Publish(ctx, &paho.Publish{Topic: resp.Topic, Payload: resp.Body})
return
}
slog.Warn("insert failed", "error", err)
},
})
See Error handling guide — store/IO boundaries for the full pattern and rationale.
Observer integration — stats.SQLObserver¶
stats.SQLObserver is an optional extension to stats.Observer. The adapter
type-asserts the configured observer to SQLObserver before calling SQL-specific
hooks — existing observer implementations need not change.
type SQLObserver interface {
// Called after every Validate call — err is nil on success.
RecordValidation(table, op string, dur time.Duration, err error)
// Called once per applied or rolled-back migration file during Up/Down.
RecordMigration(op, name string, version int64, dur time.Duration, err error)
}
Per-field validation failures are always reported via
stats.Observer.RecordValidationError("sql_row", constraint, field), regardless
of whether SQLObserver is implemented — consistent with "file" (file
formats), "payload" (MQTT), and "body" (HTTP).
stats.NoopObserver, stats.LoggingObserver, and stats.NewFanout all
implement SQLObserver.
Implementing SQLObserver¶
type myMetrics struct {
// embed NoopObserver so the base Observer interface is satisfied
stats.NoopObserver
}
func (m *myMetrics) RecordValidation(table, op string, dur time.Duration, err error) {
status := "ok"
if err != nil { status = "error" }
sqlValidations.With("table", table, "op", op, "status", status).
Observe(dur.Seconds())
}
func (m *myMetrics) RecordMigration(op, name string, version int64, dur time.Duration, err error) {
slog.Info("migration", "op", op, "name", name, "version", version,
"ms", dur.Milliseconds(), "err", err)
}
Sharing a Codec[T] across SQL and REST/MQTT¶
A codec declared against a sqlc-generated struct is an ordinary Codec[T] and
can be reused as the response codec for a REST route or MQTT channel — one
source of truth for what a valid value looks like, regardless of transport:
// REST response uses the same codec:
userRoute := rest.NewRoute[GetUserReq, db.User](
rest.WithResponseCodec(userCodec), ...)
// SQL validation uses the same codec:
u, err = sqladapter.Validate(userCodec, u, sqladapter.ValidateOptions{...})
Updating a Refine rule in userCodec propagates automatically to both
the HTTP response validation and the SQL row validation.
What adapters/sql does NOT do¶
- No query builder — callers write SQL text directly in sqlc query files.
- No row scanning — sqlc's generated methods already handle this per-driver.
- No ORM —
Validateis the only boundary between go-codex and the DB row. - No spec generation — SQL tables are not API surfaces; the same
Codec[T]contributes its schema when registered with a REST route or MQTT channel. - No reflection — codecs are declared by hand with explicit get/set closures, consistent with the no-reflection design principle throughout go-codex.