Skip to content

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():

import "github.com/oliverandrich/den/where"

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:

  1. Reuse the reserved-field constants for Base / SoftDelete fields: where.Field(den.FieldID), where.Field(den.FieldCreatedAt), etc.
  2. Validate user-defined field names at startup against the registered metadata. den.Meta[T](db).Fields returns 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

where.Field("metadata").HasKey("version")   // object has key

Pattern Matching

where.Field("name").RegExp("^Wid.*")   // regex match

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

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):

houses, err := den.NewQuery[House](db).
    WithFetchLinks().WithNestingDepth(2).All(ctx)

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):

all, err := den.NewQuery[Product](db).IncludeDeleted().All(ctx)

For fields tagged with den:"fts", use the Search method:

results, err := den.NewQuery[Article](db).Search(ctx, "golang concurrency")

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