Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add DateOnly and TimeOnly support to System.Text.Json #69160

Merged
merged 4 commits into from
May 13, 2022
Merged
Show file tree
Hide file tree
Changes from 2 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
11 changes: 4 additions & 7 deletions src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ private sealed class Parser

private readonly Type? _timeSpanType;
private readonly Type? _dateTimeOffsetType;
private readonly Type? _dateOnlyType;
private readonly Type? _timeOnlyType;
private readonly Type? _byteArrayType;
private readonly Type? _guidType;
private readonly Type? _uriType;
Expand All @@ -103,10 +105,6 @@ private sealed class Parser
private readonly Type _intPtrType;
private readonly Type _uIntPtrType;

// Unsupported types that may not resolve
eiriktsarpalis marked this conversation as resolved.
Show resolved Hide resolved
private readonly Type? _dateOnlyType;
private readonly Type? _timeOnlyType;

// Needed for converter validation
private readonly Type _jsonConverterOfTType;

Expand Down Expand Up @@ -1551,6 +1549,8 @@ private void PopulateKnownTypes()
AddTypeIfNotNull(_knownTypes, _byteArrayType);
AddTypeIfNotNull(_knownTypes, _timeSpanType);
AddTypeIfNotNull(_knownTypes, _dateTimeOffsetType);
AddTypeIfNotNull(_knownTypes, _dateOnlyType);
AddTypeIfNotNull(_knownTypes, _timeOnlyType);
AddTypeIfNotNull(_knownTypes, _guidType);
AddTypeIfNotNull(_knownTypes, _uriType);
AddTypeIfNotNull(_knownTypes, _versionType);
Expand All @@ -1566,9 +1566,6 @@ private void PopulateKnownTypes()
_knownUnsupportedTypes.Add(_intPtrType);
_knownUnsupportedTypes.Add(_uIntPtrType);

AddTypeIfNotNull(_knownUnsupportedTypes, _dateOnlyType);
AddTypeIfNotNull(_knownUnsupportedTypes, _timeOnlyType);

static void AddTypeIfNotNull(HashSet<Type> types, Type? type)
{
if (type != null)
Expand Down
1 change: 1 addition & 0 deletions src/libraries/System.Text.Json/ref/System.Text.Json.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
<Compile Include="System.Text.Json.Typeforwards.netcoreapp.cs" />
<Compile Include="System.Text.Json.netcoreapp.cs" />
</ItemGroup>
eiriktsarpalis marked this conversation as resolved.
Show resolved Hide resolved

<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
Expand Down
14 changes: 14 additions & 0 deletions src/libraries/System.Text.Json/ref/System.Text.Json.netcoreapp.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// ------------------------------------------------------------------------------
// Changes to this file must follow the https://aka.ms/api-review process.
// ------------------------------------------------------------------------------

namespace System.Text.Json.Serialization.Metadata
{
public static partial class JsonMetadataServices
{
public static System.Text.Json.Serialization.JsonConverter<System.DateOnly> DateOnlyConverter { get { throw null; } }
public static System.Text.Json.Serialization.JsonConverter<System.TimeOnly> TimeOnlyConverter { get { throw null; } }
}
}
19 changes: 2 additions & 17 deletions src/libraries/System.Text.Json/src/Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -312,20 +312,8 @@
<data name="InvalidComparison" xml:space="preserve">
<value>Cannot compare the value of a token type '{0}' to text.</value>
</data>
<data name="FormatDateTime" xml:space="preserve">
<value>The JSON value is not in a supported DateTime format.</value>
</data>
<data name="FormatDateTimeOffset" xml:space="preserve">
<value>The JSON value is not in a supported DateTimeOffset format.</value>
</data>
<data name="FormatTimeSpan" xml:space="preserve">
<value>The JSON value is not in a supported TimeSpan format.</value>
</data>
<data name="FormatGuid" xml:space="preserve">
<value>The JSON value is not in a supported Guid format.</value>
</data>
<data name="FormatVersion" xml:space="preserve">
<value>The JSON value is not in a supported Version format.</value>
<data name="UnsupportedFormat" xml:space="preserve">
<value>The JSON value is not in a supported {0} format.</value>
</data>
<data name="ExpectedStartOfPropertyOrValueAfterComment" xml:space="preserve">
<value>'{0}' is an invalid start of a property name or value, after a comment.</value>
Expand Down Expand Up @@ -518,9 +506,6 @@
<data name="DefaultIgnoreConditionInvalid" xml:space="preserve">
<value>The value cannot be 'JsonIgnoreCondition.Always'.</value>
</data>
<data name="FormatBoolean" xml:space="preserve">
<value>The JSON value is not in a supported Boolean format.</value>
</data>
<data name="DictionaryKeyTypeNotSupported" xml:space="preserve">
<value>The type '{0}' is not a supported dictionary key using converter of type '{1}'.</value>
</data>
Expand Down
4 changes: 3 additions & 1 deletion src/libraries/System.Text.Json/src/System.Text.Json.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(NetCoreAppCurrent);$(NetCoreAppMinimum);netstandard2.0;$(NetFrameworkMinimum)</TargetFrameworks>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
Expand Down Expand Up @@ -335,6 +335,8 @@ System.Text.Json.Nodes.JsonValue</PackageDescription>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
<Compile Include="System.Text.Json.Typeforwards.netcoreapp.cs" />
<Compile Include="System\Text\Json\Serialization\JsonSerializerOptionsUpdateHandler.cs" />
<Compile Include="System\Text\Json\Serialization\Converters\Value\DateOnlyConverter.cs" />
<Compile Include="System\Text\Json\Serialization\Converters\Value\TimeOnlyConverter.cs" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ private struct DateTimeParseData
public int Year;
public int Month;
public int Day;
public bool IsCalendarDateOnly;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if it would help with #69447 (comment), but defining this field here will increase the size of this struct from 40 bytes to 48 bytes on 64-bit. If you instead move this field to the end, the size should stay at 40.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not seeing a difference in benchmarks when I rearrange the fields, but I could try pushing the change and see if it registers in the performance infrastructure.

public int Hour;
public int Minute;
public int Second;
Expand All @@ -23,94 +24,6 @@ private struct DateTimeParseData
public byte OffsetToken;
}

