Skip to content

Commit 628724a

Browse files
Optional ToLower() for string filters (#298)
1 parent 4b9b56e commit 628724a

File tree

6 files changed

+148
-25
lines changed

6 files changed

+148
-25
lines changed

net/DevExtreme.AspNet.Data.Tests/FilterExpressionCompilerTests.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,42 +51,42 @@ public void ComparisonOperations() {
5151
[Fact]
5252
public void StringContains() {
5353
var expr = Compile<DataItem1>(new[] { "StringProp", "contains", "Abc" });
54-
Assert.Equal("obj.StringProp.ToLower().Contains(\"abc\")", expr.Body.ToString());
54+
Assert.Equal("obj.StringProp.Contains(\"Abc\")", expr.Body.ToString());
5555
}
5656

5757
[Fact]
5858
public void StringNotContains() {
5959
var expr = Compile<DataItem1>(new[] { "StringProp", "notContains", "Abc" });
60-
Assert.Equal("Not(obj.StringProp.ToLower().Contains(\"abc\"))", expr.Body.ToString());
60+
Assert.Equal("Not(obj.StringProp.Contains(\"Abc\"))", expr.Body.ToString());
6161
}
6262

6363
[Fact]
6464
public void StartsWith() {
6565
var expr = Compile<DataItem1>(new[] { "StringProp", "startsWith", "Prefix" });
66-
Assert.Equal("obj.StringProp.ToLower().StartsWith(\"prefix\")", expr.Body.ToString());
66+
Assert.Equal("obj.StringProp.StartsWith(\"Prefix\")", expr.Body.ToString());
6767
}
6868

6969
[Fact]
7070
public void EndsWith() {
7171
var expr = Compile<DataItem1>(new[] { "StringProp", "endsWith", "Postfix" });
72-
Assert.Equal("obj.StringProp.ToLower().EndsWith(\"postfix\")", expr.Body.ToString());
72+
Assert.Equal("obj.StringProp.EndsWith(\"Postfix\")", expr.Body.ToString());
7373
}
7474

7575
[Fact]
7676
public void StringFunctionOnNonStringData() {
7777
var expr = Compile<DataItem1>(new[] { "IntProp", "contains", "Abc" });
78-
Assert.Equal("obj.IntProp.ToString().ToLower().Contains(\"abc\")", expr.Body.ToString());
78+
Assert.Equal("obj.IntProp.ToString().Contains(\"Abc\")", expr.Body.ToString());
7979
}
8080

8181
[Fact]
8282
public void StringFunctionGuardNulls() {
8383
Assert.Equal(
84-
@"(IIF((obj == null), null, obj.StringProp) ?? """").ToLower().StartsWith(""abc"")",
84+
@"(IIF((obj == null), null, obj.StringProp) ?? """").StartsWith(""abc"")",
8585
Compile<DataItem1>(new[] { "StringProp", "startswith", "abc" }, true).Body.ToString()
8686
);
8787

8888
Assert.Equal(
89-
@"(IIF((obj == null), null, obj.IntProp.ToString()) ?? """").ToLower().StartsWith(""abc"")",
89+
@"(IIF((obj == null), null, obj.IntProp.ToString()) ?? """").StartsWith(""abc"")",
9090
Compile<DataItem1>(new[] { "IntProp", "startswith", "abc" }, true).Body.ToString()
9191
);
9292
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
using System;
2+
using System.Linq;
3+
using Xunit;
4+
5+
namespace DevExtreme.AspNet.Data.Tests {
6+
7+
public class StringToLowerTests {
8+
9+
[Theory]
10+
[InlineData("=", "(obj.ToLower() == 't')")]
11+
[InlineData("<>", "(obj.ToLower() != 't')")]
12+
[InlineData(">", "(Compare(obj.ToLower(), 't') > 0)")]
13+
[InlineData("<", "(Compare(obj.ToLower(), 't') < 0)")]
14+
[InlineData(">=", "(Compare(obj.ToLower(), 't') >= 0)")]
15+
[InlineData("<=", "(Compare(obj.ToLower(), 't') <= 0)")]
16+
[InlineData("startswith", "obj.ToLower().StartsWith('t')")]
17+
[InlineData("endswith", "obj.ToLower().EndsWith('t')")]
18+
[InlineData("contains", "obj.ToLower().Contains('t')")]
19+
[InlineData("notcontains", "Not(obj.ToLower().Contains('t'))")]
20+
public void True(string op, string expectedExpr) {
21+
AssertFilter<string>(false, true, op, expectedExpr);
22+
}
23+
24+
[Theory]
25+
[InlineData("=", "(obj == 'T')")]
26+
[InlineData("<>", "(obj != 'T')")]
27+
[InlineData(">", "(Compare(obj, 'T') > 0)")]
28+
[InlineData("<", "(Compare(obj, 'T') < 0)")]
29+
[InlineData(">=", "(Compare(obj, 'T') >= 0)")]
30+
[InlineData("<=", "(Compare(obj, 'T') <= 0)")]
31+
[InlineData("startswith", "obj.StartsWith('T')")]
32+
[InlineData("endswith", "obj.EndsWith('T')")]
33+
[InlineData("contains", "obj.Contains('T')")]
34+
[InlineData("notcontains", "Not(obj.Contains('T'))")]
35+
public void False(string op, string expectedExpr) {
36+
AssertFilter<string>(false, false, op, expectedExpr);
37+
}
38+
39+
[Fact]
40+
public void ImplicitTrueForL2O() {
41+
var loadResult = DataSourceLoader.Load(new[] { "T" }, new SampleLoadOptions {
42+
Filter = new[] { "this", "t" },
43+
IsCountQuery = true
44+
});
45+
46+
Assert.Equal(1, loadResult.totalCount);
47+
}
48+
49+
[Fact]
50+
public void ForceToString_ToLower_GuardNulls() {
51+
AssertFilter<int?>(true, true, "contains", "(IIF((obj == null), null, obj.ToString().ToLower()) ?? '').Contains('t')");
52+
}
53+
54+
[Fact]
55+
public void Dynamic() {
56+
var compiler = new FilterExpressionCompiler<dynamic>(true, true);
57+
var expr = compiler.Compile(new object[] {
58+
new[] { "this", "startswith", "1" },
59+
"or",
60+
new[] { "this", "b" },
61+
"or",
62+
new[] { "this", ">=", "c" }
63+
});
64+
65+
var expectedExpr = "(((IIF((obj == null), null, obj.ToString().ToLower()) ?? '').StartsWith('1')"
66+
+ " OrElse (IIF((obj == null), null, obj.ToString().ToLower()) == 'b'))"
67+
+ " OrElse (Compare(IIF((obj == null), null, obj.ToString().ToLower()), 'c') >= 0))";
68+
69+
Assert.Equal(
70+
expectedExpr.Replace("'", "\""),
71+
expr.Body.ToString()
72+
);
73+
74+
var method = expr.Compile();
75+
Assert.True((bool)method.DynamicInvoke(1));
76+
Assert.True((bool)method.DynamicInvoke("b"));
77+
Assert.True((bool)method.DynamicInvoke('c'));
78+
}
79+
80+
void AssertFilter<T>(bool guardNulls, bool stringToLower, string op, string expectedExpr) {
81+
expectedExpr = expectedExpr.Replace("'", "\"");
82+
83+
Assert.Equal(
84+
expectedExpr,
85+
new FilterExpressionCompiler<T>(guardNulls, stringToLower)
86+
.Compile(new[] { "this", op, "T" })
87+
.Body.ToString()
88+
);
89+
}
90+
91+
92+
}
93+
94+
}

net/DevExtreme.AspNet.Data/DataSourceExpressionBuilder.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ namespace DevExtreme.AspNet.Data {
1313
class DataSourceExpressionBuilder<T> {
1414
DataSourceLoadOptionsBase _loadOptions;
1515
bool _guardNulls;
16+
bool _stringToLower;
1617
AnonTypeNewTweaks _anonTypeNewTweaks;
1718

18-
public DataSourceExpressionBuilder(DataSourceLoadOptionsBase loadOptions, bool guardNulls = false, AnonTypeNewTweaks anonTypeNewTweaks = null) {
19+
public DataSourceExpressionBuilder(DataSourceLoadOptionsBase loadOptions, bool guardNulls = false, bool stringToLower = false, AnonTypeNewTweaks anonTypeNewTweaks = null) {
1920
_loadOptions = loadOptions;
2021
_guardNulls = guardNulls;
22+
_stringToLower = stringToLower;
2123
_anonTypeNewTweaks = anonTypeNewTweaks;
2224
}
2325

@@ -38,7 +40,7 @@ Expression BuildCore(Expression expr, bool paginate = false, bool isCountQuery =
3840
var genericTypeArguments = new[] { typeof(T) };
3941

4042
if(_loadOptions.HasFilter)
41-
expr = Expression.Call(queryableType, "Where", genericTypeArguments, expr, Expression.Quote(new FilterExpressionCompiler<T>(_guardNulls).Compile(_loadOptions.Filter)));
43+
expr = Expression.Call(queryableType, "Where", genericTypeArguments, expr, Expression.Quote(new FilterExpressionCompiler<T>(_guardNulls, _stringToLower).Compile(_loadOptions.Filter)));
4244

4345
if(!isCountQuery) {
4446
if(!remoteGrouping) {

net/DevExtreme.AspNet.Data/DataSourceLoadOptionsBase.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ public class DataSourceLoadOptionsBase {
9595
/// </summary>
9696
public string DefaultSort { get; set; }
9797

98+
/// <summary>
99+
/// A flag that indicates whether filter expressions should include a ToLower() call that makes string comparison case-insensitive.
100+
/// Defaults to true for LINQ to Objects, false for any other provider.
101+
/// </summary>
102+
public bool? StringToLower { get; set; }
103+
98104
#if DEBUG
99105
internal Action<Expression> ExpressionWatcher;
100106
internal bool UseEnumerableOnce;

net/DevExtreme.AspNet.Data/DataSourceLoaderImpl.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,15 @@ class DataSourceLoaderImpl<S> {
2525

2626
public DataSourceLoaderImpl(IQueryable<S> source, DataSourceLoadOptionsBase options) {
2727
QueryProviderInfo = new QueryProviderInfo(source.Provider);
28-
Builder = new DataSourceExpressionBuilder<S>(options, QueryProviderInfo.IsLinqToObjects, new AnonTypeNewTweaks {
29-
AllowEmpty = !QueryProviderInfo.IsL2S,
30-
AllowUnusedMembers = !QueryProviderInfo.IsL2S
31-
});
28+
Builder = new DataSourceExpressionBuilder<S>(
29+
options,
30+
QueryProviderInfo.IsLinqToObjects,
31+
options.StringToLower.GetValueOrDefault(QueryProviderInfo.IsLinqToObjects),
32+
new AnonTypeNewTweaks {
33+
AllowEmpty = !QueryProviderInfo.IsL2S,
34+
AllowUnusedMembers = !QueryProviderInfo.IsL2S
35+
}
36+
);
3237
ShouldEmptyGroups = options.HasGroups && !options.Group.Last().GetIsExpanded();
3338
CanUseRemoteGrouping = options.RemoteGrouping ?? ShouldUseRemoteGrouping(QueryProviderInfo, options);
3439
SummaryIsTotalCountOnly = !options.HasGroupSummary && options.HasSummary && options.TotalSummary.All(i => i.SummaryType == AggregateName.COUNT);

net/DevExtreme.AspNet.Data/FilterExpressionCompiler.cs

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@ const string
1616
STARTS_WITH = "startswith",
1717
ENDS_WITH = "endswith";
1818

19-
public FilterExpressionCompiler(bool guardNulls)
19+
bool _stringToLower;
20+
21+
public FilterExpressionCompiler(bool guardNulls, bool stringToLower = false)
2022
: base(guardNulls) {
23+
_stringToLower = stringToLower;
2124
}
2225

2326
public LambdaExpression Compile(IList criteriaJson) {
@@ -45,8 +48,11 @@ Expression CompileBinary(ParameterExpression dataItemExpr, IList criteriaJson) {
4548
var isStringOperation = clientOperation == CONTAINS || clientOperation == NOT_CONTAINS || clientOperation == STARTS_WITH || clientOperation == ENDS_WITH;
4649

4750
var accessorExpr = CompileAccessorExpression(dataItemExpr, clientAccessor, progression => {
48-
if(isStringOperation)
51+
if(isStringOperation || clientAccessor is String && progression.Last().Type == typeof(Object))
4952
ForceToString(progression);
53+
54+
if(_stringToLower)
55+
AddToLower(progression);
5056
});
5157

5258
if(isStringOperation) {
@@ -79,12 +85,12 @@ Expression CompileBinary(ParameterExpression dataItemExpr, IList criteriaJson) {
7985
}
8086
}
8187

88+
if(_stringToLower && clientValue is String)
89+
clientValue = ((string)clientValue).ToLower();
90+
8291
Expression valueExpr = Expression.Constant(clientValue, accessorExpr.Type);
8392

8493
if(accessorExpr.Type == typeof(String) && IsInequality(expressionType)) {
85-
if(clientValue == null)
86-
valueExpr = Expression.Constant(null, typeof(String));
87-
8894
var compareMethod = typeof(String).GetMethod(nameof(String.Compare), new[] { typeof(String), typeof(String) });
8995
accessorExpr = Expression.Call(null, compareMethod, accessorExpr, valueExpr);
9096
valueExpr = Expression.Constant(0);
@@ -103,7 +109,7 @@ bool IsInequality(ExpressionType type) {
103109
}
104110

105111
Expression CompileStringFunction(Expression accessorExpr, string clientOperation, string value) {
106-
if(value != null)
112+
if(_stringToLower && value != null)
107113
value = value.ToLower();
108114

109115
var invert = false;
@@ -116,14 +122,9 @@ Expression CompileStringFunction(Expression accessorExpr, string clientOperation
116122
if(GuardNulls)
117123
accessorExpr = Expression.Coalesce(accessorExpr, Expression.Constant(""));
118124

119-
var toLowerMethod = typeof(String).GetMethod(nameof(String.ToLower), Type.EmptyTypes);
120125
var operationMethod = typeof(String).GetMethod(GetStringOperationMethodName(clientOperation), new[] { typeof(String) });
121126

122-
Expression result = Expression.Call(
123-
Expression.Call(accessorExpr, toLowerMethod),
124-
operationMethod,
125-
Expression.Constant(value)
126-
);
127+
Expression result = Expression.Call(accessorExpr, operationMethod, Expression.Constant(value));
127128

128129
if(invert)
129130
result = Expression.Not(result);
@@ -209,6 +210,21 @@ string GetStringOperationMethodName(string clientOperation) {
209210

210211
return nameof(String.Contains);
211212
}
213+
214+
static void AddToLower(List<Expression> progression) {
215+
var last = progression.Last();
216+
217+
if(last.Type != typeof(String))
218+
return;
219+
220+
var toLowerMethod = typeof(String).GetMethod(nameof(String.ToLower), Type.EmptyTypes);
221+
var toLowerCall = Expression.Call(last, toLowerMethod);
222+
223+
if(last is MethodCallExpression lastCall && lastCall.Method.Name == nameof(ToString))
224+
progression.RemoveAt(progression.Count - 1);
225+
226+
progression.Add(toLowerCall);
227+
}
212228
}
213229

214230
}

0 commit comments

Comments
 (0)