Skip to content

Commit

Permalink
Add DateOnly and TimeOnly literal converters (#282)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
brantburnett authored Feb 1, 2025
1 parent 8380f19 commit 7c418a4
Show file tree
Hide file tree
Showing 4 changed files with 212 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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()
{
Expand Down Expand Up @@ -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<char> 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()
{
Expand Down Expand Up @@ -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<char> 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<char> 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()
{
Expand Down Expand Up @@ -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<DateOnly>("2020-01-02", format);

// Assert

result.Should().Be(new DateOnly(year: 2020, 01, 02));
}

[Theory]
[InlineData("partial-time")]
[InlineData("date-span")]
Expand Down Expand Up @@ -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<TimeOnly>("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<TimeOnly>("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<TimeOnly>("2.13:01:02.234000", format);

act.Should().Throw<FormatException>();
}

[Theory]
[InlineData("partial-time")]
public void Deserialize_TimeOnlyWithNegative_ThrowsFormatException(string format)
{
// Act/Assert

Action act = () => LiteralSerializer.Deserialize<TimeOnly>("-13:01:02.234000", format);

act.Should().Throw<FormatException>();
}

[Fact]
public void Deserialize_DateWithUnexpectedTime_FormatException()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System;

namespace RootNamespace.Serialization.Literals.Converters;

internal sealed class DateOnlyLiteralConverter : ValueTypeLiteralConverter<DateOnly>
{
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<char> format, Span<char> destination, out int charsWritten) =>
value.TryFormat(destination, out charsWritten, "o");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System;
using Yardarm.Client.Internal;

namespace RootNamespace.Serialization.Literals.Converters;

internal sealed class TimeOnlyLiteralConverter : ValueTypeLiteralConverter<TimeOnly>
{
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<char> format, Span<char> destination, out int charsWritten) =>
value.ToTimeSpan().TryFormat(destination, out charsWritten, "c");
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
;
}

0 comments on commit 7c418a4

Please sign in to comment.