public static string FormatDateTimeOffset(DateTimeOffset value)
{
Span<byte> span = stackalloc byte[JsonConstants.MaximumFormatDateTimeOffsetLength];

JsonWriterHelper.WriteDateTimeOffsetTrimmed(span, value, out int bytesWritten);

return JsonReaderHelper.GetTextFromUtf8(span.Slice(0, bytesWritten));
}

public static string FormatDateTime(DateTime value)
{
Span<byte> span = stackalloc byte[JsonConstants.MaximumFormatDateTimeOffsetLength];

JsonWriterHelper.WriteDateTimeTrimmed(span, value, out int bytesWritten);

return JsonReaderHelper.GetTextFromUtf8(span.Slice(0, bytesWritten));
}

public static bool TryParseAsISO(ReadOnlySpan<char> source, out DateTime value)
{
if (!IsValidDateTimeOffsetParseLength(source.Length))
{
value = default;
return false;
}

int maxLength = checked(source.Length * JsonConstants.MaxExpansionFactorWhileTranscoding);

Span<byte> bytes = maxLength <= JsonConstants.StackallocByteThreshold
? stackalloc byte[JsonConstants.StackallocByteThreshold]
: new byte[maxLength];

int length = JsonReaderHelper.GetUtf8FromText(source, bytes);

bytes = bytes.Slice(0, length);

if (bytes.IndexOf(JsonConstants.BackSlash) != -1)
{
return JsonReaderHelper.TryGetEscapedDateTime(bytes, out value);
}

Debug.Assert(bytes.IndexOf(JsonConstants.BackSlash) == -1);

if (TryParseAsISO(bytes, out DateTime tmp))
{
value = tmp;
return true;
}

value = default;
return false;
}

