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 bc05982..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) @@ -405,6 +412,33 @@ 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, Type? columnType, CompilationContext context, bool negate) + { + 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); + if (unwrapped is null) + continue; + + var coerced = Coerce(unwrapped, columnType); + references.Add(context.AddValue(coerced)); + } + + 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..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. @@ -340,6 +344,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 +367,58 @@ 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 || raw is string) + return negate + ? (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)!; + + foreach (var element in enumerable) + { + var unwrapped = ConditionSemantics.Unwrap(element); + if (unwrapped is null) + continue; + + if (TryConvert(unwrapped, targetType, out var converted) && converted is not null) + { + list.Add(converted); + } + } + + if (list.Count == 0) + return negate ? Expression.Constant(true) : Expression.Constant(false); + + var closedContains = EnumerableContains.MakeGenericMethod(targetType); + var sourceExpr = Parameterize(list, listType); + + Expression body = Expression.Call(closedContains, sourceExpr, 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 if nullable. + return IsNullable(targetType) + ? Expression.AndAlso(Expression.NotEqual(member, Expression.Constant(null, targetType)), body) + : body; + } + /// /// Builds the SQL-faithful negation of a condition. /// @@ -395,6 +457,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/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. diff --git a/src/PepperX.QueryForge/Querying/ConditionSemantics.cs b/src/PepperX.QueryForge/Querying/ConditionSemantics.cs index 9e2472d..e4224c5 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 value — an empty set matches + /// nothing for In, and everything for NotIn. /// 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; @@ -71,6 +78,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 +102,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() }; } diff --git a/src/PepperX.QueryForge/Querying/InMemoryQueryEngine.cs b/src/PepperX.QueryForge/Querying/InMemoryQueryEngine.cs index 0a3a1b1..8e80fbc 100644 --- a/src/PepperX.QueryForge/Querying/InMemoryQueryEngine.cs +++ b/src/PepperX.QueryForge/Querying/InMemoryQueryEngine.cs @@ -257,6 +257,46 @@ 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) + { + var unwrappedCandidate = ConditionSemantics.Unwrap(candidate); + if (unwrappedCandidate is null) + continue; + + if (QueryValueComparer.Instance.AreEqual(actual, unwrappedCandidate)) + { + 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) + { + var unwrappedCandidate = ConditionSemantics.Unwrap(candidate); + if (unwrappedCandidate is null) + continue; + + if (QueryValueComparer.Instance.AreEqual(actual, unwrappedCandidate)) + { + foundInNot = true; + break; + } + } + return !foundInNot; + default: return null; } diff --git a/tests/PepperX.QueryForge.Conformance/QueryForgeConformanceTests.cs b/tests/PepperX.QueryForge.Conformance/QueryForgeConformanceTests.cs index 958ac7e..ef19a5d 100644 --- a/tests/PepperX.QueryForge.Conformance/QueryForgeConformanceTests.cs +++ b/tests/PepperX.QueryForge.Conformance/QueryForgeConformanceTests.cs @@ -561,6 +561,128 @@ 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 })), + SortColumns = [new SortDescriptor("Id")], + Paging = AllRows + }); + + ids.Should().Equal(1, 9, 10); + } + + #endregion + #region Criteria — nulls and unusable conditions [SkippableFact]