OR Disjunction (DNF Predicates)
||in aWhereFieldpredicate, normalized to Disjunctive Normal Form and evaluated as independent branches.
Status: โ Implemented ยท Visibility: Public ยท Level: ๐ฃ Advanced ยท Category: Querying
Assumes: Indexed Field Predicates (WhereField)
๐ฏ What it solves
Real filters are rarely a single AND chain โ "high level OR in this faction", "(active AND high-level) OR admin". Hand-rolling that as separate queries unioned in application code works for one-shot reads, but breaks down for a reactive ToView(): each branch's membership has to be tracked independently, because a field going IN on one branch can keep an entity in the view even while another branch's field goes OUT. OR disjunction support means you write the natural boolean C# expression and the engine handles branch bookkeeping for you, for both one-shot queries and persistent views.
โ๏ธ How it works (in brief)
WhereField<T>(p => p.Level > 50 || p.Faction == 1) is parsed into Disjunctive Normal Form (DNF) โ an OR of AND-clauses โ at query-build time. ! is pushed to the leaves via De Morgan's law before normalization (!(A && B) โ !A || !B), and AND(OR(A,B), C) distributes into OR(AND(A,C), AND(B,C)). The result is FieldPredicate[][]: each outer entry is one branch, each inner array is that branch's AND-clause. One-shot terminals (Execute/Count/Any) run each branch as an independent index-driven plan and union the results into one HashSet<EntityId> โ deduplication is free. ToView() instead enters OR mode: EcsView<TArchetype> tracks, per entity, a 16-bit bitmap of which branches currently hold; the entity is in the view whenever any bit is set, so on a per-field change only the branches referencing that field are re-evaluated.
๐ป Usage
// One-shot: OR across two field comparisons โ union of both branches, deduplicated
var result = tx.Query<PlayerArch>()
.WhereField<Player>(p => p.Level > 50 || p.Faction == 1)
.Execute(); // โ HashSet<EntityId>
// Mixed AND/OR โ DNF: [[Level>50, Faction==1], [Rarity>=4]]
using var view = tx.Query<PlayerArch>()
.WhereField<Player>(p => (p.Level > 50 && p.Faction == 1) || p.Rarity >= 4)
.ToView(); // โ EcsView<PlayerArch> in OR mode (2 branches)
// NOT โ De Morgan applied at parse time, zero runtime cost
var negated = tx.Query<PlayerArch>()
.WhereField<Player>(p => !(p.Level > 50 && p.Faction == 1)) // โ Level <= 50 || Faction != 1
.Execute();
// Chained WhereField calls cross-product as AND-of-OR
var crossed = tx.Query<PlayerArch>()
.WhereField<Player>(p => p.A > 1 || p.B > 2) // 2 branches
.WhereField<Player>(p => p.C > 3 || p.D > 4) // 2 branches
.Execute(); // โ cross-product: 4 DNF branches
โ ๏ธ Guarantees & limits
- Maximum 16 DNF branches. Each entity's branch membership is tracked in a
ushortbitmap (one bit per branch). Exceeding 16 throwsInvalidOperationExceptionnaming the actual branch count. - Exponential blowup risk: ANDing multiple OR-pairs multiplies branch count โ
(A||B) && (C||D) && (E||F)is already 2ร2ร2 = 8 branches; chainingWhereFieldcalls that each contain OR cross-products the same way. Restructure as separate queries (unioned in application code) or reduce ANDed OR-pairs if you hit the cap. ExecuteOrdered()is not supported with OR predicates โ merging multiple independently-ordered index scans isn't implemented; sort the unorderedExecute()result yourself if needed.- In OR-mode views, a component read only happens when a changed field crosses a branch's predicate boundary (matching โ not-matching or vice versa) โ not on every change to a tracked field.
- Memory cost for an OR-mode view: ~2 bytes per tracked entity for the branch bitmap, on top of the normal entity-set cost.
- Cross-component OR (branches spanning two different component types within one archetype) is not supported โ all branches must resolve against the same
WhereField<T>component.
๐งช Tests
- ExpressionParserTests โ DNF normalization: De Morgan's law, double/triple negation, branch flattening, the 16-branch cap
- EcsOrViewTests โ OR-mode
ToView(): union-of-branches population, per-branch field-crossing in/out, last-branch-fails removal
๐ Related
- Parent feature: Fluent Query API & Predicate Parsing
- Sibling: Indexed Field Predicates (WhereField) โ OR disjunction is the
||extension of aWhereFieldpredicate - Sibling: Foreign-Key Navigation Joins (L4) โ OR predicates inside a navigation
.Where(...)aren't supported