diff --git a/src/libraries/System.Text.Json/ref/System.Text.Json.cs b/src/libraries/System.Text.Json/ref/System.Text.Json.cs index 481eddecc3134b..bb05814756c62e 100644 --- a/src/libraries/System.Text.Json/ref/System.Text.Json.cs +++ b/src/libraries/System.Text.Json/ref/System.Text.Json.cs @@ -782,6 +782,7 @@ public static partial class JsonMetadataServices public static System.Text.Json.Serialization.JsonConverter SByteConverter { get { throw null; } } public static System.Text.Json.Serialization.JsonConverter SingleConverter { get { throw null; } } public static System.Text.Json.Serialization.JsonConverter StringConverter { get { throw null; } } + public static System.Text.Json.Serialization.JsonConverter TimeSpanConverter { get { throw null; } } [System.CLSCompliantAttribute(false)] public static System.Text.Json.Serialization.JsonConverter UInt16Converter { get { throw null; } } [System.CLSCompliantAttribute(false)] diff --git a/src/libraries/System.Text.Json/src/Resources/Strings.resx b/src/libraries/System.Text.Json/src/Resources/Strings.resx index 21899c4519e783..afef7060e4f77b 100644 --- a/src/libraries/System.Text.Json/src/Resources/Strings.resx +++ b/src/libraries/System.Text.Json/src/Resources/Strings.resx @@ -318,6 +318,9 @@ The JSON value is not in a supported DateTimeOffset format. + + The JSON value is not in a supported TimeSpan format. + The JSON value is not in a supported Guid format. diff --git a/src/libraries/System.Text.Json/src/System.Text.Json.csproj b/src/libraries/System.Text.Json/src/System.Text.Json.csproj index 887dba97065e82..4cf34ea87861b0 100644 --- a/src/libraries/System.Text.Json/src/System.Text.Json.csproj +++ b/src/libraries/System.Text.Json/src/System.Text.Json.csproj @@ -156,6 +156,7 @@ + diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/TimeSpanConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/TimeSpanConverter.cs new file mode 100644 index 00000000000000..1146eb4c4f1f36 --- /dev/null +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/TimeSpanConverter.cs @@ -0,0 +1,289 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; + +namespace System.Text.Json.Serialization.Converters +{ + internal sealed class TimeSpanConverter : JsonConverter + { + private static readonly JsonEncodedText Zero = JsonEncodedText.Encode("PT0S"); + private const ulong TicksPerYear = TimeSpan.TicksPerDay * 365; + private const ulong TicksPerMonth = TimeSpan.TicksPerDay * 30; + private const ulong TicksPerDay = (ulong)TimeSpan.TicksPerDay; + private const ulong TicksPerHour = (ulong)TimeSpan.TicksPerHour; + private const ulong TicksPerMinute = (ulong)TimeSpan.TicksPerMinute; + private const ulong TicksPerSecond = (ulong)TimeSpan.TicksPerSecond; + + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + static byte ReadByte(ref ReadOnlySpan span) + { + if (span.IsEmpty) + { + ThrowFormatException(); + } + + var result = span[0]; + span = span.Slice(1); + + return result; + } + + static ulong ReadDigits(ref ReadOnlySpan span) + { + if (span.IsEmpty) + { + ThrowFormatException(); + } + + ulong value = 0; + do + { + uint digit = (uint)(span[0] - '0'); + + if (digit > 9) + { + break; + } + + value = value * 10 + digit; + span = span.Slice(1); + } + while (!span.IsEmpty); + return value; + } + + var span = reader.ValueSpan; + byte current = ReadByte(ref span); + bool negative = current == '-'; + ulong value; + + if (negative) + { + current = ReadByte(ref span); + } + + if (current != 'P') + { + ThrowFormatException(); + } + + checked + { + value = ReadDigits(ref span); + current = ReadByte(ref span); + + ulong ticks = 0; + if (current == 'Y') + { + ticks += value * TicksPerYear; + + if (span.IsEmpty) + { + return Result(ticks); + } + + value = ReadDigits(ref span); + current = ReadByte(ref span); + } + + if (current == 'M') + { + ticks += value * TicksPerMonth; + + if (span.IsEmpty) + { + return Result(ticks); + } + + value = ReadDigits(ref span); + current = ReadByte(ref span); + } + + if (current == 'D') + { + ticks += value * TicksPerDay; + + if (span.IsEmpty) + { + return Result(ticks); + } + + current = ReadByte(ref span); + } + + bool hasTime = current == 'T'; + if (hasTime) + { + value = ReadDigits(ref span); + current = ReadByte(ref span); + + if (current == 'H') + { + ticks += value * TicksPerHour; + + if (span.IsEmpty) + { + return Result(ticks); + } + + value = ReadDigits(ref span); + current = ReadByte(ref span); + } + + if (current == 'M') + { + ticks += value * TicksPerMinute; + + if (span.IsEmpty) + { + return Result(ticks); + } + + value = ReadDigits(ref span); + current = ReadByte(ref span); + } + + if (current == 'S') + { + ticks += value * TicksPerSecond; + + if (span.IsEmpty) + { + return Result(ticks); + } + + current = ReadByte(ref span); + } + } + + if (current == '.' || + current == ',') + { + ulong valueFraction = ReadDigits(ref span); + (ulong ticksPerPart, ulong ticksPerFraction) = ReadByte(ref span) switch + { + (byte)'Y' => (TicksPerYear, TicksPerMonth), + (byte)'M' => hasTime + ? (TicksPerMinute, TicksPerSecond) + : (TicksPerMonth, TicksPerDay), + (byte)'D' => (TicksPerDay, TicksPerHour), + (byte)'H' => (TicksPerHour, TicksPerMinute), + (byte)'S' => (TicksPerSecond, 1U), + _ => (0U, 0U), + }; + + if (span.IsEmpty && ticksPerPart != 0) + { + ulong scale = 10; + while (valueFraction / scale != 0) + { + scale *= 10; + } + + ulong ticksFraction = (ulong)(ticksPerPart * ((double)valueFraction / (double)scale)); + + ticks += (ulong)(ticksFraction / ticksPerFraction) * ticksPerFraction; + ticks += (ulong)(value * ticksPerPart); + + return Result(ticks); + } + } + } + + ThrowFormatException(); + return default; + + TimeSpan Result(ulong ticks) => new TimeSpan(negative ? -(long)ticks : (long)ticks); + static void ThrowFormatException() => throw ThrowHelper.GetFormatException(DataType.TimeSpan); + } + + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) + { + static int Write(Span span, char designator, ref ulong ticks, ulong ticksPerPart) + { + var value = ticks / ticksPerPart; + int written = 0; + + if (value != 0) + { + ticks -= value * ticksPerPart; + written = WriteDigits(span, value); + span[written++] = (byte)designator; + + return written; + } + + return written; + } + + static int WriteDigits(Span span, ulong value) + { + int digits = value switch + { + < 10 => 1, + < 100 => 2, + < 1000 => 3, + < 10000 => 4, + < 100000 => 5, + < 1000000 => 6, + < 10000000 => 7, + _ => 0, + }; + + int digit = digits; + while (digit-- != 0) + { + ulong temp = value / 10; + + span[digit] = (byte)('0' + value - temp * 10); + value = temp; + } + + return digits; + } + + if (value == TimeSpan.Zero) + { + writer.WriteStringValue(Zero); + return; + } + + Span result = stackalloc byte[32]; + var ticks = (ulong)value.Ticks; + var position = 0; + + if (ticks > long.MaxValue) + { + result[position++] = (byte)'-'; + ticks = (ulong)-value.Ticks; + } + + result[position++] = (byte)'P'; + position += Write(result.Slice(position), 'Y', ref ticks, TicksPerYear); + position += Write(result.Slice(position), 'M', ref ticks, TicksPerMonth); + position += Write(result.Slice(position), 'D', ref ticks, TicksPerDay); + + if (ticks != 0) + { + result[position++] = (byte)'T'; + position += Write(result.Slice(position), 'H', ref ticks, TicksPerHour); + position += Write(result.Slice(position), 'M', ref ticks, TicksPerMinute); + position += Write(result.Slice(position), 'S', ref ticks, TicksPerSecond); + + if (ticks != 0) + { + for (ulong temp = ticks; + ticks - (temp /= 10) * 10 == 0; + ticks = temp); + + result[position - 1] = (byte)'.'; + position += WriteDigits(result.Slice(position), ticks); + result[position++] = (byte)'S'; + } + } + + writer.WriteStringValue(result.Slice(0, position)); + } + } +} diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.Converters.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.Converters.cs index c0c26ed1461aa4..2fd1ff4626c97f 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.Converters.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.Converters.cs @@ -71,6 +71,7 @@ private static Dictionary GetDefaultSimpleConverters() Add(JsonMetadataServices.SByteConverter); Add(JsonMetadataServices.SingleConverter); Add(JsonMetadataServices.StringConverter); + Add(JsonMetadataServices.TimeSpanConverter); Add(JsonMetadataServices.UInt16Converter); Add(JsonMetadataServices.UInt32Converter); Add(JsonMetadataServices.UInt64Converter); diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Converters.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Converters.cs index 72afc850d35741..3dc1b1285c0d22 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Converters.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Converters.cs @@ -104,6 +104,12 @@ public static partial class JsonMetadataServices public static JsonConverter StringConverter => s_stringConverter ??= new StringConverter(); private static JsonConverter? s_stringConverter; + /// + /// Returns a instance that converts values. + /// + public static JsonConverter TimeSpanConverter => s_timeSpanConverter ??= new TimeSpanConverter(); + private static JsonConverter? s_timeSpanConverter; + /// /// Returns a instance that converts values. /// diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.cs b/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.cs index 2b6b85878c9aef..96296fa7b2fedb 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.cs @@ -636,6 +636,9 @@ public static FormatException GetFormatException(DataType dateType) case DataType.DateTimeOffset: message = SR.FormatDateTimeOffset; break; + case DataType.TimeSpan: + message = SR.FormatTimeSpan; + break; case DataType.Base64String: message = SR.CannotDecodeInvalidBase64; break; @@ -723,6 +726,7 @@ internal enum DataType Boolean, DateTime, DateTimeOffset, + TimeSpan, Base64String, Guid, } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/TimeSpanTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/TimeSpanTests.cs new file mode 100644 index 00000000000000..757f40080e7a25 --- /dev/null +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/TimeSpanTests.cs @@ -0,0 +1,119 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace System.Text.Json.Serialization.Tests +{ + public static class TimeSpanTests + { + [Theory] + [InlineData(@"""PT-1S""")] + [InlineData(@"""PT-1M""")] + [InlineData(@"""PT-1H""")] + [InlineData(@"""P-1D""")] + [InlineData(@"""P-1M""")] + [InlineData(@"""P-1Y""")] + [InlineData(@"""T1S""")] + [InlineData(@"""T1M""")] + [InlineData(@"""T1H""")] + [InlineData(@"""1D""")] + [InlineData(@"""1M""")] + [InlineData(@"""1Y""")] + [InlineData(@"""P1Y2M3D4H5M6""")] + [InlineData(@"""P1Y2M3DT4H5M6""")] + [InlineData(@"""P1Y2M3DT4H5""")] + [InlineData(@"""P1Y2M3DT4""")] + [InlineData(@"""P1Y2M3""")] + [InlineData(@"""P1Y2""")] + [InlineData(@"""P1""")] + public static void ReadInvalid(string json) => + Assert.Throws(() => JsonSerializer.Deserialize(json)); + + [Theory] + [MemberData(nameof(ReadCases))] + public static void Read(string json, TimeSpan expected) => + Assert.Equal(expected, JsonSerializer.Deserialize(json)); + + [Theory] + [MemberData(nameof(WriteCases))] + public static void Write(string expected, TimeSpan value) => + Assert.Equal(expected, JsonSerializer.Serialize(value)); + + public static IEnumerable ReadCases() => + TestCases().Concat( + new [] + { + new object[] { @"""PT1.5S""", Time(0, 0, 1, 5_000_000) }, + new object[] { @"""PT1.5M""", Time(0, 1, 30) }, + new object[] { @"""PT1.5H""", Time(1, 30, 0) }, + new object[] { @"""P1.5D""", Date(0, 0, 1) + Time(12, 0, 0) }, + new object[] { @"""P1.5M""", Date(0, 1, 15) }, + new object[] { @"""P1.5Y""", Date(1, 6, 0) }, + new object[] { @"""PT1,5S""", Time(0, 0, 1, 5_000_000) }, + new object[] { @"""PT1,5M""", Time(0, 1, 30) }, + new object[] { @"""PT1,5H""", Time(1, 30, 0) }, + new object[] { @"""P1,5D""", Date(0, 0, 1) + Time(12, 0, 0) }, + new object[] { @"""P1,5M""", Date(0, 1, 15) }, + new object[] { @"""P1,5Y""", Date(1, 6, 0) }, + }); + + public static IEnumerable WriteCases() => + TestCases().Concat( + new [] + { + new object[] { @"""PT1.5S""", Time(0, 0, 1, 5_000_000) }, + new object[] { @"""PT1M30S""", Time(0, 1, 30) }, + new object[] { @"""PT1H30M""", Time(1, 30, 0) }, + new object[] { @"""P1DT12H""", Date(0, 0, 1) + Time(12, 0, 0) }, + new object[] { @"""P1M15D""", Date(0, 1, 15) }, + new object[] { @"""P1Y6M""", Date(1, 6, 0) }, + }); + + private static IEnumerable TestCases() + { + yield return new object[] { @"""PT0S""", TimeSpan.Zero }; + yield return new object[] { @"""P29247Y1M14DT2H48M5.4775807S""", TimeSpan.MaxValue }; + yield return new object[] { @"""-P29247Y1M14DT2H48M5.4775808S""", TimeSpan.MinValue }; + + yield return new object[] { @"""PT1S""", Time(0, 0, 1) }; + yield return new object[] { @"""PT1M""", Time(0, 1, 0) }; + yield return new object[] { @"""PT1H""", Time(1, 0, 0) }; + yield return new object[] { @"""P1D""", Date(0, 0, 1) }; + yield return new object[] { @"""P1M""", Date(0, 1, 0) }; + yield return new object[] { @"""P1Y""", Date(1, 0, 0) }; + + yield return new object[] { @"""PT6S""", Time(0, 0, 6) }; + yield return new object[] { @"""PT5M6S""", Time(0, 5, 6) }; + yield return new object[] { @"""PT4H5M6S""", Time(4, 5, 6) }; + yield return new object[] { @"""P3D""", Date(0, 0, 3) }; + yield return new object[] { @"""P2M3D""", Date(0, 2, 3) }; + yield return new object[] { @"""P1Y2M3D""", Date(1, 2, 3) }; + yield return new object[] { @"""P3DT4H5M6S""", Date(0, 0, 3) + Time(4, 5, 6) }; + yield return new object[] { @"""P2M3DT4H5M6S""", Date(0, 2, 3) + Time(4, 5, 6) }; + yield return new object[] { @"""P1Y2M3DT4H5M6S""", Date(1, 2, 3) + Time(4, 5, 6) }; + yield return new object[] { @"""P1Y2M3DT4H5M""", Date(1, 2, 3) + Time(4, 5, 0) }; + yield return new object[] { @"""P1Y2M3DT4H""", Date(1, 2, 3) + Time(4, 0, 0) }; + yield return new object[] { @"""P1Y2M3D""", Date(1, 2, 3) }; + yield return new object[] { @"""P1Y2M""", Date(1, 2, 0) }; + yield return new object[] { @"""P1Y""", Date(1, 0, 0) }; + + yield return new object[] { @"""PT4H6S""", Time(4, 0, 6) }; + yield return new object[] { @"""P1Y3D""", Date(1, 0, 3) }; + } + + static TimeSpan Date(int years, int months, int days) => + new TimeSpan( + years * TimeSpan.TicksPerDay * 365 + + months * TimeSpan.TicksPerDay * 30 + + days * TimeSpan.TicksPerDay); + + static TimeSpan Time(int hours, int minutes, int seconds, int nanoseconds = 0) => + new TimeSpan( + hours * TimeSpan.TicksPerHour + + minutes * TimeSpan.TicksPerMinute + + seconds * TimeSpan.TicksPerSecond + + nanoseconds); + + } +} diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/System.Text.Json.Tests.csproj b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/System.Text.Json.Tests.csproj index 1bdb9fbf82fee2..96c26f8f2a2031 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/System.Text.Json.Tests.csproj +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/System.Text.Json.Tests.csproj @@ -157,6 +157,7 @@ +