diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index f0235dd..0e69729 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -42,7 +42,7 @@ public void complex_with_lots_of_types() var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToDisplayString().Should() - .Be(""""x => (((((((((x.Title != null) AndAlso x.Title.ToLower().Contains("waffle & chicken".ToLower())) AndAlso (x.Age > 30)) OrElse (x.Id == aa648248-cb69-4217-ac95-d7484795afb2)) OrElse (x.Title == "lamb")) OrElse (x.Title == null)) AndAlso ((x.Age < 18) OrElse ((x.BirthMonth == new Nullable`1(January)) AndAlso x.Title.StartsWith("ally")))) OrElse (x.Rating > 3.5)) OrElse ((x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00))) AndAlso ((x.Date == new Nullable`1(new DateOnly(2022, 7, 1))) OrElse (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0))))))""""); + .Be(""""x => (((((((((x.Title != null) AndAlso x.Title.ToLower().Contains("waffle & chicken".ToLower())) AndAlso (x.Age > 30)) OrElse (x.Id == aa648248-cb69-4217-ac95-d7484795afb2)) OrElse (x.Title == "lamb")) OrElse (x.Title == null)) AndAlso ((x.Age < 18) OrElse ((x.BirthMonth == new Nullable`1(January)) AndAlso ((x.Title != null) AndAlso x.Title.StartsWith("ally"))))) OrElse (x.Rating > 3.5)) OrElse ((x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00))) AndAlso ((x.Date == new Nullable`1(new DateOnly(2022, 7, 1))) OrElse (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0))))))""""); } [Fact] @@ -173,7 +173,7 @@ public void starts_with_operator() { var input = """Title _= "lam" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => x.Title.StartsWith(\"lam\")"); + filterExpression.ToDisplayString().Should().Be("x => ((x.Title != null) AndAlso x.Title.StartsWith(\"lam\"))"); } [Fact] @@ -189,7 +189,7 @@ public void ends_with_operator() { var input = """Title _-= "b" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => x.Title.EndsWith(\"b\")"); + filterExpression.ToDisplayString().Should().Be("x => ((x.Title != null) AndAlso x.Title.EndsWith(\"b\"))"); } [Fact] @@ -372,7 +372,7 @@ public void multiple_properties_and_operators() { var input = """Title _= "lamb" && Age >= 25 && Rating < 4.5 && SpecificDate <= 2022-07-01T00:00:03Z && Time == 00:00:03"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => ((((x.Title.StartsWith(\"lamb\") AndAlso (x.Age >= 25)) AndAlso (x.Rating < 4.5)) AndAlso (x.SpecificDate <= new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00)))) AndAlso (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0))))"); + filterExpression.ToDisplayString().Should().Be("x => ((((((x.Title != null) AndAlso x.Title.StartsWith(\"lamb\")) AndAlso (x.Age >= 25)) AndAlso (x.Rating < 4.5)) AndAlso (x.SpecificDate <= new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00)))) AndAlso (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0))))"); } [Fact] @@ -380,7 +380,7 @@ public void complex_filter_with_nested_parentheses() { var input = """(Title == "lamb" && ((Age >= 25 && Rating < 4.5) || (SpecificDate <= 2022-07-01T00:00:03Z && Time == 00:00:03)) && (Favorite == true || Email.Value _= "example"))"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("""x => (((x.Title == "lamb") AndAlso (((x.Age >= 25) AndAlso (x.Rating < 4.5)) OrElse ((x.SpecificDate <= new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00))) AndAlso (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0)))))) AndAlso ((x.Favorite == True) OrElse x.Email.Value.StartsWith("example")))"""); + filterExpression.ToDisplayString().Should().Be("""x => (((x.Title == "lamb") AndAlso (((x.Age >= 25) AndAlso (x.Rating < 4.5)) OrElse ((x.SpecificDate <= new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00))) AndAlso (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0)))))) AndAlso ((x.Favorite == True) OrElse ((x.Email.Value != null) AndAlso x.Email.Value.StartsWith("example"))))"""); } [Fact] @@ -412,7 +412,7 @@ public void ends_with_works() { var input = """Title _-= "lamb" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => x.Title.EndsWith(\"lamb\")"); + filterExpression.ToDisplayString().Should().Be("x => ((x.Title != null) AndAlso x.Title.EndsWith(\"lamb\"))"); } [Fact] @@ -428,7 +428,7 @@ public void contains_is_case_sensitive() { var input = """Title @= "lamb" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => x.Title.Contains(\"lamb\")"); + filterExpression.ToDisplayString().Should().Be("x => ((x.Title != null) AndAlso x.Title.Contains(\"lamb\"))"); } [Fact] @@ -436,7 +436,7 @@ public void not_contains_works() { var input = """Title !@= "lamb" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => Not(x.Title.Contains(\"lamb\"))"); + filterExpression.ToDisplayString().Should().Be("x => ((x.Title == null) OrElse Not(x.Title.Contains(\"lamb\")))"); } [Fact] diff --git a/QueryKit.UnitTests/FilterParsingRegressionTests.cs b/QueryKit.UnitTests/FilterParsingRegressionTests.cs index c2c2f33..c178582 100644 --- a/QueryKit.UnitTests/FilterParsingRegressionTests.cs +++ b/QueryKit.UnitTests/FilterParsingRegressionTests.cs @@ -187,13 +187,13 @@ public void int_property_compares_to_decimal_property(string input, string expec } [Theory] - [InlineData("""Title @= "am" """)] - [InlineData("""Title _= "la" """)] - [InlineData("""Title _-= "mb" """)] - [InlineData("""Title !@= "am" """)] - [InlineData("""Title !_= "la" """)] - [InlineData("""Title !_-= "mb" """)] - public void case_sensitive_string_operator_on_null_property_throws_in_memory(string input) + [InlineData("""Title @= "am" """, new[] { "lamb" })] + [InlineData("""Title _= "la" """, new[] { "lamb" })] + [InlineData("""Title _-= "mb" """, new[] { "lamb" })] + [InlineData("""Title !@= "am" """, new[] { "null", "other" })] + [InlineData("""Title !_= "la" """, new[] { "null", "other" })] + [InlineData("""Title !_-= "mb" """, new[] { "null", "other" })] + public void case_sensitive_string_operator_handles_null_property(string input, string[] expectedFirstNames) { var people = new[] { @@ -202,9 +202,9 @@ public void case_sensitive_string_operator_on_null_property_throws_in_memory(str new TestingPerson { Title = "other", FirstName = "other" }, }; - var act = () => people.AsQueryable().ApplyQueryKitFilter(input).ToList(); + var result = people.AsQueryable().ApplyQueryKitFilter(input).ToList(); - act.Should().Throw(); + result.Select(x => x.FirstName).Should().Equal(expectedFirstNames); } public static IEnumerable ComparisonOperatorFactories() => diff --git a/QueryKit.UnitTests/OperatorAliasTests.cs b/QueryKit.UnitTests/OperatorAliasTests.cs index 234d86f..68c83ff 100644 --- a/QueryKit.UnitTests/OperatorAliasTests.cs +++ b/QueryKit.UnitTests/OperatorAliasTests.cs @@ -153,7 +153,7 @@ public void can_use_contains_not_case_sensitive() config.CaseInsensitiveAppendix = "$"; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => x.Title.Contains("titilating")"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title != null) AndAlso x.Title.Contains("titilating"))"""); } [Fact] diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 789ef3f..6fcee20 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -383,7 +383,10 @@ public override Expression GetExpression(Expression left, Expression right, T return Expression.AndAlso(nullCheck, containsCall); } - return Expression.Call(left, typeof(string).GetMethod("Contains", new[] { typeof(string) })!, right); + // null doesn't contain anything, so we need: left != null && left.Contains(right) + return Expression.AndAlso( + Expression.NotEqual(left, Expression.Constant(null, typeof(string))), + Expression.Call(left, typeof(string).GetMethod("Contains", new[] { typeof(string) })!, right)); } } @@ -415,7 +418,10 @@ public override Expression GetExpression(Expression left, Expression right, T return Expression.AndAlso(nullCheck, startsWithCall); } - return Expression.Call(left, typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!, right); + // null doesn't start with anything, so we need: left != null && left.StartsWith(right) + return Expression.AndAlso( + Expression.NotEqual(left, Expression.Constant(null, typeof(string))), + Expression.Call(left, typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!, right)); } } @@ -447,7 +453,10 @@ public override Expression GetExpression(Expression left, Expression right, T return Expression.AndAlso(nullCheck, endsWithCall); } - return Expression.Call(left, typeof(string).GetMethod("EndsWith", new[] { typeof(string) })!, right); + // null doesn't end with anything, so we need: left != null && left.EndsWith(right) + return Expression.AndAlso( + Expression.NotEqual(left, Expression.Constant(null, typeof(string))), + Expression.Call(left, typeof(string).GetMethod("EndsWith", new[] { typeof(string) })!, right)); } } @@ -479,7 +488,10 @@ public override Expression GetExpression(Expression left, Expression right, T return Expression.OrElse(nullCheck, notContainsCall); } - return Expression.Not(Expression.Call(left, typeof(string).GetMethod("Contains", new[] { typeof(string) })!, right)); + // null doesn't contain anything, so it should be included: left == null || !left.Contains(right) + return Expression.OrElse( + Expression.Equal(left, Expression.Constant(null, typeof(string))), + Expression.Not(Expression.Call(left, typeof(string).GetMethod("Contains", new[] { typeof(string) })!, right))); } } @@ -511,7 +523,10 @@ public override Expression GetExpression(Expression left, Expression right, T return Expression.OrElse(nullCheck, notStartsWithCall); } - return Expression.Not(Expression.Call(left, typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!, right)); + // null doesn't start with anything, so it should be included: left == null || !left.StartsWith(right) + return Expression.OrElse( + Expression.Equal(left, Expression.Constant(null, typeof(string))), + Expression.Not(Expression.Call(left, typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!, right))); } } @@ -543,7 +558,10 @@ public override Expression GetExpression(Expression left, Expression right, T return Expression.OrElse(nullCheck, notEndsWithCall); } - return Expression.Not(Expression.Call(left, typeof(string).GetMethod("EndsWith", new[] { typeof(string) })!, right)); + // null doesn't end with anything, so it should be included: left == null || !left.EndsWith(right) + return Expression.OrElse( + Expression.Equal(left, Expression.Constant(null, typeof(string))), + Expression.Not(Expression.Call(left, typeof(string).GetMethod("EndsWith", new[] { typeof(string) })!, right))); } }