From 9644a19212cb8f66d7a04b2be0f656ffe03a436c Mon Sep 17 00:00:00 2001 From: MrDevRobot Date: Sun, 6 Sep 2026 14:52:17 +0200 Subject: [PATCH 1/2] fix(query): fall back to full scan for computed properties in predicates BsonExpressionEvaluator translated any bare bool member access (and other member-based patterns: NOT, Equals, string methods, IN, binary comparisons, CompareTo) into a BSON-level field lookup by property name, with no check that the property is actually persisted. A get-only computed property (e.g. `public bool IsOpen => State != Closed`) has no backing BSON field, so the generated predicate scanned every field in the document, never found one named "isopen", and silently returned false for every document - regardless of the real value. `.Where(x => x.IsOpen)` / `.FindAsync(x => x.IsOpen)` therefore always returned empty, while a plain `FindByIdAsync` (no predicate) returned the correct document intact. Added IsPersistedMember (a property is only pushed down if it has a setter) and gated every member-name extraction point in BsonExpressionEvaluator on it. When the check fails, TryCompileBody returns null and the caller falls through to the existing full-scan + in-memory-filter strategy, which evaluates the real getter correctly. Co-Authored-By: Claude Sonnet 5 --- .../Query/BsonExpressionEvaluator.cs | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/src/BLite.Core/Query/BsonExpressionEvaluator.cs b/src/BLite.Core/Query/BsonExpressionEvaluator.cs index 30a3e1f..60741e0 100644 --- a/src/BLite.Core/Query/BsonExpressionEvaluator.cs +++ b/src/BLite.Core/Query/BsonExpressionEvaluator.cs @@ -123,7 +123,8 @@ private static HashSet BuildKnownBsonPrimitives() // ── Bare bool member: e => e.IsActive → IsActive == true ────────────── if (body is MemberExpression bareM && bareM.Expression == parameter && - bareM.Type == typeof(bool)) + bareM.Type == typeof(bool) && + IsPersistedMember(bareM.Member)) { var bsonName = bareM.Member.Name.ToLowerInvariant(); if (bsonName == "id") bsonName = "_id"; @@ -134,7 +135,8 @@ private static HashSet BuildKnownBsonPrimitives() if (body is MemberExpression { Member.Name: "HasValue" } hasValueExpr && hasValueExpr.Expression is MemberExpression innerHasValueMember && innerHasValueMember.Expression == parameter && - Nullable.GetUnderlyingType(innerHasValueMember.Type) != null) + Nullable.GetUnderlyingType(innerHasValueMember.Type) != null && + IsPersistedMember(innerHasValueMember.Member)) { var bsonName = innerHasValueMember.Member.Name.ToLowerInvariant(); if (bsonName == "id") bsonName = "_id"; @@ -147,7 +149,8 @@ hasValueExpr.Expression is MemberExpression innerHasValueMember && // Fast path: !e.BoolProp → BoolProp == false if (notExpr.Operand is MemberExpression notM && notM.Expression == parameter && - notM.Type == typeof(bool)) + notM.Type == typeof(bool) && + IsPersistedMember(notM.Member)) { var bsonName = notM.Member.Name.ToLowerInvariant(); if (bsonName == "id") bsonName = "_id"; @@ -167,7 +170,8 @@ hasValueExpr.Expression is MemberExpression innerHasValueMember && if (mc.Method.Name == "Equals" && mc.Arguments.Count == 1 && mc.Object is MemberExpression equalsOnMember && - equalsOnMember.Expression == parameter) + equalsOnMember.Expression == parameter && + IsPersistedMember(equalsOnMember.Member)) { var fieldName = equalsOnMember.Member.Name; var bsonName = fieldName.ToLowerInvariant(); @@ -191,7 +195,8 @@ mc.Object is MemberExpression equalsOnMember && strMember.Expression == parameter && strMember.Type == typeof(string) && mc.Arguments.Count == 1 && - mc.Method.Name is "Contains" or "StartsWith" or "EndsWith") + mc.Method.Name is "Contains" or "StartsWith" or "EndsWith" && + IsPersistedMember(strMember.Member)) { var bsonName = strMember.Member.Name.ToLowerInvariant(); if (bsonName == "id") bsonName = "_id"; @@ -210,7 +215,8 @@ mc.Method.Name is "IsNullOrEmpty" or "IsNullOrWhiteSpace" && mc.Arguments.Count == 1 && mc.Arguments[0] is MemberExpression staticStrMember && staticStrMember.Expression == parameter && - staticStrMember.Type == typeof(string)) + staticStrMember.Type == typeof(string) && + IsPersistedMember(staticStrMember.Member)) { var bsonName = staticStrMember.Member.Name.ToLowerInvariant(); if (bsonName == "id") bsonName = "_id"; @@ -227,7 +233,8 @@ mc.Arguments[0] is MemberExpression staticStrMember && { var argUnwrapped = UnwrapConvert(mc.Arguments[0]); if (argUnwrapped is MemberExpression inMember && - inMember.Expression == parameter) + inMember.Expression == parameter && + IsPersistedMember(inMember.Member)) { var (ok, collection) = TryEvaluate(mc.Object); if (ok && collection != null) @@ -242,7 +249,8 @@ mc.Arguments[0] is MemberExpression staticStrMember && { var argUnwrapped = UnwrapConvert(mc.Arguments[1]); if (argUnwrapped is MemberExpression enumInMember && - enumInMember.Expression == parameter) + enumInMember.Expression == parameter && + IsPersistedMember(enumInMember.Member)) { var (ok, collection) = TryEvaluateCollection(mc.Arguments[0]); if (ok && collection != null) @@ -288,7 +296,7 @@ ExpressionType.GreaterThan or ExpressionType.GreaterThanOrEqual or nodeType = Flip(nodeType); } - if (leftInner is MemberExpression member && member.Expression == parameter) + if (leftInner is MemberExpression member && member.Expression == parameter && IsPersistedMember(member.Member)) { var fieldName = member.Member.Name; var bsonName = fieldName.ToLowerInvariant(); @@ -341,6 +349,8 @@ ExpressionType.GreaterThan or ExpressionType.GreaterThanOrEqual or var instanceExpr = UnwrapNullableValue(UnwrapConvert(ctMc.Object!)); if (instanceExpr is not MemberExpression ctMember || ctMember.Expression != parameter) return null; + if (!IsPersistedMember(ctMember.Member)) + return null; var fieldName = ctMember.Member.Name; var bsonName = fieldName.ToLowerInvariant(); @@ -690,6 +700,16 @@ private static (bool Ok, object? Value) TryEvaluateCollection(Expression express private static bool IsDirectParameterAccess(Expression expr, ParameterExpression p) => expr is MemberExpression m && m.Expression == p; + /// + /// True for a field, or a property with a setter - the shapes BLite's document mapper actually + /// persists as a BSON field. A get-only property (public bool IsOpen => State != Closed) has + /// no backing BSON field at all, so pushing it down into would scan + /// every document for a field name that can never exist and silently return false for + /// everyone - wrong, instead of falling back to a real in-memory evaluation of the getter. + /// + private static bool IsPersistedMember(MemberInfo member) + => member is not PropertyInfo { CanWrite: false }; + /// /// Unwraps a single Convert / ConvertChecked node if present. /// Enum comparisons are compiled to Equal(Convert(x.Role,Int32), Convert(3,Int32)) From 76d8218928039337a0447be52d14791cdeea5ab9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:43:33 +0000 Subject: [PATCH 2/2] fix(query): avoid partial compound pushdown and support backed get-only members Co-authored-by: mrdevrobot <12503462+mrdevrobot@users.noreply.github.com> --- .../Query/BsonExpressionEvaluator.cs | 55 ++++++++++++++++--- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/src/BLite.Core/Query/BsonExpressionEvaluator.cs b/src/BLite.Core/Query/BsonExpressionEvaluator.cs index 60741e0..6570976 100644 --- a/src/BLite.Core/Query/BsonExpressionEvaluator.cs +++ b/src/BLite.Core/Query/BsonExpressionEvaluator.cs @@ -87,7 +87,7 @@ private static HashSet BuildKnownBsonPrimitives() var lp = TryCompileBody(andAlso.Left, parameter, registry, keyMap); var rp = TryCompileBody(andAlso.Right, parameter, registry, keyMap); if (lp != null && rp != null) return reader => lp(reader) && rp(reader); - return lp ?? rp; + return null; } return null; @@ -114,7 +114,7 @@ private static HashSet BuildKnownBsonPrimitives() var lp = TryCompileBody(orElse.Left, parameter, registry, keyMap); var rp = TryCompileBody(orElse.Right, parameter, registry, keyMap); if (lp != null && rp != null) return reader => lp(reader) || rp(reader); - return lp ?? rp; + return null; } return null; @@ -701,14 +701,53 @@ private static bool IsDirectParameterAccess(Expression expr, ParameterExpression => expr is MemberExpression m && m.Expression == p; /// - /// True for a field, or a property with a setter - the shapes BLite's document mapper actually - /// persists as a BSON field. A get-only property (public bool IsOpen => State != Closed) has - /// no backing BSON field at all, so pushing it down into would scan - /// every document for a field name that can never exist and silently return false for - /// everyone - wrong, instead of falling back to a real in-memory evaluation of the getter. + /// True when a member is expected to have a persisted BSON field: + /// fields, properties with setters, and getter-only properties with either + /// compiler-generated (<Name>k__BackingField) or conventional + /// (_name) backing fields. /// private static bool IsPersistedMember(MemberInfo member) - => member is not PropertyInfo { CanWrite: false }; + { + if (member is FieldInfo) + return true; + + if (member is not PropertyInfo property) + return false; + + if (property.CanWrite) + return true; + + var declaringType = property.DeclaringType; + if (declaringType is null) + return false; + + var autoPropertyBackingField = $"<{property.Name}>k__BackingField"; + if (HasFieldInHierarchy(declaringType, autoPropertyBackingField)) + return true; + + if (property.Name.Length == 0) + return false; + + var conventionalBackingField = $"_{char.ToLowerInvariant(property.Name[0])}{property.Name[1..]}"; + return HasFieldInHierarchy(declaringType, conventionalBackingField); + } + + private static bool HasFieldInHierarchy(Type type, string fieldName) + { +#pragma warning disable IL2070, IL2075 + for (var current = type; current is not null; current = current.BaseType) + { + var field = current.GetField( + fieldName, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly); + + if (field is not null) + return true; + } +#pragma warning restore IL2070, IL2075 + + return false; + } /// /// Unwraps a single Convert / ConvertChecked node if present.