From 85c0b76c0d91796e9125400fe733d32ca59967a0 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:15:15 +0300 Subject: [PATCH] fix(parser)!: read date values without an offset as utc A DateTime or DateTimeOffset value without an offset was read in the time zone of the server. The same filter matched different rows on servers in different zones. List values and custom operation values also used a different rule from scalar values. A value without an offset is now read as UTC. A DateTime value with an offset is converted to UTC. The scalar, list, and custom operation paths use the same two helpers. BREAKING CHANGE: a DateTime or DateTimeOffset filter value without an offset is read as UTC, not in the time zone of the server. A scalar DateTime value now has DateTimeKind.Utc, not DateTimeKind.Local. To keep a local time, send the offset in the value, for example 2022-07-01T00:00:03+03:00. --- .../Tests/FilterParsingRegressionTests.cs | 36 ++++++++++++++++ .../Tests/InListQueryCacheTests.cs | 43 +++++-------------- QueryKit.UnitTests/FilterParserTests.cs | 6 +-- .../FilterParsingRegressionTests.cs | 43 +++++++++++++++++++ QueryKit/FilterParser.cs | 24 ++++++----- 5 files changed, 106 insertions(+), 46 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs index 99bf6bd..6566d67 100644 --- a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs +++ b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs @@ -324,4 +324,40 @@ public async Task has_returns_matching_rows(string input, string[] expectedTitle // Assert recipes.Select(x => x.Title[prefix.Length..]).Should().BeEquivalentTo(expectedTitles); } + + [Theory] + [InlineData("SpecificDateTime == 2024-01-15T08:00:00")] + [InlineData("SpecificDateTime == 2024-01-15T10:00:00+02:00")] + [InlineData("SpecificDateTime ^^ [2024-01-15T08:00:00]")] + [InlineData("SpecificDateTime ^^ [2024-01-15T10:00:00+02:00]")] + [InlineData("SpecificDate == 2024-01-15T08:00:00")] + [InlineData("SpecificDate == 2024-01-15T10:00:00+02:00")] + [InlineData("SpecificDate ^^ [2024-01-15T08:00:00]")] + [InlineData("SpecificDate ^^ [2024-01-15T10:00:00+02:00]")] + public async Task date_time_without_offset_is_utc(string valueFilter) + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var title = $"utc {Guid.NewGuid()}"; + var fakePersonOne = new FakeTestingPersonBuilder() + .WithTitle(title) + .WithSpecificDateTime(new DateTime(2024, 1, 15, 8, 0, 0, DateTimeKind.Utc)) + .WithSpecificDate(new DateTimeOffset(2024, 1, 15, 8, 0, 0, TimeSpan.Zero)) + .Build(); + var fakePersonTwo = new FakeTestingPersonBuilder() + .WithTitle(title) + .WithSpecificDateTime(new DateTime(2024, 1, 15, 9, 0, 0, DateTimeKind.Utc)) + .WithSpecificDate(new DateTimeOffset(2024, 1, 15, 9, 0, 0, TimeSpan.Zero)) + .Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + var input = $"""{nameof(TestingPerson.Title)} == "{title}" && {valueFilter}"""; + + // Act + var queryablePeople = testingServiceScope.DbContext().People; + var people = await queryablePeople.ApplyQueryKitFilter(input).ToListAsync(); + + // Assert + people.Select(x => x.Id).Should().Equal(fakePersonOne.Id); + } } diff --git a/QueryKit.IntegrationTests/Tests/InListQueryCacheTests.cs b/QueryKit.IntegrationTests/Tests/InListQueryCacheTests.cs index a37f5f9..f03f414 100644 --- a/QueryKit.IntegrationTests/Tests/InListQueryCacheTests.cs +++ b/QueryKit.IntegrationTests/Tests/InListQueryCacheTests.cs @@ -29,40 +29,26 @@ public void equal_in_lists_compile_once(string input) cache.Compilations.Should().Be(1); } - // DateTime.Equals ignores Kind. If one cache entry served both requests, the first request would - // decide the result of the second. + // A list value without a zone is read as UTC, so it is the same value as a list value with the UTC zone. + // Both requests filter, and they share one compiled query. [Theory] - [InlineData("^^", true)] - [InlineData("^^*", true)] - [InlineData("!^^", true)] - [InlineData("!^^*", true)] - [InlineData("^^", false)] - [InlineData("^^*", false)] - [InlineData("!^^", false)] - [InlineData("!^^*", false)] - public async Task datetime_in_lists_that_differ_only_in_kind_do_not_share_a_query(string op, bool utcFirst) + [InlineData("^^")] + [InlineData("^^*")] + [InlineData("!^^")] + [InlineData("!^^*")] + public async Task datetime_in_list_without_a_zone_filters_like_the_utc_zone(string op) { var testingServiceScope = new TestingServiceScope(); var when = new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(Random.Shared.Next(1, 900_000_000)); var person = new FakeTestingPersonBuilder().WithSpecificDateTime(when).Build(); await testingServiceScope.InsertAsync(person); var iso = when.ToString("yyyy-MM-ddTHH:mm:ss"); - var utc = $"""SpecificDateTime {op} ["{iso}Z"]"""; - var noZone = $"""SpecificDateTime {op} ["{iso}"]"""; var cache = new QueryCache(testingServiceScope); - if (utcFirst) - { - await UtcRequestFilters(cache, utc, op, person.Id); - await NoZoneRequestFails(cache, noZone); - } - else - { - await NoZoneRequestFails(cache, noZone); - await UtcRequestFilters(cache, utc, op, person.Id); - } + await RequestFilters(cache, $"""SpecificDateTime {op} ["{iso}"]""", op, person.Id); + await RequestFilters(cache, $"""SpecificDateTime {op} ["{iso}Z"]""", op, person.Id); - cache.Compilations.Should().Be(2); + cache.Compilations.Should().Be(1); } // The hash of an array covers only its last 8 items. Lists that differ before the last 8 items @@ -84,20 +70,13 @@ public void long_in_lists_with_the_same_last_items_get_different_cache_keys(stri cache.KeyHashCodes.Distinct().Should().HaveCount(2); } - private static async Task UtcRequestFilters(QueryCache cache, string input, string op, Guid personId) + private static async Task RequestFilters(QueryCache cache, string input, string op, Guid personId) { var ids = await cache.Ids(input); ids.Contains(personId).Should().Be(!op.StartsWith('!')); } - private static async Task NoZoneRequestFails(QueryCache cache, string input) - { - var act = () => cache.Ids(input); - - await act.Should().ThrowAsync().WithMessage("*Unspecified DateTime*"); - } - private sealed class QueryCache { private readonly DbContextOptions _options; diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 200e8e0..01d8f27 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -265,7 +265,7 @@ public void can_handle_datetime() { var input = """SpecificDateTime == 2022-07-01T00:00:03"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be(""""x => (x.SpecificDateTime == new DateTime(637922304030000000, Local))""""); + filterExpression.ToDisplayString().Should().Be(""""x => (x.SpecificDateTime == new DateTime(637922304030000000, Utc))""""); } [Fact] @@ -292,7 +292,7 @@ public void can_handle_datetime_another() { var input = """SpecificDateTime == "2022-07-01T00:00:03" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be(""""x => (x.SpecificDateTime == new DateTime(637922304030000000, Local))""""); + filterExpression.ToDisplayString().Should().Be(""""x => (x.SpecificDateTime == new DateTime(637922304030000000, Utc))""""); } [Fact] @@ -340,7 +340,7 @@ public void can_handle_datetime_with_milliseconds() { var input = """SpecificDateTime == 2022-07-01T00:00:03.123"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDateTime == new DateTime(637922304031230000, Local))"); + filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDateTime == new DateTime(637922304031230000, Utc))"); } [Fact] diff --git a/QueryKit.UnitTests/FilterParsingRegressionTests.cs b/QueryKit.UnitTests/FilterParsingRegressionTests.cs index 8533635..5a06895 100644 --- a/QueryKit.UnitTests/FilterParsingRegressionTests.cs +++ b/QueryKit.UnitTests/FilterParsingRegressionTests.cs @@ -342,6 +342,49 @@ public void whitespace_around_a_group_gives_the_same_filter(string input, string result.Should().Equal(expectedResult); } + [Theory] + [InlineData("SpecificDateTime == 2024-01-15T08:00:00", "x => (x.SpecificDateTime == new DateTime(638409024000000000, Utc))")] + [InlineData("SpecificDateTime == \"2024-01-15T08:00:00\"", "x => (x.SpecificDateTime == new DateTime(638409024000000000, Utc))")] + [InlineData("SpecificDateTime == 2024-01-15T10:00:00+02:00", "x => (x.SpecificDateTime == new DateTime(638409024000000000, Utc))")] + [InlineData("SpecificDateTime == 2024-01-15T08:00:00Z", "x => (x.SpecificDateTime == new DateTime(638409024000000000, Utc))")] + [InlineData("SpecificDate == 2024-01-15T08:00:00", "x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(638409024000000000, 00:00:00)))")] + public void date_time_without_offset_is_utc(string input, string expected) + { + var filterExpression = FilterParser.ParseFilter(input); + + filterExpression.ToDisplayString().Should().Be(expected); + } + + [Theory] + [InlineData("SpecificDateTime ^^ [2024-01-15T08:00:00]")] + [InlineData("SpecificDateTime ^^ [2024-01-15T10:00:00+02:00]")] + [InlineData("SpecificDate ^^ [2024-01-15T08:00:00]")] + [InlineData("SpecificDate ^^ [2024-01-15T10:00:00+02:00]")] + public void date_time_list_value_matches_scalar_value(string input) + { + var people = new[] + { + new TestingPerson + { + Title = "match", + SpecificDateTime = new DateTime(2024, 1, 15, 8, 0, 0, DateTimeKind.Utc), + SpecificDate = new DateTimeOffset(2024, 1, 15, 8, 0, 0, TimeSpan.Zero), + }, + new TestingPerson + { + Title = "other", + SpecificDateTime = new DateTime(2024, 1, 15, 9, 0, 0, DateTimeKind.Utc), + SpecificDate = new DateTimeOffset(2024, 1, 15, 9, 0, 0, TimeSpan.Zero), + }, + }; + + var result = people.AsQueryable().ApplyQueryKitFilter(input).ToList(); + var scalarResult = people.AsQueryable().ApplyQueryKitFilter(input.Replace("^^ [", "== ").TrimEnd(']')).ToList(); + + result.Select(x => x.Title).Should().Equal("match"); + scalarResult.Select(x => x.Title).Should().Equal("match"); + } + private static TResult WithCulture(string cultureName, Func action) { var originalCulture = CultureInfo.CurrentCulture; diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 07ead9d..d2f9ebe 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -434,6 +434,13 @@ from trailingSpaces in Parse.WhiteSpace.Many() private static DateTimeOffset ToParameterOffset(DateTimeOffset value) => FilterValue.Parameterize ? value.ToUniversalTime() : value; + // A date or time value without an offset is read as UTC so the result does not depend on the server time zone. + private static DateTime ParseDateTime(string value) + => DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); + + private static DateTimeOffset ParseDateTimeOffset(string value) + => DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); + private static readonly Dictionary> TypeConversionFunctions = new() { { typeof(string), value => value }, @@ -447,8 +454,8 @@ private static DateTimeOffset ToParameterOffset(DateTimeOffset value) { typeof(long), value => long.Parse(value, CultureInfo.InvariantCulture) }, { typeof(short), value => short.Parse(value, CultureInfo.InvariantCulture) }, { typeof(byte), value => byte.Parse(value, CultureInfo.InvariantCulture) }, - { typeof(DateTime), value => DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal) }, - { typeof(DateTimeOffset), value => ToParameterOffset(DateTimeOffset.Parse(value)) }, + { typeof(DateTime), value => ParseDateTime(value) }, + { typeof(DateTimeOffset), value => ToParameterOffset(ParseDateTimeOffset(value)) }, { typeof(DateOnly), value => DateOnly.Parse(value) }, { typeof(TimeOnly), value => TimeOnly.Parse(value) }, { typeof(TimeSpan), value => TimeSpan.Parse(value) }, @@ -637,20 +644,15 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ if (targetType == typeof(DateTime)) { - var dtStyle = right.EndsWith("Z") ? DateTimeStyles.AdjustToUniversal : DateTimeStyles.AssumeLocal; - var dt = DateTime.Parse(right, CultureInfo.InvariantCulture, dtStyle); - if (right.EndsWith("Z")) - { - dt = DateTime.SpecifyKind(dt, DateTimeKind.Utc); - } + var dt = ParseDateTime(right); return FilterValue.Create(dt, rawType); } if (targetType == typeof(DateTimeOffset)) { - var dtStyle = right.EndsWith("Z") ? DateTimeStyles.AdjustToUniversal : DateTimeStyles.AssumeLocal; - var dto = DateTimeOffset.Parse(right, CultureInfo.InvariantCulture, dtStyle); + var dto = ParseDateTimeOffset(right); + return FilterValue.Create(ToParameterOffset(dto), rawType); } @@ -1676,7 +1678,7 @@ private static Expression CreateCustomOperationExpression(ParameterExpression return doubleValue; // Try DateTime - if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var dateTimeValue)) + if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dateTimeValue)) return dateTimeValue; // Try Guid