Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion samples/PepperX.QueryForge.Sample.WebApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Query>("application/json").Produces<QueryResult<TestUser>>();

filterApi.MapPost("/range", async (Query q, IDapperQueryService svc) =>
Expand Down
87 changes: 86 additions & 1 deletion samples/PepperX.QueryForge.Sample.WebApi/Requests.http
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -191,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
Expand Down Expand Up @@ -703,6 +746,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
Expand Down
6 changes: 6 additions & 0 deletions src/PepperX.QueryForge.Dapper/Compiler/SqlQueryCompiler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,12 @@ private string BuildKeyPredicate(string keyColumn, IReadOnlyList<object?> 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)}";

Expand Down
20 changes: 17 additions & 3 deletions src/PepperX.QueryForge.EFCore/Translation/ExpressionCompiler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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));
}
Expand Down
5 changes: 3 additions & 2 deletions src/PepperX.QueryForge/Models/Enums.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace PepperX.QueryForge;
namespace PepperX.QueryForge;

/// <summary>Defines the logical operators used to combine conditions or groups.</summary>
public enum Logic { And, Or, AndNot, OrNot }
Expand All @@ -11,7 +11,8 @@ public enum ConditionOperator
{
Equals, NotEquals, Contains, NotContains,
StartsWith, EndsWith, LessThan, GreaterThan,
LessThanOrEqualTo, GreaterThanOrEqualTo, Between
LessThanOrEqualTo, GreaterThanOrEqualTo, Between,
IsNull, IsNotNull
}

/// <summary>Defines the structural shape of the query result returned by the execution provider.</summary>
Expand Down
8 changes: 8 additions & 0 deletions src/PepperX.QueryForge/Querying/ConditionSemantics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -64,6 +67,11 @@ public static bool IsExecutable(Condition condition)
return true;
}

/// <summary>Whether the operator is unary (requires no comparison value, e.g. IS NULL, IS NOT NULL).</summary>
public static bool IsUnaryOperator(ConditionOperator op) => op
is ConditionOperator.IsNull
or ConditionOperator.IsNotNull;

/// <summary>Whether the operator performs a text match and therefore needs LIKE escaping.</summary>
public static bool IsPatternOperator(ConditionOperator op) => op
is ConditionOperator.Contains
Expand Down
6 changes: 6 additions & 0 deletions src/PepperX.QueryForge/Querying/InMemoryQueryEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,12 @@ public static bool Matches<TModel>(

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;

Expand Down
98 changes: 98 additions & 0 deletions tests/PepperX.QueryForge.Conformance/QueryForgeConformanceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
Loading