public static bool TryParseAsISO(ReadOnlySpan<char> source, out DateTimeOffset value)
{
if (!IsValidDateTimeOffsetParseLength(source.Length))
{
value = default;
return false;
}

int maxLength = checked(source.Length * JsonConstants.MaxExpansionFactorWhileTranscoding);

Span<byte> bytes = maxLength <= JsonConstants.StackallocByteThreshold
? stackalloc byte[JsonConstants.StackallocByteThreshold]
: new byte[maxLength];

int length = JsonReaderHelper.GetUtf8FromText(source, bytes);

bytes = bytes.Slice(0, length);

if (bytes.IndexOf(JsonConstants.BackSlash) != -1)
{
return JsonReaderHelper.TryGetEscapedDateTimeOffset(bytes, out value);
}

Debug.Assert(bytes.IndexOf(JsonConstants.BackSlash) == -1);

if (TryParseAsISO(bytes, out DateTimeOffset tmp))
{
value = tmp;
return true;
}

value = default;
return false;
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsValidDateTimeOffsetParseLength(int length)
{
Expand Down Expand Up @@ -180,6 +93,22 @@ public static bool TryParseAsISO(ReadOnlySpan<byte> source, out DateTimeOffset v
return TryCreateDateTimeOffsetInterpretingDataAsLocalTime(parseData, out value);
}

#if NET6_0_OR_GREATER
public static bool TryParseAsIso(ReadOnlySpan<byte> source, out DateOnly value)
{
if (TryParseDateTimeOffset(source, out DateTimeParseData parseData) &&
parseData.IsCalendarDateOnly &&
TryCreateDateTime(parseData, DateTimeKind.Unspecified, out DateTime dateTime))
{
value = DateOnly.FromDateTime(dateTime);
return true;
}

value = default;
return false;
}
#endif

/// <summary>
/// ISO 8601 date time parser (ISO 8601-1:2019).
/// </summary>
Expand Down Expand Up @@ -251,7 +180,7 @@ private static bool TryParseDateTimeOffset(ReadOnlySpan<byte> source, out DateTi
// We now have YYYY-MM-DD [dateX]
if (source.Length == 10)
{
// Just a calendar date
parseData.IsCalendarDateOnly = true;
return true;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Buffers;
using System.Diagnostics;
using System.Globalization;

namespace System.Text.Json.Serialization.Converters
{
internal sealed class DateOnlyConverter : JsonConverter<DateOnly>
eiriktsarpalis marked this conversation as resolved.
Show resolved Hide resolved
{
public const int FormatLength = 10; // YYYY-MM-DD
public const int MaxEscapedFormatLength = FormatLength * JsonConstants.MaxExpansionFactorWhileEscaping;

public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.String)
{
ThrowHelper.ThrowInvalidOperationException_ExpectedString(reader.TokenType);
}

return ReadCore(ref reader);
}

internal override DateOnly ReadAsPropertyNameCore(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return ReadCore(ref reader);
}

private DateOnly ReadCore(ref Utf8JsonReader reader)
{
bool isEscaped = reader._stringHasEscaping;
ReadOnlySpan<byte> source = stackalloc byte[0];

if (reader.HasValueSequence)
{
ReadOnlySequence<byte> valueSequence = reader.ValueSequence;
long sequenceLength = valueSequence.Length;

if (!JsonHelpers.IsInRangeInclusive(sequenceLength, FormatLength, MaxEscapedFormatLength))
{
ThrowHelper.ThrowFormatException(DataType.DateOnly);
}

Span<byte> stackSpan = stackalloc byte[isEscaped ? FormatLength : MaxEscapedFormatLength];
valueSequence.CopyTo(stackSpan);
source = stackSpan.Slice(0, (int)sequenceLength);
}
else
{
source = reader.ValueSpan;

if (!JsonHelpers.IsInRangeInclusive(source.Length, FormatLength, MaxEscapedFormatLength))
{
ThrowHelper.ThrowFormatException(DataType.DateOnly);
}
}

if (isEscaped)
{
int backslash = source.IndexOf(JsonConstants.BackSlash);
Debug.Assert(backslash != -1);

Span<byte> sourceUnescaped = stackalloc byte[MaxEscapedFormatLength];

JsonReaderHelper.Unescape(source, sourceUnescaped, backslash, out int written);
Debug.Assert(written > 0);

source = sourceUnescaped.Slice(0, written);
Debug.Assert(!source.IsEmpty);
}

if (!JsonHelpers.TryParseAsIso(source, out DateOnly value))
{
ThrowHelper.ThrowFormatException(DataType.DateOnly);
}

return value;
}

public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options)
{
Span<char> buffer = stackalloc char[FormatLength];
bool formattedSuccessfully = value.TryFormat(buffer, out int charsWritten, "O", CultureInfo.InvariantCulture);
Debug.Assert(formattedSuccessfully && charsWritten == FormatLength);
writer.WriteStringValue(buffer);
}

internal override void WriteAsPropertyNameCore(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options, bool isWritingExtensionDataProperty)
eiriktsarpalis marked this conversation as resolved.
Show resolved Hide resolved
{
Span<char> buffer = stackalloc char[FormatLength];
bool formattedSuccessfully = value.TryFormat(buffer, out int charsWritten, "O", CultureInfo.InvariantCulture);
Debug.Assert(formattedSuccessfully && charsWritten == FormatLength);
writer.WritePropertyName(buffer);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Buffers.Text;
using System.Diagnostics;
using System.Globalization;

namespace System.Text.Json.Serialization.Converters
{
internal sealed class TimeOnlyConverter : JsonConverter<TimeOnly>
eiriktsarpalis marked this conversation as resolved.
Show resolved Hide resolved
{
private static readonly TimeSpanConverter s_timeSpanConverter = new TimeSpanConverter();
private static readonly TimeSpan s_timeOnlyMaxValue = TimeOnly.MaxValue.ToTimeSpan();

public override TimeOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
TimeSpan timespan = s_timeSpanConverter.Read(ref reader, typeToConvert, options);
eiriktsarpalis marked this conversation as resolved.
Show resolved Hide resolved

if (timespan < TimeSpan.Zero || timespan > s_timeOnlyMaxValue)
{
ThrowHelper.ThrowJsonException();
}

return TimeOnly.FromTimeSpan(timespan);
}

public override void Write(Utf8JsonWriter writer, TimeOnly value, JsonSerializerOptions options)
{
s_timeSpanConverter.Write(writer, value.ToTimeSpan(), options);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,7 @@ public override bool CanConvert(Type type)
type == typeof(IntPtr) ||
type == typeof(UIntPtr) ||
// Exlude delegates.
typeof(Delegate).IsAssignableFrom(type) ||
// DateOnly/TimeOnly support to be added in future releases;
// guard against invalid object-based serializations for now.
// cf. https://github.com/dotnet/runtime/issues/53539
//
// For simplicity we elide equivalent checks for targets
// that are older than net6.0, since they do not include
// DateOnly or TimeOnly.
#if NETCOREAPP
type == typeof(DateOnly) ||
type == typeof(TimeOnly);
#else
false;
#endif
typeof(Delegate).IsAssignableFrom(type);
}

public override JsonConverter CreateConverter(Type type, JsonSerializerOptions options)
Expand Down
Loading