From 7c418a40f942806d45efcbae572458aba933fb14 Mon Sep 17 00:00:00 2001 From: Brant Burnett Date: Sat, 1 Feb 2025 12:33:26 -0500 Subject: [PATCH] Add DateOnly and TimeOnly literal converters (#282) Motivation ---------- We'd like to add support for DateOnly and TimeOnly when targeting modern .NET and using System.Text.Json. When we do, we'll need support for header and query string literals as well. Modifications ------------- - Add DateOnlyLiteralConverter and TimeOnlyLiteralConverter, mirroring the serialization formats used by System.Text.Json - Register on LiteralConverterRegistry Results ------- No changes for now unless an extension is used to change schema generation to use DateOnly and TimeOnly. --- .../Serialization/LiteralSerializerTests.cs | 162 ++++++++++++++++++ .../DateOnlyLiteralConverter.net6.0.cs | 15 ++ .../TimeOnlyLiteralConverter.net6.0.cs | 29 ++++ .../Literals/LiteralConverterRegistry.cs | 7 +- 4 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 src/main/Yardarm.Client/Serialization/Literals/Converters/DateOnlyLiteralConverter.net6.0.cs create mode 100644 src/main/Yardarm.Client/Serialization/Literals/Converters/TimeOnlyLiteralConverter.net6.0.cs diff --git a/src/main/Yardarm.Client.UnitTests/Serialization/LiteralSerializerTests.cs b/src/main/Yardarm.Client.UnitTests/Serialization/LiteralSerializerTests.cs index 8c0131f..f77f212 100644 --- a/src/main/Yardarm.Client.UnitTests/Serialization/LiteralSerializerTests.cs +++ b/src/main/Yardarm.Client.UnitTests/Serialization/LiteralSerializerTests.cs @@ -119,6 +119,20 @@ public void Serialize_Date_ReturnsString(string format) result.Should().Be("2020-01-02"); } + [Theory] + [InlineData("date")] + [InlineData("full-date")] + public void Serialize_DateOnly_ReturnsString(string format) + { + // Act + + string result = LiteralSerializer.Serialize(new DateOnly(2020, 1, 2), format); + + // Assert + + result.Should().Be("2020-01-02"); + } + [Fact] public void Serialize_DateTimeOffset_ReturnsString() { @@ -162,6 +176,34 @@ public void Serialize_TimeSpanMillis_ReturnsString(string format) result.Should().Be("03:04:05.1230000"); } + [Theory] + [InlineData("partial-time")] + public void Serialize_TimeOnly_ReturnsString(string format) + { + // Act + + string result = LiteralSerializer.Serialize( + new TimeOnly(3, 4, 5), format); + + // Assert + + result.Should().Be("03:04:05"); + } + + [Theory] + [InlineData("partial-time")] + public void Serialize_TimeOnlyMillis_ReturnsString(string format) + { + // Act + + string result = LiteralSerializer.Serialize( + new TimeOnly(3, 4, 5, 123), format); + + // Assert + + result.Should().Be("03:04:05.1230000"); + } + [Fact] public void Serialize_Guid_ReturnsString() { @@ -351,6 +393,26 @@ public void TrySerialize_Date_ReturnsString(string format) buffer[..charsWritten].ToString().Should().Be("2020-01-02"); } + [Theory] + [InlineData("date")] + [InlineData("full-date")] + public void TrySerialize_DateOnly_ReturnsString(string format) + { + // Arrange + + Span buffer = stackalloc char[256]; + + // Act + + bool result = LiteralSerializer.TrySerialize(new DateOnly(2020, 1, 2), + format, buffer, out var charsWritten); + + // Assert + + result.Should().BeTrue(); + buffer[..charsWritten].ToString().Should().Be("2020-01-02"); + } + [Fact] public void TrySerialize_DateTimeOffset_ReturnsString() { @@ -410,6 +472,44 @@ public void TrySerialize_TimeSpanMillis_ReturnsString(string format) buffer[..charsWritten].ToString().Should().Be("03:04:05.1230000"); } + [Theory] + [InlineData("partial-time")] + public void TrySerialize_TimeOnly_ReturnsString(string format) + { + // Arrange + + Span buffer = stackalloc char[256]; + + // Act + + bool result = LiteralSerializer.TrySerialize( + new TimeOnly(3, 4, 5), format, buffer, out var charsWritten); + + // Assert + + result.Should().BeTrue(); + buffer[..charsWritten].ToString().Should().Be("03:04:05"); + } + + [Theory] + [InlineData("partial-time")] + public void TrySerialize_TimeOnlyMillis_ReturnsString(string format) + { + // Arrange + + Span buffer = stackalloc char[256]; + + // Act + + bool result = LiteralSerializer.TrySerialize( + new TimeOnly(3, 4, 5, 123), format, buffer, out var charsWritten); + + // Assert + + result.Should().BeTrue(); + buffer[..charsWritten].ToString().Should().Be("03:04:05.1230000"); + } + [Fact] public void TrySerialize_Guid_ReturnsString() { @@ -808,6 +908,20 @@ public void Deserialize_Date_ReturnsString(string format) result.Should().Be(new DateTime(2020, 01, 02)); } + [Theory] + [InlineData("date")] + [InlineData("full-date")] + public void Deserialize_DateOnly_ReturnsString(string format) + { + // Act + + var result = LiteralSerializer.Deserialize("2020-01-02", format); + + // Assert + + result.Should().Be(new DateOnly(year: 2020, 01, 02)); + } + [Theory] [InlineData("partial-time")] [InlineData("date-span")] @@ -836,6 +950,54 @@ public void Deserialize_TimeSpanWithMillis_ReturnsString(string format) result.Should().Be(new TimeSpan(0, 13, 1, 2, 234)); } + [Theory] + [InlineData("partial-time")] + public void Deserialize_TimeOnly_ReturnsString(string format) + { + // Act + + var result = LiteralSerializer.Deserialize("13:01:02", format); + + // Assert + + result.Should().Be(new TimeOnly(13, 1, 2)); + } + + [Theory] + [InlineData("partial-time")] + public void Deserialize_TimeOnlyWithMillis_ReturnsString(string format) + { + // Act + + var result = LiteralSerializer.Deserialize("13:01:02.234000", format); + + // Assert + + result.Should().Be(new TimeOnly(13, 1, 2, 234)); + } + + [Theory] + [InlineData("partial-time")] + public void Deserialize_TimeOnlyWithDays_ThrowsFormatException(string format) + { + // Act/Assert + + Action act = () => LiteralSerializer.Deserialize("2.13:01:02.234000", format); + + act.Should().Throw(); + } + + [Theory] + [InlineData("partial-time")] + public void Deserialize_TimeOnlyWithNegative_ThrowsFormatException(string format) + { + // Act/Assert + + Action act = () => LiteralSerializer.Deserialize("-13:01:02.234000", format); + + act.Should().Throw(); + } + [Fact] public void Deserialize_DateWithUnexpectedTime_FormatException() { diff --git a/src/main/Yardarm.Client/Serialization/Literals/Converters/DateOnlyLiteralConverter.net6.0.cs b/src/main/Yardarm.Client/Serialization/Literals/Converters/DateOnlyLiteralConverter.net6.0.cs new file mode 100644 index 0000000..dd86581 --- /dev/null +++ b/src/main/Yardarm.Client/Serialization/Literals/Converters/DateOnlyLiteralConverter.net6.0.cs @@ -0,0 +1,15 @@ +using System; + +namespace RootNamespace.Serialization.Literals.Converters; + +internal sealed class DateOnlyLiteralConverter : ValueTypeLiteralConverter +{ + protected override DateOnly ReadCore(string value, string? format) => + DateOnly.ParseExact(value, "o"); + + public override string Write(DateOnly value, string? format) => + value.ToString("o"); + + public override bool TryWrite(DateOnly value, ReadOnlySpan format, Span destination, out int charsWritten) => + value.TryFormat(destination, out charsWritten, "o"); +} diff --git a/src/main/Yardarm.Client/Serialization/Literals/Converters/TimeOnlyLiteralConverter.net6.0.cs b/src/main/Yardarm.Client/Serialization/Literals/Converters/TimeOnlyLiteralConverter.net6.0.cs new file mode 100644 index 0000000..6fcb5b0 --- /dev/null +++ b/src/main/Yardarm.Client/Serialization/Literals/Converters/TimeOnlyLiteralConverter.net6.0.cs @@ -0,0 +1,29 @@ +using System; +using Yardarm.Client.Internal; + +namespace RootNamespace.Serialization.Literals.Converters; + +internal sealed class TimeOnlyLiteralConverter : ValueTypeLiteralConverter +{ + protected override TimeOnly ReadCore(string value, string? format) + { + ThrowHelper.ThrowIfNull(value); + + char firstChar = value[0]; + int firstSeparator = value.AsSpan().IndexOfAny('.', ':'); + if (!char.IsDigit(firstChar) || firstSeparator < 0 || value[firstSeparator] == '.') + { + // Note: TimeSpan.ParseExact permits leading whitespace, negative values + // and numbers of days so we need to exclude these cases here. + ThrowHelper.ThrowFormatException("The value is not in a supported TimeOnly format."); + } + + return TimeOnly.FromTimeSpan(TimeSpan.ParseExact(value, "c", null)); + } + + public override string Write(TimeOnly value, string? format) => + value.ToTimeSpan().ToString("c"); + + public override bool TryWrite(TimeOnly value, ReadOnlySpan format, Span destination, out int charsWritten) => + value.ToTimeSpan().TryFormat(destination, out charsWritten, "c"); +} diff --git a/src/main/Yardarm.Client/Serialization/Literals/LiteralConverterRegistry.cs b/src/main/Yardarm.Client/Serialization/Literals/LiteralConverterRegistry.cs index 31bcd11..940eda3 100644 --- a/src/main/Yardarm.Client/Serialization/Literals/LiteralConverterRegistry.cs +++ b/src/main/Yardarm.Client/Serialization/Literals/LiteralConverterRegistry.cs @@ -169,5 +169,10 @@ public static LiteralConverterRegistry CreateDefaultRegistry() => .Add(new DoubleLiteralConverter()) .Add(new DecimalLiteralConverter()) .Add(new StringLiteralConverter()) - .Add(new UriLiteralConverter()); + .Add(new UriLiteralConverter()) +#if NET6_0_OR_GREATER + .Add(new DateOnlyLiteralConverter()) + .Add(new TimeOnlyLiteralConverter()) +#endif + ; }