Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New feature Global DefaultStringComparisonIsCaseInsensitive #194

Merged
merged 8 commits into from
Aug 16, 2024
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 All @@ -7,7 +9,7 @@

namespace Gridify.Builder;

public class LinqQueryBuilder<T>(IGridifyMapper<T> mapper) : BaseQueryBuilder<Expression<Func<T, bool>>, T>(mapper)

Check warning on line 12 in src/Gridify/Builder/LinqQueryBuilder.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Parameter 'IGridifyMapper<T> mapper' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 12 in src/Gridify/Builder/LinqQueryBuilder.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Parameter 'IGridifyMapper<T> mapper' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 12 in src/Gridify/Builder/LinqQueryBuilder.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Parameter 'IGridifyMapper<T> mapper' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 12 in src/Gridify/Builder/LinqQueryBuilder.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Parameter 'IGridifyMapper<T> mapper' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 12 in src/Gridify/Builder/LinqQueryBuilder.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Parameter 'IGridifyMapper<T> mapper' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 12 in src/Gridify/Builder/LinqQueryBuilder.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Parameter 'IGridifyMapper<T> mapper' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 12 in src/Gridify/Builder/LinqQueryBuilder.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Parameter 'IGridifyMapper<T> mapper' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 12 in src/Gridify/Builder/LinqQueryBuilder.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Parameter 'IGridifyMapper<T> mapper' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 12 in src/Gridify/Builder/LinqQueryBuilder.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Parameter 'IGridifyMapper<T> mapper' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 12 in src/Gridify/Builder/LinqQueryBuilder.cs

View workflow job for this annotation

GitHub Actions / build

Parameter 'IGridifyMapper<T> mapper' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 12 in src/Gridify/Builder/LinqQueryBuilder.cs

View workflow job for this annotation

GitHub Actions / build

Parameter 'IGridifyMapper<T> mapper' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 12 in src/Gridify/Builder/LinqQueryBuilder.cs

View workflow job for this annotation

GitHub Actions / build

Parameter 'IGridifyMapper<T> mapper' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 12 in src/Gridify/Builder/LinqQueryBuilder.cs

View workflow job for this annotation

GitHub Actions / build

Parameter 'IGridifyMapper<T> mapper' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 12 in src/Gridify/Builder/LinqQueryBuilder.cs

View workflow job for this annotation

GitHub Actions / build

Parameter 'IGridifyMapper<T> mapper' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 12 in src/Gridify/Builder/LinqQueryBuilder.cs

View workflow job for this annotation

GitHub Actions / build

Parameter 'IGridifyMapper<T> mapper' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 12 in src/Gridify/Builder/LinqQueryBuilder.cs

View workflow job for this annotation

GitHub Actions / build

Parameter 'IGridifyMapper<T> mapper' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 12 in src/Gridify/Builder/LinqQueryBuilder.cs

View workflow job for this annotation

GitHub Actions / build

Parameter 'IGridifyMapper<T> mapper' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 12 in src/Gridify/Builder/LinqQueryBuilder.cs

View workflow job for this annotation

GitHub Actions / build

Parameter 'IGridifyMapper<T> mapper' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.
{
protected override Expression<Func<T, bool>>? BuildNestedQuery(
Expression body, IGMap<T> gMap, ValueExpressionSyntax value, ISyntaxNode op)
Expand Down Expand Up @@ -115,6 +117,15 @@

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 @@
: 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 @@
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 @@
{
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 @@
}
}

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 @@
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.

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

View workflow job for this annotation

GitHub Actions / build

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
Loading