From d2ee524d3bcba3397138b3a62f26f756925431e6 Mon Sep 17 00:00:00 2001 From: Erlend Ellefsen Date: Tue, 15 Sep 2026 13:23:28 +0200 Subject: [PATCH] fix: apply date-only filters as a whole-day range --- .../Extensions/QueryableExtensionTests.cs | 64 +++++++++++++++++- .../Filtering/FilterExpressionComposer.cs | 67 ++++++++++++++----- docs/querying.md | 2 + 3 files changed, 113 insertions(+), 20 deletions(-) diff --git a/JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs b/JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs index f704508..b94bbae 100644 --- a/JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs +++ b/JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs @@ -142,8 +142,7 @@ public void ApplyFilters_WithLessThanOrEqualDateOnlyFilter_IncludesWholeDay() [Fact] public void ApplyFilters_WithLessThanOrEqualFullTimestampFilter_DoesNotExtendToEndOfDay() { - // A full timestamp (time component present) must be used exactly as - // given — only the bare date-only case gets the end-of-day bump. + // A full timestamp is compared exactly. Only a bare date covers the whole day. var testData = new List { new TestEntity { Id = 1, CreatedAt = new DateTime(2026, 9, 14, 9, 0, 0) }, @@ -169,6 +168,67 @@ public void ApplyFilters_WithLessThanOrEqualFullTimestampFilter_DoesNotExtendToE Assert.Equal(1, result[0].Id); } + [Theory] + [InlineData(FilterOperator.Gt, new[] { 4 })] + [InlineData(FilterOperator.Eq, new[] { 1, 2, 3 })] + [InlineData(FilterOperator.Ne, new[] { 4 })] + [InlineData(FilterOperator.Lt, new int[0])] + [InlineData(FilterOperator.Ge, new[] { 1, 2, 3, 4 })] + public void ApplyFilters_WithDateOnlyFilter_TreatsValueAsWholeDay( + FilterOperator op, + int[] expectedIds + ) + { + var testData = new List + { + new TestEntity { Id = 1, CreatedAt = new DateTime(2026, 9, 14, 0, 0, 0) }, + new TestEntity { Id = 2, CreatedAt = new DateTime(2026, 9, 14, 15, 30, 0) }, + new TestEntity { Id = 3, CreatedAt = new DateTime(2026, 9, 14, 23, 59, 59) }, + new TestEntity { Id = 4, CreatedAt = new DateTime(2026, 9, 15, 0, 0, 0) }, + }.AsQueryable(); + + var filterGroup = new FilterGroup + { + Filters = new List + { + new FilterParameter + { + Field = "CreatedAt", + Operator = op, + Value = "2026-09-14", + }, + }, + }; + + var result = testData.ApplyFilters(filterGroup).Select(e => e.Id).ToList(); + + Assert.Equal(expectedIds, result); + } + + [Fact] + public void ApplyFilters_WithMaxDateOnlyFilter_DoesNotThrow() + { + var testData = new List + { + new TestEntity { Id = 1, CreatedAt = new DateTime(2026, 9, 14) }, + }.AsQueryable(); + + var filterGroup = new FilterGroup + { + Filters = new List + { + new FilterParameter + { + Field = "CreatedAt", + Operator = FilterOperator.Le, + Value = "9999-12-31", + }, + }, + }; + + Assert.Single(testData.ApplyFilters(filterGroup).ToList()); + } + [Fact] public void ApplyFilters_WithLikeFilter_FiltersCorrectly() { diff --git a/JsonApiToolkit/Extensions/Querying/Filtering/FilterExpressionComposer.cs b/JsonApiToolkit/Extensions/Querying/Filtering/FilterExpressionComposer.cs index 53900a7..c31d84e 100644 --- a/JsonApiToolkit/Extensions/Querying/Filtering/FilterExpressionComposer.cs +++ b/JsonApiToolkit/Extensions/Querying/Filtering/FilterExpressionComposer.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Globalization; using System.Linq.Expressions; using System.Reflection; using JsonApiToolkit.Helpers; @@ -345,19 +346,16 @@ int depth return null; } - // A date-only value (no time component, e.g. "2026-09-14") used with - // Le against a DateTime property means "up to and including that - // whole day" to callers — but it parses to that day's midnight, so - // an unadjusted <= would only match the exact midnight instant and - // silently exclude the rest of the day. Bump it to the last tick of - // the day so Le behaves as "on or before this date". - if ( - filter.Operator == FilterOperator.Le - && filterValue is DateTime dateOnlyBoundary - && IsDateOnlyValue(filter.Value) - ) + if (filterValue is DateTime day && IsDateOnlyValue(filter.Value)) { - filterValue = dateOnlyBoundary.Date.AddDays(1).AddTicks(-1); + Expression? wholeDay = BuildWholeDayLeaf( + propertyAccess, + filter.Operator, + day, + targetType + ); + if (wholeDay != null) + return wholeDay; } ConstantExpression constant = Expression.Constant(filterValue, targetType); @@ -621,12 +619,45 @@ private static void ThrowIfTooDeep(int depth, FilterParameter filter) } /// - /// True if the raw filter value string carries no time-of-day component - /// (e.g. "2026-09-14"), as opposed to a full timestamp (e.g. - /// "2026-09-14T10:00:00"). ISO 8601 date-times always separate the time - /// with 'T' and represent time-of-day with ':', so the absence of both - /// is a reliable signal the caller only specified a calendar date. + /// True when the raw filter value is a bare ISO calendar date such as "2026-09-14". /// private static bool IsDateOnlyValue(string value) => - !value.Contains('T') && !value.Contains(':'); + DateOnly.TryParseExact( + value, + "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _ + ); + + /// + /// Treats a bare date as the whole day, the half-open range [day, day + 1). + /// Lt and Ge already split at midnight and fall through unchanged, as does the max date. + /// + private static Expression? BuildWholeDayLeaf( + Expression propertyAccess, + FilterOperator op, + DateTime day, + Type targetType + ) + { + if (day.Date == DateTime.MaxValue.Date) + return null; + + ConstantExpression start = Expression.Constant(day, targetType); + ConstantExpression next = Expression.Constant(day.AddDays(1), targetType); + Expression onDay = Expression.AndAlso( + Expression.GreaterThanOrEqual(propertyAccess, start), + Expression.LessThan(propertyAccess, next) + ); + + return op switch + { + FilterOperator.Eq => onDay, + FilterOperator.Ne => Expression.Not(onDay), + FilterOperator.Gt => Expression.GreaterThanOrEqual(propertyAccess, next), + FilterOperator.Le => Expression.LessThan(propertyAccess, next), + _ => null, + }; + } } diff --git a/docs/querying.md b/docs/querying.md index 03c6747..4fddfac 100644 --- a/docs/querying.md +++ b/docs/querying.md @@ -16,6 +16,8 @@ JsonApiToolkit parses standard JSON:API query parameters and applies them to you | `in` / `nin` | (not) in list | `filter[genre][in]=fiction,fantasy` | | `isnull` / `isnotnull` | null check | `filter[description][isnull]=true` | +A bare date such as `2026-09-14` on a `DateTime` field covers the whole day. `le` includes every moment of that day, `gt` starts at the next day, and `eq` matches the day. A value with a time component, such as `2026-09-14T12:00:00`, is compared exactly. + ### Logical groups Combine filters with AND, OR, NOT blocks: