From e9405f1fbd9e51c15218c88a4cc8b36c2f8ae16d Mon Sep 17 00:00:00 2001 From: "mikhail.losev" <> Date: Fri, 21 Aug 2026 14:05:40 +0800 Subject: [PATCH 1/3] initial --- .../DataSourceLoaderTests.cs | 26 ++++++++++- .../DynamicBindingTests.cs | 11 +++++ ...erExpressionCompilerTypeConversionTests.cs | 22 +++++++++ .../UtilsTests.cs | 45 ++++++++++++++++++- net/DevExtreme.AspNet.Data/Utils.cs | 3 ++ net/Sample/Controllers/NorthwindController.cs | 1 + net/Sample/Models/Order.cs | 2 + net/Sample/SQL.txt | 17 ++++++- net/Sample/Views/Home/Index.cshtml | 8 ++++ 9 files changed, 131 insertions(+), 4 deletions(-) diff --git a/net/DevExtreme.AspNet.Data.Tests/DataSourceLoaderTests.cs b/net/DevExtreme.AspNet.Data.Tests/DataSourceLoaderTests.cs index 0196a1b52..c35673e9b 100644 --- a/net/DevExtreme.AspNet.Data.Tests/DataSourceLoaderTests.cs +++ b/net/DevExtreme.AspNet.Data.Tests/DataSourceLoaderTests.cs @@ -1,4 +1,5 @@ -using DevExtreme.AspNet.Data.ResponseModel; +using DevExtreme.AspNet.Data.Helpers; +using DevExtreme.AspNet.Data.ResponseModel; using System; using System.Collections.Generic; using System.Linq; @@ -56,6 +57,29 @@ public void Load_RequireTotalCount() { Assert.Equal(new[] { 3, 4 }, result.data.Cast()); } + class DateTimeOffsetTestDto { + public int Id; + public DateTimeOffset At; + } + + [Fact] + public void Load_FilterByDateTimeOffset_RoundTripsThroughRemoteQueryString() { + var items = new[] { + new DateTimeOffsetTestDto { Id = 1, At = new DateTimeOffset(2024, 6, 1, 4, 30, 0, TimeSpan.Zero) }, + new DateTimeOffsetTestDto { Id = 2, At = new DateTimeOffset(2024, 6, 2, 4, 30, 0, TimeSpan.Zero) } + }; + + var filterQueryParam = @"[""At"",""="",""2024-06-01T10:00:00+05:30""]"; + + var options = new SampleLoadOptions(); + DataSourceLoadOptionsParser.Parse(options, key => + key == DataSourceLoadOptionsParser.KEY_FILTER ? filterQueryParam : null); + + var result = DataSourceLoader.Load(items, options); + + Assert.Equal([1], result.data.Cast().Select(i => i.Id)); + } + [Fact] public void Load_GroupOnly() { var data = new[] { diff --git a/net/DevExtreme.AspNet.Data.Tests/DynamicBindingTests.cs b/net/DevExtreme.AspNet.Data.Tests/DynamicBindingTests.cs index 06a51cce5..8dcf5aeda 100644 --- a/net/DevExtreme.AspNet.Data.Tests/DynamicBindingTests.cs +++ b/net/DevExtreme.AspNet.Data.Tests/DynamicBindingTests.cs @@ -280,6 +280,17 @@ public void T819075() { }).totalCount); } + [Fact] + public void T819075_DateTimeOffset() { + dynamic sourceItem = new ExpandoObject(); + sourceItem.p = new DateTimeOffset(2024, 6, 1, 4, 30, 0, TimeSpan.Zero); + + Assert.Equal(1, DataSourceLoader.Load(new[] { sourceItem }, new SampleLoadOptions { + Filter = new[] { "p", "2024-06-01T10:00:00+05:30" }, + RequireTotalCount = true + }).totalCount); + } + [Fact] public void Issue413() { // https://github.com/DevExpress/DevExtreme.AspNet.Data/issues/413#issuecomment-580766581 diff --git a/net/DevExtreme.AspNet.Data.Tests/FilterExpressionCompilerTypeConversionTests.cs b/net/DevExtreme.AspNet.Data.Tests/FilterExpressionCompilerTypeConversionTests.cs index 37d1c80d3..0f40ea3e6 100644 --- a/net/DevExtreme.AspNet.Data.Tests/FilterExpressionCompilerTypeConversionTests.cs +++ b/net/DevExtreme.AspNet.Data.Tests/FilterExpressionCompilerTypeConversionTests.cs @@ -168,6 +168,28 @@ public void InvalidValueHandling() { Assert.Equal("False", compiler.Compile(new[] { "byte", "-3" }).Body.ToString()); Assert.Equal("False", compiler.Compile(new[] { "byte", "257" }).Body.ToString()); Assert.Equal("False", compiler.Compile(new[] { "int", "not-int" }).Body.ToString()); + Assert.Equal("False", compiler.Compile(new[] { "dateTimeOffset", "not-a-date" }).Body.ToString()); + } + + [Fact] + public void DateTimeOffset_DifferentOffset_MatchesSameInstant() { + var obj = new Structs { + dateTimeOffset = new DateTimeOffset(2024, 6, 1, 4, 30, 0, TimeSpan.Zero) + }; + + // Same instant, expressed with a client-side offset that differs from the stored value's offset. + AssertEvaluation(obj, "dateTimeOffset", "=", "2024-06-01T10:00:00+05:30"); + AssertEvaluation(obj, "dateTimeOffset", "<=", "2024-06-01T10:00:00+05:30"); + AssertEvaluation(obj, "dateTimeOffset", ">=", "2024-06-01T10:00:00+05:30"); + } + + [Fact] + public void NullableDateTimeOffset_DifferentOffset_MatchesSameInstant() { + var obj = new NullableStructs { + dateTimeOffset = new DateTimeOffset(2024, 6, 1, 4, 30, 0, TimeSpan.Zero) + }; + + AssertEvaluation(obj, "dateTimeOffset", "=", "2024-06-01T10:00:00+05:30"); } diff --git a/net/DevExtreme.AspNet.Data.Tests/UtilsTests.cs b/net/DevExtreme.AspNet.Data.Tests/UtilsTests.cs index 10a143270..8ff33d7b6 100644 --- a/net/DevExtreme.AspNet.Data.Tests/UtilsTests.cs +++ b/net/DevExtreme.AspNet.Data.Tests/UtilsTests.cs @@ -105,6 +105,49 @@ public void ConvertClientValue_Numeric() { Assert.Equal(123L, output); } - } + [Fact] + public void ConvertClientValue_DateTimeOffset_PreservesExplicitOffset() { + var output = Utils.ConvertClientValue("2024-06-01T10:00:00+05:30", typeof(DateTimeOffset)); + + Assert.IsType(output); + Assert.Equal(new DateTimeOffset(2024, 6, 1, 10, 0, 0, TimeSpan.FromHours(5.5)), output); + Assert.Equal(TimeSpan.FromHours(5.5), ((DateTimeOffset)output).Offset); + } + + [Fact] + public void ConvertClientValue_DateTimeOffset_Utc() { + var output = Utils.ConvertClientValue("2024-06-01T04:30:00Z", typeof(DateTimeOffset)); + + Assert.Equal(new DateTimeOffset(2024, 6, 1, 4, 30, 0, TimeSpan.Zero), output); + } + + [Fact] + public void ConvertClientValue_DateTimeOffset_DifferentOffsets_SameInstantAreEqual() { + var fromClient = (DateTimeOffset)Utils.ConvertClientValue("2024-06-01T10:00:00+05:30", typeof(DateTimeOffset)); + var utcEquivalent = new DateTimeOffset(2024, 6, 1, 4, 30, 0, TimeSpan.Zero); + + Assert.Equal(utcEquivalent, fromClient); + } + [Fact] + public void ConvertClientValue_NullableDateTimeOffset_FromString() { + var output = Utils.ConvertClientValue("2024-06-01T10:00:00+05:30", typeof(DateTimeOffset?)); + + Assert.Equal(new DateTimeOffset(2024, 6, 1, 10, 0, 0, TimeSpan.FromHours(5.5)), output); + } + + [Fact] + public void ConvertClientValue_DateTimeOffset_FromDateTime_UsesLocalOffset() { + // Compatibility path: Newtonsoft-based callers may already have parsed the value into a DateTime. + var date = new DateTime(2024, 6, 1, 10, 0, 0); + var output = Utils.ConvertClientValue(date, typeof(DateTimeOffset)); + + Assert.Equal(new DateTimeOffset(date), output); + } + + [Fact] + public void ConvertClientValue_DateTimeOffset_InvalidString_Throws() { + Assert.ThrowsAny(() => Utils.ConvertClientValue("not-a-date", typeof(DateTimeOffset))); + } + } } diff --git a/net/DevExtreme.AspNet.Data/Utils.cs b/net/DevExtreme.AspNet.Data/Utils.cs index 675253e2b..ee73e0901 100644 --- a/net/DevExtreme.AspNet.Data/Utils.cs +++ b/net/DevExtreme.AspNet.Data/Utils.cs @@ -45,6 +45,9 @@ public static object ConvertClientValue(object value, Type type) { if(type == typeof(DateTime) && value is String) return DateTime.Parse((string)value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + if(type == typeof(DateTimeOffset) && value is String) + return DateTimeOffset.Parse((string)value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + if(type == typeof(DateTimeOffset) && value is DateTime date) return new DateTimeOffset(date); diff --git a/net/Sample/Controllers/NorthwindController.cs b/net/Sample/Controllers/NorthwindController.cs index c8a266995..018b8f1ea 100644 --- a/net/Sample/Controllers/NorthwindController.cs +++ b/net/Sample/Controllers/NorthwindController.cs @@ -26,6 +26,7 @@ public async Task Orders(DataSourceLoadOptions loadOptions) { //---------------------------------------- o.OrderDateOnly, o.OrderTimeOnly, + o.OrderDateTimeOffset, //---------------------------------------- o.Freight, o.ShipCountry, diff --git a/net/Sample/Models/Order.cs b/net/Sample/Models/Order.cs index c5d5882cf..12fbfac0f 100644 --- a/net/Sample/Models/Order.cs +++ b/net/Sample/Models/Order.cs @@ -33,6 +33,8 @@ public Order() { public TimeOnly? OrderTimeOnly { get; set; } + public DateTimeOffset? OrderDateTimeOffset { get; set; } + //---------------------------------------- [Column(TypeName = "datetime")] diff --git a/net/Sample/SQL.txt b/net/Sample/SQL.txt index 813394dcc..07b1fbb23 100644 --- a/net/Sample/SQL.txt +++ b/net/Sample/SQL.txt @@ -9,10 +9,23 @@ ALTER TABLE [Orders] ADD [OrderTimeOnly] time NULL GO UPDATE [Orders] SET [OrderTimeOnly] = CONVERT(time, DATEADD(hour, ([OrderID] + 1), [OrderDate])) +GO +ALTER TABLE [Orders] +ADD [OrderDateTimeOffset] datetimeoffset NULL +GO +-- (-14:00..+14:00) +-- IF EXISTS ( +-- SELECT 1 +-- FROM [Orders] +-- WHERE ([OrderID] - 10247) NOT BETWEEN -840 AND 840 +-- ) +-- THROW 50000, 'OrderDateTimeOffset offset minutes must be between -840 and 840.', 1 +-- GO +UPDATE [Orders] SET [OrderDateTimeOffset] = TODATETIMEOFFSET([OrderDate], ([OrderID] - 10247)) --- -dotnet add package Microsoft.EntityFrameworkCore.Tools -dotnet add package Microsoft.EntityFrameworkCore.SqlServer +dotnet package add Microsoft.EntityFrameworkCore.Tools --project Sample +dotnet package add Microsoft.EntityFrameworkCore.SqlServer --project Sample Scaffold-DbContext "Data Source=(localdb)\MSSQLLocalDB; Database=Northwind; Integrated Security=True; MultipleActiveResultSets=True; App=EntityFramework" Microsoft.EntityFrameworkCore.SqlServer -OutputDir Models diff --git a/net/Sample/Views/Home/Index.cshtml b/net/Sample/Views/Home/Index.cshtml index f455e4e6e..bf97d3627 100644 --- a/net/Sample/Views/Home/Index.cshtml +++ b/net/Sample/Views/Home/Index.cshtml @@ -53,6 +53,14 @@ //groupInterval: "quarter" // N|A } }, + { + dataField: "orderDateTimeOffset", + dataType: "date", + headerFilter: { + groupInterval: "quarter" + } + }, + { dataField: "freight", headerFilter: { From baf8be7c9415656fc166b5913417b9192663afad Mon Sep 17 00:00:00 2001 From: "mikhail.losev" <> Date: Fri, 21 Aug 2026 14:13:33 +0800 Subject: [PATCH 2/3] fix --- net/DevExtreme.AspNet.Data.Tests/DataSourceLoaderTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/net/DevExtreme.AspNet.Data.Tests/DataSourceLoaderTests.cs b/net/DevExtreme.AspNet.Data.Tests/DataSourceLoaderTests.cs index c35673e9b..ebaf94ec4 100644 --- a/net/DevExtreme.AspNet.Data.Tests/DataSourceLoaderTests.cs +++ b/net/DevExtreme.AspNet.Data.Tests/DataSourceLoaderTests.cs @@ -77,7 +77,7 @@ public void Load_FilterByDateTimeOffset_RoundTripsThroughRemoteQueryString() { var result = DataSourceLoader.Load(items, options); - Assert.Equal([1], result.data.Cast().Select(i => i.Id)); + Assert.Equal(new[] { 1 }, result.data.Cast().Select(i => i.Id)); } [Fact] From a7e4f71b4c6f9997f9ff9a67fdb983e1be96341d Mon Sep 17 00:00:00 2001 From: "mikhail.losev" <> Date: Fri, 21 Aug 2026 21:50:51 +0800 Subject: [PATCH 3/3] small refactoring --- .../DataSourceLoaderTests.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/net/DevExtreme.AspNet.Data.Tests/DataSourceLoaderTests.cs b/net/DevExtreme.AspNet.Data.Tests/DataSourceLoaderTests.cs index ebaf94ec4..e0cbca841 100644 --- a/net/DevExtreme.AspNet.Data.Tests/DataSourceLoaderTests.cs +++ b/net/DevExtreme.AspNet.Data.Tests/DataSourceLoaderTests.cs @@ -69,12 +69,9 @@ public void Load_FilterByDateTimeOffset_RoundTripsThroughRemoteQueryString() { new DateTimeOffsetTestDto { Id = 2, At = new DateTimeOffset(2024, 6, 2, 4, 30, 0, TimeSpan.Zero) } }; - var filterQueryParam = @"[""At"",""="",""2024-06-01T10:00:00+05:30""]"; - - var options = new SampleLoadOptions(); - DataSourceLoadOptionsParser.Parse(options, key => - key == DataSourceLoadOptionsParser.KEY_FILTER ? filterQueryParam : null); - + var options = new SampleLoadOptions { + Filter = new[] { "At", "=", "2024-06-01T10:00:00+05:30" } + }; var result = DataSourceLoader.Load(items, options); Assert.Equal(new[] { 1 }, result.data.Cast().Select(i => i.Id));