Opaque Post-Filter Predicates (Where)
Arbitrary per-entity C# predicate evaluated after a broad archetype scan — for logic the index system can't express.
Status: ✅ Implemented · Visibility: Public · Level: 🟢 Start Here · Category: Querying
🎯 What it solves
Some filtering logic isn't a single indexed-field comparison: it combines several fields with custom logic, calls into application code, or targets a field that has no index at all. Where<T>(Func<T, bool> predicate) accepts any compiled C# delegate and applies it as a per-entity post-filter, trading the index-driven speed of WhereField for unrestricted expressiveness — useful for one-shot queries where simplicity matters more than scan cost, or whenever the condition genuinely can't be reduced to an indexed comparison.
⚙️ How it works (in brief)
Where<T> takes an ordinary Func<T, bool> — a compiled delegate, not a parsed expression tree — and chains it into a per-entity filter; multiple Where calls AND together. Execution always broad-scans the archetype mask's candidate entities, opens each one (Transaction.Open + TryRead<T>), and evaluates the delegate — there is no index lookup and no key-only short-circuit. Because the predicate is opaque, the engine cannot determine which fields it depends on, so it cannot wire commit-time change capture for it: an EcsView built from Where alone falls back to a full re-query (pull mode) on every Refresh() instead of an incremental ring-buffer drain.
💻 Usage
[Component("Game.Player", 1)]
public struct Player
{
public int Level;
public int Faction;
}
[Archetype]
public class PlayerArch : Archetype<PlayerArch>
{
public static readonly Comp<Player> Data = Register<Player>();
}
using var tx = dbe.CreateQuickTransaction();
// Arbitrary per-entity logic — no index involved, evaluated via Transaction.Open + TryRead
var matched = tx.Query<PlayerArch>()
.Where<Player>(p => SomeOpaqueRule(p.Level, p.Faction))
.Execute(); // → HashSet<EntityId>
// Pull-mode view: re-evaluates the full archetype scan on every Refresh()
using var view = tx.Query<PlayerArch>()
.Where<Player>(p => p.Level > 50)
.ToView(); // → EcsView<PlayerArch> (pull mode)
⚠️ Guarantees & limits
- No index requirement — works on any field, indexed or not, and accepts any computation expressible as
Func<T, bool>. - Cannot be combined with
WhereFieldforToView(): an incremental view's predicate must come entirely fromWhereField. Mixing throwsInvalidOperationException("fold the condition into WhereField, or drop WhereField to build a pull view from.Where(...)"). - Cost is O(archetype candidate count) per
Execute()and perRefresh()— every mask-matching entity is opened and read, regardless of how selective the predicate turns out to be. - An
EcsViewbuilt fromWherealone is always pull mode: full re-query on eachRefresh(), no ring-buffer delta tracking, no boundary-crossing short-circuit. - Multiple
.Where<T>()calls chain as AND only — there is no OR composition across separate.Wherecalls (express OR inside the single lambda instead). ExecuteOrdered()requiresWhereFieldand ignores.Where(...); combining the two throwsInvalidOperationException.
🧪 Tests
- EcsQueryTests —
Where_*: field-delegate filtering, empty/all-match edges, two-component combination, foreach, and pull-mode view refresh
🔗 Related
- Parent feature: Fluent Query API & Predicate Parsing
- Sibling: Indexed Field Predicates (WhereField) — the index-driven counterpart; use it when the field is indexed and only fall back to opaque
Whereotherwise