diff --git a/JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs b/JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs index 6066e63..f704508 100644 --- a/JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs +++ b/JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs @@ -107,6 +107,68 @@ public void ApplyFilters_WithGreaterThanFilter_FiltersCorrectly() Assert.Equal(5, result[1].Id); } + [Fact] + public void ApplyFilters_WithLessThanOrEqualDateOnlyFilter_IncludesWholeDay() + { + // A date-only Le value (no time component) must include every moment + // of that day, not just its exact midnight instant. + 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 = FilterOperator.Le, + Value = "2026-09-14", + }, + }, + }; + + var result = testData.ApplyFilters(filterGroup).ToList(); + + Assert.Equal(3, result.Count); + Assert.DoesNotContain(result, e => e.Id == 4); + } + + [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. + var testData = new List + { + new TestEntity { Id = 1, CreatedAt = new DateTime(2026, 9, 14, 9, 0, 0) }, + new TestEntity { Id = 2, CreatedAt = new DateTime(2026, 9, 14, 15, 30, 0) }, + }.AsQueryable(); + + var filterGroup = new FilterGroup + { + Filters = new List + { + new FilterParameter + { + Field = "CreatedAt", + Operator = FilterOperator.Le, + Value = "2026-09-14T12:00:00", + }, + }, + }; + + var result = testData.ApplyFilters(filterGroup).ToList(); + + Assert.Single(result); + Assert.Equal(1, result[0].Id); + } + [Fact] public void ApplyFilters_WithLikeFilter_FiltersCorrectly() { diff --git a/JsonApiToolkit.TypeGen/packages.lock.json b/JsonApiToolkit.TypeGen/packages.lock.json index 46770c8..f155670 100644 --- a/JsonApiToolkit.TypeGen/packages.lock.json +++ b/JsonApiToolkit.TypeGen/packages.lock.json @@ -40,27 +40,27 @@ }, "Intility.Extensions.Logging": { "type": "Transitive", - "resolved": "3.1.6", - "contentHash": "de+ySanItzm3jrpKOqtwWa86hoRQmwuAjyIQX9Tj4JA8tbeV+0Mjs2V4auJ9boCsLcqSv75sK1Ac6fsY9zkcDw==", + "resolved": "3.1.7", + "contentHash": "slDca7FHqWrV53U+B7rkKZJXlcORFxn7Ux/Ku37XuJdZxfAc3SyD/yP3Gj/G1Ji8qvcnqo/+wQ2ZTG8TFo4Jew==", "dependencies": { - "Microsoft.Extensions.Hosting.Abstractions": "10.0.10", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.11", "Serilog": "4.4.0" } }, "Intility.Logging.AspNetCore": { "type": "Transitive", - "resolved": "3.1.6", - "contentHash": "6dYcQnj/5ZLnomhyHUFWppVr8dwjgp8KTqhYaHVtXC3pC8P2OtqjQIFkhlHJCrgLLUBVhKwiNLhVzYGUR1s7/g==", + "resolved": "3.1.7", + "contentHash": "uVNl/D+HAVh6A9AIaFT5QxQwrFG5BypwUfMQ3GFZ5pHoILAsCAqY3Z/YDkksN4mfUkGc20pVqLlyc8rOeO6j4w==", "dependencies": { - "Intility.Extensions.Logging": "3.1.6", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.10", - "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Intility.Extensions.Logging": "3.1.7", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", "Serilog.AspNetCore": "10.0.0", "Serilog.Enrichers.AssemblyName": "2.0.0", "Serilog.Enrichers.AzureClaims": "1.2.9", "Serilog.Enrichers.Environment": "3.0.1", "Serilog.Enrichers.Memory": "1.0.4", - "System.Security.Cryptography.Xml": "10.0.10" + "System.Security.Cryptography.Xml": "10.0.11" } }, "Microsoft.AspNetCore.Authentication.JwtBearer": { @@ -120,54 +120,54 @@ }, "Microsoft.EntityFrameworkCore": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "VOSGU8en6HZJs8t7UMFN+9vGcRgVOOn6fA44Ngcg2NyvJ3P1KE94iAb0XzaVaGhXGtt+qaM/VtEn0/hzluQJeg==", + "resolved": "10.0.12", + "contentHash": "e7OrVN8U5yr4kuwdTHWBHGVQIVsGfMkAr0Ej1/BnrS989Q0XBVANpIzNZ2bc1GvMZ0/XwMt5InTnmKskxGy99Q==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.11", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.11", - "Microsoft.Extensions.Caching.Memory": "10.0.11", - "Microsoft.Extensions.Logging": "10.0.11" + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.12", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.12", + "Microsoft.Extensions.Caching.Memory": "10.0.12", + "Microsoft.Extensions.Logging": "10.0.12" } }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "6auJR+9+9VunznKfH7WGrHMrnrmA0F7JZ22EXzwXvVhjfnbu9Xq7NSIWaOf3KJsOanM2qf5ajJ2JR5TlcPZTLA==" + "resolved": "10.0.12", + "contentHash": "kDrux7T6C3V/YYYe2LXr7x6AEsVyq5Y2YoTE9er1SwbDtap3z1g8RgxNh5eKQVyB4T+DKvBBJ4Ezd/VZzJQJZg==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "Bv7X4wSSnzCQED9WYXKJ8fwgyvKwf0xZM1GO8xkf6CF9zl+UBnvjxmcPnokJRy0JKjc1SlHSzzhx1HcL4jitTQ==" + "resolved": "10.0.12", + "contentHash": "Kd4o2oO1A6dfKxjz5LS16QyLtpCFTUiFWwmBABETAXRsSImBdRdWeKjSgbbRhAMiV3a3/nWr95ZtzClT4uaADQ==" }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "grznnTJgEYxaWpdKAsTzg6j+89jHgCXWYp+QGtlX5O92+w/VuhWM6JLPYb+uw8M9VhGUvOTsO76dYOy9vNPd5Q==", + "resolved": "10.0.12", + "contentHash": "OiHmr8XzX96dbgMsYGe8qoqg4KxibdZG2r0QUVJBGktFInOp8IrABe8jvcbo5hrqeWfbwpK7fCEBiEPbY/ik4A==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.11", - "Microsoft.Extensions.Caching.Memory": "10.0.11", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", - "Microsoft.Extensions.Logging": "10.0.11" + "Microsoft.EntityFrameworkCore": "10.0.12", + "Microsoft.Extensions.Caching.Memory": "10.0.12", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Logging": "10.0.12" } }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "vUl798SmruTqqlt/xH2gDk3tJlhk6k3HdOXAHirlRfbNKDym4g/kRpUL9S4sl6F6FsOTOMW+ZsDapqlZMOOiEw==", + "resolved": "10.0.12", + "contentHash": "sI3A6MUAyZwccLnwq0cXuRmC3WBtXieimBWafXSfhRJ4jnM2uGVXrqImDDVyW27u4vVaTOGlJxNJd8d1FGvCNQ==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Primitives": "10.0.12" } }, "Microsoft.Extensions.Caching.Memory": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "el1g0mBEbDBGY2bT9mcSfrTWO8QlPdq2nOCnvQugioOFwHV+bVBMeiakoI0dNOdj8d6Hi9K6HY2xzRUWJiDR3w==", + "resolved": "10.0.12", + "contentHash": "fhC3DHcBcFF4bXvHIxFtXJYNut7q7ZItqNFcgWfrIgmpVImlcEKNHhE7ztpNSPJYMHYYStS5QGND2I5mC26yeA==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.11", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Logging.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Caching.Abstractions": "10.0.12", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" } }, "Microsoft.Extensions.Configuration": { @@ -181,10 +181,10 @@ }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==", + "resolved": "10.0.12", + "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Primitives": "10.0.12" } }, "Microsoft.Extensions.Configuration.Binder": { @@ -198,16 +198,16 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", @@ -216,58 +216,58 @@ }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "9uWiKpeOVac355STyChWR/pliFX/5CeLqChW9kKsaxyDH4EUTZxMkT4Jwp/J/peLm0GBFmSX5c0WCse3yCnq1Q==", + "resolved": "10.0.11", + "contentHash": "se7Kx8QpJEt+nf26L4qIVAofGTDr1wbexxsh/Fm3Xc04xUkqUXK06KUS7FLwSQYSjqb7q9n+T7MEcXYBhI1Y5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "c5zqFCY9DiIpMovLd7/d/CTiEtrMOuQ639dhv3PABtKQIKNQikSHwQt8+N679uii9q+B55lgK28Uv64FOwEu8w==", + "resolved": "10.0.11", + "contentHash": "JOjac6SQQgZmdmB8WGEw61/7siqMZoWJMkmq2p1goJGxqI59lO6oB4bOl0jNsbaPBdYy5Mlkb+6U7T4+CjnD8Q==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "5LugpYGHk+mkn0a8IZgcyfBca8PCTAU9RQFoMrTdtOOidq88M2SI5f3px6ugnzgxC+eTkvYYJi8pzlUnG5xdAQ==", + "resolved": "10.0.11", + "contentHash": "pwtpF7iF/NNaOBcX+pvMZ7y2+JAVbH5KkNrH9uMZtuVxVJsFTDiWiCR7Tk3HVptsAijaetipUZVRVK2LLq+nvA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", - "Microsoft.Extensions.Logging.Abstractions": "10.0.10" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "nUOJwgFkSiLHiVGFpU22pIJtuWYewuSYQ3JVuP/gdK8ASMT807Px+TYQiRWs6uSsOmoyFTaVCwKXTasczV6BpA==", + "resolved": "10.0.12", + "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.11", - "Microsoft.Extensions.Logging.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11" + "Microsoft.Extensions.DependencyInjection": "10.0.12", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", + "resolved": "10.0.12", + "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { @@ -284,8 +284,8 @@ }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" }, "Microsoft.Identity.Abstractions": { "type": "Transitive", @@ -603,8 +603,8 @@ }, "System.Security.Cryptography.Pkcs": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "KG8t5RHczdgB3IWvRndpI0nE1DSo7ikDYSDbITxfgRFyIvnYiKzuPjaZOsPnwFQ1aOGUl9sEulJul9wlHLEUoA==" + "resolved": "10.0.11", + "contentHash": "8IV+rI3xN/Mkq9MsSX7VZTv9T9Wt+tyhHLwBX9VNZBk8m8kknEs1JeTSjI2x9M2L/fSja+c2WSE5n5Yy0GXdoQ==" }, "System.Security.Cryptography.ProtectedData": { "type": "Transitive", @@ -613,18 +613,18 @@ }, "System.Security.Cryptography.Xml": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "Mz6Y+s+uMQKj1H2BUF0OhlmaOQkLgSG34/IrUaBDirYBQ6bgwcFvO/XtXWcoDlBLFoFB0uKqDNhybHOd+fQK1A==", + "resolved": "10.0.11", + "contentHash": "TokfVsaU2fmcwvsU6ihLCkseVfwhLX6Tmbl3qh3nOBIqHSX4yqFllpslA+PuHERH/ENu3kwqo4deZiyT0Wvh3Q==", "dependencies": { - "System.Security.Cryptography.Pkcs": "10.0.10" + "System.Security.Cryptography.Pkcs": "10.0.11" } }, "Intility.JsonApiToolkit": { "type": "Project", "dependencies": { - "Intility.Logging.AspNetCore": "[3.1.6, )", - "Microsoft.EntityFrameworkCore": "[10.0.11, )", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.11, )" + "Intility.Logging.AspNetCore": "[3.1.7, )", + "Microsoft.EntityFrameworkCore": "[10.0.12, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.12, )" } } } diff --git a/JsonApiToolkit/Extensions/Querying/Filtering/FilterExpressionComposer.cs b/JsonApiToolkit/Extensions/Querying/Filtering/FilterExpressionComposer.cs index 265241f..53900a7 100644 --- a/JsonApiToolkit/Extensions/Querying/Filtering/FilterExpressionComposer.cs +++ b/JsonApiToolkit/Extensions/Querying/Filtering/FilterExpressionComposer.cs @@ -345,6 +345,21 @@ 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) + ) + { + filterValue = dateOnlyBoundary.Date.AddDays(1).AddTicks(-1); + } + ConstantExpression constant = Expression.Constant(filterValue, targetType); return filter.Operator switch @@ -604,4 +619,14 @@ 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. + /// + private static bool IsDateOnlyValue(string value) => + !value.Contains('T') && !value.Contains(':'); }