Indexed Field Predicates (WhereField)
Expression-parsed predicate that drives a targeted B+Tree scan and powers incrementally-maintained reactive views.
Status: โ Implemented ยท Visibility: Public ยท Level: ๐ต Core ยท Category: Querying
๐ฏ What it solves
Filtering entities by reading every archetype member's component is wasteful when the field already has a secondary index โ the matching set can usually be found directly from the index without touching most entities. WhereField lets you express a field comparison as ordinary C# (p => p.Level >= 50) while the engine compiles it into an index-driven scan plan. Just as important: because the predicate is understood structurally (not an opaque delegate), it can also drive a persistent EcsView that updates itself incrementally as data changes, instead of re-scanning everything on every refresh.
โ๏ธ How it works (in brief)
WhereField<T>(Expression<Func<T, bool>> predicate) parses the lambda's expression tree (not a compiled delegate) into one or more field predicates, normalized to Disjunctive Normal Form when it contains ||. Unsupported syntax โ method calls, arithmetic between fields, field-to-field comparisons โ is rejected immediately as the lambda is parsed. Whether the referenced field actually carries [Index] is checked later, the moment a terminal operation (Execute/Count/Any/ExecuteOrdered/ToView) resolves the predicate against the component's schema โ before any B+Tree access, never a silent broad scan. At execution, the planner consults index statistics (histogram / most-common-values / HyperLogLog) to pick the most selective predicate as the primary B+Tree scan stream and applies the rest as ordered filters. Multiple WhereField calls AND together (cross-producting any DNF branches). Because the predicate decomposes into key comparisons, an EcsView built from it can re-evaluate membership from the before/after key values delivered at commit โ no component read needed on the common path.
๐ป Usage
[Component("Game.Player", 1)]
public struct Player
{
[Index]
public int Level;
[Index(AllowMultiple = true)]
public int Faction;
}
[Archetype]
public class PlayerArch : Archetype<PlayerArch>
{
public static readonly Comp<Player> Data = Register<Player>();
}
using var tx = dbe.CreateQuickTransaction();
// One-shot: matching EntityIds, index-driven scan
var rarePlayers = tx.Query<PlayerArch>()
.WhereField<Player>(p => p.Level >= 50)
.Execute(); // โ HashSet<EntityId>
// Zero-allocation aggregation โ fused index streaming, no collection built
int count = tx.Query<PlayerArch>()
.WhereField<Player>(p => p.Level >= 50)
.Count();
// Ordering + pagination (OrderByField requires WhereField to identify the component table)
var topPlayers = tx.Query<PlayerArch>()
.WhereField<Player>(p => p.Level >= 10)
.OrderByFieldDescending<Player, int>(p => p.Level)
.Skip(20).Take(10)
.ExecuteOrdered(); // โ List<EntityId>
// Reactive: incremental view, refreshed via ring-buffer deltas, not a full re-scan
using var view = tx.Query<PlayerArch>()
.WhereField<Player>(p => p.Level > 50 || p.Faction == 1)
.ToView(); // โ EcsView<PlayerArch> (OR mode: 2 DNF branches)
โ ๏ธ Guarantees & limits
- The predicate's field must carry
[Index](B+Tree secondary index) โ a field that resolves but isn't indexed throwsInvalidOperationExceptionnaming the field, raised at the first terminal call, not insideWhereFielditself. - Operators:
==,!=,>,<,>=,<=, combined with&&/||/!(De Morgan applied at parse time); method calls, arithmetic on fields, and field-to-field comparisons throwNotSupportedExceptionimmediately insideWhereField. - String-typed fields (
String64) have no key-type mapping for predicates โ referencing one throwsNotSupportedExceptionat the first terminal call; use.Where(lambda)for string-field filtering instead. - OR expressions normalize to DNF capped at 16 branches; exceeding it throws
InvalidOperationExceptionโ restructure as multiple queries or fewer ANDed OR-pairs (each ANDed OR-pair multiplies the branch count). ToView()on aWhereFieldquery is the only path that yields an incrementally-maintainedEcsView(ring-buffer delta refresh, O(1) typical cost per change);Where(opaque) forces full-rescan pull mode instead.ExecuteOrdered()requiresOrderByField/OrderByFieldDescendingon an indexed field and does not support multi-branch (OR) predicates.- Multiple
WhereFieldcalls on the same query cross-product as AND-of-OR โ chaining ANDed OR-pairs grows DNF branches multiplicatively against the 16-branch cap. - Entities spawned (but not yet committed) in the same transaction have no index entry yet;
Execute/Count/Anystill see them via a deferred-compiled fallback predicate so read-your-own-writes holds, even though the targeted scan itself can't find them.
๐งช Tests
- EcsQueryTargetedScanTests โ targeted B+Tree scan vs. broad-scan equivalence,
Count/Any,ExecuteOrderedguard throws - TransientIndexTests โ
WhereFieldindex-driven scan on the Transient storage discipline, with a non-primary filter - EcsHardeningTests โ
Query_WhereField_SeesPendingSpawns: deferred-compiled fallback so uncommitted spawns are still visible
๐ Related
- Parent feature: Fluent Query API & Predicate Parsing
- Sibling: Query System (EcsQuery) โ the Ecs-category entry point this whole category is the deep-dive extension of
- Sibling: Execution Planning & Pipeline Execution โ consumes
WhereFieldpredicates to pick the primary scan - Sibling: Result Ordering & Pagination โ
OrderByFieldrequires a precedingWhereFieldto identify the table - Sibling: Lookup and Range-Scan Operations โ
WhereFieldcompiles down to an index range scan