From 959171da2d51a161ca04adaa298d977e91b312bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D8=A7=D9=85=DB=8C=D8=B1=D8=AD=D8=B3=DB=8C=D9=86=20=D8=B7?= =?UTF-8?q?=D9=87=D8=A7=D9=86=20=D9=BE=D9=88=D8=B1?= Date: Mon, 7 Sep 2026 15:57:45 +0330 Subject: [PATCH 1/5] feat(core): add In and NotIn membership operators to ConditionOperator enum --- src/PepperX.QueryForge/Models/Enums.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PepperX.QueryForge/Models/Enums.cs b/src/PepperX.QueryForge/Models/Enums.cs index 00a41d4..7ac2a37 100644 --- a/src/PepperX.QueryForge/Models/Enums.cs +++ b/src/PepperX.QueryForge/Models/Enums.cs @@ -11,7 +11,8 @@ public enum ConditionOperator { Equals, NotEquals, Contains, NotContains, StartsWith, EndsWith, LessThan, GreaterThan, - LessThanOrEqualTo, GreaterThanOrEqualTo, Between + LessThanOrEqualTo, GreaterThanOrEqualTo, Between, + In, NotIn } /// Defines the structural shape of the query result returned by the execution provider. From 2cb938a0c050532d78c3e822cf1075ae29ea07ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D8=A7=D9=85=DB=8C=D8=B1=D8=AD=D8=B3=DB=8C=D9=86=20=D8=B7?= =?UTF-8?q?=D9=87=D8=A7=D9=86=20=D9=BE=D9=88=D8=B1?= Date: Mon, 7 Sep 2026 15:58:17 +0330 Subject: [PATCH 2/5] feat(core): unwrap JsonValueKind.Array in ConditionSemantics.Unwrap for In/NotIn --- .../Querying/ConditionSemantics.cs | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/PepperX.QueryForge/Querying/ConditionSemantics.cs b/src/PepperX.QueryForge/Querying/ConditionSemantics.cs index 9e2472d..c1e86c0 100644 --- a/src/PepperX.QueryForge/Querying/ConditionSemantics.cs +++ b/src/PepperX.QueryForge/Querying/ConditionSemantics.cs @@ -1,3 +1,5 @@ +using System.Collections; +using System.Linq; using System.Text.Json; namespace PepperX.QueryForge.Querying; @@ -38,7 +40,9 @@ public static class ConditionSemantics /// and , where a /// null value is a deliberate IS NULL / IS NOT NULL test, and /// , which additionally requires - /// . + /// . Membership operators ( and + /// ) require a non-null enumerable value with at least one + /// element — an empty set never matches. /// public static bool IsExecutable(Condition condition) { @@ -55,6 +59,9 @@ public static bool IsExecutable(Condition condition) if (condition.Operator is ConditionOperator.Equals or ConditionOperator.NotEquals) return true; + if (condition.Operator is ConditionOperator.In or ConditionOperator.NotIn) + return value is not null; // empty enumerable is executable: IN [] → match nothing, NOT IN [] → match everything + if (value is null) return false; @@ -64,6 +71,19 @@ public static bool IsExecutable(Condition condition) return true; } + /// Whether is a non-empty enumerable. + private static bool IsNonEmptyEnumerable(object? value) + { + if (value is IEnumerable enumerable) + { + foreach (var _ in enumerable) + return true; + return false; + } + + return false; + } + /// Whether the operator performs a text match and therefore needs LIKE escaping. public static bool IsPatternOperator(ConditionOperator op) => op is ConditionOperator.Contains @@ -71,6 +91,9 @@ or ConditionOperator.NotContains or ConditionOperator.StartsWith or ConditionOperator.EndsWith; + /// Whether the operator matches a value against a set of candidates. + public static bool IsMembershipOperator(ConditionOperator op) => op is ConditionOperator.In or ConditionOperator.NotIn; + /// /// Converts a value that arrived as JSON into a plain CLR value. /// @@ -92,6 +115,9 @@ or ConditionOperator.StartsWith JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(), + JsonValueKind.Array => element.EnumerateArray() + .Select(e => Unwrap(e)).ToList(), + JsonValueKind.Object => element.Deserialize>(), _ => element.GetRawText() }; } From 72f124e521e151c09d5ffc46c88f9b972a27f4ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D8=A7=D9=85=DB=8C=D8=B1=D8=AD=D8=B3=DB=8C=D9=86=20=D8=B7?= =?UTF-8?q?=D9=87=D8=A7=D9=86=20=D9=BE=D9=88=D8=B1?= Date: Mon, 7 Sep 2026 15:58:35 +0330 Subject: [PATCH 3/5] feat(inmem): evaluate In and NotIn operators in InMemoryQueryEngine with empty-list semantics --- .../Querying/InMemoryQueryEngine.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/PepperX.QueryForge/Querying/InMemoryQueryEngine.cs b/src/PepperX.QueryForge/Querying/InMemoryQueryEngine.cs index 0a3a1b1..e31d048 100644 --- a/src/PepperX.QueryForge/Querying/InMemoryQueryEngine.cs +++ b/src/PepperX.QueryForge/Querying/InMemoryQueryEngine.cs @@ -257,6 +257,38 @@ public static bool Matches( return QueryValueComparer.Instance.Compare(actual, expected) >= 0 && QueryValueComparer.Instance.Compare(actual, upper) <= 0; + case ConditionOperator.In: + if (expected is not System.Collections.IEnumerable enumerable) + return null; + + // Empty enumerable: IN [] matches nothing + bool foundIn = false; + foreach (var candidate in enumerable) + { + if (QueryValueComparer.Instance.AreEqual(actual, candidate)) + { + foundIn = true; + break; + } + } + return foundIn; + + case ConditionOperator.NotIn: + if (expected is not System.Collections.IEnumerable notInEnumerable) + return null; + + // Empty enumerable: NOT IN [] matches everything + bool foundInNot = false; + foreach (var candidate in notInEnumerable) + { + if (QueryValueComparer.Instance.AreEqual(actual, candidate)) + { + foundInNot = true; + break; + } + } + return !foundInNot; + default: return null; } From 01e03501c95f607fd9149dc849ee88605e9e3d55 Mon Sep 17 00:00:00 2001 From: AmirHossein Tahanpour Date: Tue, 8 Sep 2026 10:30:00 +0330 Subject: [PATCH 4/5] in notin operators --- .../Compiler/SqlQueryCompiler.cs | 28 ++++ .../Translation/ExpressionCompiler.cs | 69 ++++++++++ .../QueryForgeConformanceTests.cs | 121 ++++++++++++++++++ 3 files changed, 218 insertions(+) diff --git a/src/PepperX.QueryForge.Dapper/Compiler/SqlQueryCompiler.cs b/src/PepperX.QueryForge.Dapper/Compiler/SqlQueryCompiler.cs index bc05982..4462577 100644 --- a/src/PepperX.QueryForge.Dapper/Compiler/SqlQueryCompiler.cs +++ b/src/PepperX.QueryForge.Dapper/Compiler/SqlQueryCompiler.cs @@ -353,6 +353,12 @@ private string BuildKeyPredicate(string keyColumn, IReadOnlyList groupK var upper = Coerce(condition.ValueTo, columnType); return $"{column} BETWEEN {context.AddValue(value)} AND {context.AddValue(upper)}"; + case ConditionOperator.In: + return BuildMembershipIn(column, value, context, negate: false); + + case ConditionOperator.NotIn: + return BuildMembershipIn(column, value, context, negate: true); + case ConditionOperator.Contains: return Like(column, value, prefixWildcard: true, suffixWildcard: true, negate: false, context); @@ -405,6 +411,28 @@ private string Like( return $"{column} {op} {reference}{_dialect.LikeEscapeClause}"; } + /// + /// Builds a membership predicate — IN or NOT IN — against a set of candidate values. + /// + private string BuildMembershipIn(string column, object? value, CompilationContext context, bool negate) + { + if (value is not System.Collections.IEnumerable enumerable) + return negate ? $"{column} IS NOT NULL" : $"{column} IS NULL"; + + var references = new List(); + foreach (var element in enumerable) + { + var unwrapped = ConditionSemantics.Unwrap(element); + references.Add(context.AddValue(unwrapped)); + } + + if (references.Count == 0) + return negate ? "1 = 1" : "1 = 0"; + + var op = negate ? "NOT IN" : "IN"; + return $"{column} {op} ({string.Join(", ", references)})"; + } + #endregion /// Accumulates parameters while a single statement is being built. diff --git a/src/PepperX.QueryForge.EFCore/Translation/ExpressionCompiler.cs b/src/PepperX.QueryForge.EFCore/Translation/ExpressionCompiler.cs index 0b3d3c4..35f4da6 100644 --- a/src/PepperX.QueryForge.EFCore/Translation/ExpressionCompiler.cs +++ b/src/PepperX.QueryForge.EFCore/Translation/ExpressionCompiler.cs @@ -340,6 +340,12 @@ private static bool IsOrderable(Type type) ? null : Expression.AndAlso(lowerBound, upperBound); + case ConditionOperator.In: + return BuildMembership(member, property, condition, negate: false); + + case ConditionOperator.NotIn: + return BuildMembership(member, property, condition, negate: true); + case ConditionOperator.Contains: return BuildLike(member, targetType, raw, StringContains, negate: false); @@ -357,6 +363,67 @@ private static bool IsOrderable(Type type) } } + /// + /// Builds a membership predicate — IN or NOT IN — against a set of candidate values. + /// + private static Expression? BuildMembership( + MemberExpression member, + PropertyInfo property, + Condition condition, + bool negate) + { + var targetType = property.PropertyType; + var raw = ConditionSemantics.Unwrap(condition.Value); + + if (raw is not IEnumerable enumerable) + return negate + ? Expression.NotEqual(member, Expression.Constant(null, targetType)) + : Expression.Equal(member, Expression.Constant(null, targetType)); + + var elements = enumerable.Cast().Select(element => + { + var unwrapped = ConditionSemantics.Unwrap(element); + if (!TryConvert(unwrapped, targetType, out var converted)) + return null; + + try + { + return Parameterize(converted, targetType); + } + catch (InvalidOperationException) + { + return null; + } + }).Where(e => e is not null).ToArray(); + + if (elements.Length == 0) + return negate ? Expression.Constant(true) : Expression.Constant(false); + + var containsMethod = typeof(Enumerable).GetMethod( + nameof(Enumerable.Contains), + BindingFlags.Public | BindingFlags.Static, + binder: null, + types: [typeof(IEnumerable), typeof(object)], + modifiers: null)!; + + // Build the closed Contains(IEnumerable, T) for the element type. + var closedContains = containsMethod.MakeGenericMethod(targetType); + var arrayConstant = Expression.Constant(elements); + + Expression? body = Expression.Call(closedContains, arrayConstant, member); + + if (negate) + body = Expression.Not(body); + + // Null columns: for IN, no match (null not in any set); for NOT IN, null matches (null not in set). + // SQL: NULL IN (1,2,3) → unknown → false; NULL NOT IN (1,2,3) → unknown → false. + // But the EF Core provider's SQL-faithful stance is that a NULL column should not match either + // membership form — the row is excluded either way. Guard with IS NOT NULL. + return Expression.AndAlso( + Expression.NotEqual(member, Expression.Constant(null, targetType)), + body); + } + /// /// Builds the SQL-faithful negation of a condition. /// @@ -395,6 +462,8 @@ private static bool IsOrderable(Type type) ConditionOperator.GreaterThanOrEqualTo => ConditionOperator.LessThan, ConditionOperator.Contains => ConditionOperator.NotContains, ConditionOperator.NotContains => ConditionOperator.Contains, + ConditionOperator.In => ConditionOperator.NotIn, + ConditionOperator.NotIn => ConditionOperator.In, _ => condition.Operator }; diff --git a/tests/PepperX.QueryForge.Conformance/QueryForgeConformanceTests.cs b/tests/PepperX.QueryForge.Conformance/QueryForgeConformanceTests.cs index 958ac7e..642ccb3 100644 --- a/tests/PepperX.QueryForge.Conformance/QueryForgeConformanceTests.cs +++ b/tests/PepperX.QueryForge.Conformance/QueryForgeConformanceTests.cs @@ -561,6 +561,127 @@ public async Task Criteria_TextOperatorOnNullColumn_ShouldNotMatch() #endregion + #region Criteria — membership operators + + [SkippableTheory] + [InlineData(ConditionOperator.In, new[] { 4, 6, 10, 11 })] + [InlineData(ConditionOperator.NotIn, new[] { 1, 2, 3, 5, 7, 8, 9, 12 })] + public async Task Criteria_In_ShouldMatchAnyOfTheGivenValues( + ConditionOperator op, int[] expected) + { + var ids = await IdsAsync(new Query + { + Criteria = Group(new Condition("Id", op, new[] { 4, 6, 10, 11 })), + Paging = AllRows + }); + + ids.Should().Equal(expected); + } + + [SkippableFact] + public async Task Criteria_In_ShouldAcceptValuesAsStringsAndCompareNumerically() + { + var ids = await IdsAsync(new Query + { + Criteria = Group(new Condition("Id", ConditionOperator.In, new[] { "4", "6", "10", "11" })), + Paging = AllRows + }); + + ids.Should().Equal(4, 6, 10, 11); + } + + [SkippableFact] + public async Task Criteria_In_OnAStringColumn_ShouldMatch() + { + var ids = await IdsAsync(new Query + { + Criteria = Group(new Condition("Category", ConditionOperator.In, new[] { "Tools", "Promo" })), + SortColumns = [new SortDescriptor("Id")], + Paging = AllRows + }); + + ids.Should().Equal(1, 3, 4, 8, 9, 11, 12); + } + + [SkippableFact] + public async Task Criteria_In_WithAnEmptyList_ShouldNotMatchAnything() + { + var result = await RunAsync(new Query + { + Criteria = Group(new Condition("Id", ConditionOperator.In, Array.Empty())), + Paging = AllRows + }); + + result.Meta.Total.Rows.Should().Be(0); + } + + [SkippableFact] + public async Task Criteria_In_WithNullItem_ShouldStillMatchTheNonNullOnes() + { + var ids = await IdsAsync(new Query + { + Criteria = Group(new Condition("Id", ConditionOperator.In, new object?[] { 4, null, 11 })), + Paging = AllRows + }); + + // The null item is ignored — only 4 and 11 match. + ids.Should().Equal(4, 11); + } + + [SkippableFact] + public async Task Criteria_NotIn_ShouldExcludeAllOfTheGivenValues() + { + var ids = await IdsAsync(new Query + { + Criteria = Group(new Condition("Id", ConditionOperator.NotIn, new[] { 1, 2, 3 })), + Paging = AllRows + }); + + ids.Should().Equal(4, 5, 6, 7, 8, 9, 10, 11, 12); + } + + [SkippableFact] + public async Task Criteria_NotIn_WithAnEmptyList_ShouldMatchEverything() + { + var result = await RunAsync(new Query + { + Criteria = Group(new Condition("Id", ConditionOperator.NotIn, Array.Empty())), + Paging = AllRows + }); + + result.Meta.Total.Rows.Should().Be(12); + } + + [SkippableFact] + public async Task Criteria_In_ShouldTreatNullColumnAsNotMatching() + { + var ids = await IdsAsync(new Query + { + Criteria = Group(new Condition("Category", ConditionOperator.In, new[] { "Parts" })), + Paging = AllRows + }); + + // Null category rows (6, 7) must not match even though they aren't in the set. + ids.Should().Equal(2, 5, 10); + ids.Should().NotContain(6); + ids.Should().NotContain(7); + } + + [SkippableFact] + public async Task Criteria_In_ShouldWorkWithMixedTypeValues() + { + // A client sending a mix of int and string candidates for a numeric column. + var ids = await IdsAsync(new Query + { + Criteria = Group(new Condition("Quantity", ConditionOperator.In, new object?[] { 5, "9", 12 })), + Paging = AllRows + }); + + ids.Should().Equal(10); + } + + #endregion + #region Criteria — nulls and unusable conditions [SkippableFact] From 041176127c5cc153d7d585329a25fbf602505fae Mon Sep 17 00:00:00 2001 From: amirtahan Date: Mon, 28 Sep 2026 01:55:22 +0330 Subject: [PATCH 5/5] feat: add In and NotIn membership operators across all providers - Add ConditionOperator.In (11) and NotIn (12) to core enum - Extend ConditionSemantics to unwrap JsonArray and handle empty-list semantics - Add In/NotIn evaluation in InMemoryQueryEngine with null-safe logic - Add BuildMembershipIn in Dapper SqlQueryCompiler with per-element parameterization - Add Enumerable.Contains expression building in EF Core ExpressionCompiler - Add 9 conformance tests covering all providers and edge cases - Add /api/users/filters/membership sample endpoint with HTTP request examples Empty-list semantics: IN [] matches nothing, NOT IN [] matches everything --- .../Program.cs | 10 ++ .../Requests.http | 95 ++++++++++++++++++- .../Compiler/SqlQueryCompiler.cs | 24 +++-- .../Translation/ExpressionCompiler.cs | 53 +++++------ .../Querying/ConditionSemantics.cs | 17 +--- .../Querying/InMemoryQueryEngine.cs | 12 ++- .../QueryForgeConformanceTests.cs | 3 +- 7 files changed, 157 insertions(+), 57 deletions(-) diff --git a/samples/PepperX.QueryForge.Sample.WebApi/Program.cs b/samples/PepperX.QueryForge.Sample.WebApi/Program.cs index c31f00d..445c2e7 100644 --- a/samples/PepperX.QueryForge.Sample.WebApi/Program.cs +++ b/samples/PepperX.QueryForge.Sample.WebApi/Program.cs @@ -169,6 +169,16 @@ 4. The `usp_GetUserReport` Stored Procedure. .WithDescription("Logic Enums: 0=And, 1=Or, 2=AndNot, 3=OrNot.") .Accepts("application/json").Produces>(); + filterApi.MapPost("/membership", async (Query q, IDapperQueryService svc) => + { + var dq = DapperQueryBuilder.FromBase(q).ForObject("TestUsers", "dbo", DapperObjectType.Table).Build(); + return await svc.QueryAsync(dq); + }) + .WithName("MembershipFiltering") + .WithSummary("In and NotIn operators for set membership.") + .WithDescription("Operator 11=In, 12=NotIn. Matches or excludes a set of candidate values.") + .Accepts("application/json").Produces>(); + // ========================================== // GROUP 3: SECURITY & VALIDATION // ========================================== diff --git a/samples/PepperX.QueryForge.Sample.WebApi/Requests.http b/samples/PepperX.QueryForge.Sample.WebApi/Requests.http index 5e93456..86fdba8 100644 --- a/samples/PepperX.QueryForge.Sample.WebApi/Requests.http +++ b/samples/PepperX.QueryForge.Sample.WebApi/Requests.http @@ -24,7 +24,8 @@ ### 4=StartsWith 5=EndsWith ### 6=LessThan 7=GreaterThan ### 8=LessThanOrEqualTo 9=GreaterThanOrEqualTo -### 10=Between +### 10=Between 11=In +### 12=NotIn ### ### -------------------------------------------------------------------------- ### The five things a Query can carry @@ -308,6 +309,52 @@ Content-Type: application/json "paging": { "size": 5, "number": 1 } } +### + +# 14b. IN operator — matches any of the given countries: Germany or Japan. +# Operator 11 (In). Pass candidates as a JSON array in value. +POST {{baseUrl}}/api/users/filters/membership +Content-Type: application/json + +{ + "criteria": { + "logic": 0, + "groups": [ + { + "logic": 0, + "conditions": [ + { "columnName": "Country", "operator": 11, "value": [ "Germany", "Japan" ] } + ] + } + ] + }, + "paging": { "size": 20, "number": 1 }, + "sortColumns": [ { "columnName": "UserId", "sortOrder": 0 } ] +} + +### + +# 14c. NOT IN operator — excludes departments HR and IT. +# Operator 12 (NotIn). Pass candidates as a JSON array in value. +POST {{baseUrl}}/api/users/filters/membership +Content-Type: application/json + +{ + "criteria": { + "logic": 0, + "groups": [ + { + "logic": 0, + "conditions": [ + { "columnName": "Department", "operator": 12, "value": [ "HR", "IT" ] } + ] + } + ] + }, + "paging": { "size": 20, "number": 1 }, + "sortColumns": [ { "columnName": "UserId", "sortOrder": 0 } ] +} + ### ========================================================================== ### GROUP 3: SECURITY & VALIDATION (Dapper — needs SQL Server) @@ -703,6 +750,52 @@ Content-Type: application/json ### +# 35b. IN operator (In-Memory) — users from Germany or Iran. +# Operator 11 (In) with an array of candidates. No database required! +POST {{baseUrl}}/api/inmemory/users/query +Content-Type: application/json + +{ + "criteria": { + "logic": 0, + "groups": [ + { + "logic": 0, + "conditions": [ + { "columnName": "Country", "operator": 11, "value": [ "Germany", "Iran" ] } + ] + } + ] + }, + "paging": { "size": 20, "number": 1 }, + "sortColumns": [ { "columnName": "UserId", "sortOrder": 0 } ] +} + +### + +# 35c. NOT IN operator (In-Memory) — exclude HR and IT departments. +# Operator 12 (NotIn) with an array of candidates. +POST {{baseUrl}}/api/inmemory/users/query +Content-Type: application/json + +{ + "criteria": { + "logic": 0, + "groups": [ + { + "logic": 0, + "conditions": [ + { "columnName": "Department", "operator": 12, "value": [ "HR", "IT" ] } + ] + } + ] + }, + "paging": { "size": 20, "number": 1 }, + "sortColumns": [ { "columnName": "UserId", "sortOrder": 0 } ] +} + +### + # 36. Multi-level sort — country ascending, then score descending within each. POST {{baseUrl}}/api/inmemory/users/query Content-Type: application/json diff --git a/src/PepperX.QueryForge.Dapper/Compiler/SqlQueryCompiler.cs b/src/PepperX.QueryForge.Dapper/Compiler/SqlQueryCompiler.cs index 4462577..5867aa6 100644 --- a/src/PepperX.QueryForge.Dapper/Compiler/SqlQueryCompiler.cs +++ b/src/PepperX.QueryForge.Dapper/Compiler/SqlQueryCompiler.cs @@ -327,6 +327,13 @@ private string BuildKeyPredicate(string keyColumn, IReadOnlyList groupK { var column = _dialect.QuoteIdentifier(condition.ColumnName); var columnType = columns.TypeOf(condition.ColumnName); + + if (condition.Operator is ConditionOperator.In) + return BuildMembershipIn(column, condition.Value, columnType, context, negate: false); + + if (condition.Operator is ConditionOperator.NotIn) + return BuildMembershipIn(column, condition.Value, columnType, context, negate: true); + var value = Coerce(condition.Value, columnType); switch (condition.Operator) @@ -353,12 +360,6 @@ private string BuildKeyPredicate(string keyColumn, IReadOnlyList groupK var upper = Coerce(condition.ValueTo, columnType); return $"{column} BETWEEN {context.AddValue(value)} AND {context.AddValue(upper)}"; - case ConditionOperator.In: - return BuildMembershipIn(column, value, context, negate: false); - - case ConditionOperator.NotIn: - return BuildMembershipIn(column, value, context, negate: true); - case ConditionOperator.Contains: return Like(column, value, prefixWildcard: true, suffixWildcard: true, negate: false, context); @@ -414,16 +415,21 @@ private string Like( /// /// Builds a membership predicate — IN or NOT IN — against a set of candidate values. /// - private string BuildMembershipIn(string column, object? value, CompilationContext context, bool negate) + private string BuildMembershipIn(string column, object? value, Type? columnType, CompilationContext context, bool negate) { - if (value is not System.Collections.IEnumerable enumerable) + var unwrappedValue = ConditionSemantics.Unwrap(value); + if (unwrappedValue is not System.Collections.IEnumerable enumerable || unwrappedValue is string) return negate ? $"{column} IS NOT NULL" : $"{column} IS NULL"; var references = new List(); foreach (var element in enumerable) { var unwrapped = ConditionSemantics.Unwrap(element); - references.Add(context.AddValue(unwrapped)); + if (unwrapped is null) + continue; + + var coerced = Coerce(unwrapped, columnType); + references.Add(context.AddValue(coerced)); } if (references.Count == 0) diff --git a/src/PepperX.QueryForge.EFCore/Translation/ExpressionCompiler.cs b/src/PepperX.QueryForge.EFCore/Translation/ExpressionCompiler.cs index 35f4da6..4c2d255 100644 --- a/src/PepperX.QueryForge.EFCore/Translation/ExpressionCompiler.cs +++ b/src/PepperX.QueryForge.EFCore/Translation/ExpressionCompiler.cs @@ -34,6 +34,10 @@ public static class ExpressionCompiler private static readonly MethodInfo StringEndsWith = typeof(string).GetMethod(nameof(string.EndsWith), [typeof(string)])!; + private static readonly MethodInfo EnumerableContains = + typeof(Enumerable).GetMethods(BindingFlags.Public | BindingFlags.Static) + .First(m => m.Name == nameof(Enumerable.Contains) && m.GetParameters().Length == 2); + /// /// Builds the predicate for a , or when /// nothing usable remains after unknown columns and unfilled values are dropped. @@ -375,42 +379,33 @@ private static bool IsOrderable(Type type) var targetType = property.PropertyType; var raw = ConditionSemantics.Unwrap(condition.Value); - if (raw is not IEnumerable enumerable) + if (raw is not IEnumerable enumerable || raw is string) return negate - ? Expression.NotEqual(member, Expression.Constant(null, targetType)) - : Expression.Equal(member, Expression.Constant(null, targetType)); + ? (IsNullable(targetType) ? Expression.NotEqual(member, Expression.Constant(null, targetType)) : Expression.Constant(true)) + : Expression.Constant(false); + + var listType = typeof(List<>).MakeGenericType(targetType); + var list = (IList)Activator.CreateInstance(listType)!; - var elements = enumerable.Cast().Select(element => + foreach (var element in enumerable) { var unwrapped = ConditionSemantics.Unwrap(element); - if (!TryConvert(unwrapped, targetType, out var converted)) - return null; + if (unwrapped is null) + continue; - try + if (TryConvert(unwrapped, targetType, out var converted) && converted is not null) { - return Parameterize(converted, targetType); + list.Add(converted); } - catch (InvalidOperationException) - { - return null; - } - }).Where(e => e is not null).ToArray(); + } - if (elements.Length == 0) + if (list.Count == 0) return negate ? Expression.Constant(true) : Expression.Constant(false); - var containsMethod = typeof(Enumerable).GetMethod( - nameof(Enumerable.Contains), - BindingFlags.Public | BindingFlags.Static, - binder: null, - types: [typeof(IEnumerable), typeof(object)], - modifiers: null)!; + var closedContains = EnumerableContains.MakeGenericMethod(targetType); + var sourceExpr = Parameterize(list, listType); - // Build the closed Contains(IEnumerable, T) for the element type. - var closedContains = containsMethod.MakeGenericMethod(targetType); - var arrayConstant = Expression.Constant(elements); - - Expression? body = Expression.Call(closedContains, arrayConstant, member); + Expression body = Expression.Call(closedContains, sourceExpr, member); if (negate) body = Expression.Not(body); @@ -418,10 +413,10 @@ private static bool IsOrderable(Type type) // Null columns: for IN, no match (null not in any set); for NOT IN, null matches (null not in set). // SQL: NULL IN (1,2,3) → unknown → false; NULL NOT IN (1,2,3) → unknown → false. // But the EF Core provider's SQL-faithful stance is that a NULL column should not match either - // membership form — the row is excluded either way. Guard with IS NOT NULL. - return Expression.AndAlso( - Expression.NotEqual(member, Expression.Constant(null, targetType)), - body); + // membership form — the row is excluded either way. Guard with IS NOT NULL if nullable. + return IsNullable(targetType) + ? Expression.AndAlso(Expression.NotEqual(member, Expression.Constant(null, targetType)), body) + : body; } /// diff --git a/src/PepperX.QueryForge/Querying/ConditionSemantics.cs b/src/PepperX.QueryForge/Querying/ConditionSemantics.cs index c1e86c0..e4224c5 100644 --- a/src/PepperX.QueryForge/Querying/ConditionSemantics.cs +++ b/src/PepperX.QueryForge/Querying/ConditionSemantics.cs @@ -41,8 +41,8 @@ public static class ConditionSemantics /// null value is a deliberate IS NULL / IS NOT NULL test, and /// , which additionally requires /// . Membership operators ( and - /// ) require a non-null enumerable value with at least one - /// element — an empty set never matches. + /// ) require a non-null value — an empty set matches + /// nothing for In, and everything for NotIn. /// public static bool IsExecutable(Condition condition) { @@ -71,19 +71,6 @@ public static bool IsExecutable(Condition condition) return true; } - /// Whether is a non-empty enumerable. - private static bool IsNonEmptyEnumerable(object? value) - { - if (value is IEnumerable enumerable) - { - foreach (var _ in enumerable) - return true; - return false; - } - - return false; - } - /// Whether the operator performs a text match and therefore needs LIKE escaping. public static bool IsPatternOperator(ConditionOperator op) => op is ConditionOperator.Contains diff --git a/src/PepperX.QueryForge/Querying/InMemoryQueryEngine.cs b/src/PepperX.QueryForge/Querying/InMemoryQueryEngine.cs index e31d048..8e80fbc 100644 --- a/src/PepperX.QueryForge/Querying/InMemoryQueryEngine.cs +++ b/src/PepperX.QueryForge/Querying/InMemoryQueryEngine.cs @@ -265,7 +265,11 @@ public static bool Matches( bool foundIn = false; foreach (var candidate in enumerable) { - if (QueryValueComparer.Instance.AreEqual(actual, candidate)) + var unwrappedCandidate = ConditionSemantics.Unwrap(candidate); + if (unwrappedCandidate is null) + continue; + + if (QueryValueComparer.Instance.AreEqual(actual, unwrappedCandidate)) { foundIn = true; break; @@ -281,7 +285,11 @@ public static bool Matches( bool foundInNot = false; foreach (var candidate in notInEnumerable) { - if (QueryValueComparer.Instance.AreEqual(actual, candidate)) + var unwrappedCandidate = ConditionSemantics.Unwrap(candidate); + if (unwrappedCandidate is null) + continue; + + if (QueryValueComparer.Instance.AreEqual(actual, unwrappedCandidate)) { foundInNot = true; break; diff --git a/tests/PepperX.QueryForge.Conformance/QueryForgeConformanceTests.cs b/tests/PepperX.QueryForge.Conformance/QueryForgeConformanceTests.cs index 642ccb3..ef19a5d 100644 --- a/tests/PepperX.QueryForge.Conformance/QueryForgeConformanceTests.cs +++ b/tests/PepperX.QueryForge.Conformance/QueryForgeConformanceTests.cs @@ -674,10 +674,11 @@ public async Task Criteria_In_ShouldWorkWithMixedTypeValues() var ids = await IdsAsync(new Query { Criteria = Group(new Condition("Quantity", ConditionOperator.In, new object?[] { 5, "9", 12 })), + SortColumns = [new SortDescriptor("Id")], Paging = AllRows }); - ids.Should().Equal(10); + ids.Should().Equal(1, 9, 10); } #endregion