Queries¶
QuerySet¶
den.NewQuery[T] returns a chainable, lazy query builder. No database call is made until you invoke an execution method (All, First, Count, etc.):
products, err := den.NewQuery[Product](db,
where.Field("price").Gte(10.0),
where.Field("category.name").Eq("Electronics"),
).Sort("price", den.Asc).Limit(20).Skip(10).All(ctx)
Execution Methods¶
| Method | Return Type | Description |
|---|---|---|
All() |
[]*T, error |
All matching documents |
First() |
*T, error |
First matching document (ErrNotFound if none) |
Count() |
int64, error |
Number of matching documents |
Exists() |
bool, error |
Whether any document matches |
AllWithCount() |
[]*T, int64, error |
Documents plus total count (for offset pagination) |
// First matching document
product, err := den.NewQuery[Product](db,
where.Field("name").Eq("Widget"),
).First(ctx)
// Count
count, err := den.NewQuery[Product](db,
where.Field("price").Gt(100),
).Count(ctx)
// Exists
exists, err := den.NewQuery[Product](db,
where.Field("sku").Eq("ABC123"),
).Exists(ctx)
// All with total count (for pagination UI)
notes, total, err := den.NewQuery[Note](db,
where.Field("user").Eq(userID),
).Sort("_created_at", den.Desc).Limit(20).Skip(40).AllWithCount(ctx)
// total = 347 -> compute TotalPages, HasMore
Iter -- Streaming Results¶
Iter() returns a Go range-compatible iterator that streams documents without loading them all into memory:
for doc, err := range den.NewQuery[Product](db).Iter(ctx) {
if err != nil {
return err
}
fmt.Println(doc.Name)
}
Tip
Use Iter() for large result sets or migrations where loading all documents into memory at once would be impractical.
Where Conditions¶
Import the where package and build conditions with Field():
Field names are stringly-typed
where.Field("name") takes the JSON tag value as a string — typos surface as backend errors at query time, not at compile time. Compile-safety would require a code-generation step that Den deliberately doesn't include. Two pragmatic mitigations:
- Reuse the reserved-field constants for
Base/SoftDeletefields:where.Field(den.FieldID),where.Field(den.FieldCreatedAt), etc. -
Validate user-defined field names at startup against the registered metadata.
den.Meta[T](db).Fieldsreturns every field Den knows about for a type — wrap it in a sanity check so a typo fails fast at process boot instead of on the first matching query:meta, _ := den.Meta[Product](db) known := make(map[string]struct{}, len(meta.Fields)) for _, f := range meta.Fields { known[f.Name] = struct{}{} } for _, name := range []string{"name", "price", "category"} { if _, ok := known[name]; !ok { log.Fatalf("Product has no JSON field %q — possible typo", name) } }
Comparison Operators¶
where.Field("price").Eq(10) // field == value
where.Field("price").Ne(10) // field != value
where.Field("price").Gt(10) // field > value
where.Field("price").Gte(10) // field >= value
where.Field("price").Lt(10) // field < value
where.Field("price").Lte(10) // field <= value
Null Checks¶
where.Field("read_at").IsNil() // field is null / not set
where.Field("read_at").IsNotNil() // field is not null / is set
Set Operators¶
where.Field("status").In("active", "pending") // value in set
where.Field("status").NotIn("deleted") // value not in set
Spreading a typed slice into In / NotIn
In and NotIn are variadic over any. Passing a typed slice
without spreading it silently matches against the literal slice
value, not its elements:
ids := []string{"a", "b", "c"}
where.Field("id").In(ids) // ❌ matches the literal []string slice
where.Field("id").In(ids[0], ids[1]) // ✅ explicit spread (only works for fixed N)
where.Field("id").In(where.AnyOf(ids)...) // ✅ generic spread for any []T
where.AnyOf[T any](values []T) []any is the typed-spread shortcut.
Type inference picks T from the argument; no explicit type
parameter at the call site.
Array Operators¶
where.Field("tags").Contains("golang") // array contains value
where.Field("tags").ContainsAny("golang", "go") // array contains any of these
where.Field("tags").ContainsAll("golang", "go") // array contains all of these
Map / Object Operators¶
Pattern Matching¶
String Operators¶
where.Field("name").StringContains("get") // field contains substring
where.Field("name").StartsWith("Wid") // field starts with prefix
where.Field("name").EndsWith("get") // field ends with suffix
Logical Combinators¶
Combine conditions with And, Or, and Not:
// AND -- all conditions must match
where.And(
where.Field("price").Gt(10),
where.Field("price").Lt(100),
)
// OR -- at least one condition must match
where.Or(
where.Field("status").Eq("active"),
where.Field("featured").Eq(true),
)
// NOT -- negate a condition
where.Not(where.Field("deleted").Eq(true))
Note
Multiple conditions passed to NewQuery are implicitly combined with AND. Use where.Or() explicitly when you need disjunction.
Nested Field Access¶
Use dot notation to query fields in embedded objects:
where.Field("address.city").Eq("Berlin")
where.Field("category.name").Eq("Electronics")
where.Field("tags.0").Eq("featured") // array index access
The same dotted path also works for den: tags at registration — see Nested Field Indexes for den:"index" / den:"unique" / den:"fts" on fields of named struct fields.
Sort, Limit, Skip¶
den.NewQuery[Product](db).
Sort("price", den.Asc). // ascending by price
Sort("name", den.Desc). // then descending by name
Limit(20). // at most 20 results
Skip(40). // skip the first 40
All(ctx)
Cursor-Based Pagination¶
For large result sets, cursor-based pagination with After / Before is more efficient than Skip:
// First page
page1, err := den.NewQuery[Entry](db,
where.Field("read_at").IsNil(),
).Sort("published", den.Desc).Limit(20).All(ctx)
// Next page: pass the last document's ID as cursor
lastID := page1[len(page1)-1].ID
page2, err := den.NewQuery[Entry](db,
where.Field("read_at").IsNil(),
).Sort("published", den.Desc).After(lastID).Limit(20).All(ctx)
// Previous page (backward pagination)
firstID := page2[0].ID
prevPage, err := den.NewQuery[Entry](db,
where.Field("read_at").IsNil(),
).Sort("published", den.Desc).Before(firstID).Limit(20).All(ctx)
Tip
Skip(n) works for small offsets but degrades at high page numbers (O(n) skip cost). After / Before use row-value comparisons like WHERE (sort_field, id) < (?, ?), giving O(log n) performance regardless of position. Always prefer cursor-based pagination for user-facing paginated lists.
Projections¶
When you only need a subset of fields, projections reduce I/O and decode cost. Define a projection struct with den tags:
type ProductSummary struct {
Name string `json:"name"`
Price float64 `json:"price"`
}
var summaries []ProductSummary
err := den.NewQuery[Product](db,
where.Field("category.name").Eq("Chocolate"),
).Project(ctx, &summaries)
For projections that extract nested fields, use den:"from:...":
type ProductView struct {
Name string `json:"name"`
CategoryName string `den:"from:category.name"` // extract nested field
}
var views []ProductView
err := den.NewQuery[Product](db).Project(ctx, &views)
Query Options¶
WithFetchLinks¶
Eagerly resolve all Link[T] fields during the query:
houses, err := den.NewQuery[House](db).WithFetchLinks().All(ctx)
// houses[0].Door.Value != nil
// houses[0].Windows[0].Value != nil
.All(ctx) runs one batched WHERE _id IN (…) per target type per nesting level and deduplicates ids across parents — shared targets are fetched once and the pointer is shared. .Iter(ctx) keeps its per-row resolver to preserve streaming. See the Relations guide for the full behavior.
WithNestingDepth¶
Control how deep link resolution recurses (default: 3):
Recursion is uniform across read terminals: .All, .AllWithCount, .Search, and .Iter all descend to the same depth set by WithNestingDepth(n). The first three batch by target type per level; .Iter resolves per yielded row so streaming stays incremental. FindByID, FindByIDs, and Refresh route through the same resolver with a fixed depth of 3 — they have no QuerySet to thread WithNestingDepth through.
IncludeDeleted¶
Include soft-deleted documents in results (only relevant for types embedding document.SoftDelete):
Full-Text Search¶
For fields tagged with den:"fts", use the Search method:
Translates to FTS5 MATCH queries against a virtual table.
Translates to tsvector / tsquery operations with GIN index acceleration.
Aggregations¶
Scalar Aggregations¶
avgPrice, err := den.NewQuery[Product](db,
where.Field("category.name").Eq("Chocolate"),
).Avg(ctx, "price")
totalRevenue, err := den.NewQuery[Product](db).Sum(ctx, "price")
cheapest, err := den.NewQuery[Product](db).Min(ctx, "price")
mostExpensive, err := den.NewQuery[Product](db).Max(ctx, "price")
GroupBy¶
Collect grouped aggregations into a user-defined struct:
type CategoryStats struct {
Category string `den:"group_key"`
AvgPrice float64 `den:"avg:price"`
Total float64 `den:"sum:price"`
Count int64 `den:"count"`
MinPrice float64 `den:"min:price"`
MaxPrice float64 `den:"max:price"`
}
var results []CategoryStats
err := den.NewQuery[Product](db,
where.Field("status").Eq("active"),
).GroupBy("category.name").Into(ctx, &results)
The den tag on the stats struct declares the aggregation function:
| Tag | Meaning |
|---|---|
den:"group_key" |
Receives the group key value |
den:"avg:field" |
Average of field within the group |
den:"sum:field" |
Sum of field within the group |
den:"min:field" |
Minimum of field within the group |
den:"max:field" |
Maximum of field within the group |
den:"count" |
Number of documents in the group |