Skip to content

Commit

Permalink
feat: CaseInsensitiveFiltering settings and general improvements (#194)
Browse files Browse the repository at this point in the history
* New feature - #193

* PR refactors

* fix: issue 193 and case sensitivity support improvements

* test: add string operator test cases

* fix: other string operators case insensitive search

---------

Co-authored-by: AliReZa Sabouri <alirezanet@outlook.com>
  • Loading branch information
moxplod and alirezanet authored Aug 16, 2024
1 parent 33718be commit 0f4b323
Show file tree
Hide file tree
Showing 10 changed files with 370 additions and 54 deletions.
7 changes: 7 additions & 0 deletions docs/pages/guide/gridifyGlobalConfiguration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions docs/pages/guide/gridifyMapper.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,17 @@ By setting this to `false`, Gridify don't allow searching on null values using t
var mapper = new GridifyMapper<Person>(q => q.AllowNullSearch = false);
```

### CaseInsensitiveFiltering

If true, string comparison operations are case insensitive by default.

- type: `bool`
- default: `false`

``` csharp
var mapper = new GridifyMapper<Person>(q => q.CaseInsensitiveFiltering = true);
```

### DefaultDateTimeKind

By setting this property to a `DateTimeKind` value, you can change the default `DateTimeKind` used when parsing dates.
Expand Down
12 changes: 0 additions & 12 deletions src/Gridify/Builder/BaseQueryBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using System;
using System.ComponentModel;
using System.Linq.Expressions;
using Gridify.Reflection;
using Gridify.Syntax;

namespace Gridify.Builder;
Expand Down Expand Up @@ -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;
}
Expand Down
128 changes: 92 additions & 36 deletions src/Gridify/Builder/LinqQueryBuilder.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -115,6 +117,15 @@ protected override Expression<Func<T, bool>> 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;
Expand All @@ -131,6 +142,15 @@ protected override Expression<Func<T, bool>> 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));
Expand Down Expand Up @@ -174,60 +194,74 @@ protected override Expression<Func<T, bool>> 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);
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}

Expand Down
7 changes: 7 additions & 0 deletions src/Gridify/GridifyGlobalConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ public static class GridifyGlobalConfiguration
/// </summary>
public static bool DisableNullChecks { get; set; } = false;

/// <summary>
/// By default, string comparison is case sensitive.
/// You can change this behavior by setting this property to true.
/// Default is false
/// </summary>
public static bool CaseInsensitiveFiltering { get; set; } = false;

/// <summary>
/// By default, DateTimeKind.Unspecified is used.
/// You can change this behavior by setting this property to a DateTimeKind value.
Expand Down
6 changes: 6 additions & 0 deletions src/Gridify/GridifyMapperConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ public record GridifyMapperConfiguration
/// </summary>
public bool IgnoreNotMappedFields { get; set; } = GridifyGlobalConfiguration.IgnoreNotMappedFields;

/// <summary>
/// If true, string comparison operations are case insensitive by default.
/// Default is false
/// </summary>
public bool CaseInsensitiveFiltering { get; set; } = GridifyGlobalConfiguration.CaseInsensitiveFiltering;

/// <summary>
/// By default, DateTimeKind.Unspecified is used.
/// You can change this behavior by setting this property to a DateTimeKind value.
Expand Down
30 changes: 25 additions & 5 deletions src/Gridify/Reflection/MethodInfoHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)])!;
}
}
65 changes: 65 additions & 0 deletions test/EntityFrameworkPostgreSqlIntegrationTests/Issue193Tests.cs
Original file line number Diff line number Diff line change
@@ -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<Test>(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; }

Check warning on line 51 in test/EntityFrameworkPostgreSqlIntegrationTests/Issue193Tests.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Non-nullable property 'FavouriteColorList' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

public static IQueryable<Test> GetTestDataSource()
{
return new List<Test>()
{
new() { FavouriteColorList = ["Green", "Blue"] },
new() { FavouriteColorList = ["White", "Yellow"] },
new() { FavouriteColorList = ["Red", "Orange"] },
new() { FavouriteColorList = ["Purple", "Pink"] },
new() { FavouriteColorList = ["Black", "Gray"] }
}.AsQueryable();
}
}
}
Loading

0 comments on commit 0f4b323

Please sign in to comment.