diff --git a/docs/pages/guide/filtering.md b/docs/pages/guide/filtering.md index 3068fe12..960e2dca 100644 --- a/docs/pages/guide/filtering.md +++ b/docs/pages/guide/filtering.md @@ -173,3 +173,56 @@ Registration Example: GridifyGlobalConfiguration.CustomOperators.Register(); GridifyGlobalConfiguration.CustomOperators.Register(); ``` + +## 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( + 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( + 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) +``` diff --git a/docs/pages/guide/gridifyGlobalConfiguration.md b/docs/pages/guide/gridifyGlobalConfiguration.md index 9994c12b..9e988fd9 100644 --- a/docs/pages/guide/gridifyGlobalConfiguration.md +++ b/docs/pages/guide/gridifyGlobalConfiguration.md @@ -63,6 +63,16 @@ If true, string comparison operations are case insensitive by default. - type: `bool` - default: `false` +### AllowFieldToFieldComparison + +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. diff --git a/docs/pages/guide/gridifyMapper.md b/docs/pages/guide/gridifyMapper.md index c08a3226..75299ca5 100644 --- a/docs/pages/guide/gridifyMapper.md +++ b/docs/pages/guide/gridifyMapper.md @@ -401,6 +401,24 @@ This setting is the same as [`EntityFrameworkCompatibilityLayer`](./extensions/e var mapper = new GridifyMapper(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(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. diff --git a/src/Gridify/Builder/BaseQueryBuilder.cs b/src/Gridify/Builder/BaseQueryBuilder.cs index 973fb8c8..f4d77c9d 100644 --- a/src/Gridify/Builder/BaseQueryBuilder.cs +++ b/src/Gridify/Builder/BaseQueryBuilder.cs @@ -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; @@ -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); + } + + /// + /// Builds a query that compares two fields against each other. + /// Subclasses can override this to support field-to-field comparison. + /// + 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) { diff --git a/src/Gridify/Builder/LinqQueryBuilder.cs b/src/Gridify/Builder/LinqQueryBuilder.cs index 16116720..0031333e 100644 --- a/src/Gridify/Builder/LinqQueryBuilder.cs +++ b/src/Gridify/Builder/LinqQueryBuilder.cs @@ -264,6 +264,92 @@ protected override Expression> CombineWithOrOperator(Expression>? BuildFieldToFieldQuery(LambdaExpression leftMap, LambdaExpression rightMap, ISyntaxNode op) + { + var leftBody = leftMap.Body; + var rightBody = rightMap.Body; + + // Remove boxing (Convert) wrappers produced by Expression> + 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>(comparison, leftMap.Parameters[0]); + } + + private Expression>? 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>; + + return null; + } + + private static Expression BuildFieldComparison(Expression left, Expression right, ISyntaxNode op) + { + return op.Kind switch + { + 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>? GenerateNestedExpression(Expression body, IGMap gMap, ValueExpressionSyntax value, ISyntaxNode op) { while (true) diff --git a/src/Gridify/GridifyGlobalConfiguration.cs b/src/Gridify/GridifyGlobalConfiguration.cs index cc2a285d..0e43d975 100644 --- a/src/Gridify/GridifyGlobalConfiguration.cs +++ b/src/Gridify/GridifyGlobalConfiguration.cs @@ -77,6 +77,14 @@ public static class GridifyGlobalConfiguration /// public static Func? CustomElasticsearchNamingAction { get; set; } + /// + /// Enables field-to-field comparison in filtering expressions. + /// When enabled, the right-hand side of a filter condition can reference another field + /// using the (fieldName) syntax. For example: age > (minAge). + /// Default is false to avoid breaking changes. + /// + public static bool AllowFieldToFieldComparison { get; set; } = false; + /// /// You can extend the gridify supported operators by adding /// your own operators to OperatorManager. diff --git a/src/Gridify/GridifyMapperConfiguration.cs b/src/Gridify/GridifyMapperConfiguration.cs index f103a6df..c4f5361a 100644 --- a/src/Gridify/GridifyMapperConfiguration.cs +++ b/src/Gridify/GridifyMapperConfiguration.cs @@ -54,6 +54,14 @@ public record GridifyMapperConfiguration /// public bool DisableCollectionNullChecks { get; set; } = GridifyGlobalConfiguration.DisableNullChecks; + /// + /// Enables field-to-field comparison in filtering expressions. + /// When enabled, the right-hand side of a filter condition can reference another field + /// using the (fieldName) syntax. For example: age > (minAge). + /// Default is false to avoid breaking changes. + /// + public bool AllowFieldToFieldComparison { get; set; } = GridifyGlobalConfiguration.AllowFieldToFieldComparison; + /// /// It makes the generated expressions compatible /// with the entity framework. diff --git a/src/Gridify/Syntax/FieldReferenceExpressionSyntax.cs b/src/Gridify/Syntax/FieldReferenceExpressionSyntax.cs new file mode 100644 index 00000000..d4d69738 --- /dev/null +++ b/src/Gridify/Syntax/FieldReferenceExpressionSyntax.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace Gridify.Syntax; + +/// +/// Represents a field reference on the right-hand side of a comparison operator. +/// Syntax: (fieldName) — the parentheses differentiate it from a literal value. +/// +public sealed class FieldReferenceExpressionSyntax(FieldExpressionSyntax fieldExpression) : ExpressionSyntax +{ + public override SyntaxKind Kind => SyntaxKind.FieldReferenceExpression; + + public FieldExpressionSyntax FieldExpression { get; } = fieldExpression; + + public override IEnumerable GetChildren() + { + yield return FieldExpression; + } +} diff --git a/src/Gridify/Syntax/Parser.cs b/src/Gridify/Syntax/Parser.cs index 33717cab..b2c3d120 100644 --- a/src/Gridify/Syntax/Parser.cs +++ b/src/Gridify/Syntax/Parser.cs @@ -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) { diff --git a/src/Gridify/Syntax/SyntaxKind.cs b/src/Gridify/Syntax/SyntaxKind.cs index 8dea74c2..ce084211 100644 --- a/src/Gridify/Syntax/SyntaxKind.cs +++ b/src/Gridify/Syntax/SyntaxKind.cs @@ -34,5 +34,6 @@ public enum SyntaxKind NotStartsWith, NotEndsWith, CaseInsensitive, - FieldIndexer + FieldIndexer, + FieldReferenceExpression } diff --git a/test/EntityFrameworkSqlProviderIntegrationTests/Issue155Tests.cs b/test/EntityFrameworkSqlProviderIntegrationTests/Issue155Tests.cs new file mode 100644 index 00000000..668a6a42 --- /dev/null +++ b/test/EntityFrameworkSqlProviderIntegrationTests/Issue155Tests.cs @@ -0,0 +1,61 @@ +using System.Linq; +using Gridify; +using Microsoft.EntityFrameworkCore; +using xRetry; +using Xunit; + +namespace EntityFrameworkIntegrationTests.cs; + +public class Issue155Tests +{ + private readonly MyDbContext _dbContext = new(); + + // issue #155 - field-to-field comparison should work with Entity Framework + [RetryFact] + public void FieldToField_SimpleComparison_ShouldGenerateValidSql() + { + // arrange + GridifyGlobalConfiguration.EnableEntityFrameworkCompatibilityLayer(); + + // Map the same field under two different aliases to verify SQL generation + var mapper = new GridifyMapper(new GridifyMapperConfiguration + { AllowFieldToFieldComparison = true, EntityFrameworkCompatibilityLayer = true }) + .AddMap("id", u => u.Id) + .AddMap("sameId", u => u.Id); + + var expected = _dbContext.Users.Where(u => u.Id == u.Id).ToQueryString(); + + // act + var actual = _dbContext.Users + .ApplyFiltering("id=(sameId)", mapper) + .ToQueryString(); + + // assert - verify the generated SQL is the same as the LINQ query + Assert.Equal(expected, actual); + } + + // issue #155 - field-to-field comparison with nested collection should work with EF + [RetryFact] + public void FieldToField_NestedCollectionComparison_ShouldGenerateValidSql() + { + // arrange + GridifyGlobalConfiguration.EnableEntityFrameworkCompatibilityLayer(); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration + { AllowFieldToFieldComparison = true, EntityFrameworkCompatibilityLayer = true }) + .AddMap("groupId", u => u.Groups.Select(g => g.Id)) + .AddMap("groupSameId", u => u.Groups.Select(g => g.Id)); + + var expected = _dbContext.Users + .Where(u => u.Groups.Any(g => g.Id == g.Id)) + .ToQueryString(); + + // act + var actual = _dbContext.Users + .ApplyFiltering("groupId=(groupSameId)", mapper) + .ToQueryString(); + + // assert + Assert.Equal(expected, actual); + } +} diff --git a/test/Gridify.Tests/IssueTests/Issue155Tests.cs b/test/Gridify.Tests/IssueTests/Issue155Tests.cs new file mode 100644 index 00000000..faad6faa --- /dev/null +++ b/test/Gridify.Tests/IssueTests/Issue155Tests.cs @@ -0,0 +1,679 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Gridify.Tests.IssueTests; + +/// +/// Tests for field-to-field comparison feature (issues #155 and #153). +/// Syntax: field1 = (field2) where (field2) is a field reference on the RHS. +/// +public class Issue155Tests +{ + // ------------------------------------------------------------------------------- + // Simple (non-nested) field-to-field comparisons + // ------------------------------------------------------------------------------- + + [Fact] + public void SimpleFieldToFieldEqual_ShouldFilterCorrectly() + { + var items = new List + { + new(1, "Alice", 1), + new(2, "Bob", 3), + new(3, "Charlie", 3), + }.AsQueryable(); + + var expected = items.Where(x => x.Id == x.Score); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("id", p => p.Id) + .AddMap("score", p => p.Score); + + var actual = items.ApplyFiltering("id=(score)", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Fact] + public void SimpleFieldToFieldNotEqual_ShouldFilterCorrectly() + { + var items = new List + { + new(1, "Alice", 1), + new(2, "Bob", 3), + new(3, "Charlie", 3), + }.AsQueryable(); + + var expected = items.Where(x => x.Id != x.Score); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("id", p => p.Id) + .AddMap("score", p => p.Score); + + var actual = items.ApplyFiltering("id!=(score)", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Fact] + public void SimpleFieldToFieldGreaterThan_ShouldFilterCorrectly() + { + var items = new List + { + new(1, "Alice", 2), + new(5, "Bob", 3), + new(3, "Charlie", 3), + }.AsQueryable(); + + var expected = items.Where(x => x.Id > x.Score); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("id", p => p.Id) + .AddMap("score", p => p.Score); + + var actual = items.ApplyFiltering("id>(score)", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Fact] + public void SimpleFieldToFieldLessThan_ShouldFilterCorrectly() + { + var items = new List + { + new(1, "Alice", 2), + new(5, "Bob", 3), + new(3, "Charlie", 3), + }.AsQueryable(); + + var expected = items.Where(x => x.Id < x.Score); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("id", p => p.Id) + .AddMap("score", p => p.Score); + + var actual = items.ApplyFiltering("id<(score)", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Fact] + public void SimpleFieldToFieldGreaterOrEqual_ShouldFilterCorrectly() + { + var items = new List + { + new(1, "Alice", 2), + new(5, "Bob", 3), + new(3, "Charlie", 3), + }.AsQueryable(); + + var expected = items.Where(x => x.Id >= x.Score); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("id", p => p.Id) + .AddMap("score", p => p.Score); + + var actual = items.ApplyFiltering("id>=(score)", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Fact] + public void SimpleFieldToFieldLessOrEqual_ShouldFilterCorrectly() + { + var items = new List + { + new(1, "Alice", 2), + new(5, "Bob", 3), + new(3, "Charlie", 3), + }.AsQueryable(); + + var expected = items.Where(x => x.Id <= x.Score); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("id", p => p.Id) + .AddMap("score", p => p.Score); + + var actual = items.ApplyFiltering("id<=(score)", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + // ------------------------------------------------------------------------------- + // Nested collection field-to-field comparisons (PR #182 original test case) + // ------------------------------------------------------------------------------- + + [Fact] + public void NestedCollectionFieldToField_LessThan_ShouldReturnCorrectResult() + { + List items = + [ + new Item("Item1", [new TimeSchedule(1, 2), new TimeSchedule(2, 3)]), + new Item("Item2", [new TimeSchedule(1, 4), new TimeSchedule(4, 3)]), + new Item("Item3", [new TimeSchedule(3, 2), new TimeSchedule(2, 3)]), + ]; + + var expected = items.AsQueryable().Where(x => x.Schedules.Any(s => s.End < s.Start)); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("start", p => p.Schedules.Select(x => x.Start)) + .AddMap("end", p => p.Schedules.Select(x => x.End)); + + var actual = items.AsQueryable().ApplyFiltering("end<(start)", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Fact] + public void NestedCollectionFieldToField_GreaterThan_ShouldReturnCorrectResult() + { + List items = + [ + new Item("Item1", [new TimeSchedule(1, 2), new TimeSchedule(2, 3)]), + new Item("Item2", [new TimeSchedule(5, 4), new TimeSchedule(4, 3)]), + new Item("Item3", [new TimeSchedule(3, 2), new TimeSchedule(2, 3)]), + ]; + + var expected = items.AsQueryable().Where(x => x.Schedules.Any(s => s.End > s.Start)); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("start", p => p.Schedules.Select(x => x.Start)) + .AddMap("end", p => p.Schedules.Select(x => x.End)); + + var actual = items.AsQueryable().ApplyFiltering("end>(start)", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Fact] + public void NestedCollectionFieldToField_Equal_ShouldReturnCorrectResult() + { + List items = + [ + new Item("Item1", [new TimeSchedule(2, 2), new TimeSchedule(3, 1)]), + new Item("Item2", [new TimeSchedule(1, 4), new TimeSchedule(4, 3)]), + new Item("Item3", [new TimeSchedule(3, 2), new TimeSchedule(2, 2)]), + ]; + + var expected = items.AsQueryable().Where(x => x.Schedules.Any(s => s.End == s.Start)); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("start", p => p.Schedules.Select(x => x.Start)) + .AddMap("end", p => p.Schedules.Select(x => x.End)); + + var actual = items.AsQueryable().ApplyFiltering("end=(start)", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + // ------------------------------------------------------------------------------- + // Configuration: feature is disabled by default + // ------------------------------------------------------------------------------- + + [Fact] + public void WhenFeatureDisabled_ShouldThrowGridifyFilteringException() + { + var items = new List { new(1, "Alice", 1) }.AsQueryable(); + + // AllowFieldToFieldComparison defaults to false + var mapper = new GridifyMapper() + .AddMap("id", p => p.Id) + .AddMap("score", p => p.Score); + + Assert.Throws(() => items.ApplyFiltering("id=(score)", mapper)); + } + + [Fact] + public void WhenGlobalConfigEnabled_ShouldWork() + { + var original = GridifyGlobalConfiguration.AllowFieldToFieldComparison; + try + { + GridifyGlobalConfiguration.AllowFieldToFieldComparison = true; + + var items = new List + { + new(1, "Alice", 1), + new(2, "Bob", 3), + }.AsQueryable(); + + var mapper = new GridifyMapper() + .AddMap("id", p => p.Id) + .AddMap("score", p => p.Score); + + var actual = items.ApplyFiltering("id=(score)", mapper); + Assert.Single(actual); + Assert.Equal(1, actual.First().Id); + } + finally + { + GridifyGlobalConfiguration.AllowFieldToFieldComparison = original; + } + } + + // ------------------------------------------------------------------------------- + // QueryBuilder integration + // ------------------------------------------------------------------------------- + + [Fact] + public void QueryBuilder_FieldToField_ShouldWork() + { + var items = new List + { + new(1, "Alice", 1), + new(2, "Bob", 3), + new(3, "Charlie", 3), + }.AsQueryable(); + + var expected = items.Where(x => x.Id == x.Score); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("id", p => p.Id) + .AddMap("score", p => p.Score); + + var actual = new QueryBuilder() + .UseCustomMapper(mapper) + .AddCondition("id=(score)") + .Build(items); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Fact] + public void QueryBuilder_FieldToField_NestedCollection_ShouldWork() + { + List items = + [ + new Item("Item1", [new TimeSchedule(1, 2), new TimeSchedule(2, 3)]), + new Item("Item2", [new TimeSchedule(1, 4), new TimeSchedule(4, 3)]), + new Item("Item3", [new TimeSchedule(3, 2), new TimeSchedule(2, 3)]), + ]; + + var expected = items.AsQueryable().Where(x => x.Schedules.Any(s => s.End < s.Start)); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("start", p => p.Schedules.Select(x => x.Start)) + .AddMap("end", p => p.Schedules.Select(x => x.End)); + + var actual = new QueryBuilder() + .UseCustomMapper(mapper) + .AddCondition("end<(start)") + .Build(items.AsQueryable()); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + // ------------------------------------------------------------------------------- + // Combined with other conditions + // ------------------------------------------------------------------------------- + + [Fact] + public void FieldToField_CombinedWithValueFilter_ShouldWork() + { + var items = new List + { + new(1, "Alice", 1), + new(2, "Bob", 3), + new(3, "Charlie", 3), + new(4, "Dave", 4), + }.AsQueryable(); + + // id == score AND id > 1 + var expected = items.Where(x => x.Id == x.Score && x.Id > 1); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("id", p => p.Id) + .AddMap("score", p => p.Score); + + var actual = items.ApplyFiltering("id=(score),id>1", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + // ------------------------------------------------------------------------------- + // String operators for field-to-field comparison + // ------------------------------------------------------------------------------- + + [Fact] + public void FieldToField_Like_ShouldFilterCorrectly() + { + var items = new List + { + new("Hello World", "World"), + new("Hello World", "Missing"), + new("Gridify", "Grid"), + }.AsQueryable(); + + var expected = items.Where(x => x.Name.Contains(x.Tag)); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("name", p => p.Name) + .AddMap("tag", p => p.Tag); + + var actual = items.ApplyFiltering("name=*(tag)", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Fact] + public void FieldToField_NotLike_ShouldFilterCorrectly() + { + var items = new List + { + new("Hello World", "World"), + new("Hello World", "Missing"), + new("Gridify", "Grid"), + }.AsQueryable(); + + var expected = items.Where(x => !x.Name.Contains(x.Tag)); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("name", p => p.Name) + .AddMap("tag", p => p.Tag); + + var actual = items.ApplyFiltering("name!*(tag)", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Fact] + public void FieldToField_StartsWith_ShouldFilterCorrectly() + { + var items = new List + { + new("Hello World", "Hello"), + new("Hello World", "World"), + new("Gridify", "Grid"), + }.AsQueryable(); + + var expected = items.Where(x => x.Name.StartsWith(x.Tag)); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("name", p => p.Name) + .AddMap("tag", p => p.Tag); + + var actual = items.ApplyFiltering("name^(tag)", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Fact] + public void FieldToField_NotStartsWith_ShouldFilterCorrectly() + { + var items = new List + { + new("Hello World", "Hello"), + new("Hello World", "World"), + new("Gridify", "Grid"), + }.AsQueryable(); + + var expected = items.Where(x => !x.Name.StartsWith(x.Tag)); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("name", p => p.Name) + .AddMap("tag", p => p.Tag); + + var actual = items.ApplyFiltering("name!^(tag)", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Fact] + public void FieldToField_EndsWith_ShouldFilterCorrectly() + { + var items = new List + { + new("Hello World", "World"), + new("Hello World", "Hello"), + new("Gridify", "ify"), + }.AsQueryable(); + + var expected = items.Where(x => x.Name.EndsWith(x.Tag)); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("name", p => p.Name) + .AddMap("tag", p => p.Tag); + + var actual = items.ApplyFiltering("name$(tag)", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Fact] + public void FieldToField_NotEndsWith_ShouldFilterCorrectly() + { + var items = new List + { + new("Hello World", "World"), + new("Hello World", "Hello"), + new("Gridify", "ify"), + }.AsQueryable(); + + var expected = items.Where(x => !x.Name.EndsWith(x.Tag)); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("name", p => p.Name) + .AddMap("tag", p => p.Tag); + + var actual = items.ApplyFiltering("name!$(tag)", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + // ------------------------------------------------------------------------------- + // Grouping/parentheses conflict tests - ensure field reference (fieldName) on RHS + // does NOT conflict with Gridify's existing grouping syntax ( expr | expr ) + // ------------------------------------------------------------------------------- + + [Fact] + public void FieldToField_WithGroupingOnLeft_ShouldWork() + { + // "(name=*J|name=*S)" is a group expression - should remain a group + // "id=(score)" is a field-to-field comparison - should be a field reference + var items = new List + { + new(1, "John", 1), + new(2, "Sara", 3), + new(3, "Bob", 3), + new(4, "Dave", 4), + }.AsQueryable(); + + // (name contains J OR name contains S) AND id == score + var expected = items.Where(x => (x.Name.Contains("J") || x.Name.Contains("S")) && x.Id == x.Score); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("id", p => p.Id) + .AddMap("score", p => p.Score) + .AddMap("name", p => p.Name); + + var actual = items.ApplyFiltering("(name=*J|name=*S),id=(score)", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Fact] + public void FieldToField_InsideGrouping_ShouldWork() + { + // field-to-field comparison inside a group: (id=(score)|id>2) + var items = new List + { + new(1, "Alice", 1), + new(2, "Bob", 3), + new(3, "Charlie", 3), + new(5, "Dave", 4), + }.AsQueryable(); + + // id == score OR id > 2 + var expected = items.Where(x => x.Id == x.Score || x.Id > 2); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("id", p => p.Id) + .AddMap("score", p => p.Score); + + var actual = items.ApplyFiltering("(id=(score)|id>2)", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Fact] + public void GroupingWithoutFieldReference_ShouldNotBeAffectedByFeature() + { + // Ensure regular grouping still works when feature is DISABLED + var items = new List + { + new(1, "John", 1), + new(3, "Sara", 3), + new(4, "Bob", 4), + }.AsQueryable(); + + var expected = items.Where(x => (x.Name.Contains("J") || x.Name.Contains("S")) && x.Id < 5); + + // Feature disabled, but grouping should still work normally + var mapper = new GridifyMapper() + .AddMap("id", p => p.Id) + .AddMap("name", p => p.Name); + + var actual = items.ApplyFiltering("(name=*J|name=*S),id<5", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Fact] + public void ParenthesisInValue_ShouldStillBeEscapedAsValue() + { + // Escaped parentheses in value should still be treated as a literal value, not a field reference + var items = new List + { + new(1, "test(value)", 1), + new(2, "normal", 2), + }.AsQueryable(); + + var expected = items.Where(x => x.Name == "test(value)"); + + // feature enabled, but escaped '(' should still be a value + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("name", p => p.Name); + + // The value contains escaped parentheses \( and \) which should be treated as literal characters + var actual = items.ApplyFiltering(@"name=test\(value\)", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Theory] + [InlineData(@"name=\(somevalue\)", "(somevalue)")] // value wrapped in parentheses + [InlineData(@"name=\(somevalue", "(somevalue")] // only opening parenthesis escaped + [InlineData(@"name=somevalue\)", "somevalue)")] // only closing parenthesis escaped + [InlineData(@"name=some\(value", "some(value")] // opening parenthesis in the middle + [InlineData(@"name=some\)value", "some)value")] // closing parenthesis in the middle + public void EscapedParenthesesInValue_ShouldBeSearchedAsLiteralValue(string filter, string expectedValue) + { + // When AllowFieldToFieldComparison is enabled, escaped parentheses in values must still be + // treated as literal characters, not as field reference syntax + var items = new List + { + new(1, expectedValue, 1), + new(2, "normal", 2), + }.AsQueryable(); + + var expected = items.Where(x => x.Name == expectedValue); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("name", p => p.Name); + + var actual = items.ApplyFiltering(filter, mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Fact] + public void FieldToField_ORWithValueFilter_ShouldWork() + { + var items = new List + { + new(1, "Alice", 1), + new(2, "Bob", 3), + new(3, "Charlie", 5), + new(5, "Dave", 5), + }.AsQueryable(); + + // id == score OR id == 2 + var expected = items.Where(x => x.Id == x.Score || x.Id == 2); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("id", p => p.Id) + .AddMap("score", p => p.Score); + + var actual = items.ApplyFiltering("id=(score)|id=2", mapper); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + // ------------------------------------------------------------------------------- + // QueryBuilder integration - advanced scenarios + // ------------------------------------------------------------------------------- + + [Fact] + public void QueryBuilder_FieldToField_WithBuildFilteringExpression_ShouldWork() + { + var items = new List + { + new(1, "Alice", 1), + new(2, "Bob", 3), + new(3, "Charlie", 3), + }.AsQueryable(); + + var expected = items.Where(x => x.Id == x.Score); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("id", p => p.Id) + .AddMap("score", p => p.Score); + + var filter = new GridifyQuery { Filter = "id=(score)" }; + var expr = filter.GetFilteringExpression(mapper); + var actual = items.Where(expr); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + [Fact] + public void QueryBuilder_FieldToField_ComplexConditions_ShouldWork() + { + var items = new List + { + new(1, "Alice", 1), + new(2, "Bob", 3), + new(3, "Charlie", 3), + new(4, "Dave", 4), + new(5, "Eve", 3), + }.AsQueryable(); + + // (id == score OR id > 4) AND id >= 1 + var expected = items.Where(x => (x.Id == x.Score || x.Id > 4) && x.Id >= 1); + + var mapper = new GridifyMapper(new GridifyMapperConfiguration { AllowFieldToFieldComparison = true }) + .AddMap("id", p => p.Id) + .AddMap("score", p => p.Score); + + var actual = new QueryBuilder() + .UseCustomMapper(mapper) + .AddCondition("(id=(score)|id>4),id>=1") + .Build(items); + + Assert.Equal(expected.ToList(), actual.ToList()); + } + + // ------------------------------------------------------------------------------- + // Model types used in tests + // ------------------------------------------------------------------------------- + + private record SimpleItem(int Id, string Name, int Score); + + private record StringItem(string Name, string Tag); + + private record Item(string Name, List Schedules); + + private record TimeSchedule(int Start, int End); +}