Skip to content

Query predicate on a computed (get-only) property silently returns false for every document #143

Description

@mrdevrobot

Bug

BsonExpressionEvaluator.TryCompileBody pushes down member-name-based predicates (bare bool member, !x.Prop, .Equals(), string methods, IN, binary comparisons, CompareTo) into a raw BSON field lookup by property name, without checking whether that property is actually persisted.

A get-only computed property - e.g.

public bool IsOpen => State != ReservationState.Closed;

has no backing BSON field (nothing to deserialize back into on write), so the generated predicate scans every field in the document, never finds one literally named "isopen", and falls through to return false - for every document, regardless of the real value.

Repro

// entity.State == ReservationState.Created, so entity.IsOpen == true
await collection.InsertAsync(entity);

var direct = await collection.FindByIdAsync(entity.Id); // correct, IsOpen == true on the returned object
var viaQuery = await collection.FindAsync(x => x.IsOpen).ToListAsync(); // empty - wrong

FindByIdAsync (no predicate) returns the document intact; any query with a predicate that touches IsOpen returns nothing, because the BSON-level pushdown path silently misfires instead of falling back.

Impact

Silent wrong results, not an exception - much worse than the "not the same in-memory data cannot be used in this context" style errors most providers throw for untranslatable expressions. Any .Where/.FindAsync predicate anywhere touching a get-only computed property is affected, not just IsOpen - this is a general pushdown correctness bug, not specific to one entity.

Root cause

BsonExpressionEvaluator's member-based branches (bare bool, NOT, .Equals(), string methods, IN operator, binary comparisons via x.Prop op value, CompareTo) extract member.Name and build a BSON-field predicate unconditionally - there is no check that the member is actually stored (i.e. has a setter) rather than computed.

Fix (see linked PR)

Added IsPersistedMember(MemberInfo) (member is not PropertyInfo { CanWrite: false }) and gated every direct-member extraction point on it. When a member fails the check, TryCompileBody returns null, so the caller (DocumentCollection.FetchAsync) falls through to the existing full-scan + in-memory-filter strategy (Strategy 3), which compiles the real expression and evaluates the actual getter correctly.

Found and reproduced live via Modulon.Bills.Domain.Aggregates.Reservations.Reservation.IsOpen in a downstream app: a waiter terminal's local read replica had the reservation data (confirmed via FindByIdAsync) but GetOpenReservations()/GetByTableId() (both filtering on r.IsOpen) always returned nothing, making an occupied table appear free in the UI.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcore

    Type

    Projects

    • Status
      Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions