Skip to content
Improve this page

Query Engine

The fluent query API is the single read surface on a Prague cache. It is shaped by the indices you declared and executed against the in-memory storage with zero reflection. Every block on this page runs against the sample Order and Customer caches and is asserted in samples/Prague.Samples/Concepts/QueryEngine.cs.

Query intersection: each index lane produces a candidate key set; the sets are intersected in call order on a bitmap, the Where predicate runs on the survivors, and only those keys are dereferenced into the result buffer.

Anatomy of a query

using var results = _orders.Query()
    .WithCustomerId(42)                                 // Many index lane
    .WithCreatedAtUnixMs(q => q.Gte(yesterdayUnixMs))   // Range index lane
    .ExecutePooled(skip: 0, take: 100);                 // pooled materialization

Three phases run end-to-end:

  1. Planning — each WithXxx/UseIndex call appends a candidate-narrowing step to a compile-time-typed builder. The C# type of the builder grows with every chained call; no runtime plan tree exists.
  2. Intersection — at Execute*, the first lane seeds the candidate set and every later lane intersects it, in declaration order, on a stackalloc bitmap. The first empty lane stops the query. (A frozen query picks the seed itself at execution time.)
  3. Materialization — Where predicates run on the survivors, then the keys are dereferenced into the result array (pooled or allocated).

Execute variants

Every query terminates in one of these methods on the combined builder:

Method Allocates Clones Use case
Execute() new T[] no One-off use; convenient for tests.
ExecuteCloned() new T[] yes Caller mutates and we don't want the cache copy mutated.
ExecutePooled() ArrayPool rent no Default on hot paths. Caller must using.
ExecutePooledCloned() ArrayPool rent yes Pooled buffer + safe-to-mutate elements.
Count() none — Matching count without materializing values.
var allocated = _orders.Query().WithCustomerId(42).Execute();                   // new T[]; nothing to dispose
var clones = _orders.Query().WithCustomerId(42).ExecuteCloned();                // new T[] of deep clones
using var pooled = _orders.Query().WithCustomerId(42).ExecutePooled();          // rented buffer: dispose it
using var pooledClones = _orders.Query().WithCustomerId(42).ExecutePooledCloned();
var matching = _orders.Query().WithCustomerId(42).Count();                      // no rows materialized

The Execute* methods accept skip and take integer arguments for offset pagination.

QueryResults<T>

The return type is QueryResults<T>, a readonly struct implementing IList<T>, IReadOnlyList<T> and IDisposable:

using var page = _orders.Query().WithCustomerId(42).ExecutePooled(skip: 1, take: 2);
var onPage = page.Count;                 // rows in this page
var matched = page.TotalCount;           // rows before paging — "2 of 5"
ReadOnlySpan<Order> span = page;         // implicit span view; AsSpan() / AsMemory() too
foreach (ref var order in page)          // ref enumerator: Current is the array slot itself
    Console.WriteLine(order.OrderId);
var copy = page.ToArray();               // copies out and disposes the pooled buffer
  • Count is the rows in this result; TotalCount the matches before skip/take — the "2 of 5" number. Truncated is true only if a result slot could not hold every row; expected false.
  • AsSpan(), AsMemory(), the implicit ReadOnlySpan<T> conversion and Slice(index, count) are views over the same buffer.
  • The ref enumerator — foreach (ref var x in results) — hands out the array slots themselves.
  • Map(Func<T, TMapped>) projects into a result of the same pooled kind and consumes the source (see Result mapping); Sort(comparer) sorts in place.
  • ToArray() copies out and disposes the pooled buffer; ToList(), ToHashSet() and ToDictionary(...) do the same into a collection. ToPooledArray(ArrayPool<T> pool) hands you a rented array you return to that pool yourself; the parameterless ToPooledArray() rents from Prague's internal pool, which you cannot return to — use it only when the array's lifetime is the process's.
  • CloneElements() deep-clones every row in place (see Deep cloning).
  • Dispose() returns a pooled buffer to the pool; the buffer must not be used after disposal. Pooled join results share one rented buffer, returned by the single Dispose() on the outer result. QueryResults<T>.Empty is the shared empty instance.

