diff --git a/docs/pages/guide/gridifyGlobalConfiguration.md b/docs/pages/guide/gridifyGlobalConfiguration.md index 3a58756c..1e4e64a6 100644 --- a/docs/pages/guide/gridifyGlobalConfiguration.md +++ b/docs/pages/guide/gridifyGlobalConfiguration.md @@ -47,6 +47,13 @@ some ORMs like NHibernate don't support this. You can disable this behavior by s - type: `bool` - default: `false` +### CaseInsensitiveFiltering + +If true, string comparison operations are case insensitive by default. + +- type: `bool` +- default: `false` + ### 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 d9842d28..690ced1d 100644 --- a/docs/pages/guide/gridifyMapper.md +++ b/docs/pages/guide/gridifyMapper.md @@ -149,6 +149,17 @@ By setting this to `false`, Gridify don't allow searching on null values using t var mapper = new GridifyMapper(q => q.AllowNullSearch = false); ``` +### CaseInsensitiveFiltering + +If true, string comparison operations are case insensitive by default. + +- type: `bool` +- default: `false` + +``` csharp +var mapper = new GridifyMapper(q => q.CaseInsensitiveFiltering = true); +``` + ### DefaultDateTimeKind By setting this property to a `DateTimeKind` value, you can change the default `DateTimeKind` used when parsing dates. diff --git a/src/Gridify/Builder/BaseQueryBuilder.cs b/src/Gridify/Builder/BaseQueryBuilder.cs index fc2c7679..3bb31807 100644 --- a/src/Gridify/Builder/BaseQueryBuilder.cs +++ b/src/Gridify/Builder/BaseQueryBuilder.cs @@ -1,7 +1,6 @@ using System; using System.ComponentModel; using System.Linq.Expressions; -using Gridify.Reflection; using Gridify.Syntax; namespace Gridify.Builder; @@ -208,17 +207,6 @@ private static object AddIndexerNullCheck(LambdaExpression mapTarget, object que } } - // handle case-Insensitive search - if (value is not null && valueExpression.IsCaseInsensitive - && op.Kind is not SyntaxKind.GreaterThan - && op.Kind is not SyntaxKind.LessThan - && op.Kind is not SyntaxKind.GreaterOrEqualThan - && op.Kind is not SyntaxKind.LessOrEqualThan) - { - value = value.ToString()?.ToLower(); - body = Expression.Call(body, MethodInfoHelper.GetToLowerMethod()); - } - var query = BuildQueryAccordingToValueType(body, parameter, value, op, valueExpression); return query; } diff --git a/src/Gridify/Builder/LinqQueryBuilder.cs b/src/Gridify/Builder/LinqQueryBuilder.cs index 435c3f84..9c788c5f 100644 --- a/src/Gridify/Builder/LinqQueryBuilder.cs +++ b/src/Gridify/Builder/LinqQueryBuilder.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Data; +using System.Globalization; using System.Linq; using System.Linq.Expressions; using Gridify.Reflection; @@ -115,6 +117,15 @@ protected override Expression> BuildAlwaysFalseQuery(ParameterExpr switch (op.Kind) { + case SyntaxKind.Equal when !valueExpression.IsNullOrDefault && areBothStrings && (valueExpression.IsCaseInsensitive || mapper.Configuration.CaseInsensitiveFiltering): + be = Expression.Call( + null, + MethodInfoHelper.GetCaseAwareEqualsMethod(), + body, + GetValueExpression(body.Type, value), + Expression.Constant(StringComparison.InvariantCultureIgnoreCase) + ); + break; case SyntaxKind.Equal when !valueExpression.IsNullOrDefault: be = Expression.Equal(body, GetValueExpression(body.Type, value)); break; @@ -131,6 +142,15 @@ protected override Expression> BuildAlwaysFalseQuery(ParameterExpr : Expression.Equal(body, Expression.Default(body.Type)); } + break; + case SyntaxKind.NotEqual when !valueExpression.IsNullOrDefault && areBothStrings && (valueExpression.IsCaseInsensitive || mapper.Configuration.CaseInsensitiveFiltering): + be = Expression.Not(Expression.Call( + null, + MethodInfoHelper.GetCaseAwareEqualsMethod(), + body, + GetValueExpression(body.Type, value), + Expression.Constant(StringComparison.InvariantCultureIgnoreCase)) + ); break; case SyntaxKind.NotEqual when !valueExpression.IsNullOrDefault: be = Expression.NotEqual(body, GetValueExpression(body.Type, value)); @@ -174,60 +194,74 @@ protected override Expression> BuildAlwaysFalseQuery(ParameterExpr case SyntaxKind.LessOrEqualThan when areBothStrings: be = GetLessThanOrEqualExpression(body, valueExpression, value); break; - case SyntaxKind.Like: - be = Expression.Call(body, MethodInfoHelper.GetStringContainsMethod(), GetValueExpression(body.Type, value)); - break; - case SyntaxKind.NotLike: - be = Expression.Not(Expression.Call(body, MethodInfoHelper.GetStringContainsMethod(), GetValueExpression(body.Type, value))); - break; - case SyntaxKind.StartsWith: - if (body.Type != typeof(string)) + case SyntaxKind.Like or SyntaxKind.NotLike: + if (areBothStrings && (valueExpression.IsCaseInsensitive || mapper.Configuration.CaseInsensitiveFiltering)) { - body = Expression.Call(body, MethodInfoHelper.GetToStringMethod()); - be = Expression.Call(body, MethodInfoHelper.GetStartWithMethod(), GetValueExpression(body.Type, value?.ToString())); + be = Expression.Call( + body, + MethodInfoHelper.GetCaseAwareStringContainsMethod(), + GetValueExpression(body.Type, value), + Expression.Constant(StringComparison.InvariantCultureIgnoreCase) + ); } else { - be = Expression.Call(body, MethodInfoHelper.GetStartWithMethod(), GetValueExpression(body.Type, value)); + be = Expression.Call(body, MethodInfoHelper.GetStringContainsMethod(), GetValueExpression(body.Type, value)); } + if (op.Kind == SyntaxKind.NotLike) + be = Expression.Not(be); + break; - case SyntaxKind.EndsWith: + case SyntaxKind.StartsWith or SyntaxKind.NotStartsWith: if (body.Type != typeof(string)) { body = Expression.Call(body, MethodInfoHelper.GetToStringMethod()); - be = Expression.Call(body, MethodInfoHelper.GetEndsWithMethod(), GetValueExpression(body.Type, value?.ToString())); - } - else - { - be = Expression.Call(body, MethodInfoHelper.GetEndsWithMethod(), GetValueExpression(body.Type, value)); + be = Expression.Call(body, MethodInfoHelper.GetStartWithMethod(), GetValueExpression(body.Type, value?.ToString())); } - - break; - case SyntaxKind.NotStartsWith: - if (body.Type != typeof(string)) + else if (areBothStrings && (valueExpression.IsCaseInsensitive || mapper.Configuration.CaseInsensitiveFiltering)) { - body = Expression.Call(body, MethodInfoHelper.GetToStringMethod()); - be = Expression.Not(Expression.Call(body, MethodInfoHelper.GetStartWithMethod(), GetValueExpression(body.Type, value?.ToString()))); + be = Expression.Call( + body, + MethodInfoHelper.GetCaseAwareStartsWithMethod(), + GetValueExpression(body.Type, value), + Expression.Constant(StringComparison.InvariantCultureIgnoreCase) + ); } else { - be = Expression.Not(Expression.Call(body, MethodInfoHelper.GetStartWithMethod(), GetValueExpression(body.Type, value))); + be = Expression.Call(body, MethodInfoHelper.GetStartWithMethod(), GetValueExpression(body.Type, value)); } + if (op.Kind == SyntaxKind.NotStartsWith) + be = Expression.Not(be); + break; - case SyntaxKind.NotEndsWith: + case SyntaxKind.EndsWith or SyntaxKind.NotEndsWith: if (body.Type != typeof(string)) { body = Expression.Call(body, MethodInfoHelper.GetToStringMethod()); - be = Expression.Not(Expression.Call(body, MethodInfoHelper.GetEndsWithMethod(), GetValueExpression(body.Type, value?.ToString()))); + be = Expression.Call(body, MethodInfoHelper.GetStartWithMethod(), GetValueExpression(body.Type, value?.ToString())); + } + else if (areBothStrings && (valueExpression.IsCaseInsensitive || mapper.Configuration.CaseInsensitiveFiltering)) + { + be = Expression.Call( + body, + MethodInfoHelper.GetCaseAwareEndsWithMethod(), + GetValueExpression(body.Type, value), + Expression.Constant(StringComparison.InvariantCultureIgnoreCase) + ); } else { - be = Expression.Not(Expression.Call(body, MethodInfoHelper.GetEndsWithMethod(), GetValueExpression(body.Type, value))); + be = Expression.Call(body, MethodInfoHelper.GetEndsWithMethod(), GetValueExpression(body.Type, value)); } + if (op.Kind == SyntaxKind.NotEndsWith) + be = Expression.Not(be); + break; + case SyntaxKind.CustomOperator: var token = (SyntaxToken)op; var customOperator = GridifyGlobalConfiguration.CustomOperators.Operators.First(q => q.GetOperator() == token!.Text); @@ -302,14 +336,14 @@ private static LambdaExpression ParseMethodCallExpression(MethodCallExpression e { case MemberExpression member: { - if (op.Kind is SyntaxKind.Equal or SyntaxKind.NotEqual && - member.Type.IsSimpleTypeCollection(out _) && - predicate.Body is BinaryExpression binaryExpression) + if (op.Kind is not (SyntaxKind.Equal or SyntaxKind.NotEqual) || + !member.Type.IsSimpleTypeCollection(out _)) return GetAnyExpression(member, predicate); + return predicate.Body switch { - return GetContainsExpression(member, binaryExpression, op); - } - - return GetAnyExpression(member, predicate); + BinaryExpression binaryExpression => GetContainsExpression(member, binaryExpression, op), + MethodCallExpression { Method.Name: "Equals" } methodCallExpression => GetCaseSensitiveContainsExpression(member, methodCallExpression, op), + _ => GetAnyExpression(member, predicate) + }; } case MethodCallExpression { Method.Name: "SelectMany" } subExp when subExp.Arguments.Last() @@ -348,6 +382,28 @@ when subExp.Arguments.Last() is LambdaExpression wherePredicate && } } + private static LambdaExpression GetCaseSensitiveContainsExpression(MemberExpression member, MethodCallExpression methodCallExpression, ISyntaxNode op) + { + var param = GetParameterExpression(member); + var prop = GetPropertyOrField(member, param); + + var tp = prop.Type.IsGenericType + ? prop.Type.GenericTypeArguments.First() // list + : prop.Type.GetElementType(); // array + + if (tp == null) throw new GridifyFilteringException($"Can not detect the '{member.Member.Name}' property type."); + + var containsMethod = MethodInfoHelper.GetCaseAwareContainsMethod(tp); + var ignoreCaseComparerExpression = Expression.Constant(StringComparer.InvariantCultureIgnoreCase); + var value = methodCallExpression.Arguments[1]; + Expression containsExp = Expression.Call(containsMethod, prop, value, ignoreCaseComparerExpression); + if (op.Kind == SyntaxKind.NotEqual) + { + containsExp = Expression.Not(containsExp); + } + return GetExpressionWithNullCheck(prop, param, containsExp); + } + private static LambdaExpression GetContainsExpression(MemberExpression member, BinaryExpression binaryExpression, ISyntaxNode op) { var param = GetParameterExpression(member); @@ -474,10 +530,10 @@ private BinaryExpression GetGreaterThanExpression(Expression body, ValueExpressi GetStringComparisonCaseExpression(valueExpression.IsCaseInsensitive)), Expression.Constant(0)); } - private ConstantExpression GetStringComparisonCaseExpression(bool isCaseInsensitive) + private static ConstantExpression GetStringComparisonCaseExpression(bool isCaseInsensitive) { return isCaseInsensitive - ? Expression.Constant(StringComparison.OrdinalIgnoreCase) + ? Expression.Constant(StringComparison.InvariantCultureIgnoreCase) : Expression.Constant(StringComparison.Ordinal); } diff --git a/src/Gridify/GridifyGlobalConfiguration.cs b/src/Gridify/GridifyGlobalConfiguration.cs index b1eb4229..6db4b47b 100644 --- a/src/Gridify/GridifyGlobalConfiguration.cs +++ b/src/Gridify/GridifyGlobalConfiguration.cs @@ -47,6 +47,13 @@ public static class GridifyGlobalConfiguration /// public static bool DisableNullChecks { get; set; } = false; + /// + /// By default, string comparison is case sensitive. + /// You can change this behavior by setting this property to true. + /// Default is false + /// + public static bool CaseInsensitiveFiltering { get; set; } = false; + /// /// By default, DateTimeKind.Unspecified is used. /// You can change this behavior by setting this property to a DateTimeKind value. diff --git a/src/Gridify/GridifyMapperConfiguration.cs b/src/Gridify/GridifyMapperConfiguration.cs index aed7526e..fef58f85 100644 --- a/src/Gridify/GridifyMapperConfiguration.cs +++ b/src/Gridify/GridifyMapperConfiguration.cs @@ -24,6 +24,12 @@ public record GridifyMapperConfiguration /// public bool IgnoreNotMappedFields { get; set; } = GridifyGlobalConfiguration.IgnoreNotMappedFields; + /// + /// If true, string comparison operations are case insensitive by default. + /// Default is false + /// + public bool CaseInsensitiveFiltering { get; set; } = GridifyGlobalConfiguration.CaseInsensitiveFiltering; + /// /// By default, DateTimeKind.Unspecified is used. /// You can change this behavior by setting this property to a DateTimeKind value. diff --git a/src/Gridify/Reflection/MethodInfoHelper.cs b/src/Gridify/Reflection/MethodInfoHelper.cs index 0a4a7e2f..fe9d8c15 100644 --- a/src/Gridify/Reflection/MethodInfoHelper.cs +++ b/src/Gridify/Reflection/MethodInfoHelper.cs @@ -6,11 +6,6 @@ namespace Gridify.Reflection; public static class MethodInfoHelper { - public static MethodInfo GetToLowerMethod() - { - return typeof(string).GetMethod("ToLower", [])!; - } - public static MethodInfo GetAnyMethod(Type type) { return typeof(Enumerable).GetMethods().Single(m => m.Name == "Any" && m.GetParameters().Length == 2).MakeGenericMethod(type); @@ -60,4 +55,29 @@ public static MethodInfo GetSelectMethod(this Type type) { return typeof(Enumerable).GetMethods().First(m => m.Name == "Select").MakeGenericMethod([type, type]); } + + public static MethodInfo GetCaseAwareContainsMethod(Type tp) + { + return typeof(Enumerable).GetMethods().Last(x => x.Name == "Contains").MakeGenericMethod(tp); + } + + public static MethodInfo GetCaseAwareStringContainsMethod() + { + return typeof(string).GetMethod("Contains", [typeof(string), typeof(StringComparison)])!; + } + + public static MethodInfo GetCaseAwareEqualsMethod() + { + return typeof(string).GetMethod("Equals", [typeof(string), typeof(string), typeof(StringComparison)])!; + } + + public static MethodInfo GetCaseAwareStartsWithMethod() + { + return typeof(string).GetMethod("StartsWith", [typeof(string), typeof(StringComparison)])!; + } + + public static MethodInfo GetCaseAwareEndsWithMethod() + { + return typeof(string).GetMethod("EndsWith", [typeof(string), typeof(StringComparison)])!; + } } diff --git a/test/EntityFrameworkPostgreSqlIntegrationTests/Issue193Tests.cs b/test/EntityFrameworkPostgreSqlIntegrationTests/Issue193Tests.cs new file mode 100644 index 00000000..f3e77631 --- /dev/null +++ b/test/EntityFrameworkPostgreSqlIntegrationTests/Issue193Tests.cs @@ -0,0 +1,65 @@ +using Gridify; +using Xunit; + +namespace EntityFrameworkPostgreSqlIntegrationTests; + +public class Issue193Tests +{ + + [Fact] + public void ApplyFiltering_WithCaseInsensitiveOperator_ShouldReturnExpectedResult() + { + // arrange + var dataSource = Test.GetTestDataSource(); + + var expected = dataSource.Where(q => q.FavouriteColorList.Contains("red", StringComparer.InvariantCultureIgnoreCase) | + q.FavouriteColorList.Contains("blue", StringComparer.InvariantCultureIgnoreCase)) + .ToList(); + + // act + var actual = dataSource.ApplyFiltering("FavouriteColorList=red/i|FavouriteColorList=blue/i").ToList(); + + // assert + Assert.NotEmpty(expected); + Assert.NotEmpty(actual); + Assert.Equal(expected.Count, actual.Count); + } + + [Fact] + public void ApplyFiltering_WithDefaultCaseInsensitiveFiltering_ShouldReturnExpectedResult() + { + // arrange + var dataSource = Test.GetTestDataSource(); + + var expected = dataSource.Where(q => q.FavouriteColorList.Contains("red", StringComparer.InvariantCultureIgnoreCase) | + q.FavouriteColorList.Contains("blue", StringComparer.InvariantCultureIgnoreCase)) + .ToList(); + + var mapper = new GridifyMapper(q => q.CaseInsensitiveFiltering = true).GenerateMappings(); + + // act + var actual = dataSource.ApplyFiltering("FavouriteColorList=red|FavouriteColorList=blue", mapper).ToList(); + + // assert + Assert.NotEmpty(expected); + Assert.NotEmpty(actual); + Assert.Equal(expected.Count, actual.Count); + } + + class Test + { + public string[] FavouriteColorList { get; set; } + + public static IQueryable GetTestDataSource() + { + return new List() + { + new() { FavouriteColorList = ["Green", "Blue"] }, + new() { FavouriteColorList = ["White", "Yellow"] }, + new() { FavouriteColorList = ["Red", "Orange"] }, + new() { FavouriteColorList = ["Purple", "Pink"] }, + new() { FavouriteColorList = ["Black", "Gray"] } + }.AsQueryable(); + } + } +} diff --git a/test/Gridify.Tests/GridifyExtensionsShould.cs b/test/Gridify.Tests/GridifyExtensionsShould.cs index f0266cb2..167de72e 100644 --- a/test/Gridify.Tests/GridifyExtensionsShould.cs +++ b/test/Gridify.Tests/GridifyExtensionsShould.cs @@ -581,7 +581,7 @@ public void ApplyFiltering_GreaterThanOrEqualBetweenTwoStrings() public void ApplyFiltering_GreaterThanOrEqual_CaseInsensitive_BetweenTwoStrings() { var actual = _fakeRepository.AsQueryable().ApplyFiltering("name >= j/i").ToList(); - var expected = _fakeRepository.Where(q => string.Compare(q.Name, "j", StringComparison.OrdinalIgnoreCase) >= 0).ToList(); + var expected = _fakeRepository.Where(q => string.Compare(q.Name, "j", StringComparison.InvariantCultureIgnoreCase) >= 0).ToList(); Assert.Equal(expected.Count, actual.Count); Assert.Equal(expected, actual); diff --git a/test/Gridify.Tests/IssueTests/Issue191Tests.cs b/test/Gridify.Tests/IssueTests/Issue191Tests.cs new file mode 100644 index 00000000..06e2e5a3 --- /dev/null +++ b/test/Gridify.Tests/IssueTests/Issue191Tests.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Gridify.Tests.IssueTests; + +public class Issue191Tests +{ + private readonly List _fakeRepository = [.. GridifyExtensionsShould.GetSampleData()]; + + [Fact] + public void ApplyFiltering_Equals_GlobalCaseInsensitiveSearch() + { + var mapper = new GridifyMapper(m => m.CaseInsensitiveFiltering = true).GenerateMappings(); + + var gq = new GridifyQuery { Filter = "name=BOB" }; + + var expected = _fakeRepository.Where(q => string.Equals(q.Name!, "BOB", StringComparison.InvariantCultureIgnoreCase)).ToList(); + + var actual = _fakeRepository.AsQueryable() + .ApplyFiltering(gq, mapper) + .ToList(); + + Assert.Equal(expected.Count, actual.Count); + Assert.Equal(expected, actual); + Assert.True(actual.Any()); + } + + [Fact] + public void ApplyFiltering_NotEquals_GlobalCaseInsensitiveSearch() + { + var mapper = new GridifyMapper(m => m.CaseInsensitiveFiltering = true).GenerateMappings(); + + var gq = new GridifyQuery { Filter = "name!=BOB" }; + + var expected = _fakeRepository.Where(q => !string.Equals(q.Name!, "BOB", StringComparison.InvariantCultureIgnoreCase)).ToList(); + + var actual = _fakeRepository.AsQueryable() + .ApplyFiltering(gq, mapper) + .ToList(); + + Assert.Equal(expected.Count, actual.Count); + Assert.Equal(expected, actual); + Assert.True(actual.Any()); + } + + [Fact] + public void ApplyFiltering_Contains_GlobalCaseInsensitiveSearch() + { + var mapper = new GridifyMapper(m => m.CaseInsensitiveFiltering = true).GenerateMappings(); + + var gq = new GridifyQuery { Filter = "name=*BO" }; + + var expected = _fakeRepository.Where(q => q.Name!.Contains("BO", StringComparison.InvariantCultureIgnoreCase)).ToList(); + + var actual = _fakeRepository.AsQueryable() + .ApplyFiltering(gq, mapper) + .ToList(); + + Assert.Equal(expected.Count, actual.Count); + Assert.Equal(expected, actual); + Assert.True(actual.Any()); + } + + [Fact] + public void ApplyFiltering_NotContains_GlobalCaseInsensitiveSearch() + { + var mapper = new GridifyMapper(m => m.CaseInsensitiveFiltering = true).GenerateMappings(); + + var gq = new GridifyQuery { Filter = "name!*BO" }; + + var expected = _fakeRepository.Where(q => !q.Name!.Contains("BO", StringComparison.InvariantCultureIgnoreCase)).ToList(); + + var actual = _fakeRepository.AsQueryable() + .ApplyFiltering(gq, mapper) + .ToList(); + + Assert.Equal(expected.Count, actual.Count); + Assert.Equal(expected, actual); + Assert.True(actual.Any()); + } + + [Fact] + public void ApplyFiltering_StartWith_GlobalCaseInsensitiveSearch() + { + var mapper = new GridifyMapper(m => m.CaseInsensitiveFiltering = true).GenerateMappings(); + + var gq = new GridifyQuery { Filter = "name^BO" }; + + var expected = _fakeRepository.Where(q => q.Name!.StartsWith("BO", StringComparison.InvariantCultureIgnoreCase)).ToList(); + + var actual = _fakeRepository.AsQueryable() + .ApplyFiltering(gq, mapper) + .ToList(); + + Assert.Equal(expected.Count, actual.Count); + Assert.Equal(expected, actual); + Assert.True(actual.Any()); + } + + [Fact] + public void ApplyFiltering_NotStartWith_GlobalCaseInsensitiveSearch() + { + var mapper = new GridifyMapper(m => m.CaseInsensitiveFiltering = true).GenerateMappings(); + + var gq = new GridifyQuery { Filter = "name!^BO" }; + + var expected = _fakeRepository.Where(q => !q.Name!.StartsWith("BO", StringComparison.InvariantCultureIgnoreCase)).ToList(); + + var actual = _fakeRepository.AsQueryable() + .ApplyFiltering(gq, mapper) + .ToList(); + + Assert.Equal(expected.Count, actual.Count); + Assert.Equal(expected, actual); + Assert.True(actual.Any()); + } + + [Fact] + public void ApplyFiltering_EndWith_GlobalCaseInsensitiveSearch() + { + var mapper = new GridifyMapper(m => m.CaseInsensitiveFiltering = true).GenerateMappings(); + + var gq = new GridifyQuery { Filter = "name$OB" }; + + var expected = _fakeRepository.Where(q => q.Name!.EndsWith("OB", StringComparison.InvariantCultureIgnoreCase)).ToList(); + + var actual = _fakeRepository.AsQueryable() + .ApplyFiltering(gq, mapper) + .ToList(); + + Assert.Equal(expected.Count, actual.Count); + Assert.Equal(expected, actual); + Assert.True(actual.Any()); + } + + [Fact] + public void ApplyFiltering_NotEndWith_GlobalCaseInsensitiveSearch() + { + var mapper = new GridifyMapper(m => m.CaseInsensitiveFiltering = true).GenerateMappings(); + + var gq = new GridifyQuery { Filter = "name!$OB" }; + + var expected = _fakeRepository.Where(q => !q.Name!.EndsWith("OB", StringComparison.InvariantCultureIgnoreCase)).ToList(); + + var actual = _fakeRepository.AsQueryable() + .ApplyFiltering(gq, mapper) + .ToList(); + + Assert.Equal(expected.Count, actual.Count); + Assert.Equal(expected, actual); + Assert.True(actual.Any()); + } + +}