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