Skip to content
Merged
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
10 changes: 10 additions & 0 deletions samples/PepperX.QueryForge.Sample.WebApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,16 @@ 4. The `usp_GetUserReport` Stored Procedure.
.WithDescription("Logic Enums: 0=And, 1=Or, 2=AndNot, 3=OrNot.")
.Accepts<Query>("application/json").Produces<QueryResult<TestUser>>();

filterApi.MapPost("/membership", async (Query q, IDapperQueryService svc) =>
{
var dq = DapperQueryBuilder.FromBase(q).ForObject("TestUsers", "dbo", DapperObjectType.Table).Build();
return await svc.QueryAsync<TestUser>(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<Query>("application/json").Produces<QueryResult<TestUser>>();

// ==========================================
// GROUP 3: SECURITY & VALIDATION
// ==========================================
Expand Down
95 changes: 94 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=In
### 12=NotIn
###
### --------------------------------------------------------------------------
### The five things a Query can carry
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions src/PepperX.QueryForge.Dapper/Compiler/SqlQueryCompiler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,13 @@ private string BuildKeyPredicate(string keyColumn, IReadOnlyList<object?> 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)
Expand Down Expand Up @@ -405,6 +412,33 @@ private string Like(
return $"{column} {op} {reference}{_dialect.LikeEscapeClause}";
}

/// <summary>
/// Builds a membership predicate — IN or NOT IN — against a set of candidate values.
/// </summary>
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<string>();
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

/// <summary>Accumulates parameters while a single statement is being built.</summary>
Expand Down
64 changes: 64 additions & 0 deletions src/PepperX.QueryForge.EFCore/Translation/ExpressionCompiler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>
/// Builds the predicate for a <see cref="QueryCriteria"/>, or <see langword="null"/> when
/// nothing usable remains after unknown columns and unfilled values are dropped.
Expand Down Expand Up @@ -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);

Expand All @@ -357,6 +367,58 @@ private static bool IsOrderable(Type type)
}
}

/// <summary>
/// Builds a membership predicate — IN or NOT IN — against a set of candidate values.
/// </summary>
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;
}

/// <summary>
/// Builds the SQL-faithful negation of a condition.
/// </summary>
Expand Down Expand Up @@ -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
};

Expand Down
3 changes: 2 additions & 1 deletion src/PepperX.QueryForge/Models/Enums.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public enum ConditionOperator
{
Equals, NotEquals, Contains, NotContains,
StartsWith, EndsWith, LessThan, GreaterThan,
LessThanOrEqualTo, GreaterThanOrEqualTo, Between
LessThanOrEqualTo, GreaterThanOrEqualTo, Between,
In, NotIn
}

/// <summary>Defines the structural shape of the query result returned by the execution provider.</summary>
Expand Down
15 changes: 14 additions & 1 deletion src/PepperX.QueryForge/Querying/ConditionSemantics.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Collections;
using System.Linq;
using System.Text.Json;

namespace PepperX.QueryForge.Querying;
Expand Down Expand Up @@ -38,7 +40,9 @@ public static class ConditionSemantics
/// <see cref="ConditionOperator.Equals"/> and <see cref="ConditionOperator.NotEquals"/>, where a
/// null value is a deliberate IS NULL / IS NOT NULL test, and
/// <see cref="ConditionOperator.Between"/>, which additionally requires
/// <see cref="Condition.ValueTo"/>.
/// <see cref="Condition.ValueTo"/>. Membership operators (<see cref="ConditionOperator.In"/> and
/// <see cref="ConditionOperator.NotIn"/>) require a non-null value — an empty set matches
/// nothing for In, and everything for NotIn.
/// </remarks>
public static bool IsExecutable(Condition condition)
{
Expand All @@ -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;

Expand All @@ -71,6 +78,9 @@ or ConditionOperator.NotContains
or ConditionOperator.StartsWith
or ConditionOperator.EndsWith;

/// <summary>Whether the operator matches a value against a set of candidates.</summary>
public static bool IsMembershipOperator(ConditionOperator op) => op is ConditionOperator.In or ConditionOperator.NotIn;

/// <summary>
/// Converts a value that arrived as JSON into a plain CLR value.
/// </summary>
Expand All @@ -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<Dictionary<string, object?>>(),
_ => element.GetRawText()
};
}
Expand Down
40 changes: 40 additions & 0 deletions src/PepperX.QueryForge/Querying/InMemoryQueryEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,46 @@ public static bool Matches<TModel>(
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;
}
Expand Down
Loading
Loading