Skip to content
Draft
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
53 changes: 53 additions & 0 deletions docs/pages/guide/filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,56 @@ Registration Example:
GridifyGlobalConfiguration.CustomOperators.Register<RegexMatchOperator>();
GridifyGlobalConfiguration.CustomOperators.Register<InOperator>();
```

## Field-to-Field Comparison

Starting from version `v2.20.0`, Gridify supports comparing a field against another field (instead of a fixed value). To reference a field on the right-hand side, wrap it in parentheses: `(fieldName)`.

::: info
Field-to-field comparison is **opt-in** and disabled by default to avoid breaking changes.
You must enable it explicitly through configuration before using this syntax.
:::

### Enabling the Feature

```csharp
// Global configuration (applies to all mappers)
GridifyGlobalConfiguration.AllowFieldToFieldComparison = true;

// Or per-mapper configuration
var mapper = new GridifyMapper<MyEntity>(
new GridifyMapperConfiguration { AllowFieldToFieldComparison = true });
```

### Syntax

```
fieldName operator (otherFieldName)
```

The parentheses around the right-hand field name signal that it is a **field reference**, not a literal value.

### Examples

```csharp
var mapper = new GridifyMapper<Order>(
new GridifyMapperConfiguration { AllowFieldToFieldComparison = true })
.AddMap("price", o => o.Price)
.AddMap("discount", o => o.Discount);

// Find orders where price is greater than discount
orders.ApplyFiltering("price>(discount)", mapper);
// equivalent to: orders.Where(o => o.Price > o.Discount)

// Find orders where price equals discount
orders.ApplyFiltering("price=(discount)", mapper);
// equivalent to: orders.Where(o => o.Price == o.Discount)

// Can be combined with regular filters using logical operators
orders.ApplyFiltering("price>(discount),price>100", mapper);
// equivalent to: orders.Where(o => o.Price > o.Discount && o.Price > 100)

// Can be grouped with parentheses alongside regular conditions
orders.ApplyFiltering("(price=(discount)|price>200),discount>0", mapper);
// equivalent to: orders.Where(o => (o.Price == o.Discount || o.Price > 200) && o.Discount > 0)
```
10 changes: 10 additions & 0 deletions docs/pages/guide/gridifyGlobalConfiguration.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ If true, string comparison operations are case insensitive by default.
- type: `bool`
- default: `false`

### AllowFieldToFieldComparison
Comment thread
alirezanet marked this conversation as resolved.

Enables field-to-field comparison in filtering expressions. When enabled, the right-hand side of a filter condition can reference another mapped field using the `(fieldName)` syntax. For example: `age>(minAge)`.

This option is disabled by default to avoid any breaking changes for existing users. You must opt in explicitly.

- type: `bool`
- default: `false`
- related to: [Filtering - Field-to-Field Comparison](./filtering.md#field-to-field-comparison)

### DefaultDateTimeKind

By default, Gridify uses the `DateTimeKind.Unspecified` when parsing dates. You can change this behavior by setting this property to `DateTimeKind.Utc` or `DateTimeKind.Local`. This option is useful when you want to use Gridify with a database that requires a specific `DateTimeKind`, for example when using npgsql and postgresql.
Expand Down
18 changes: 18 additions & 0 deletions docs/pages/guide/gridifyMapper.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,24 @@ This setting is the same as [`EntityFrameworkCompatibilityLayer`](./extensions/e
var mapper = new GridifyMapper<Person>(q => q.EntityFrameworkCompatibilityLayer = true);
```

### AllowFieldToFieldComparison

