pdo

package module
v0.2.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 27, 2026 License: MIT Imports: 3 Imported by: 0

README

pdo

pdo is a small, request-scoped SQL client for Go services. It fits the shape of a normal net/http server: the standard library already gives every request its own goroutine, so the natural unit of database state is one client per request. Rather than threading connections, transactions, and "last insert id" bookkeeping through every function call, you create one client at the top of a handler and use it for the rest of that request.

The client handles query observation, pooling, and single-connection use. The API is short and readable: it covers CRUD in an ORM-like way and falls back to plain SQL for everything else.

A function that writes a row looks the same whether it runs on its own or as one step inside a larger transaction. Typed reads use Go 1.27 generic methods (Get[T], Select[T]), so results scan straight into your structs and your storage and repository packages stay simple.

Requires Go 1.27+ (gotip). The public API uses generic methods, a language feature not yet in released Go. See Building & testing.

Requirements: sqlx

pdo is currently a thin layer over jmoiron/sqlx, which it requires for setup. You own the *sqlx.DB connection pool - opening it, configuring it, and running migrations - and you hand that pool to pdo.New. The pool is safe for concurrent use; the per-request *PDO you create from it is not. That's the whole point: it's single-goroutine, request-scoped state.

import (
	"github.com/jmoiron/sqlx"
	_ "modernc.org/sqlite"
)

// ExampleService holds the shared, concurrency-safe pool. It is created once
// at startup and lives for the life of the process.
type ExampleService struct {
	db *sqlx.DB
}

func NewExampleService() (*ExampleService, error) {
	db, err := sqlx.Open("sqlite", "file:app.db")
	if err != nil {
		return nil, err
	}
	return &ExampleService{db: db}, nil
}

Request-scoped usage

Each handler creates its own client from the shared pool. Because net/http runs every request on its own goroutine, the client never needs locking: it is created, used, and discarded inside a single goroutine.

type User struct {
	ID    string `db:"id"`
	Name  string `db:"name"`
	Email string `db:"email"`
}

func (s *ExampleService) GetUser(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	db := pdo.New(s.db) // request-scoped client over the shared pool

	u, err := db.Get[User](ctx, "SELECT id, name, email FROM user WHERE id = ?", r.PathValue("id"))
	if err != nil {
		http.Error(w, err.Error(), http.StatusNotFound)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(u)
}

Reads and writes use the same client:

func (s *ExampleService) CreateUser(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	db := pdo.New(s.db)

	var u User
	if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	// Insert builds a parameterized INSERT from the struct's db tags.
	if err := db.Insert(ctx, "user", u); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusCreated)
}

Pinning a connection with Connect()

By default each query borrows a connection from the pool and returns it. When a request needs several queries on the same physical connection - for session settings, temporary tables, or read-your-write consistency on reads - pin one with Connect() and release it with Close():

