Skip to content
Open
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
31 changes: 31 additions & 0 deletions QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -320,4 +320,35 @@ public async Task has_returns_matching_rows(string input, string[] expectedTitle
// Assert
recipes.Select(x => x.Title[prefix.Length..]).Should().BeEquivalentTo(expectedTitles);
}

[Fact]
public async Task custom_operation_keeps_quoted_value_as_string()
{
// Arrange
var testingServiceScope = new TestingServiceScope();
var firstName = $"custom op {Guid.NewGuid()}";
var fakePersonOne = new FakeTestingPersonBuilder()
.WithFirstName(firstName)
.WithTitle("001")
.Build();
var fakePersonTwo = new FakeTestingPersonBuilder()
.WithFirstName(firstName)
.WithTitle("1")
.Build();
await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo);

var input = $"""titleIs == "001" && {nameof(TestingPerson.FirstName)} == "{firstName}" """;
var config = new QueryKitConfiguration(config =>
{
config.CustomOperation<TestingPerson>((x, op, value) => x.Title == (string)value)
.HasQueryName("titleIs");
});

// Act
var queryablePeople = testingServiceScope.DbContext().People;
var people = await queryablePeople.ApplyQueryKitFilter(input, config).ToListAsync();

// Assert
people.Select(x => x.Id).Should().Equal(fakePersonOne.Id);
}
}
46 changes: 46 additions & 0 deletions QueryKit.UnitTests/FilterParsingRegressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ namespace QueryKit.UnitTests;
using System.Globalization;
using System.Linq.Expressions;
using System.Reflection;
using Configuration;
using Exceptions;
using FluentAssertions;
using Operators;
Expand Down Expand Up @@ -286,6 +287,51 @@ public void has_returns_matching_rows(string input, string[] expectedTitles)
result.Select(x => x.Title).Should().BeEquivalentTo(expectedTitles);
}

[Theory]
[InlineData("""titleIs == "123" """, "123")]
[InlineData("""titleIs == "4.5" """, "4.5")]
[InlineData("""titleIs == "true" """, "true")]
[InlineData("""titleIs == "null" """, "null")]
[InlineData("""titleIs == "" """, "")]
public void custom_operation_keeps_quoted_value_as_string(string input, string expectedTitle)
{
var people = new[] { "123", "4.5", "true", "null", "", "other" }
.Select(title => new TestingPerson { Title = title })
.ToArray();
var config = new QueryKitConfiguration(config =>
{
config.CustomOperation<TestingPerson>((x, op, value) => x.Title == (string)value)
.HasQueryName("titleIs");
});

var result = people.AsQueryable().ApplyQueryKitFilter(input, config).ToList();

result.Select(x => x.Title).Should().Equal(expectedTitle);
}

[Fact]
public void custom_operation_converts_unquoted_number_and_quoted_date()
{
var people = new[]
{
new TestingPerson { Title = "match", Age = 30, SpecificDateTime = new DateTime(2024, 1, 15, 8, 0, 0, DateTimeKind.Utc) },
new TestingPerson { Title = "other", Age = 31, SpecificDateTime = new DateTime(2024, 1, 15, 9, 0, 0, DateTimeKind.Utc) },
};
var config = new QueryKitConfiguration(config =>
{
config.CustomOperation<TestingPerson>((x, op, value) => x.Age == (int)value)
.HasQueryName("ageIs");
config.CustomOperation<TestingPerson>((x, op, value) => x.SpecificDateTime == (DateTime)value)
.HasQueryName("createdAt");
});

var result = people.AsQueryable()
.ApplyQueryKitFilter("""ageIs == 30 && createdAt == "2024-01-15T08:00:00Z" """, config)
.ToList();

result.Select(x => x.Title).Should().Equal("match");
}

private static TResult WithCulture<TResult>(string cultureName, Func<TResult> action)
{
var originalCulture = CultureInfo.CurrentCulture;
Expand Down
46 changes: 26 additions & 20 deletions QueryKit/FilterParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -805,7 +805,7 @@ private static Parser<Expression> ComparisonExprParser<T>(ParameterExpression pa
return RemovedClauseExpression.Instance;
}

return CreateCustomOperationExpression<T>(parameter, temp.reference.Mapping!, temp.op, temp.right);
return CreateCustomOperationExpression<T>(parameter, temp.reference.Mapping!, temp.op, temp.right, temp.rightIsQuotedLiteral);
}

if (temp.reference.Kind == PropertyReferenceKind.Unknown)
Expand Down Expand Up @@ -1484,14 +1484,14 @@ private static Type GetWiderNumericType(Type type1, Type type2)
return rank1 >= rank2 ? type1 : type2;
}

private static Expression CreateCustomOperationExpression<T>(ParameterExpression parameter, QueryKitPropertyInfo customOperationInfo, ComparisonOperator op, string rightValue)
private static Expression CreateCustomOperationExpression<T>(ParameterExpression parameter, QueryKitPropertyInfo customOperationInfo, ComparisonOperator op, string rightValue, bool rightIsQuotedLiteral)
{
if (customOperationInfo.CustomOperation == null)
throw new ArgumentException("Custom operation expression is null");

// For custom operations, we need to convert the string value to the appropriate basic type
// instead of trying to match it to the entity type
object? convertedValue = ConvertStringToBasicType(rightValue);
object? convertedValue = ConvertStringToBasicType(rightValue, rightIsQuotedLiteral);

// Create the parameter expressions for the custom operation
var entityParameter = Expression.Convert(parameter, typeof(object));
Expand All @@ -1505,30 +1505,36 @@ private static Expression CreateCustomOperationExpression<T>(ParameterExpression
return invocationExpression;
}

private static object? ConvertStringToBasicType(string value)
private static object? ConvertStringToBasicType(string value, bool isQuotedLiteral)
{
// Handle null
if (string.IsNullOrEmpty(value) || value.Equals("null", StringComparison.InvariantCultureIgnoreCase))
return null;
// A quoted value is text, so it never becomes null, a boolean, or a number.
// A quoted value in the date format of the grammar or a quoted guid still converts below.
if (!isQuotedLiteral)
{
// Handle null
if (string.IsNullOrEmpty(value) || value.Equals("null", StringComparison.InvariantCultureIgnoreCase))
return null;

// Try boolean
if (bool.TryParse(value, out var boolValue))
return boolValue;
// Try boolean
if (bool.TryParse(value, out var boolValue))
return boolValue;

// Try int
if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var intValue))
return intValue;
// Try int
if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var intValue))
return intValue;

// Try decimal
if (decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var decimalValue))
return decimalValue;
// Try decimal
if (decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var decimalValue))
return decimalValue;

// Try double
if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var doubleValue))
return doubleValue;
// Try double
if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var doubleValue))
return doubleValue;
}

// Try DateTime
if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var dateTimeValue))
if ((!isQuotedLiteral || DateTimeFormatParser.End().TryParse(value).WasSuccessful) &&
DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var dateTimeValue))
return dateTimeValue;

// Try Guid
Expand Down
Loading