This setting is the same as [`AllowFieldToFieldComparison`](./gridifyGlobalConfiguration.md#allowfieldtofieldcomparison) in the global configuration, but it allows you to enable this setting on a per-mapper basis. When set to true, filter expressions can reference another mapped field on the right-hand side using the `(fieldName)` syntax.

- **Type:** `bool`
- **Default:** `false`

```csharp
var mapper = new GridifyMapper<Order>(q => q.AllowFieldToFieldComparison = true)
.AddMap("price", o => o.Price)
.AddMap("discount", o => o.Discount);

// Find orders where price is greater than discount
orders.ApplyFiltering("price>(discount)", mapper);
```

For more information, see [Field-to-Field Comparison](./filtering.md#field-to-field-comparison).

## Filtering on Nested Collections

You can use LINQ `Select` and `SelectMany` methods to filter your data using its nested collections.
Expand Down
50 changes: 50 additions & 0 deletions src/Gridify/Builder/BaseQueryBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ public TQuery Build(ExpressionSyntax expression)
throw;
}

// Handle field-to-field comparison: field1 op (field2)
if (bExp.Left is FieldExpressionSyntax && bExp.Right is FieldReferenceExpressionSyntax)
try
{
return ConvertFieldToFieldQuery(bExp)
?? throw new GridifyFilteringException("Invalid expression");
}
catch (GridifyMapperException)
{
if (mapper.Configuration.IgnoreNotMappedFields)
return (BuildAlwaysTrueQuery(), false);

throw;
}

(TQuery query, bool isNested) leftQuery;
(TQuery query, bool isNested) rightQuery;

Expand Down Expand Up @@ -204,6 +219,41 @@ public TQuery Build(ExpressionSyntax expression)
return ((TQuery)query, false);
}

private (TQuery, bool)? ConvertFieldToFieldQuery(BinaryExpressionSyntax binarySyntax)
{
if (!mapper.Configuration.AllowFieldToFieldComparison)
throw new GridifyFilteringException(
"Field-to-field comparison is disabled. " +
"Enable it by setting AllowFieldToFieldComparison to true in GridifyMapperConfiguration or GridifyGlobalConfiguration.");

var leftFieldExpr = binarySyntax.Left as FieldExpressionSyntax;
var rightFieldRef = binarySyntax.Right as FieldReferenceExpressionSyntax;
var op = binarySyntax.OperatorToken;

var leftField = leftFieldExpr!.FieldToken.Text.Trim();
var rightField = rightFieldRef!.FieldExpression.FieldToken.Text.Trim();

var leftGMap = mapper.GetGMap(leftField);
if (leftGMap == null) throw new GridifyMapperException($"Mapping '{leftField}' not found");

var rightGMap = mapper.GetGMap(rightField);
if (rightGMap == null) throw new GridifyMapperException($"Mapping '{rightField}' not found");

var result = BuildFieldToFieldQuery(leftGMap.To, rightGMap.To, op);
if (result == null) return null;

return (result, false);
}

/// <summary>
/// Builds a query that compares two fields against each other.
/// Subclasses can override this to support field-to-field comparison.
/// </summary>
protected virtual TQuery? BuildFieldToFieldQuery(LambdaExpression leftMap, LambdaExpression rightMap, ISyntaxNode op)
{
throw new GridifyFilteringException("Field-to-field comparison is not supported by the current query builder.");
}