Intersection semantics

  • Index hits intersect with logical AND.
  • A Many index with multiple values (span, array or list overload) is treated as OR internally, then intersected with siblings as a single set.
  • Empty candidate sets short-circuit: subsequent narrowing steps are skipped, and Execute* returns QueryResults<T>.Empty without scanning the storage.
  • Range indices contribute via B-tree walks bounded by the operator (Gte, Lt, etc.); the walked key range feeds the same intersecter.

On the eager path there is no runtime cost-based optimizer — your call order is the plan. Place the most selective index first to keep intermediate sets small. BuildFrozen() lifts this restriction by seeding from the narrowest lane for each execution's arguments; see Prepared and Frozen Queries.

Where — residual filter

Where(v => bool) runs on the survivors of all index lanes and never uses an index. It is the right place for predicates on non-indexed properties; a Where with no preceding lane walks the whole cache.

using var pending = _orders.Query()
    .WithCustomerId(42)                                 // index lane first…
    .Where(o => o.Total > 100m)                         // …then the predicate runs on the survivors only
    .ExecutePooled();

UseIndex — manual planning

When you have a reference to a generated CacheUniqueIndex<...> / CacheKeyValueListIndex<...> (often because you want to feed values from another cache), UseIndex lets you narrow the candidate set without going through WithProperty. It is also the only form on the raw InMemoryDataCache<TKey, TValue> behind orderCache.Cache, which has no generated WithXxx:

using var gold = _customers.Query().WithTier(CustomerTier.Gold).ExecutePooled();
using var goldOrders = _orders.Query()
    .UseIndex(_orders.CustomerIdIndex, gold.AsSpan(), static c => c.Id)   // rows of another cache + key selector
    .ExecutePooled();

ReadOnlySpan<int> customerIds = [42, 7];
using var byIds = _orders.Query().UseIndex(_orders.CustomerIdIndex, customerIds).ExecutePooled();   // a span of index keys
using var raw = _orders.Cache.Query().UseIndex(_orders.CustomerIdIndex, 42).ExecutePooled();       // the raw cache has no WithXxx

UseIndex accepts:

  • index, TIndexKey value — single equality.
  • index, List<TIndexKey> values — multi-value OR.
  • index, ReadOnlySpan<TIndexKey> values — same, span variant.
  • index, ReadOnlySpan<TOtherValue> values, Func<TOtherValue, TIndexKey> keySelector — span + key transform for cross-key-type joins (Many indices only).

Or — disjunction

Two-branch .Or(b1, b2) injects a disjunction step into the candidate-narrowing pipeline. Both branches are evaluated, their results unioned, and the union is intersected with the surrounding chain.

using var results = _orders.Query()
    .WithStatus(OrderStatus.Pending)
    .Or(
        b => b.WithCustomerId(42),
        b => b.WithCustomerId(7))
    .ExecutePooled();
// Status = Pending AND (CustomerId = 42 OR CustomerId = 7)

Semantics

  • Fixed two-branch arity. Three or more disjuncts are expressed by nesting an Or inside a branch:

    using var results = _orders.Query()
        .Or(
            b => b.WithCustomerId(42),
            b => b.Or(
                c => c.WithCustomerId(7),
                c => c.WithCustomerId(9)))
        .ExecutePooled();
    
  • Restricted branch surface. Each branch lambda receives a builder where only WithXxx / UseIndex / nested Or are reachable. Where, Sort, Join, and Execute* are compile-unreachable — enforced by a dedicated discriminator type (NarrowOnlyQuery<TCache>).

  • Within a branch, chained WithXxx composes as AND. Each successive call prunes the marks produced by the previous one.

    // (Country = US AND Tier = Gold) OR (Country = GB AND Tier = Standard)
    using var results = _customers.Query()
        .Or(
            b => b.WithCountry("US").WithTier(CustomerTier.Gold),
            b => b.WithCountry("GB").WithTier(CustomerTier.Standard))
        .ExecutePooled();
    
  • Across branches, the union semantic. Marks from b1 and b2 are OR'd in a shared bitmap; the result is intersected with the outer narrowing.

  • No-op branch excluded. A branch lambda that doesn't call any WithXxx contributes nothing to the union — useful for conditional inclusion.

  • Reachable inside join filters. The right-cache filter callback of a JoinWith{T}(q => ...) is itself a narrow-capable builder, so q.Or(...) inside the filter narrows which right-cache entries attach to each left row. See Joins.

