diff --git a/QueryKit.IntegrationTests/Tests/NullFilteringTests.cs b/QueryKit.IntegrationTests/Tests/NullFilteringTests.cs index 2f2e679..e9c4289 100644 --- a/QueryKit.IntegrationTests/Tests/NullFilteringTests.cs +++ b/QueryKit.IntegrationTests/Tests/NullFilteringTests.cs @@ -69,6 +69,46 @@ public async Task can_filter_nullable_string_not_equals_null() people[0].Title.Should().NotBeNull(); } + [Theory] + [InlineData("""Title == "null" """, "TextNull")] + [InlineData("""Title != "null" """, "RealNull,Other")] + [InlineData("""Title @= "null" """, "TextNull")] + [InlineData("""Title ^^ ["null"] """, "TextNull")] + [InlineData("""Title == null""", "RealNull")] + [InlineData("""Title != null""", "TextNull,Other")] + public async Task quoted_null_matches_the_text_null_and_unquoted_null_matches_null(string filter, string expectedFirstNames) + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var uniqueLastName = $"QuotedNullTest_{Guid.NewGuid()}"; + var personWithNullTitle = new FakeTestingPersonBuilder() + .WithTitle(null) + .WithFirstName("RealNull") + .WithLastName(uniqueLastName) + .Build(); + var personWithTextNullTitle = new FakeTestingPersonBuilder() + .WithTitle("null") + .WithFirstName("TextNull") + .WithLastName(uniqueLastName) + .Build(); + var personWithOtherTitle = new FakeTestingPersonBuilder() + .WithTitle("Mr.") + .WithFirstName("Other") + .WithLastName(uniqueLastName) + .Build(); + await testingServiceScope.InsertAsync(personWithNullTitle, personWithTextNullTitle, personWithOtherTitle); + + var input = $"""({filter}) && LastName == "{uniqueLastName}" """; + + // Act + var queryablePeople = testingServiceScope.DbContext().People; + var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input); + var people = await appliedQueryable.ToListAsync(); + + // Assert + people.Select(x => x.FirstName).Should().BeEquivalentTo(expectedFirstNames.Split(',')); + } + [Fact] public async Task can_filter_nullable_int_equals_null() { diff --git a/QueryKit.UnitTests/EnumerableFilteringTests.cs b/QueryKit.UnitTests/EnumerableFilteringTests.cs index b65729f..8c4aa91 100644 --- a/QueryKit.UnitTests/EnumerableFilteringTests.cs +++ b/QueryKit.UnitTests/EnumerableFilteringTests.cs @@ -259,6 +259,29 @@ public void can_apply_query_kit_data_to_enumerable() result.Select(x => x.Title).Should().Equal("high", "middle"); } + [Theory] + [InlineData("""Email == "null" """, "Bob")] + [InlineData("""Email != "null" """, "Alice,Carol")] + [InlineData("""Email ^^ ["null"] """, "Bob")] + [InlineData("""Email == null""", "Alice")] + [InlineData("""Email != null""", "Bob,Carol")] + public void quoted_null_matches_the_text_null_and_unquoted_null_matches_null(string filter, string expectedNames) + { + // Arrange + var items = new List + { + new() { Name = "Alice", Email = null }, + new() { Name = "Bob", Email = "null" }, + new() { Name = "Carol", Email = "carol@yahoo.com" } + }; + + // Act + var result = items.ApplyQueryKitFilter(filter).ToList(); + + // Assert + string.Join(",", result.Select(x => x.Name)).Should().Be(expectedNames); + } + private class PersonWithNullableEmail { public string Name { get; set; } = null!; diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 28bc08f..2681f88 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -70,6 +70,34 @@ public void can_handle_null() filterExpression.ToDisplayString().Should().Be("x => (x.Title == null)"); } + [Theory] + [InlineData("""Title == "null" """, "x => (x.Title == \"null\")")] + [InlineData("""Title != "null" """, "x => (x.Title != \"null\")")] + [InlineData("""Title @= "null" """, "x => ((x.Title != null) AndAlso x.Title.Contains(\"null\"))")] + [InlineData("""Title !@= "null" """, "x => ((x.Title == null) OrElse Not(x.Title.Contains(\"null\")))")] + [InlineData("""Title ==* "null" """, "x => ((x.Title != null) AndAlso (x.Title.ToLower() == \"null\".ToLower()))")] + public void quoted_null_is_text_for_a_string(string input, string expected) + { + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToDisplayString().Should().Be(expected); + } + + [Fact] + public void unquoted_null_is_null_for_a_string_not_equal() + { + var input = "Title != null"; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToDisplayString().Should().Be("x => (x.Title != null)"); + } + + [Fact] + public void quoted_null_is_null_for_a_type_that_has_no_text() + { + var input = """Age == "null" """; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToDisplayString().Should().Be("x => (x.Age == null)"); + } + [Fact] public void can_handle_guid_with_double_quotes() { diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index d12d118..5c03726 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -437,7 +437,7 @@ from trailingSpaces in Parse.WhiteSpace.Many() { typeof(sbyte), value => sbyte.Parse(value, CultureInfo.InvariantCulture) }, }; - private static Expression CreateRightExpr(Expression leftExpr, string right, ComparisonOperator op, + private static Expression CreateRightExpr(Expression leftExpr, string right, bool rightIsQuotedLiteral, ComparisonOperator op, IQueryKitConfiguration? config = null, string? propertyPath = null, string? memberPath = null) { var targetType = leftExpr.Type; @@ -519,11 +519,11 @@ private static Expression CreateRightExpr(Expression leftExpr, string right, Com { // The lookup by query name above misses a property with a different query name. v1.14.2 then threw, // because it can not read a value of this type, so find the conversion by the property path instead. - return CreateStringConversionRightExpr(leftExpr.Type, right) ?? CreateRightExprFromType(targetType, right, op); + return CreateStringConversionRightExpr(leftExpr.Type, right) ?? CreateRightExprFromType(targetType, right, rightIsQuotedLiteral, op); } } - return CreateRightExprFromType(targetType, right, op); + return CreateRightExprFromType(targetType, right, rightIsQuotedLiteral, op); } // Builds the right side for a property with HasConversion() from a constructor that takes a string. @@ -553,7 +553,7 @@ private static bool CanCreateRightExprFromType(Type type) return IsEnumerable(type) || TypeConversionFunctions.ContainsKey(targetType) || targetType.IsEnum || targetType == typeof(object); } - private static Expression CreateRightExprFromType(Type leftExprType, string right, ComparisonOperator op) + private static Expression CreateRightExprFromType(Type leftExprType, string right, bool rightIsQuotedLiteral, ComparisonOperator op) { var isEnumerable = IsEnumerable(leftExprType); var targetType = leftExprType; @@ -564,7 +564,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ return FilterValue.Create(intVal, typeof(int)); } targetType = targetType.GetGenericArguments()[0]; - return CreateRightExprFromType(targetType, right, op); + return CreateRightExprFromType(targetType, right, rightIsQuotedLiteral, op); } var rawType = targetType; @@ -573,7 +573,8 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ if (TypeConversionFunctions.TryGetValue(targetType, out var conversionFunction)) { - if (right == "null") + // A quoted "null" is the text null for a string. Other types have no text value, so it stays a null. + if (right == "null" && !(rightIsQuotedLiteral && targetType == typeof(string))) { if (rawType == typeof(Guid?)) { @@ -910,12 +911,12 @@ private static Parser ComparisonExprParser(ParameterExpression pa var leftExprForRightSide = guidConfig?.UsesConversion == true && guidConfig.ConversionTargetType == typeof(string) ? guidStringExpr : leftExpr; - return temp.op.GetExpression(guidStringExpr, CreateRightExpr(leftExprForRightSide, temp.right, temp.op, config, guidPropertyPath), + return temp.op.GetExpression(guidStringExpr, CreateRightExpr(leftExprForRightSide, temp.right, temp.rightIsQuotedLiteral, temp.op, config, guidPropertyPath), config?.DbContextType, ResolveCaseMode(guidPropertyPath, config)); } // For non-string operators, use direct GUID comparison - return temp.op.GetExpression(leftExpr, CreateRightExpr(leftExpr, temp.right, temp.op, config, guidPropertyPath), + return temp.op.GetExpression(leftExpr, CreateRightExpr(leftExpr, temp.right, temp.rightIsQuotedLiteral, temp.op, config, guidPropertyPath), config?.DbContextType); } @@ -1025,7 +1026,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa } } - var rightExpr = CreateRightExpr(leftExprForComparison, temp.right, temp.op, config, propertyPath); + var rightExpr = CreateRightExpr(leftExprForComparison, temp.right, temp.rightIsQuotedLiteral, temp.op, config, propertyPath); // Handle nested collection filtering if (leftExprForComparison is MethodCallExpression methodCall && IsNestedCollectionExpression(methodCall)) @@ -1246,7 +1247,7 @@ private static Parser PropertyListComparisonExprParser( .SelectMany(properties => comparisonOperatorParser, (properties, op) => new { properties, op }) .SelectMany(temp => rightSideValueParser, - (temp, rightValue) => new { temp.properties, temp.op, right = rightValue.Value, cultureNumberPrefix = rightValue.CultureNumberPrefix }) + (temp, rightValue) => new { temp.properties, temp.op, right = rightValue.Value, rightIsQuotedLiteral = rightValue.IsQuotedLiteral, cultureNumberPrefix = rightValue.CultureNumberPrefix }) .Select(clause => BuildClauseLikeV1142(clause.cultureNumberPrefix, clause.right, right => { var temp = clause with { right = right }; @@ -1294,7 +1295,7 @@ private static Parser PropertyListComparisonExprParser( leftExpr = HandleGuidConversion(leftExpr, leftExpr.Type); } - var rightExpr = CreateRightExpr(leftExpr, temp.right, temp.op, config, fullPropPath, reference.Path); + var rightExpr = CreateRightExpr(leftExpr, temp.right, temp.rightIsQuotedLiteral, temp.op, config, fullPropPath, reference.Path); var comparison = temp.op.GetExpression(leftExpr, rightExpr, config?.DbContextType, ResolveCaseMode(fullPropPath, config)); // Combine with AND for negative operators, OR for positive operators