Index Types
Every query in Prague is planned over typed indices. Each [DataCacheIndex] (and its specialized cousins below) becomes a strongly-typed field on the generated cache class plus a corresponding With{Property}(...) builder method. The examples on this page run against the sample domain — Order, Customer, Product and two smaller types — and every block is compiled and asserted in samples/Prague.Samples/Concepts/IndexTypes.cs.
The Order entity declares most of what this page covers:
public enum OrderStatus {
Pending,
Paid,
Shipped,
Cancelled,
}
[DataCache] // emits OrderCache; [DataCache("OrdersCache")] renames it
[DataCacheTopic("orders.[v:env]")] // Kafka topic; [v:env] resolves from KafkaCachesOptions.Vars
[DataCacheSort("ByCreatedAt", "CreatedAtUnixMs:-1", "OrderId:1")] // emits OrderCache.ByCreatedAtComparer (newest first)
public partial class Order { // must be partial
[DataCacheKey]
public required string OrderId { get; init; }
[DataCacheForeignKey<Customer>(DataCacheJoinType.ManyToOne)] // many orders → one customer; also indexes CustomerId
[DataCacheGlobalLastUpdateIndex<CustomerActivityIndex>] // every order write stamps its customer's activity
public required int CustomerId { get; init; }
[DataCacheIndex(DataCacheIndexType.Range)] // sorted — Gte / Lt / … queries
public required long CreatedAtUnixMs { get; init; }
[DataCacheIndex(DataCacheIndexType.Many)] // 1:N — many orders share a status
public required OrderStatus Status { get; init; }
[DataCacheHasValueIndex] // key set: WithSettledAtUnixMs() = settled (non-null)
public long? SettledAtUnixMs { get; init; }
public required decimal Total { get; init; }
}
Primary index
[DataCacheKey] marks the PK property. It must be applied exactly once per [DataCache] type and drives the TKey of the underlying InMemoryDataCache<TKey, TValue> (TKey : notnull, IEquatable<TKey>). The builder narrows by it under two names — the property's own verb and the generic WithKey(...):
using var byProperty = _orders.Query().WithOrderId("ORD-001").ExecutePooled(); // With{KeyProperty}
using var byKey = _orders.Query().WithKey("ORD-001").ExecutePooled(); // the same lane under its generic name
Secondary indices
[DataCacheIndex(DataCacheIndexType.X)] declares a secondary index. Three flavours exist:
| Variant | Generated field | Use case |
|---|---|---|
Unique |
CacheUniqueIndex<TKey, TValue, TIndexKey> |
O(1) equality; one entity per index value. |
Many |
CacheKeyValueListIndex<TKey, TValue, TIndexKey> |
O(1) equality; many entities per index value. |
Range |
CacheRangeIndex<TKey, TValue, TIndexKey> |
Ordered range scans over a pooled B-tree; property must implement IComparable<T>. |
Many is the default if you omit the index type. A Many index takes a per-key bucket capacity hint — [DataCacheIndex(DataCacheIndexType.Many, 4)], or just [DataCacheIndex(4)] — which sizes each per-key bucket up front; the other two types reject it at compile time. Product carries one of each, plus a hinted Brand index:
// docs: the index-type showcase — Unique (Sku), explicit symmetric Many (CategoryId), Many with a
// bucket-capacity hint (Brand), Range over a decimal (Price), a named value index (InStock), has-value
// indices on a nullable and on a bool, [DataCacheIgnoreEquality] for conditional updates, and a
// [DataCacheSort] comparer for paging. OrderLine points at it with a ManyToOne foreign key.
namespace Prague.Samples.Models;
using Prague.Core;
[DataCache]
[DataCacheSort("ByPrice", "Price:1", "Id:1")] // total order: Id breaks price ties
public partial class Product {
[DataCacheKey]
public required int Id { get; init; }
[DataCacheIndex(DataCacheIndexType.Unique)]
public required string Sku { get; init; }
[DataCacheIndex(DataCacheIndexType.Many, Symmetric = true)] // reverse map (Id → CategoryId) for index-driven joins
public required int CategoryId { get; init; }
[DataCacheIndex(DataCacheIndexType.Many, 4)] // bucket capacity hint: few products per brand
public required string Brand { get; init; }
[DataCacheIndex(DataCacheIndexType.Range)]
public required decimal Price { get; init; }
[DataCacheValueIndex(CompareOp.GreaterThan, 0, IndexName = "InStock")] // key set: WithInStock() = Stock > 0
public required int Stock { get; init; }
[DataCacheHasValueIndex] // bool: WithIsPublished() = IsPublished == true
public required bool IsPublished { get; init; }
[DataCacheHasValueIndex] // nullable: WithDiscontinuedAtUnixMs() = non-null
public long? DiscontinuedAtUnixMs { get; init; }
public required string Name { get; init; }
[DataCacheIgnoreEquality] // changes here never count as an Update
public long LastSeenAtUnixMs { get; init; }
}
Each flavour narrows differently: a Unique lane yields at most one row, a Many lane every row carrying the value, a Range lane an ordered walk from the bound.
using var alice = _customers.Query().WithEmail("alice@example.com").ExecutePooled(); // Unique: at most one row
using var ofCustomer = _orders.Query().WithCustomerId(42).ExecutePooled(); // Many: every row with the value
using var recent = _orders.Query().WithCreatedAtUnixMs(q => q.Gte(since)).ExecutePooled(); // Range: ordered walk from the bound
Unique and Many emit With{Property}(...) overloads for a single value, a nullable value (null skips the lane and the query runs as if the call were not there), a ReadOnlySpan<T>, an array and a List<T>. A multi-value call is an OR within that lane:
var emails = new[] { "alice@example.com", "bob@example.com" };
using var byArray = _customers.Query().WithEmail(emails).ExecutePooled(); // array or ReadOnlySpan<T>: OR within the lane
using var byList = _customers.Query().WithCountry(new List<string> { "US", "GB" }).ExecutePooled();
int? customerId = null;
using var unfiltered = _orders.Query().WithCustomerId(customerId).ExecutePooled(); // null skips the lane entirely
Range indices expose a sub-builder instead. It is type-state: open with Gt / Gte or Lt / Lte, then optionally close the other side. There is no Between or Eq; a disjunction is the query-level .Or(...) from Query Engine.
using var since = _orders.Query().WithCreatedAtUnixMs(q => q.Gte(twoDaysAgo)).ExecutePooled(); // open on the right
using var window = _orders.Query().WithCreatedAtUnixMs(q => q.Gte(twoDaysAgo).Lt(Now)).ExecutePooled(); // [twoDaysAgo, Now)
using var before = _orders.Query().WithCreatedAtUnixMs(q => q.Lt(twoDaysAgo)).ExecutePooled(); // open on the left
Reading an index directly
The index fields are public, and they answer with primary keys, not rows. TryGetValue on a Unique index, GetValues on a Many index, GetValuesGte / GetValuesLt / GetValuesBetween (both bounds inclusive) on a Range index, ordered by the index key:
if (_customers.EmailIndex.TryGetValue("alice@example.com", out var aliceId)) // Unique → the primary key
Console.WriteLine(aliceId);
var ordersOf42 = _orders.CustomerIdIndex.GetValues(42); // Many → primary keys
var lastTwoDays = _orders.CreatedAtUnixMsIndex.GetValuesGte(Now - 2 * DayMs); // Range → primary keys, ordered by the index key
var inWindow = _orders.CreatedAtUnixMsIndex.GetValuesBetween(Now - 2 * DayMs, Now); // both bounds inclusive
Symmetric variants
Symmetric = true upgrades a Many index to CacheSymmetricKeyValueListIndex<TKey, TValue, TIndexKey> and a Unique index to CacheSymmetricUniqueIndex<TKey, TValue, TIndexKey>. The symmetric variant maintains a reverse map (TKey → TIndexKey), required for left-index-driven joins, where the left entity carries the index value rather than the right's primary key. Product.CategoryId above is declared that way explicitly. Foreign keys declared with [DataCacheForeignKey<T>(ManyToOne)] get the symmetric upgrade automatically — Order.CustomerId has no [DataCacheIndex] at all, yet OrderCache.CustomerIdIndex is a CacheSymmetricKeyValueListIndex<string, Order, int>. See Joins.
Predicate-based key-set indices
When a query is repeatedly filtered by value OP constant against a property, a key-set index — the set of keys for which the predicate holds, evaluated once per write — is cheaper than a Many index: it stores keys only, and the lane is one set lookup. Prague offers four variants:
| Attribute | Builder method | What it tracks |
|---|---|---|
[DataCacheHasValueIndex] |
With{Property}() |
Keys where Property != null (for ref / nullable types) or Property == true (for bool). |
[DataCacheHasNotValueIndex] |
Without{Property}() |
Keys where Property == null (for ref / nullable types) or Property == false (for bool). |
[DataCacheValueIndex(CompareOp, value)] |
With{IndexName}() |
Keys where Property OP value is true. |
[DataCacheNoValueIndex(CompareOp, value)] |
Without{IndexName}() |
Keys where Property OP value is false. |
Each attribute emits exactly the verb in its row: a has-value index gives you WithSettledAtUnixMs() and nothing else; the complement needs its own [DataCacheHasNotValueIndex] (and its own set), which ShipmentLeg declares:
// docs: the selector-form foreign key in joins.md. A shipment travels in numbered hops; the leg stores
// its place in the route as the compound (ShipmentId, Sequence), not as a bare shipment id, so the
// generated join cannot compare the property to Shipment's primary key directly. The selector hands
// it the ShipmentId half. ManyToOne: several legs resolve to the same shipment. Forward join only:
// ShipmentLegCache.JoinWithShipment() yields Shipment?; the selector side gets no reverse join.
// Also the home of the has-not-value verb: WithoutArrivedAtUnixMs() in index-types.md.
namespace Prague.Samples.Models;
using Prague.Core;
public readonly struct ShipmentLegShipmentKey : IForeignKeySelector<(string ShipmentId, int Sequence), string> {
public static string Select((string ShipmentId, int Sequence) hop) => hop.ShipmentId;
}
[DataCache]
public partial class ShipmentLeg {
[DataCacheKey]
public required string LegId { get; init; } // "SHP-1/1"
[DataCacheForeignKey<Shipment, ShipmentLegShipmentKey>(DataCacheJoinType.ManyToOne)] // (ShipmentId, Sequence) → Shipment.ShipmentId
public required (string ShipmentId, int Sequence) Hop { get; init; }
public required string FromHub { get; init; }
public required string ToHub { get; init; }
[DataCacheHasNotValueIndex] // key set: WithoutArrivedAtUnixMs() = still in flight
public long? ArrivedAtUnixMs { get; init; }
}
The four lanes in use — a nullable, a bool, a named value index and a has-not-value index:
using var settled = _orders.Query().WithSettledAtUnixMs().ExecutePooled(); // [DataCacheHasValueIndex] on long?: SettledAtUnixMs != null
using var published = _products.Query().WithIsPublished().ExecutePooled(); // [DataCacheHasValueIndex] on bool: IsPublished == true
using var inStock = _products.Query().WithInStock().ExecutePooled(); // [DataCacheValueIndex(GreaterThan, 0, IndexName = "InStock")]: Stock > 0
using var inFlight = legs.Query().WithoutArrivedAtUnixMs().ExecutePooled(); // [DataCacheHasNotValueIndex]: ArrivedAtUnixMs == null
All four are backed by CacheKeySetIndex<TKey, TValue>; the predicate is evaluated once per write. CompareOp is Equal, NotEqual, GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual. IndexName names the generated verb and defaults to the property name; Product.Stock sets it to InStock so the verb reads WithInStock() rather than WithStock().
Sort definitions
[DataCacheSort("Name", "Prop:1", "OtherProp:-1")] declares a class-level comparer that you can pass to Sort(...) or SortBounded(...) on a query. :1 is ascending, :-1 is descending; later properties break ties of earlier ones. Multiple sort attributes per class are allowed. Order declares ByCreatedAt (newest first, then OrderId) and Product declares ByPrice; the generator emits them as OrderCache.ByCreatedAtComparer and ProductCache.ByPriceComparer, static readonly IComparer<T> fields:
using var newestFirst = _orders.Query().WithCustomerId(42).Sort(OrderCache.ByCreatedAtComparer).ExecutePooled();
using var cheapest = _products.Query().WithCategoryId(3).SortBounded(ProductCache.ByPriceComparer).ExecutePooled(skip: 0, take: 1);
See Sorted Paging for when to pick Sort over SortBounded. Any IComparer<T> works; a readonly struct comparer makes either sort allocation-free.
External types
If you can't put attributes on the POCO (a protobuf- or OpenAPI-generated class), use the type-parameter form [DataCache<T>(keyPropertyName)] on a cache class of your own and declare the indices there with the property-name overload [DataCacheIndex("PropertyName", DataCacheIndexType.X)]. Two constraints:
Tmust bepartialand compiled with you: the generator completes it with theCacheEquals/Clonepartials it needs. A type from a referenced assembly cannot be cached this way.- Only
[DataCacheIndex(propertyName, …)]is honoured on the cache class. The property-name constructors of the key-set attributes ([DataCacheHasValueIndex("Prop")],[DataCacheValueIndex("Prop", …)]) compile but produce no index and no verb today.
// docs: the external-type form in index-types.md. CarrierTrackingEvent stands in for a type whose source
// you do not control but which is compiled with you — a protobuf or OpenAPI-generated class. Such types
// are partial (the generator completes them with CacheEquals / Clone) but carry no Prague attributes;
// the cache class declares the key and the indices by property name instead.
namespace Prague.Samples.Models;
using Prague.Core;
public partial class CarrierTrackingEvent { // generated elsewhere: partial, no attributes
public string EventId { get; set; } = "";
public string ShipmentId { get; set; } = "";
public string Status { get; set; } = "";
public long? DeliveredAtUnixMs { get; set; }
}
[DataCache<CarrierTrackingEvent>(nameof(CarrierTrackingEvent.EventId))] // key property by name
[DataCacheIndex(nameof(CarrierTrackingEvent.ShipmentId), DataCacheIndexType.Many)] // WithShipmentId(...)
[DataCacheIndex(nameof(CarrierTrackingEvent.Status), DataCacheIndexType.Many, 4)] // WithStatus(...), bucket hint
public partial class CarrierTrackingEventCache;
The cache behaves like any other — the query below runs over the two Many indices declared above:
var events = new CarrierTrackingEventCache();
events.AddOrUpdate(new CarrierTrackingEvent { EventId = "evt-1", ShipmentId = "SHP-1", Status = "IN_TRANSIT" });
events.AddOrUpdate(new CarrierTrackingEvent { EventId = "evt-2", ShipmentId = "SHP-1", Status = "DELIVERED", DeliveredAtUnixMs = Now });
events.AddOrUpdate(new CarrierTrackingEvent { EventId = "evt-3", ShipmentId = "SHP-2", Status = "IN_TRANSIT" });
using var delivered = events.Query().WithShipmentId("SHP-1").WithStatus("DELIVERED").ExecutePooled();
Global last-update index
[DataCacheGlobalLastUpdateIndex<TIndex>] on a property tracks the latest update per group key, shared across caches, and emits UpdatedAfter(...) on the cache whose group key is its primary key. Order.CustomerId and Customer.Id above both feed CustomerActivityIndex. See Global Last Update Index.
Next
- Query Engine — how the planner intersects these indices.
- Joins — how indices participate in cross-cache joins.