private object AddNullPropagator(LambdaExpression mapTarget, object query)
{
Expand Down
86 changes: 86 additions & 0 deletions src/Gridify/Builder/LinqQueryBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,92 @@ protected override Expression<Func<T, bool>> CombineWithOrOperator(Expression<Fu
return left.Or(right);
}

protected override Expression<Func<T, bool>>? BuildFieldToFieldQuery(LambdaExpression leftMap, LambdaExpression rightMap, ISyntaxNode op)
{
var leftBody = leftMap.Body;
var rightBody = rightMap.Body;

// Remove boxing (Convert) wrappers produced by Expression<Func<T, object?>>
if (leftBody.NodeType == ExpressionType.Convert) leftBody = ((UnaryExpression)leftBody).Operand;
if (rightBody.NodeType == ExpressionType.Convert) rightBody = ((UnaryExpression)rightBody).Operand;

// Nested collection-to-collection comparison (e.g. end < (start) where both map to a Select)
if (leftBody is MethodCallExpression { Method.Name: "Select" } leftSelect &&
rightBody is MethodCallExpression { Method.Name: "Select" } rightSelect)
{
// Unify the outer parameter so both Select calls reference the same root object
var rightSelectNormalized =
new ReplaceExpressionVisitor(rightMap.Parameters[0], leftMap.Parameters[0]).Visit(rightSelect)
as MethodCallExpression ?? rightSelect;

return BuildNestedFieldToFieldQuery(leftSelect, rightSelectNormalized, op);
}

// Reject mixing nested and non-nested fields
if (leftBody is MethodCallExpression { Method.Name: "Select" } ||
rightBody is MethodCallExpression { Method.Name: "Select" })
throw new GridifyFilteringException(
"Field-to-field comparison between a nested collection field and a non-nested field is not supported.");

// Simple property-to-property comparison
var rightBodyNormalized =
new ReplaceExpressionVisitor(rightMap.Parameters[0], leftMap.Parameters[0]).Visit(rightBody);

var comparison = BuildFieldComparison(leftBody, rightBodyNormalized, op);
return Expression.Lambda<Func<T, bool>>(comparison, leftMap.Parameters[0]);
}

private Expression<Func<T, bool>>? BuildNestedFieldToFieldQuery(
MethodCallExpression leftSelect, MethodCallExpression rightSelect, ISyntaxNode op)
{
// Extract the inner lambda (e.g. x => x.End) from each Select call
var leftLambda = leftSelect.Arguments.Single(a => a.NodeType == ExpressionType.Lambda) as LambdaExpression;
var rightLambda = rightSelect.Arguments.Single(a => a.NodeType == ExpressionType.Lambda) as LambdaExpression;

if (leftLambda == null || rightLambda == null) return null;

// Get inner bodies, stripping any boxing Convert
var leftInnerBody = leftLambda.Body.NodeType == ExpressionType.Convert
? ((UnaryExpression)leftLambda.Body).Operand
: leftLambda.Body;

// Unify inner parameter: replace right's inner param with left's inner param
var rightInnerBodyRaw = rightLambda.Body.NodeType == ExpressionType.Convert
? ((UnaryExpression)rightLambda.Body).Operand
: rightLambda.Body;
var rightInnerBodyNormalized =
new ReplaceExpressionVisitor(rightLambda.Parameters[0], leftLambda.Parameters[0]).Visit(rightInnerBodyRaw);

var comparison = BuildFieldComparison(leftInnerBody, rightInnerBodyNormalized, op);
var predicate = Expression.Lambda(comparison, leftLambda.Parameters[0]);

// The collection source is the first argument of the left Select call
if (leftSelect.Arguments.First() is MemberExpression collectionMember)
return GetAnyExpression(collectionMember, predicate) as Expression<Func<T, bool>>;

return null;
}

private static Expression BuildFieldComparison(Expression left, Expression right, ISyntaxNode op)
{
return op.Kind switch
Comment thread
alirezanet marked this conversation as resolved.
{
SyntaxKind.Equal => Expression.Equal(left, right),
SyntaxKind.NotEqual => Expression.NotEqual(left, right),
SyntaxKind.GreaterThan => Expression.GreaterThan(left, right),
SyntaxKind.LessThan => Expression.LessThan(left, right),
SyntaxKind.GreaterOrEqualThan => Expression.GreaterThanOrEqual(left, right),
SyntaxKind.LessOrEqualThan => Expression.LessThanOrEqual(left, right),
SyntaxKind.Like => Expression.Call(left, MethodInfoHelper.GetStringContainsMethod(), right),
SyntaxKind.NotLike => Expression.Not(Expression.Call(left, MethodInfoHelper.GetStringContainsMethod(), right)),
SyntaxKind.StartsWith => Expression.Call(left, MethodInfoHelper.GetStartWithMethod(), right),
SyntaxKind.NotStartsWith => Expression.Not(Expression.Call(left, MethodInfoHelper.GetStartWithMethod(), right)),
SyntaxKind.EndsWith => Expression.Call(left, MethodInfoHelper.GetEndsWithMethod(), right),
SyntaxKind.NotEndsWith => Expression.Not(Expression.Call(left, MethodInfoHelper.GetEndsWithMethod(), right)),
_ => throw new GridifyFilteringException($"Operator '{op.Kind}' is not supported for field-to-field comparison.")
};
}

private Expression<Func<T, bool>>? GenerateNestedExpression(Expression body, IGMap<T> gMap, ValueExpressionSyntax value, ISyntaxNode op)
{
while (true)
Expand Down
8 changes: 8 additions & 0 deletions src/Gridify/GridifyGlobalConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ public static class GridifyGlobalConfiguration
/// </example>
public static Func<string, string>? CustomElasticsearchNamingAction { get; set; }

/// <summary>
/// Enables field-to-field comparison in filtering expressions.
/// When enabled, the right-hand side of a filter condition can reference another field
/// using the <c>(fieldName)</c> syntax. For example: <c>age &gt; (minAge)</c>.
/// Default is false to avoid breaking changes.
/// </summary>
public static bool AllowFieldToFieldComparison { get; set; } = false;

/// <summary>
/// You can extend the gridify supported operators by adding
/// your own operators to OperatorManager.
Expand Down
8 changes: 8 additions & 0 deletions src/Gridify/GridifyMapperConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ public record GridifyMapperConfiguration
/// </summary>
public bool DisableCollectionNullChecks { get; set; } = GridifyGlobalConfiguration.DisableNullChecks;

/// <summary>
/// Enables field-to-field comparison in filtering expressions.
/// When enabled, the right-hand side of a filter condition can reference another field
/// using the <c>(fieldName)</c> syntax. For example: <c>age &gt; (minAge)</c>.
/// Default is false to avoid breaking changes.
/// </summary>
public bool AllowFieldToFieldComparison { get; set; } = GridifyGlobalConfiguration.AllowFieldToFieldComparison;

/// <summary>
/// It makes the generated expressions compatible
/// with the entity framework.
Expand Down
19 changes: 19 additions & 0 deletions src/Gridify/Syntax/FieldReferenceExpressionSyntax.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Collections.Generic;

namespace Gridify.Syntax;

/// <summary>
/// Represents a field reference on the right-hand side of a comparison operator.
/// Syntax: <c>(fieldName)</c> — the parentheses differentiate it from a literal value.
/// </summary>
public sealed class FieldReferenceExpressionSyntax(FieldExpressionSyntax fieldExpression) : ExpressionSyntax
Comment thread
alirezanet marked this conversation as resolved.
{
public override SyntaxKind Kind => SyntaxKind.FieldReferenceExpression;

public FieldExpressionSyntax FieldExpression { get; } = fieldExpression;

public override IEnumerable<ISyntaxNode> GetChildren()
{
yield return FieldExpression;
}
}
15 changes: 13 additions & 2 deletions src/Gridify/Syntax/Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,26 @@ private ExpressionSyntax ParseFactor()
while (IsOperator(Current.Kind))
{
var operatorToken = NextToken();
var right = ParseValueExpression();
var right = ParseRightHandExpression();
left = new BinaryExpressionSyntax(left, operatorToken, right);
}

return left;
}

private ValueExpressionSyntax ParseValueExpression()
private ExpressionSyntax ParseRightHandExpression()
{
// Detect field reference syntax: op (fieldName) or op (fieldName[idx])
// The lexer directly emits OpenParenthesisToken after an operator when the value starts with '('.
if (Current.Kind == SyntaxKind.OpenParenthesisToken &&
Peek(1).Kind == SyntaxKind.FieldToken)
{
NextToken(); // consume '('
var fieldExpr = ParseFieldExpression();
Match(SyntaxKind.CloseParenthesis); // consume ')'
return new FieldReferenceExpressionSyntax(fieldExpr);
}

// field=
if (Current.Kind != SyntaxKind.ValueToken)
{
Expand Down
3 changes: 2 additions & 1 deletion src/Gridify/Syntax/SyntaxKind.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,6 @@ public enum SyntaxKind
NotStartsWith,
NotEndsWith,
CaseInsensitive,
FieldIndexer
FieldIndexer,
FieldReferenceExpression
}
Loading
Loading