Recipes¶
Patterns most apps need eventually. Each recipe is a few lines of code with the surrounding context — meant for copy-paste, not exhaustive coverage. For the underlying primitives see the linked guide pages.
Update one field on one known document¶
When you know the ID and only want to flip a single field, route through QuerySet.UpdateOne so the update is atomic and avoids the read-modify-write round-trip:
done, err := den.NewQuery[Todo](db, where.Field("_id").Eq(todoID)).
UpdateOne(ctx, den.SetFields{"done": true})
Returns ErrNotFound if the document was deleted between your read and this call. If the document carries document.SoftDelete and you want to flip a field on a soft-deleted doc, chain .IncludeDeleted().
Find or create with defaults¶
Atomic find-or-create using QuerySet.UpsertOne. The defaults template is used only on miss; fields is applied on both branches (pass den.SetFields{} if you want existing rows untouched):
user, inserted, err := den.NewQuery[User](db, where.Field("email").Eq("x@y.z")).
UpsertOne(ctx,
&User{Email: "x@y.z", LoginCount: 0}, // defaults — applied on miss only
den.SetFields{"last_seen": time.Now()}, // applied on both paths
)
if inserted {
log.Println("created new user")
}
Concurrent inserts that both miss race on the unique constraint — one wins, the other gets ErrDuplicate. There is no internal retry; callers decide.
Atomic counter increment¶
QuerySet.UpdateOne plus a tx for the read-then-write. The simplest correct version under contention uses ForUpdate to lock the row:
err := den.RunInTransaction(ctx, db, func(tx *den.Tx) error {
counter, err := den.NewQuery[Counter](tx,
where.Field("name").Eq("page_views"),
).ForUpdate().First(ctx)
if err != nil {
return err
}
counter.Value++
return den.Save(ctx, tx, counter)
})
For high-throughput counters that don't need exact consistency, consider sharded counters (one row per shard, sum on read).
→ ForUpdate · RunInTransaction
Claim one job (queue-style worker)¶
The canonical "worker pool" pattern: each goroutine claims a single pending job, marks it in-flight, and releases the lock. ForUpdate(SkipLocked()) lets workers race without blocking each other:
err := den.RunInTransaction(ctx, db, func(tx *den.Tx) error {
job, err := den.NewQuery[Job](tx,
where.Field("status").Eq("pending"),
).Sort("created_at", den.Asc).
Limit(1).
ForUpdate(den.SkipLocked()).
First(ctx)
if errors.Is(err, den.ErrNotFound) {
return nil // no work right now
}
if err != nil {
return err
}
job.Status = "in_flight"
job.WorkerID = workerID
return den.Save(ctx, tx, job)
})
SkipLocked() skips rows another worker already locked instead of blocking. PostgreSQL maps this to FOR UPDATE SKIP LOCKED; SQLite serializes writers via IMMEDIATE tx so the option is a no-op there (one worker at a time anyway).
Top-N with grouping¶
Server-side Top-N — compute groups, sort by an aggregate, limit. No Go-side post-processing:
type Top struct {
Category string `den:"group_key"`
Total float64 `den:"sum:price"`
}
var top []Top
err := den.NewQuery[Sale](db).
Limit(5).
GroupBy("category").
OrderByAgg(den.OpSum, "price", den.Desc).
Into(ctx, &top)
Sorting by group key uses the parent Sort(...); sorting by an aggregate uses OrderByAgg(op, field, dir) because no source-field name identifies the synthetic aggregate column. Limit / Skip cap and offset the group rows, not the underlying documents.
Find with eager-loaded links¶
By default Link[T].Value is nil — explicit hydration via WithFetchLinks() resolves them in one batched IN-query per nesting level:
posts, err := den.NewQuery[Post](db,
where.Field("status").Eq("published"),
).WithFetchLinks().All(ctx)
// each post.Author.Value is now non-nil
Forgetting WithFetchLinks() and dereferencing .Value is one of the more common new-user bugs — the linter won't catch it. If you need deeper nesting, WithNestingDepth(n) overrides the default of 3 levels.
Cursor pagination¶
Stable pagination across writes: cursor on _id (ULIDs sort chronologically) instead of offset:
const pageSize = 50
// First page
page, err := den.NewQuery[Post](db).Sort("_id", den.Asc).Limit(pageSize).All(ctx)
// Subsequent pages
last := page[len(page)-1].ID
next, err := den.NewQuery[Post](db).After(last).Sort("_id", den.Asc).Limit(pageSize).All(ctx)
After(id) and Before(id) translate to _id > ? / _id < ?. Sorting by _id is required to make the cursor meaningful. Mixing cursor with offset (Skip) returns ErrIncompatiblePagination — the two pagination styles have no defined interaction.
→ Queries
Upsert by unique field¶
You have a stream of records to ingest, and the natural deduplication key is a unique column (email, SKU, slug). QuerySet.UpsertOne does the right thing in one round-trip per record:
for _, record := range incoming {
_, _, err := den.NewQuery[Customer](db, where.Field("email").Eq(record.Email)).UpsertOne(ctx, &Customer{Email: record.Email, Name: record.Name, Source: "import"}, den.SetFields{"name": record.Name, "last_synced_at": time.Now()})
if err != nil {
return err
}
}
If two ingest workers race on the same email, one wins and the other gets ErrDuplicate from the unique constraint — handle by retrying, logging, or surfacing depending on your semantics.
For batch ingests with no per-record uniqueness needs, prefer SaveAll(ctx, db, docs) — much faster, but fail-fast: any per-doc error rolls back the whole transaction.