uow

package
v0.0.0-...-575b669 Latest Latest
Warning

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

Go to latest
Published: Nov 4, 2026 License: MIT Imports: 10 Imported by: 0

Documentation

Overview

Package uow provides a Unit of Work over sqlkit table bindings. A uow.Scope tracks entities loaded through it and the rows staged on it, then flushes them as one ordered batch of statements through a sqlkit.Querier — typically a *sqlkit.Session so load and flush share one transaction.

It is deliberately named Scope, not Session, so it reads clearly alongside sqlkit.Session (the transaction handle it flushes through):

db.WithSession(ctx, func(s *sqlkit.Session) error {
	scope := uow.NewScope(s)

	var u User
	scope.Get(ctx, app.Users, &u, 1) // loaded => managed
	u.Name = "renamed"               // mutate in place

	carol := &User{Email: "c@example.com"}
	scope.Add(app.Users, carol)      // INSERT; carol.ID written back on flush

	return scope.Flush(ctx)          // UPDATE only u's changed columns, INSERT carol
})

Tracking is snapshot-based (the Hibernate model): a managed entity's column values are snapshotted when it is loaded, and at flush the current values are diffed against the snapshot so only changed columns are written. Go cannot intercept field assignments, so dirty state is computed at flush rather than on mutation; mutate the loaded instance (not a copy) for changes to be seen.

Nested scopes are savepoint-backed: child := scope.NewScope() flushes within a SAVEPOINT, and child.Rollback rolls back to it without disturbing the parent transaction. Because rolling back to a savepoint undoes everything executed after it, Rollback refuses (ErrRollbackDiscardsWork) once another scope has flushed work after the child's savepoint; finish or roll back a nested scope before its parent flushes again.

Index

Constants

This section is empty.

Variables

View Source
var ErrAttachRequiresPointer = errors.New("uow: Attach requires a pointer to a struct")

ErrAttachRequiresPointer reports a non-pointer row passed to Attach. The snapshot diff re-reads the row at flush time, so a value copy could never reflect later mutations.

View Source
var ErrBindingConflict = errors.New("uow: conflicting bindings for the same table")

ErrBindingConflict reports two bindings used for the same table that differ in structure (checksum) or in the generator that produced them (sqlkit version / generated-code version). It catches a Unit of Work assembled from packages whose bindings drifted out of sync.

View Source
var ErrNestingRequiresTx = errors.New("uow: nested scope requires a transaction-backed executor")

ErrNestingRequiresTx reports a nested scope flushed over an executor that cannot open savepoints — nesting requires a transaction (a *sqlkit.Session).

View Source
var ErrPrimaryKeyChanged = errors.New("uow: primary key of a managed row changed")

ErrPrimaryKeyChanged reports a managed entity whose primary-key fields were mutated after it was loaded; identity is fixed at load time.

View Source
var ErrRollbackDiscardsWork = errors.New("uow: Rollback would discard work another scope flushed after this scope's savepoint")

ErrRollbackDiscardsWork reports a nested scope's Rollback refused because it would also undo work that another scope — an enclosing scope or a sibling — flushed after this scope's savepoint was taken. Rolling back to the savepoint discards everything executed since, and that other scope has already cleared its staged work believing it written, so the rows would be lost silently. Roll back (or finish) the nested scope before the enclosing scope flushes again, or roll back the whole session.

Functions

func WithScope

func WithScope(ctx context.Context, db *sqlkit.Database, fn func(*Scope) error, opts ...Option) error

WithScope opens a session on db, runs fn against a Unit of Work bound to it, and on success flushes the scope and commits the session; on error or panic the session rolls back and nothing is committed. It is the Unit-of-Work counterpart of Database.WithSession, folding the NewScope and final Flush into the callback boundary so a transactional unit of work reads as one block:

err := uow.WithScope(ctx, db, func(s *uow.Scope) error {
	var u User
	if _, err := s.Get(ctx, app.Users, &u, 1); err != nil {
		return err
	}
	u.Name = "renamed"           // mutate in place
	s.Add(app.Users, &newUser)   // stage an insert
	return nil                   // WithScope flushes, then commits
})

The scope shares the session's transaction, so its loads and its flush run together and nested scopes use savepoints within fn. opts configure the scope (WithAutoflush, WithFlushHook, WithBatchSize, …). fn may call Flush itself for intermediate rounds; WithScope's closing Flush then emits only what remains.

Types

type AfterFlushHook

type AfterFlushHook interface {
	AfterFlush(ctx context.Context, c *ChangeSet) error
}

