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.
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:
- Planning — each
WithXxx/UseIndexcall 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. - Intersection — at
Execute*, the first lane seeds the candidate set and every later lane intersects it, in declaration order, on astackallocbitmap. The first empty lane stops the query. (A frozen query picks the seed itself at execution time.) - Materialization —
Wherepredicates 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
Countis the rows in this result;TotalCountthe matches beforeskip/take— the "2 of 5" number.Truncatedis true only if a result slot could not hold every row; expected false.AsSpan(),AsMemory(), the implicitReadOnlySpan<T>conversion andSlice(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()andToDictionary(...)do the same into a collection.ToPooledArray(ArrayPool<T> pool)hands you a rented array you return to that pool yourself; the parameterlessToPooledArray()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 singleDispose()on the outer result.QueryResults<T>.Emptyis the shared empty instance.
Intersection semantics
- Index hits intersect with logical AND.
- A
Manyindex 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*returnsQueryResults<T>.Emptywithout 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 (Manyindices 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
Orinside 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/ nestedOrare reachable.Where,Sort,Join, andExecute*are compile-unreachable — enforced by a dedicated discriminator type (NarrowOnlyQuery<TCache>).Within a branch, chained
WithXxxcomposes 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
WithXxxcontributes 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, soq.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 perOrlevel (typically ~1 KB, falls back toArrayPoolonly for huge candidate sets). No per-branchValueSet<TKey>allocation. - Cross-branch merge uses
BitHelper.Union— SIMDVector<int>OR; ~32 keys/cycle on AVX2, 64 on AVX-512. - Chained
WithXxxwithin 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
staticlambdas (or with theTArgoverload 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
- Sorted Paging —
SortvsSortBounded. - Prepared and Frozen Queries — build once, execute at 0 B.
- Conditional Updates — how writes interact with deep equality.
- Joins — extending a query with another cache's view.