diff --git a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs index 99bf6bd..441606c 100644 --- a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs +++ b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs @@ -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(); @@ -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] diff --git a/QueryKit.UnitTests/FilterParsingRegressionTests.cs b/QueryKit.UnitTests/FilterParsingRegressionTests.cs index 4bb5df8..33898fa 100644 --- a/QueryKit.UnitTests/FilterParsingRegressionTests.cs +++ b/QueryKit.UnitTests/FilterParsingRegressionTests.cs @@ -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(input); - result.Select(x => x.Title).Should().Equal("whole second"); + act.Should().Throw(); } - 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() { diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index b77608b..e501818 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -214,14 +214,12 @@ private static readonly Parser DoubleQuoteParser private static readonly Parser DateTimeTimeParser = Parse.Regex(@"T\d{2}:\d{2}:\d{2}").Text().Optional().Select(x => x.GetOrElse("")); private static readonly Parser DateTimeMicrosParser = Parse.Regex(@"\.\d{1,7}").Text().Optional().Select(x => x.GetOrElse("")); private static readonly Parser 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 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 ','. @@ -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; @@ -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; @@ -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; @@ -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) @@ -792,12 +769,12 @@ private static Parser ComparisonExprParser(ParameterExpression pa if (temp.op.IsStringComparisonOperator()) { var guidStringExpr = HandleGuidConversion(leftExpr, leftExpr.Type); - return temp.op.GetExpression(guidStringExpr, CreateRightExpr(leftExpr, temp.right, temp.rightIsQuotedLiteral, temp.op, config, guidPropertyPath), + return temp.op.GetExpression(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(leftExpr, CreateRightExpr(leftExpr, temp.right, temp.rightIsQuotedLiteral, temp.op, config, guidPropertyPath), + return temp.op.GetExpression(leftExpr, CreateRightExpr(leftExpr, temp.right, temp.op, config, guidPropertyPath), config?.DbContextType); } @@ -907,7 +884,7 @@ private static Parser ComparisonExprParser(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)) @@ -1125,7 +1102,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, rightIsQuotedLiteral = rightValue.IsQuotedLiteral }) + (temp, rightValue) => new { temp.properties, temp.op, right = rightValue.Value }) .Select(temp => { if (!temp.properties.Any()) @@ -1172,7 +1149,7 @@ private static Parser PropertyListComparisonExprParser( 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(leftExpr, rightExpr, config?.DbContextType, ResolveCaseMode(fullPropPath, config)); // Combine with AND for negative operators, OR for positive operators diff --git a/README.md b/README.md index 2899dc1..b34f4a4 100644 --- a/README.md +++ b/README.md @@ -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: