From b0cc92e32da17dfb8fcad1f80f5221c976b822b6 Mon Sep 17 00:00:00 2001 From: amirtahan Date: Thu, 1 Oct 2026 19:27:58 +0330 Subject: [PATCH 1/2] feat: add IsNull and IsNotNull unary condition operators - Add ConditionOperator.IsNull (11) and ConditionOperator.IsNotNull (12) - Update ConditionSemantics to treat IsNull and IsNotNull as executable unary operators without requiring a value parameter - Implement null-safe evaluation in InMemoryQueryEngine - Add Dapper SqlQueryCompiler support generating IS NULL and IS NOT NULL predicates - Add EF Core ExpressionCompiler translation using direct null comparisons and handling negations - Add comprehensive conformance tests verifying IS NULL / IS NOT NULL behavior across providers - Add sample requests in Requests.http for In-Memory provider --- .../Requests.http | 45 ++++++++- .../Compiler/SqlQueryCompiler.cs | 6 ++ .../Translation/ExpressionCompiler.cs | 20 +++- src/PepperX.QueryForge/Models/Enums.cs | 5 +- .../Querying/ConditionSemantics.cs | 8 ++ .../Querying/InMemoryQueryEngine.cs | 6 ++ .../QueryForgeConformanceTests.cs | 98 +++++++++++++++++++ 7 files changed, 182 insertions(+), 6 deletions(-) diff --git a/samples/PepperX.QueryForge.Sample.WebApi/Requests.http b/samples/PepperX.QueryForge.Sample.WebApi/Requests.http index 5e93456..ab2c1e9 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=IsNull +### 12=IsNotNull ### ### -------------------------------------------------------------------------- ### The five things a Query can carry @@ -703,6 +704,48 @@ Content-Type: application/json ### +# 35a. Active users using IsNull (DeletedAt IS NULL) — operator 11, expects 37 users. +POST {{baseUrl}}/api/inmemory/users/query +Content-Type: application/json + +{ + "criteria": { + "logic": 0, + "groups": [ + { + "logic": 0, + "conditions": [ + { "columnName": "DeletedAt", "operator": 11 } + ] + } + ] + }, + "paging": { "size": 50, "number": 1 } +} + +### + +# 35b. Soft-deleted users using IsNotNull (DeletedAt IS NOT NULL) — operator 12, expects UserId 11, 22, 33. +POST {{baseUrl}}/api/inmemory/users/query +Content-Type: application/json + +{ + "criteria": { + "logic": 0, + "groups": [ + { + "logic": 0, + "conditions": [ + { "columnName": "DeletedAt", "operator": 12 } + ] + } + ] + }, + "paging": { "size": 20, "number": 1 } +} + +### + # 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..f8c7bf6 100644 --- a/src/PepperX.QueryForge.Dapper/Compiler/SqlQueryCompiler.cs +++ b/src/PepperX.QueryForge.Dapper/Compiler/SqlQueryCompiler.cs @@ -331,6 +331,12 @@ private string BuildKeyPredicate(string keyColumn, IReadOnlyList groupK switch (condition.Operator) { + case ConditionOperator.IsNull: + return $"{column} IS NULL"; + + case ConditionOperator.IsNotNull: + return $"{column} IS NOT NULL"; + case ConditionOperator.Equals: return value is null ? $"{column} IS NULL" : $"{column} = {context.AddValue(value)}"; diff --git a/src/PepperX.QueryForge.EFCore/Translation/ExpressionCompiler.cs b/src/PepperX.QueryForge.EFCore/Translation/ExpressionCompiler.cs index 0b3d3c4..438d882 100644 --- a/src/PepperX.QueryForge.EFCore/Translation/ExpressionCompiler.cs +++ b/src/PepperX.QueryForge.EFCore/Translation/ExpressionCompiler.cs @@ -303,6 +303,16 @@ private static bool IsOrderable(Type type) switch (condition.Operator) { + case ConditionOperator.IsNull: + return IsNullable(targetType) + ? Expression.Equal(member, Expression.Constant(null, targetType)) + : Expression.Constant(false); + + case ConditionOperator.IsNotNull: + return IsNullable(targetType) + ? Expression.NotEqual(member, Expression.Constant(null, targetType)) + : Expression.Constant(true); + case ConditionOperator.Equals when raw is null: return IsNullable(targetType) ? Expression.Equal(member, Expression.Constant(null, targetType)) @@ -375,12 +385,16 @@ private static bool IsOrderable(Type type) var raw = ConditionSemantics.Unwrap(condition.Value); // IS NULL and IS NOT NULL are definite even when the value is null, so they simply invert. - if (raw is null && condition.Operator is ConditionOperator.Equals or ConditionOperator.NotEquals) + if (condition.Operator is ConditionOperator.IsNull or ConditionOperator.IsNotNull + || (raw is null && condition.Operator is ConditionOperator.Equals or ConditionOperator.NotEquals)) { + var isNullTest = condition.Operator is ConditionOperator.IsNull + || (condition.Operator is ConditionOperator.Equals && raw is null); + if (!IsNullable(targetType)) - return Expression.Constant(condition.Operator is ConditionOperator.Equals); + return Expression.Constant(isNullTest); - return condition.Operator is ConditionOperator.Equals + return isNullTest ? Expression.NotEqual(member, Expression.Constant(null, targetType)) : Expression.Equal(member, Expression.Constant(null, targetType)); } diff --git a/src/PepperX.QueryForge/Models/Enums.cs b/src/PepperX.QueryForge/Models/Enums.cs index 00a41d4..942324b 100644 --- a/src/PepperX.QueryForge/Models/Enums.cs +++ b/src/PepperX.QueryForge/Models/Enums.cs @@ -1,4 +1,4 @@ -namespace PepperX.QueryForge; +namespace PepperX.QueryForge; /// Defines the logical operators used to combine conditions or groups. public enum Logic { And, Or, AndNot, OrNot } @@ -11,7 +11,8 @@ public enum ConditionOperator { Equals, NotEquals, Contains, NotContains, StartsWith, EndsWith, LessThan, GreaterThan, - LessThanOrEqualTo, GreaterThanOrEqualTo, Between + LessThanOrEqualTo, GreaterThanOrEqualTo, Between, + IsNull, IsNotNull } /// 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..aabb188 100644 --- a/src/PepperX.QueryForge/Querying/ConditionSemantics.cs +++ b/src/PepperX.QueryForge/Querying/ConditionSemantics.cs @@ -50,6 +50,9 @@ public static bool IsExecutable(Condition condition) if (!Enum.IsDefined(condition.Operator)) return false; + if (condition.Operator is ConditionOperator.IsNull or ConditionOperator.IsNotNull) + return true; + var value = Unwrap(condition.Value); if (condition.Operator is ConditionOperator.Equals or ConditionOperator.NotEquals) @@ -64,6 +67,11 @@ public static bool IsExecutable(Condition condition) return true; } + /// Whether the operator is unary (requires no comparison value, e.g. IS NULL, IS NOT NULL). + public static bool IsUnaryOperator(ConditionOperator op) => op + is ConditionOperator.IsNull + or ConditionOperator.IsNotNull; + /// 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 0a3a1b1..5d49a3e 100644 --- a/src/PepperX.QueryForge/Querying/InMemoryQueryEngine.cs +++ b/src/PepperX.QueryForge/Querying/InMemoryQueryEngine.cs @@ -206,6 +206,12 @@ public static bool Matches( switch (condition.Operator) { + case ConditionOperator.IsNull: + return actual is null; + + case ConditionOperator.IsNotNull: + return actual is not null; + case ConditionOperator.Equals when expected is null: return actual is null; diff --git a/tests/PepperX.QueryForge.Conformance/QueryForgeConformanceTests.cs b/tests/PepperX.QueryForge.Conformance/QueryForgeConformanceTests.cs index 958ac7e..ef21a2a 100644 --- a/tests/PepperX.QueryForge.Conformance/QueryForgeConformanceTests.cs +++ b/tests/PepperX.QueryForge.Conformance/QueryForgeConformanceTests.cs @@ -589,6 +589,104 @@ public async Task Criteria_NotEqualsNull_ShouldMatchOnlyNonNullValues() ids.Should().Equal(1, 2, 3, 4, 5, 8, 9, 10, 11, 12); } + [SkippableFact] + public async Task Criteria_IsNull_ShouldMatchOnlyNullValues() + { + var ids = await IdsAsync(new Query + { + Criteria = Group(new Condition("Category", ConditionOperator.IsNull)), + SortColumns = [new SortDescriptor("Id")], + Paging = AllRows + }); + + ids.Should().Equal(6, 7); + } + + [SkippableFact] + public async Task Criteria_IsNotNull_ShouldMatchOnlyNonNullValues() + { + var ids = await IdsAsync(new Query + { + Criteria = Group(new Condition("Category", ConditionOperator.IsNotNull)), + SortColumns = [new SortDescriptor("Id")], + Paging = AllRows + }); + + ids.Should().Equal(1, 2, 3, 4, 5, 8, 9, 10, 11, 12); + } + + [SkippableFact] + public async Task Criteria_IsNull_InNegatedGroup_ShouldInvertToIsNotNull() + { + var ids = await IdsAsync(new Query + { + Criteria = new QueryCriteria + { + Groups = + [ + new ConditionGroup + { + Logic = Logic.AndNot, + Conditions = [new Condition("Category", ConditionOperator.IsNull)] + } + ] + }, + SortColumns = [new SortDescriptor("Id")], + Paging = AllRows + }); + + ids.Should().Equal(1, 2, 3, 4, 5, 8, 9, 10, 11, 12); + } + + [SkippableFact] + public async Task Criteria_IsNotNull_InNegatedGroup_ShouldInvertToIsNull() + { + var ids = await IdsAsync(new Query + { + Criteria = new QueryCriteria + { + Groups = + [ + new ConditionGroup + { + Logic = Logic.AndNot, + Conditions = [new Condition("Category", ConditionOperator.IsNotNull)] + } + ] + }, + SortColumns = [new SortDescriptor("Id")], + Paging = AllRows + }); + + ids.Should().Equal(6, 7); + } + + [SkippableFact] + public async Task Criteria_IsNull_OnNonNullableColumn_ShouldMatchNothing() + { + var ids = await IdsAsync(new Query + { + Criteria = Group(new Condition("Id", ConditionOperator.IsNull)), + SortColumns = [new SortDescriptor("Id")], + Paging = AllRows + }); + + ids.Should().BeEmpty(); + } + + [SkippableFact] + public async Task Criteria_IsNotNull_OnNonNullableColumn_ShouldMatchAllRows() + { + var ids = await IdsAsync(new Query + { + Criteria = Group(new Condition("Id", ConditionOperator.IsNotNull)), + SortColumns = [new SortDescriptor("Id")], + Paging = AllRows + }); + + ids.Should().Equal(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12); + } + [SkippableTheory] [InlineData(ConditionOperator.GreaterThan)] [InlineData(ConditionOperator.LessThan)] From 3353516b27f9685ab9f5f5dbf5fc0fe27a633a37 Mon Sep 17 00:00:00 2001 From: amirtahan Date: Thu, 1 Oct 2026 19:33:29 +0330 Subject: [PATCH 2/2] docs(sample): document IsNull and IsNotNull in NullChecks endpoint and Requests.http --- .../Program.cs | 2 +- .../Requests.http | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/samples/PepperX.QueryForge.Sample.WebApi/Program.cs b/samples/PepperX.QueryForge.Sample.WebApi/Program.cs index c31f00d..5148b31 100644 --- a/samples/PepperX.QueryForge.Sample.WebApi/Program.cs +++ b/samples/PepperX.QueryForge.Sample.WebApi/Program.cs @@ -146,7 +146,7 @@ 4. The `usp_GetUserReport` Stored Procedure. }) .WithName("NullChecks") .WithSummary("IS NULL and IS NOT NULL checks.") - .WithDescription("Pass `value: null` with Operator 0 (Equals) to generate `IS NULL`. Use Operator 1 (NotEquals) for `IS NOT NULL`.") + .WithDescription("Use Operator 11 (IsNull) for `IS NULL`, or Operator 12 (IsNotNull) for `IS NOT NULL`. Alternatively, pass `value: null` with Operator 0 (Equals) or 1 (NotEquals).") .Accepts("application/json").Produces>(); filterApi.MapPost("/range", async (Query q, IDapperQueryService svc) => diff --git a/samples/PepperX.QueryForge.Sample.WebApi/Requests.http b/samples/PepperX.QueryForge.Sample.WebApi/Requests.http index ab2c1e9..a44e2db 100644 --- a/samples/PepperX.QueryForge.Sample.WebApi/Requests.http +++ b/samples/PepperX.QueryForge.Sample.WebApi/Requests.http @@ -192,6 +192,48 @@ Content-Type: application/json ### +# 8a. IS NULL using ConditionOperator.IsNull (11) — explicit unary null check. +POST {{baseUrl}}/api/users/filters/null-checks +Content-Type: application/json + +{ + "criteria": { + "logic": 0, + "groups": [ + { + "logic": 0, + "conditions": [ + { "columnName": "DeletedAt", "operator": 11 } + ] + } + ] + }, + "paging": { "size": 20, "number": 1 } +} + +### + +# 9a. IS NOT NULL using ConditionOperator.IsNotNull (12) — explicit unary non-null check. +POST {{baseUrl}}/api/users/filters/null-checks +Content-Type: application/json + +{ + "criteria": { + "logic": 0, + "groups": [ + { + "logic": 0, + "conditions": [ + { "columnName": "DeletedAt", "operator": 12 } + ] + } + ] + }, + "paging": { "size": 20, "number": 1 } +} + +### + # 10. Between — inclusive at both ends. Needs both value and valueTo. POST {{baseUrl}}/api/users/filters/range Content-Type: application/json