Zero-allocation TArg overload

For closure-free static-lambda use, the TArg overload threads a single argument to both branches:

var args = (First: 42, Second: 7);
using var results = _orders.Query()
    .Or(
        static (b, a) => b.WithCustomerId(a.First),
        static (b, a) => b.WithCustomerId(a.Second),
        args)
    .ExecutePooled();

Performance

  • One stackalloc'd Span<int> bitmap per Or level (typically ~1 KB, falls back to ArrayPool only for huge candidate sets). No per-branch ValueSet<TKey> allocation.
  • Cross-branch merge uses BitHelper.Union — SIMD Vector<int> OR; ~32 keys/cycle on AVX2, 64 on AVX-512.
  • Chained WithXxx within a branch uses mark-then-prune: the first call marks bits, each subsequent call walks the current marks and unmarks slots whose key isn't in the new index's result. Work scales with |surviving marks|, not with the index's lookup result — narrowing chains get faster, not slower.
  • Zero GC allocations with static lambdas (or with the TArg overload when state must flow through).

Sort and SortBounded

Two sorted plans exist and you pick one: Sort materializes every candidate and runs a stable full sort; SortBounded selects just the requested page (top-k) and is the choice for paging.

using var page = _orders.Query()
    .WithCustomerId(42)
    .SortBounded(OrderCache.ByCreatedAtComparer)        // newest first, from [DataCacheSort]
    .ExecutePooled(skip: 0, take: 2);

Both take any IComparer<T>; the generator emits one per [DataCacheSort] declaration (see Index Types), and a readonly struct comparer makes either sort 0 B. Sorting runs after intersection on the candidate set, not on the whole cache. After Sort / SortBounded only the outer, unfiltered JoinWith{T}() binds. Both plans return the same rows in the same order — ties break by encounter order in each — so the choice is purely cost; Sorted Paging has the numbers.

Counting

Count() returns the matching count without materializing the values:

var n = _orders.Query()
    .WithCustomerId(42)
    .Count();

It uses the same intersection plan but skips the result-array allocation.

Result mapping

Map lives on QueryResults<T>, not on the builder: execute first, then project. It returns a result of the same pooled kind with TotalCount preserved and consumes the source (the source is disposed for you), so dispose the mapped result.

using var totals = _orders.Query()
    .WithCustomerId(42)
    .ExecutePooled(skip: 1, take: 2)
    .Map(o => (o.OrderId, o.Total));                    // consumes the source; dispose the mapped result

Deep cloning

The generator emits Clone() for every [DataCache] type (recursive, collection-aware, no reflection). ExecuteCloned() / ExecutePooledCloned() return cloned rows you can mutate without touching the cache; results.CloneElements() does the same after the fact.

var copy = _orders.Query().WithOrderId("ORD-001").Execute()[0].Clone();   // generated: recursive, collection-aware

using var rows = _orders.Query().WithCustomerId(42).ExecutePooled();
rows.CloneElements();                                                       // clone every row in place, after the fact

Batch reads

For a known set of keys, skip the builder and read the raw cache through orderCache.Cache:

var keys = new[] { "ORD-001", "ORD-002", "ORD-404" };

Span<Order> buffer = new Order[keys.Length];
var found = _orders.Cache.TryGet(keys, buffer);                   // fills the span, returns the hit count

var orders = _orders.Cache.TryGet(keys);                          // List<Order>: only the hits
var big = _orders.Cache.TryGet(keys, o => o.Total > 100m);        // hits that also pass the predicate

String queries

Each generated cache also exposes IQueryParser<TValue> QueryParser { get; } for string-driven queries — useful for read APIs:

using var results = _orders.QueryParser.StringQueryPooled("customerId=42&total>100");   // names are case-insensitive
var fields = _orders.QueryParser.QueryableFields;                                        // what a client may ask for

The query language is simple equality + range (>, >=, <, <=) over the queryable fields enumerated by QueryParser.QueryableFields; field names are case-insensitive and a non-indexed field falls back to a Where. Prague.Api's app.MapPragueEndpoints() exposes the same parser over HTTP: GET /prague (statistics), GET /prague/{CacheName}?customerId=42, GET /prague/{CacheName}/{id}; Prague.Api.UI adds a MudBlazor front end (AddPragueUI() / MapPragueUI()).

Next