Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions QueryKit.IntegrationTests/Tests/NullFilteringTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
23 changes: 23 additions & 0 deletions QueryKit.UnitTests/EnumerableFilteringTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PersonWithNullableEmail>
{
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!;
Expand Down
28 changes: 28 additions & 0 deletions QueryKit.UnitTests/FilterParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestingPerson>(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<TestingPerson>(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<TestingPerson>(input);
filterExpression.ToDisplayString().Should().Be("x => (x.Age == null)");
}

[Fact]
public void can_handle_guid_with_double_quotes()
{
Expand Down
23 changes: 12 additions & 11 deletions QueryKit/FilterParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>() from a constructor that takes a string.
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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?))
{
Expand Down Expand Up @@ -910,12 +911,12 @@ private static Parser<Expression> ComparisonExprParser<T>(ParameterExpression pa
var leftExprForRightSide = guidConfig?.UsesConversion == true && guidConfig.ConversionTargetType == typeof(string)
? guidStringExpr
: leftExpr;
return temp.op.GetExpression<T>(guidStringExpr, CreateRightExpr(leftExprForRightSide, temp.right, temp.op, config, guidPropertyPath),
return temp.op.GetExpression<T>(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<T>(leftExpr, CreateRightExpr(leftExpr, temp.right, temp.op, config, guidPropertyPath),
return temp.op.GetExpression<T>(leftExpr, CreateRightExpr(leftExpr, temp.right, temp.rightIsQuotedLiteral, temp.op, config, guidPropertyPath),
config?.DbContextType);
}

Expand Down Expand Up @@ -1025,7 +1026,7 @@ private static Parser<Expression> ComparisonExprParser<T>(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))
Expand Down Expand Up @@ -1246,7 +1247,7 @@ private static Parser<Expression> PropertyListComparisonExprParser<T>(
.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 };
Expand Down Expand Up @@ -1294,7 +1295,7 @@ private static Parser<Expression> PropertyListComparisonExprParser<T>(
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<T>(leftExpr, rightExpr, config?.DbContextType, ResolveCaseMode(fullPropPath, config));

// Combine with AND for negative operators, OR for positive operators
Expand Down
Loading