AfterFlushHook is invoked after a Flush has succeeded — its statements emitted, staged work cleared, and managed snapshots refreshed — with a ChangeSet of the rows the flush wrote. Returning an error propagates from Flush, but it does not undo the flush: the rows are written (inside the surrounding transaction, if any) and are no longer staged, so a retried Flush emits nothing. To discard the work on an AfterFlush error, roll back the surrounding session or, on a nested scope, call Rollback.

type BeforeFlushHook

type BeforeFlushHook interface {
	BeforeFlush(ctx context.Context, c *ChangeSet) error
}

BeforeFlushHook is invoked at the start of a Flush, before any statement is emitted, with a ChangeSet describing the work about to run. Returning an error aborts the flush before it writes anything (on a nested scope nothing reaches the savepoint; on a session-backed root scope the surrounding session rolls back). Use it to stamp audit columns, enforce invariants, or observe what is changing — mutating a pointer returned by ChangeSet.Dirty here is picked up by the diff the flush then computes.

type ChangeSet

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

ChangeSet is a view of the work a Flush performs, passed to flush hooks. In BeforeFlush it is live: it describes the staged work about to run, and to stamp a column on the rows about to change you mutate the pointers Dirty returns. In AfterFlush it is a snapshot of what the flush wrote, taken after the BeforeFlush hooks ran, so it still reports the rows even though the staged work has been cleared by then.

func (*ChangeSet) Dirty

func (c *ChangeSet) Dirty(table sqlkit.Table) []any

Dirty returns the managed entity pointers of table whose columns changed since they were loaded or attached — the rows the flush will UPDATE. A row also staged for removal is excluded. Mutating a returned pointer in BeforeFlush is reflected in the UPDATE the flush emits.

func (*ChangeSet) Empty

func (c *ChangeSet) Empty() bool

Empty reports whether the flush has (in AfterFlush: had) no work to do.

func (*ChangeSet) Inserted

func (c *ChangeSet) Inserted(table sqlkit.Table) []any

Inserted returns the rows staged for INSERT on table (pointers and values, in staging order). Mutating a pointer row in BeforeFlush is reflected in the INSERT; a value row is a copy and cannot be changed through the result.

func (*ChangeSet) Removed

func (c *ChangeSet) Removed(table sqlkit.Table) [][]any

Removed returns the primary keys staged for DELETE on table, in staging order.

type Option

type Option func(*Scope)

Option configures a Scope.

func WithAutoChunk

func WithAutoChunk(enabled bool) Option

WithAutoChunk overrides, for this scope, whether the flush automatically batches its writes within the dialect's bind-parameter limit: removals of rows with a single-column primary key are merged into chunked DELETE ... WHERE pk IN (...) statements, and updates are applied with a chunked UPDATE ... FROM (VALUES ...) join instead of one statement per row. Without it a scope inherits the executor's WithAutoChunk setting; pass false to keep the per-row flush even when the executor enables splitting, or true to batch over an executor that has it off. Composite-key tables always flush per row. The setting is inherited by nested scopes.

func WithAutoflush

func WithAutoflush(enabled bool) Option

WithAutoflush makes the scope flush its pending work before each Get and Find, so a read observes the scope's own staged inserts/updates/deletes and the dirty changes of its managed entities — the autoflush behavior of an ORM session. Without it a read runs against the database as last flushed and does not see work still buffered on the scope.

The flush runs only when there is pending work, so a read on a scope with nothing staged doesn't flush — but checking costs a column diff of every managed entity on each Get/Find (O(managed entities), including marshaling json-tagged columns), so prefer explicit Flush calls on scopes that manage many rows. On a session-backed scope it shares the transaction (and a nested scope flushes within its savepoint); on a Database-backed scope each flushed statement auto-commits, as it would on an explicit Flush. The setting is inherited by nested scopes.

func WithBatchSize

func WithBatchSize(n int) Option

WithBatchSize splits the value rows staged with Add (those passed by value, without primary-key write-back) into INSERT statements of at most n rows each per table at flush, keeping large bulk inserts within the database's bind-parameter and statement-size limits. n <= 0 (the default) inserts each table's value rows in a single statement. Pointer rows still insert one at a time so their generated keys can be written back. The setting is inherited by nested scopes.

func WithFlushHook

func WithFlushHook(h any) Option

WithFlushHook registers an observer of the flush lifecycle. The hook may implement BeforeFlushHook, AfterFlushHook, or both; each is optional and discovered by type assertion, mirroring how the compiler probes dialect capabilities. Multiple hooks run in registration order, and the registration is inherited by nested scopes.

func WithIdentities

func WithIdentities(identities *meta.IdentityMap) Option

WithIdentities validates every table touched by the scope against an identity map built from the catalog (the source of truth): a binding absent from the map, or whose checksum differs (schema drift), fails fast.

type Scope

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

