Skip to content
Closed
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
14 changes: 6 additions & 8 deletions QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,11 @@ public async Task date_time_offset_value_with_offset_matches_same_instant(string
}

[Theory]
[InlineData("SpecificDateTime == 2024-01-15T08:00:00.500Z", true)]
[InlineData("SpecificDateTime == 2024-01-15T08:00:00Z.5", true)]
[InlineData("SpecificDate == 2024-01-15T10:00:00.5+02:00", true)]
[InlineData("Time == 08:30:00.5", true)]
[InlineData("Time == \"08:30:00.500\"", true)]
[InlineData("Time == \"08:30:00.5\"", false)]
public async Task fractional_second_value_matches_by_its_fraction(string valueFilter, bool expectFractionPerson)
[InlineData("SpecificDateTime == 2024-01-15T08:00:00.500Z")]
[InlineData("SpecificDate == 2024-01-15T10:00:00.5+02:00")]
[InlineData("Time == 08:30:00.5")]
[InlineData("Time == \"08:30:00.5\"")]
public async Task fractional_seconds_are_kept(string valueFilter)
{
// Arrange
var testingServiceScope = new TestingServiceScope();
Expand All @@ -174,7 +172,7 @@ public async Task fractional_second_value_matches_by_its_fraction(string valueFi
var people = await queryablePeople.ApplyQueryKitFilter(input).ToListAsync();

// Assert
people.Select(x => x.Id).Should().Equal(expectFractionPerson ? fakePersonOne.Id : fakePersonTwo.Id);
people.Select(x => x.Id).Should().Equal(fakePersonOne.Id);
}

[Theory]
Expand Down
53 changes: 26 additions & 27 deletions QueryKit.UnitTests/FilterParsingRegressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,46 +137,45 @@ public void enum_list_value_is_split_into_items()
[InlineData("SpecificDateTime ^^ [2024-01-15T08:00:00.5Z]")]
[InlineData("SpecificDate == 2024-01-15T10:00:00.5+02:00")]
[InlineData("SpecificDate == 2024-01-15T08:00:00.5000000Z")]
[InlineData("SpecificDateTime == 2024-01-15T08:00:00Z.5")]
[InlineData("SpecificDate == 2024-01-15T10:00:00+02:00.500")]
[InlineData("Time == 08:30:00.5")]
[InlineData("Time == \"08:30:00.500\"")]
[InlineData("Time == \"08:30:00.5\"")]
[InlineData("Time == \"08:30:00.50\"")]
[InlineData("Time ^^ [08:30:00.5]")]
public void fractional_seconds_are_kept(string input)
{
var result = FractionalSecondPeople().AsQueryable().ApplyQueryKitFilter(input).ToList();
var people = new[]
{
new TestingPerson
{
Title = "match",
SpecificDateTime = new DateTime(2024, 1, 15, 8, 0, 0, 500, DateTimeKind.Utc),
SpecificDate = new DateTimeOffset(2024, 1, 15, 8, 0, 0, 500, TimeSpan.Zero),
Time = new TimeOnly(8, 30, 0, 500),
},
new TestingPerson
{
Title = "whole second",
SpecificDateTime = new DateTime(2024, 1, 15, 8, 0, 0, DateTimeKind.Utc),
SpecificDate = new DateTimeOffset(2024, 1, 15, 8, 0, 0, TimeSpan.Zero),
Time = new TimeOnly(8, 30, 0),
},
};

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

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

[Theory]
[InlineData("Time == \"08:30:00.5\"")]
[InlineData("Time == \"08:30:00.50\"")]
public void quoted_time_with_fewer_than_three_fraction_digits_drops_the_fraction(string input)
[InlineData("SpecificDateTime == 2024-01-15T08:00:00Z.5")]
[InlineData("SpecificDate == 2024-01-15T10:00:00+02:00.500")]
public void zone_before_the_fraction_throws(string input)
{
var result = FractionalSecondPeople().AsQueryable().ApplyQueryKitFilter(input).ToList();
var act = () => FilterParser.ParseFilter<TestingPerson>(input);

result.Select(x => x.Title).Should().Equal("whole second");
act.Should().Throw<ParsingException>();
}

private static TestingPerson[] FractionalSecondPeople() => new[]
{
new TestingPerson
{
Title = "match",
SpecificDateTime = new DateTime(2024, 1, 15, 8, 0, 0, 500, DateTimeKind.Utc),
SpecificDate = new DateTimeOffset(2024, 1, 15, 8, 0, 0, 500, TimeSpan.Zero),
Time = new TimeOnly(8, 30, 0, 500),
},
new TestingPerson
{
Title = "whole second",
SpecificDateTime = new DateTime(2024, 1, 15, 8, 0, 0, DateTimeKind.Utc),
SpecificDate = new DateTimeOffset(2024, 1, 15, 8, 0, 0, TimeSpan.Zero),
Time = new TimeOnly(8, 30, 0),
},
};

[Fact]
public void time_fraction_keeps_microseconds()
{
Expand Down
51 changes: 14 additions & 37 deletions QueryKit/FilterParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -214,14 +214,12 @@ private static readonly Parser<string> DoubleQuoteParser
private static readonly Parser<string> DateTimeTimeParser = Parse.Regex(@"T\d{2}:\d{2}:\d{2}").Text().Optional().Select(x => x.GetOrElse(""));
private static readonly Parser<string> DateTimeMicrosParser = Parse.Regex(@"\.\d{1,7}").Text().Optional().Select(x => x.GetOrElse(""));
private static readonly Parser<string> DateTimeZoneParser = Parse.Regex(@"Z|[+-]\d{2}(:\d{2})?").Text().Optional().Select(x => x.GetOrElse(""));
// v1.14.2 read the zone before the fraction, so 2022-07-01T00:00:02Z.5 is a valid value. A zone after the fraction is also valid.
private static readonly Parser<string> DateTimeFormatParser =
from dateFormat in Parse.Regex(@"\d{4}-\d{2}-\d{2}").Text()
from timeFormat in DateTimeTimeParser
from zoneBeforeMicros in DateTimeZoneParser
from micros in DateTimeMicrosParser
from zoneAfterMicros in zoneBeforeMicros == "" ? DateTimeZoneParser : Parse.Return("")
select dateFormat + timeFormat + micros + zoneBeforeMicros + zoneAfterMicros;
from timeZone in DateTimeZoneParser
select dateFormat + timeFormat + micros + timeZone;

// A number with a '.' decimal point, or with the decimal separator of the current culture.
// The longer match wins, so '4.5' parses in every culture and '4,5' still parses in a culture that uses ','.
Expand Down Expand Up @@ -375,7 +373,7 @@ private static DateTimeOffset ToParameterOffset(DateTimeOffset value)
{ typeof(sbyte), value => sbyte.Parse(value, CultureInfo.InvariantCulture) },
};

private static Expression CreateRightExpr(Expression leftExpr, string right, bool rightIsQuotedLiteral, ComparisonOperator op,
private static Expression CreateRightExpr(Expression leftExpr, string right, ComparisonOperator op,
IQueryKitConfiguration? config = null, string? propertyPath = null)
{
var targetType = leftExpr.Type;
Expand Down Expand Up @@ -446,10 +444,10 @@ private static Expression CreateRightExpr(Expression leftExpr, string right, boo
}
}

return CreateRightExprFromType(targetType, right, rightIsQuotedLiteral, op);
return CreateRightExprFromType(targetType, right, op);
}

private static Expression CreateRightExprFromType(Type leftExprType, string right, bool rightIsQuotedLiteral, ComparisonOperator op)
private static Expression CreateRightExprFromType(Type leftExprType, string right, ComparisonOperator op)
{
var isEnumerable = IsEnumerable(leftExprType);
var targetType = leftExprType;
Expand All @@ -460,7 +458,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ
return FilterValue.Create(intVal, typeof(int));
}
targetType = targetType.GetGenericArguments()[0];
return CreateRightExprFromType(targetType, right, rightIsQuotedLiteral, op);
return CreateRightExprFromType(targetType, right, op);
}

var rawType = targetType;
Expand Down Expand Up @@ -539,30 +537,9 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ
{
var time = TimeOnly.Parse(right, CultureInfo.InvariantCulture);

int millisecond = 0, microsecond = 0;
if (rightIsQuotedLiteral)
{
// Like v1.14.2, the milliseconds of a quoted value need at least 3 fraction digits and the microseconds need at least 6.
if (right.Contains('.'))
{
var fractionalSeconds = right.Split('.')[1];
if (fractionalSeconds.Length >= 3)
{
millisecond = int.Parse(fractionalSeconds.Substring(0, 3));
}
if (fractionalSeconds.Length >= 6)
{
microsecond = int.Parse(fractionalSeconds.Substring(3, 3));
}
}
}
else
{
// v1.14.2 did not accept an unquoted fraction, so an unquoted value keeps its full fraction.
var fractionalTicks = time.Ticks % TimeSpan.TicksPerSecond;
millisecond = (int)(fractionalTicks / TimeSpan.TicksPerMillisecond);
microsecond = (int)(fractionalTicks % TimeSpan.TicksPerMillisecond / 10);
}
var fractionalTicks = time.Ticks % TimeSpan.TicksPerSecond;
var millisecond = (int)(fractionalTicks / TimeSpan.TicksPerMillisecond);
var microsecond = (int)(fractionalTicks % TimeSpan.TicksPerMillisecond / 10);

// One microsecond is 10 ticks. The TimeOnly constructor with microseconds needs .NET 7.
var value = new TimeOnly(time.Hour, time.Minute, time.Second, millisecond)
Expand Down Expand Up @@ -792,12 +769,12 @@ private static Parser<Expression> ComparisonExprParser<T>(ParameterExpression pa
if (temp.op.IsStringComparisonOperator())
{
var guidStringExpr = HandleGuidConversion(leftExpr, leftExpr.Type);
return temp.op.GetExpression<T>(guidStringExpr, CreateRightExpr(leftExpr, temp.right, temp.rightIsQuotedLiteral, temp.op, config, guidPropertyPath),
return temp.op.GetExpression<T>(guidStringExpr, CreateRightExpr(leftExpr, temp.right, 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.rightIsQuotedLiteral, temp.op, config, guidPropertyPath),
return temp.op.GetExpression<T>(leftExpr, CreateRightExpr(leftExpr, temp.right, temp.op, config, guidPropertyPath),
config?.DbContextType);
}

Expand Down Expand Up @@ -907,7 +884,7 @@ private static Parser<Expression> ComparisonExprParser<T>(ParameterExpression pa
}
}

var rightExpr = CreateRightExpr(leftExprForComparison, temp.right, temp.rightIsQuotedLiteral, temp.op, config, propertyPath);
var rightExpr = CreateRightExpr(leftExprForComparison, temp.right, temp.op, config, propertyPath);

// Handle nested collection filtering
if (leftExprForComparison is MethodCallExpression methodCall && IsNestedCollectionExpression(methodCall))
Expand Down Expand Up @@ -1125,7 +1102,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, rightIsQuotedLiteral = rightValue.IsQuotedLiteral })
(temp, rightValue) => new { temp.properties, temp.op, right = rightValue.Value })
.Select(temp =>
{
if (!temp.properties.Any())
Expand Down Expand Up @@ -1172,7 +1149,7 @@ private static Parser<Expression> PropertyListComparisonExprParser<T>(
leftExpr = HandleGuidConversion(leftExpr, leftExpr.Type);
}

var rightExpr = CreateRightExpr(leftExpr, temp.right, temp.rightIsQuotedLiteral, temp.op, config, fullPropPath);
var rightExpr = CreateRightExpr(leftExpr, temp.right, temp.op, config, fullPropPath);
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@ Property list grouping is particularly useful for:
* `TimeOnly`:
* `var filterInput = """Time == "12:30:00" """;`
* `var filterInput = """Time == "12:30:00.678722" """;`
* A time fraction keeps every digit. For example, `"12:30:00.5"` is 12:30:00 and 500 milliseconds.
* In a date and time value, write the zone after the fraction: `2022-07-01T00:00:03.5Z`. An unquoted value with the zone before the fraction (`2022-07-01T00:00:03Z.5`) throws a `ParsingException`.

* `bool` properties need to use `== true`, `== false`, or the same using the `!=` operator. they can not be standalone properies:

Expand Down
Loading