func (s *ExampleService) Report(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	db := pdo.New(s.db)

	if err := db.Connect(ctx); err != nil { // take an exclusive connection
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	defer db.Close() // return it to the pool

	// All reads below run on the pinned connection.
	rows, err := db.Select[User](ctx, "SELECT id, name, email FROM user ORDER BY id")
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	_ = json.NewEncoder(w).Encode(rows)
}

After Connect(), Begin starts its transaction on the pinned connection. Once the transaction commits, the connection stays pinned until you call Close().

Note: reads run on the pinned connection, but writes still go through the pool, because *sqlx.Conn cannot bind named (:name) parameters. Don't pin the only connection of a single-connection pool and then write, or the write will block waiting for a connection that is already held.

The simplest way to make named parameters work with Exec is to start a transaction before issuing those writes.

Storage code is transaction-agnostic

The key property is that storage functions don't need to know whether a transaction is active. They take the client and call Insert/Update/Exec normally; the client routes the work to the open transaction if there is one, or straight to the pool if there isn't. So the same function works both standalone and as one step of a larger transaction, with no special "tx" parameter and no duplicate transactional and non-transactional variants.

Start a transaction with Begin, finalize it with Commit, or revert it with Rollback. Once a transaction is open on a client, every later query on that client runs inside it until you commit or roll back.

package storage

// createUser writes a single row. It is not transaction-aware.
func createUser(ctx context.Context, db *pdo.PDO, u User) error {
	return db.Insert(ctx, "user", u)
}

// addMembership writes a single row. Also not transaction-aware.
func addMembership(ctx context.Context, db *pdo.PDO, m Membership) error {
	return db.Insert(ctx, "membership", m)
}

Standalone, each call is its own implicit unit of work:

// Runs on the pool, committed immediately by the driver.
if err := createUser(ctx, db, u); err != nil {
	return err
}

Composed, the caller opens the transaction and the very same functions now participate in it automatically:

func Signup(ctx context.Context, db *pdo.PDO, u User, m Membership) error {
	if err := db.Begin(ctx); err != nil {
		return err
	}
	defer db.Rollback()

	if err := createUser(ctx, db, u); err != nil { // joins the transaction
		return err
	}
	if err := addMembership(ctx, db, m); err != nil { // joins the transaction
		return err
	}

	return db.Commit()
}

This removes a lot of complexity from the storage layer. Most storage operations are a single statement, and a single statement is just the smallest possible transaction. By treating every interaction as transactional by default, the storage package no longer needs two code paths (one taking a *sql.Tx and one taking a *sql.DB); it exposes one consistent set of functions that compose freely. The caller decides the boundary, and the functions don't change.

In practice a transaction is mostly a batch of INSERT/UPDATE statements. You can interleave SELECTs inside one, but it's rare and usually worth avoiding so read logic stays decoupled from write boundaries. Keep transactions short and write-focused, and run reads outside them where you can.

API reference

The entry point is pdo.PDO, created with pdo.New(*sqlx.DB).

Reads (generic)
Method Description
Get[T](ctx, query, args...) (*T, error) Scan the first row into *T. Errors when no rows.
Select[T](ctx, query, args...) ([]T, error) Scan all rows into []T.
Writes
Method Description
Insert[T](ctx, table, value) INSERT INTO built from db tags.
Replace[T](ctx, table, value) REPLACE INTO built from db tags.
Update[T](ctx, table, value, keyCols...) UPDATE ... SET ... WHERE keyCols.
Exec(ctx, query, args...) Arbitrary write; supports bulk insert/update.

After a write, InsertID() int64 and RowsAffected() int64 return state from the last statement.

Connection & transaction
Method Description
Connect(ctx) Pin an exclusive connection from the pool.
Close() Return the pinned connection; no-op if not pinned.
Begin(ctx) Start a transaction (nested begins error).
Commit() Commit changes made in transaction.
Rollback() Roll back changes due to an error.
Parameter binding

Queries are always parameterized - never build SQL by string interpolation. Two styles are supported and auto-detected:

  • Positional: "... WHERE id = ?" with trailing args.
  • Named: "... WHERE id = :id" with a single struct or map[string]any / map[string]string argument. Struct named params are driven by db tags.
Observability

Attach an observer to record every executed query (query text, args, duration, error, transaction depth):

obs := &client.Observer{}

db.WithObserver(obs.Observe)
// ... run queries ...
for _, e := range obs.Entries() {
	log.Printf("%s (%s) err=%v", e.Query, e.Duration, e.Err)
}

Project layout

Path Description
pdo.go Public PDO type; generic method API (New, Get, Insert)
interfaces.go Public interfaces (Reader, Writer, Transactor, ...)
driver.go Internal typeless driver interface
client/ Client: sqlx wrapper, query building, observer
model/ Generated data models + query builders (mig)
schema/ SQL migrations + schema.yml
tests/ Shared test helpers + HTTP handler tests/benchmarks
docs/ Design notes on generic methods and DB access

The model/ package is generated by go-bridget/mig from schema/. It exposes typed structs plus Insert()/Select()/Update()/Delete() query builders. Both packages are used for testing.

Building & testing

The public API depends on Go 1.27 generic methods, so a stable Go toolchain will not compile it. Use gotip:

go install golang.org/dl/gotip@latest
gotip download

gotip build ./...
gotip test ./...
gotip test -bench=StatelessHTTPHandler -benchmem ./tests/

CI runs via atkins. The default task formats, tests (with benchmarks and coverage), and prints a per-package coverage summary:

atkins   # runs gotip fmt + gotip test -bench=. -cover + coverage summary

License

See LICENSE.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Connector

type Connector interface {
	// Connect takes an exclusive connection from the pool.
	Connect(ctx context.Context) error
	// Close returns the exclusive connection to the pool.
	Close() error
}

Connector manages an exclusive connection taken from the pool.

type Handle

type Handle[T any] interface {
	QueryResultState
	Transactor
	Observer

	Writer[T]
	Reader[T]
}

Handle is a complete client interface. It's not in use as we can't assert on generic methods.

type Observer

type Observer interface {
	WithObserver(client.ObserveFunc)
}

Observer allows to define an observer to collect queries that executed during the request into an in-memory log.

type PDO

type PDO struct {
	// contains filtered or unexported fields
}

PDO implements the client with a go generics 1.27+ method API. Its data methods provide compile-time type safety through generic type parameters.

A PDO is intended for request-scoped allocation: create one per request with New, optionally take an exclusive connection with Connect, and release it with Close when the request completes. It is not safe for concurrent use across goroutines.

func New

func New(db *sqlx.DB) *PDO

New creates a new database client for exclusive use.

func (*PDO) Begin

func (h *PDO) Begin(ctx context.Context) error

Begin starts a transaction.

func (*PDO) Close

func (h *PDO) Close() error

Close returns the exclusive connection taken by Connect to the pool. It is a no-op if Connect was not called.

func (*PDO) Commit

func (h *PDO) Commit() error

Commit will write out transaction data.

func (*PDO) Connect

func (h *PDO) Connect(ctx context.Context) error

Connect takes an exclusive connection from the pool for use by the client. Subsequent reads run on this connection until Close is called. Writes continue to use the pool, since *sqlx.Conn cannot bind named parameters.

func (*PDO) Exec

func (h *PDO) Exec(ctx context.Context, query string, args ...any) error

Exec runs a custom query to insert or modify data. It allows a bulk insert/update.

func (*PDO) Get

func (h *PDO) Get[T any](ctx context.Context, query string, args ...any) (*T, error)

Get executes a query and returns the first result as T. Returns error if no rows found.

func (*PDO) Insert

func (h *PDO) Insert[T any](ctx context.Context, table string, value T) error

Insert inserts a value into the table.

func (*PDO) InsertID

func (h *PDO) InsertID() int64

InsertID returns the ID of the last inserted row.

func (*PDO) Replace

func (h *PDO) Replace[T any](ctx context.Context, table string, value T) error

Replace performs a REPLACE INTO.

func (*PDO) Rollback

func (h *PDO) Rollback() error

Rollback will rollback the transaction, reverting it in case of error.

func (*PDO) RowsAffected

func (h *PDO) RowsAffected() int64

RowsAffected returns the number of rows affected by the statement.

func (*PDO) Select

func (h *PDO) Select[T any](ctx context.Context, query string, args ...any) ([]T, error)

Select executes a query and returns all results as []T.

func (*PDO) Update

func (h *PDO) Update[T any](ctx context.Context, table string, value T, keyCols ...string) error

Update updates rows matching the given key columns.

func (*PDO) WithObserver

func (h *PDO) WithObserver(observerFn client.ObserveFunc)

WithObserver passes along an observer to the client.

type QueryResultState

type QueryResultState interface {
	// InsertID returns the last insert ID from an insert operation.
	InsertID() int64
	// RowsAffected returns the number of rows affected by the last write operation.
	RowsAffected() int64
}

QueryResultState contains introspection for the last ran query.

type Reader

type Reader[T any] interface {
	// Select returns all results.
	Select(ctx context.Context, query string, args ...any) ([]T, error)
	// Get returns the first result.
	Get(ctx context.Context, query string, args ...any) (T, error)
}

Reader contains read operations for storage.

type Transactor

type Transactor interface {
	// Begin starts a transaction.
	Begin(ctx context.Context) error
	// Rollback rolls back the transaction.
	Rollback() error
	// Commit commits the transaction.
	Commit() error
}

Transactor contains mutators to begin, commit and rollback transaction.

type Writer

type Writer[T any] interface {
	// Insert inserts a struct into the table.
	Insert(ctx context.Context, table string, value T) error
	// Replace performs a REPLACE INTO operation.
	Replace(ctx context.Context, table string, value T) error
	// Update updates rows using a struct.
	Update(ctx context.Context, table string, value T, keyCols ...string) error
	// Exec executes a query with args.
	Exec(ctx context.Context, query string, args ...any) error
}

Writer contains write operations for storage. Delete is intentionally not implemented (soft deletes).

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL