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
36 changes: 36 additions & 0 deletions QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
43 changes: 11 additions & 32 deletions QueryKit.IntegrationTests/Tests/InListQueryCacheTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ArgumentException>().WithMessage("*Unspecified DateTime*");
}

private sealed class QueryCache
{
private readonly DbContextOptions<TestingDbContext> _options;
Expand Down
6 changes: 3 additions & 3 deletions QueryKit.UnitTests/FilterParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ public void can_handle_datetime()
{
var input = """SpecificDateTime == 2022-07-01T00:00:03""";
var filterExpression = FilterParser.ParseFilter<TestingPerson>(input);
filterExpression.ToDisplayString().Should().Be(""""x => (x.SpecificDateTime == new DateTime(637922304030000000, Local))"""");
filterExpression.ToDisplayString().Should().Be(""""x => (x.SpecificDateTime == new DateTime(637922304030000000, Utc))"""");
}

[Fact]
Expand All @@ -292,7 +292,7 @@ public void can_handle_datetime_another()
{
var input = """SpecificDateTime == "2022-07-01T00:00:03" """;
var filterExpression = FilterParser.ParseFilter<TestingPerson>(input);
filterExpression.ToDisplayString().Should().Be(""""x => (x.SpecificDateTime == new DateTime(637922304030000000, Local))"""");
filterExpression.ToDisplayString().Should().Be(""""x => (x.SpecificDateTime == new DateTime(637922304030000000, Utc))"""");
}

[Fact]
Expand Down Expand Up @@ -340,7 +340,7 @@ public void can_handle_datetime_with_milliseconds()
{
var input = """SpecificDateTime == 2022-07-01T00:00:03.123""";
var filterExpression = FilterParser.ParseFilter<TestingPerson>(input);
filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDateTime == new DateTime(637922304031230000, Local))");
filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDateTime == new DateTime(637922304031230000, Utc))");
}

[Fact]
Expand Down
43 changes: 43 additions & 0 deletions QueryKit.UnitTests/FilterParsingRegressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestingPerson>(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<TResult>(string cultureName, Func<TResult> action)
{
var originalCulture = CultureInfo.CurrentCulture;
Expand Down
24 changes: 13 additions & 11 deletions QueryKit/FilterParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Type, Func<string, object>> TypeConversionFunctions = new()
{
{ typeof(string), value => value },
Expand All @@ -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) },
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -1676,7 +1678,7 @@ private static Expression CreateCustomOperationExpression<T>(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
Expand Down
Loading