Scope is a Unit of Work over table bindings. Construct one with NewScope (or a child with Scope.NewScope). A Scope is not safe for concurrent use.

func NewScope

func NewScope(ex sqlkit.Querier, opts ...Option) *Scope

NewScope creates a Unit of Work that loads and flushes through ex. Pass a *sqlkit.Session so load and flush share one transaction (and so nested scopes can use savepoints); a *sqlkit.Database works too, but each flush statement then auto-commits independently.

When ex carries a catalog identity map (a database or session bound with sqlkit.WithMetadata), the scope adopts it automatically and validates its bindings against it; an explicit WithIdentities still overrides that.

func (*Scope) Add

func (s *Scope) Add(table sqlkit.Table, rows ...any) error

Add stages rows for INSERT. A row passed by pointer has its database-generated columns (primary key, defaults) written back after flush and becomes a managed entity; a row passed by value is inserted without write-back. Each row is a struct (or pointer to one) mapped to columns exactly as sqlkit's Insert does.

func (*Scope) Attach

func (s *Scope) Attach(table sqlkit.Table, rows ...any) error

Attach registers detached rows as managed entities, snapshotting their current column values. Mutate a row after attaching and Flush emits an UPDATE that sets only the changed non-primary-key columns — the same snapshot-diff path used for rows loaded with Get/Find, and emitting nothing when nothing changed. Use it for rows that originate outside the scope (for example a cache) when you want a minimal UPDATE instead of Update's full-column overwrite.

Each row must be a pointer to its struct: the diff is computed at flush time by re-reading that pointer against the snapshot, so a value copy could never reflect later mutations. Re-attaching the same primary key replaces the previous snapshot.

func (*Scope) Find

func (s *Scope) Find(ctx context.Context, table sqlkit.Table, dest any, predicates ...sqlkit.Predicate) error

Find loads every row of table matching predicates into dest (a pointer to a slice) and tracks each as a managed entity. With no predicates it loads the whole table.

func (*Scope) Flush

func (s *Scope) Flush(ctx context.Context) error

Flush emits the staged work in three phases so foreign keys hold across tables: every INSERT first, then the UPDATEs, then every DELETE. When the scope has a catalog identity map (WithMetadata or WithIdentities), the tables are ordered by their foreign keys — parents are inserted before the tables that reference them, and deleted after — so staging a child before its parent still flushes in a safe order; without metadata the staging order is used as-is. A managed or updated row that is also staged for removal is deleted without a preceding UPDATE.

On a nested scope it runs inside a savepoint, so an error rolls back to the savepoint and leaves the parent transaction intact; on a root scope it runs directly on the executor. On success the staged inserts/updates/deletes are cleared and managed entities' snapshots are refreshed, so the scope can be reused for another round.

Registered BeforeFlush hooks run first, before anything is written; an error from one aborts the flush with the staged work intact. AfterFlush hooks run once the flush has succeeded, with a ChangeSet of what was written; an error from one is returned but does not undo the flush (see AfterFlushHook).

func (*Scope) Get

func (s *Scope) Get(ctx context.Context, table sqlkit.Table, dest any, pk ...any) (bool, error)

Get loads the row of table with the given primary key into dest (a pointer to a struct) and, when found, tracks it as a managed entity. Returns whether a row was found. Mutating dest afterward and calling Flush emits an UPDATE of only the changed columns.

func (*Scope) NewScope

func (s *Scope) NewScope() *Scope

NewScope opens a nested scope sharing this scope's executor and identity map. The child accumulates its own entities and staged rows; child.Flush runs inside a SAVEPOINT so a failure there rolls back to the savepoint and leaves this (parent) scope's transaction intact.

func (*Scope) Remove

func (s *Scope) Remove(table sqlkit.Table, rows ...any) error

Remove stages rows for DELETE by primary key. Re-staging the same primary key is a no-op beyond the first.

func (*Scope) Rollback

func (s *Scope) Rollback(ctx context.Context) error

Rollback undoes a nested scope's flushed work by rolling back to its savepoint and discards its staged state. It is only valid on a scope created with Scope.NewScope.

Rolling back to the savepoint undoes everything executed since it was taken, so Rollback refuses with ErrRollbackDiscardsWork when another scope (the parent, an ancestor, or a sibling) has flushed work after that point; that work would otherwise be lost without an error.

func (*Scope) Update

func (s *Scope) Update(table sqlkit.Table, rows ...any) error

Update stages detached rows for an UPDATE by primary key that writes every mapped non-primary-key column. Use it for rows that were not loaded through the scope; entities loaded with Get/Find are updated automatically from their snapshot diff and need no Update call.

Jump to

Keyboard shortcuts

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