Skip to content
Merged
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
64 changes: 62 additions & 2 deletions JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestEntity>
{
new TestEntity { Id = 1, CreatedAt = new DateTime(2026, 9, 14, 9, 0, 0) },
Expand All @@ -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<TestEntity>
{
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<FilterParameter>
{
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<TestEntity>
{
new TestEntity { Id = 1, CreatedAt = new DateTime(2026, 9, 14) },
}.AsQueryable();

var filterGroup = new FilterGroup
{
Filters = new List<FilterParameter>
{
new FilterParameter
{
Field = "CreatedAt",
Operator = FilterOperator.Le,
Value = "9999-12-31",
},
},
};

Assert.Single(testData.ApplyFilters(filterGroup).ToList());
}

[Fact]
public void ApplyFilters_WithLikeFilter_FiltersCorrectly()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections;
using System.Globalization;
using System.Linq.Expressions;
using System.Reflection;
using JsonApiToolkit.Helpers;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -621,12 +619,45 @@ private static void ThrowIfTooDeep(int depth, FilterParameter filter)
}

/// <summary>
/// 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".
/// </summary>
private static bool IsDateOnlyValue(string value) =>
!value.Contains('T') && !value.Contains(':');
DateOnly.TryParseExact(
value,
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out _
);

/// <summary>
/// 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.
/// </summary>
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,
};
}
}
2 changes: 2 additions & 0 deletions docs/querying.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down