Skip to content

Commit d5f9544

Browse files
authored
System.Text.Json: Add TimeSpanConverter (#54186)
* Added TimeSpanConverter. * Code review. * Test tweak. * Added invalid cases. * Remove the ToArray call in the case of ValueSequence. * Support escaped strings in TimeSpanConverter. * Removed 'g' format fallback. * Fixed 'h:mm:ss' being accepted by TimeSpanConverter. * Code review. * Code review.
1 parent a789606 commit d5f9544

File tree

13 files changed

+276
-25
lines changed

13 files changed

+276
-25
lines changed

src/libraries/System.Text.Json/ref/System.Text.Json.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,7 @@ public static partial class JsonMetadataServices
903903
public static System.Text.Json.Serialization.JsonConverter<sbyte> SByteConverter { get { throw null; } }
904904
public static System.Text.Json.Serialization.JsonConverter<float> SingleConverter { get { throw null; } }
905905
public static System.Text.Json.Serialization.JsonConverter<string> StringConverter { get { throw null; } }
906+
public static System.Text.Json.Serialization.JsonConverter<System.TimeSpan> TimeSpanConverter { get { throw null; } }
906907
[System.CLSCompliantAttribute(false)]
907908
public static System.Text.Json.Serialization.JsonConverter<ushort> UInt16Converter { get { throw null; } }
908909
[System.CLSCompliantAttribute(false)]

src/libraries/System.Text.Json/src/Resources/Strings.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,9 @@
318318
<data name="FormatDateTimeOffset" xml:space="preserve">
319319
<value>The JSON value is not in a supported DateTimeOffset format.</value>
320320
</data>
321+
<data name="FormatTimeSpan" xml:space="preserve">
322+
<value>The JSON value is not in a supported TimeSpan format.</value>
323+
</data>
321324
<data name="FormatGuid" xml:space="preserve">
322325
<value>The JSON value is not in a supported Guid format.</value>
323326
</data>

src/libraries/System.Text.Json/src/System.Text.Json.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@
165165
<Compile Include="System\Text\Json\Serialization\Converters\Value\SByteConverter.cs" />
166166
<Compile Include="System\Text\Json\Serialization\Converters\Value\SingleConverter.cs" />
167167
<Compile Include="System\Text\Json\Serialization\Converters\Value\StringConverter.cs" />
168+
<Compile Include="System\Text\Json\Serialization\Converters\Value\TimeSpanConverter.cs" />
168169
<Compile Include="System\Text\Json\Serialization\Converters\Value\UInt16Converter.cs" />
169170
<Compile Include="System\Text\Json\Serialization\Converters\Value\UInt32Converter.cs" />
170171
<Compile Include="System\Text\Json\Serialization\Converters\Value\UInt64Converter.cs" />

src/libraries/System.Text.Json/src/System/Text/Json/JsonHelpers.Date.cs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,6 @@ public static bool IsValidDateTimeOffsetParseLength(int length)
117117
return IsInRangeInclusive(length, JsonConstants.MinimumDateTimeParseLength, JsonConstants.MaximumEscapedDateTimeOffsetParseLength);
118118
}
119119

120-
[MethodImpl(MethodImplOptions.AggressiveInlining)]
121-
public static bool IsValidDateTimeOffsetParseLength(long length)
122-
{
123-
return IsInRangeInclusive(length, JsonConstants.MinimumDateTimeParseLength, JsonConstants.MaximumEscapedDateTimeOffsetParseLength);
124-
}
125-
126120
/// <summary>
127121
/// Parse the given UTF-8 <paramref name="source"/> as extended ISO 8601 format.
128122
/// </summary>

src/libraries/System.Text.Json/src/System/Text/Json/Reader/Utf8JsonReader.TryGet.cs

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,25 +1073,25 @@ internal bool TryGetDateTimeCore(out DateTime value)
10731073
{
10741074
ReadOnlySpan<byte> span = stackalloc byte[0];
10751075

1076+
int maximumLength = _stringHasEscaping ? JsonConstants.MaximumEscapedDateTimeOffsetParseLength : JsonConstants.MaximumDateTimeOffsetParseLength;
1077+
10761078
if (HasValueSequence)
10771079
{
10781080
long sequenceLength = ValueSequence.Length;
1079-
1080-
if (!JsonHelpers.IsValidDateTimeOffsetParseLength(sequenceLength))
1081+
if (!JsonHelpers.IsInRangeInclusive(sequenceLength, JsonConstants.MinimumDateTimeParseLength, maximumLength))
10811082
{
10821083
value = default;
10831084
return false;
10841085
}
10851086

10861087
Debug.Assert(sequenceLength <= JsonConstants.MaximumEscapedDateTimeOffsetParseLength);
1087-
Span<byte> stackSpan = stackalloc byte[(int)sequenceLength];
1088-
1088+
Span<byte> stackSpan = stackalloc byte[_stringHasEscaping ? JsonConstants.MaximumEscapedDateTimeOffsetParseLength : JsonConstants.MaximumDateTimeOffsetParseLength];
10891089
ValueSequence.CopyTo(stackSpan);
1090-
span = stackSpan;
1090+
span = stackSpan.Slice(0, (int)sequenceLength);
10911091
}
10921092
else
10931093
{
1094-
if (!JsonHelpers.IsValidDateTimeOffsetParseLength(ValueSpan.Length))
1094+
if (!JsonHelpers.IsInRangeInclusive(ValueSpan.Length, JsonConstants.MinimumDateTimeParseLength, maximumLength))
10951095
{
10961096
value = default;
10971097
return false;
@@ -1141,25 +1141,25 @@ internal bool TryGetDateTimeOffsetCore(out DateTimeOffset value)
11411141
{
11421142
ReadOnlySpan<byte> span = stackalloc byte[0];
11431143

1144+
int maximumLength = _stringHasEscaping ? JsonConstants.MaximumEscapedDateTimeOffsetParseLength : JsonConstants.MaximumDateTimeOffsetParseLength;
1145+
11441146
if (HasValueSequence)
11451147
{
11461148
long sequenceLength = ValueSequence.Length;
1147-
1148-
if (!JsonHelpers.IsValidDateTimeOffsetParseLength(sequenceLength))
1149+
if (!JsonHelpers.IsInRangeInclusive(sequenceLength, JsonConstants.MinimumDateTimeParseLength, maximumLength))
11491150
{
11501151
value = default;
11511152
return false;
11521153
}
11531154

11541155
Debug.Assert(sequenceLength <= JsonConstants.MaximumEscapedDateTimeOffsetParseLength);
1155-
Span<byte> stackSpan = stackalloc byte[(int)sequenceLength];
1156-
1156+
Span<byte> stackSpan = stackalloc byte[_stringHasEscaping ? JsonConstants.MaximumEscapedDateTimeOffsetParseLength : JsonConstants.MaximumDateTimeOffsetParseLength];
11571157
ValueSequence.CopyTo(stackSpan);
1158-
span = stackSpan;
1158+
span = stackSpan.Slice(0, (int)sequenceLength);
11591159
}
11601160
else
11611161
{
1162-
if (!JsonHelpers.IsValidDateTimeOffsetParseLength(ValueSpan.Length))
1162+
if (!JsonHelpers.IsInRangeInclusive(ValueSpan.Length, JsonConstants.MinimumDateTimeParseLength, maximumLength))
11631163
{
11641164
value = default;
11651165
return false;
@@ -1210,24 +1210,25 @@ internal bool TryGetGuidCore(out Guid value)
12101210
{
12111211
ReadOnlySpan<byte> span = stackalloc byte[0];
12121212

1213+
int maximumLength = _stringHasEscaping ? JsonConstants.MaximumEscapedGuidLength : JsonConstants.MaximumFormatGuidLength;
1214+
12131215
if (HasValueSequence)
12141216
{
12151217
long sequenceLength = ValueSequence.Length;
1216-
if (sequenceLength > JsonConstants.MaximumEscapedGuidLength)
1218+
if (sequenceLength > maximumLength)
12171219
{
12181220
value = default;
12191221
return false;
12201222
}
12211223

12221224
Debug.Assert(sequenceLength <= JsonConstants.MaximumEscapedGuidLength);
1223-
Span<byte> stackSpan = stackalloc byte[(int)sequenceLength];
1224-
1225+
Span<byte> stackSpan = stackalloc byte[_stringHasEscaping ? JsonConstants.MaximumEscapedGuidLength : JsonConstants.MaximumFormatGuidLength];
12251226
ValueSequence.CopyTo(stackSpan);
1226-
span = stackSpan;
1227+
span = stackSpan.Slice(0, (int)sequenceLength);
12271228
}
12281229
else
12291230
{
1230-
if (ValueSpan.Length > JsonConstants.MaximumEscapedGuidLength)
1231+
if (ValueSpan.Length > maximumLength)
12311232
{
12321233
value = default;
12331234
return false;
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Buffers;
5+
using System.Buffers.Text;
6+
using System.Diagnostics;
7+
8+
namespace System.Text.Json.Serialization.Converters
9+
{
10+
internal sealed class TimeSpanConverter : JsonConverter<TimeSpan>
11+
{
12+
private const int MinimumTimeSpanFormatLength = 8; // hh:mm:ss
13+
private const int MaximumTimeSpanFormatLength = 26; // -dddddddd.hh:mm:ss.fffffff
14+
private const int MaximumEscapedTimeSpanFormatLength = JsonConstants.MaxExpansionFactorWhileEscaping * MaximumTimeSpanFormatLength;
15+
16+
public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
17+
{
18+
if (reader.TokenType != JsonTokenType.String)
19+
{
20+
throw ThrowHelper.GetInvalidOperationException_ExpectedString(reader.TokenType);
21+
}
22+
23+
bool isEscaped = reader._stringHasEscaping;
24+
int maximumLength = isEscaped ? MaximumEscapedTimeSpanFormatLength : MaximumTimeSpanFormatLength;
25+
26+
ReadOnlySpan<byte> source = stackalloc byte[0];
27+
28+
if (reader.HasValueSequence)
29+
{
30+
ReadOnlySequence<byte> valueSequence = reader.ValueSequence;
31+
long sequenceLength = valueSequence.Length;
32+
33+
if (!JsonHelpers.IsInRangeInclusive(sequenceLength, MinimumTimeSpanFormatLength, maximumLength))
34+
{
35+
throw ThrowHelper.GetFormatException(DataType.TimeSpan);
36+
}
37+
38+
Span<byte> stackSpan = stackalloc byte[isEscaped ? MaximumEscapedTimeSpanFormatLength : MaximumTimeSpanFormatLength];
39+
valueSequence.CopyTo(stackSpan);
40+
source = stackSpan.Slice(0, (int)sequenceLength);
41+
}
42+
else
43+
{
44+
source = reader.ValueSpan;
45+
46+
if (!JsonHelpers.IsInRangeInclusive(source.Length, MinimumTimeSpanFormatLength, maximumLength))
47+
{
48+
throw ThrowHelper.GetFormatException(DataType.TimeSpan);
49+
}
50+
}
51+
52+
if (isEscaped)
53+
{
54+
int backslash = source.IndexOf(JsonConstants.BackSlash);
55+
Debug.Assert(backslash != -1);
56+
57+
Span<byte> sourceUnescaped = stackalloc byte[source.Length];
58+
59+
JsonReaderHelper.Unescape(source, sourceUnescaped, backslash, out int written);
60+
Debug.Assert(written > 0);
61+
62+
source = sourceUnescaped.Slice(0, written);
63+
Debug.Assert(!source.IsEmpty);
64+
}
65+
66+
byte firstChar = source[0];
67+
if (!JsonHelpers.IsDigit(firstChar) && firstChar != '-')
68+
{
69+
// Note: Utf8Parser.TryParse allows for leading whitespace so we
70+
// need to exclude that case here.
71+
throw ThrowHelper.GetFormatException(DataType.TimeSpan);
72+
}
73+
74+
bool result = Utf8Parser.TryParse(source, out TimeSpan tmpValue, out int bytesConsumed, 'c');
75+
76+
// Note: Utf8Parser.TryParse will return true for invalid input so
77+
// long as it starts with an integer. Example: "2021-06-18" or
78+
// "1$$$$$$$$$$". We need to check bytesConsumed to know if the
79+
// entire source was actually valid.
80+
81+
if (result && source.Length == bytesConsumed)
82+
{
83+
return tmpValue;
84+
}
85+
86+
throw ThrowHelper.GetFormatException(DataType.TimeSpan);
87+
}
88+
89+
public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
90+
{
91+
Span<byte> output = stackalloc byte[MaximumTimeSpanFormatLength];
92+
93+
bool result = Utf8Formatter.TryFormat(value, output, out int bytesWritten, 'c');
94+
Debug.Assert(result);
95+
96+
writer.WriteStringValue(output.Slice(0, bytesWritten));
97+
}
98+
}
99+
}

src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.Converters.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ private void RootBuiltInConverters()
4949

5050
private static Dictionary<Type, JsonConverter> GetDefaultSimpleConverters()
5151
{
52-
const int NumberOfSimpleConverters = 23;
52+
const int NumberOfSimpleConverters = 24;
5353
var converters = new Dictionary<Type, JsonConverter>(NumberOfSimpleConverters);
5454

5555
// Use a dictionary for simple converters.
@@ -72,6 +72,7 @@ private static Dictionary<Type, JsonConverter> GetDefaultSimpleConverters()
7272
Add(JsonMetadataServices.SByteConverter);
7373
Add(JsonMetadataServices.SingleConverter);
7474
Add(JsonMetadataServices.StringConverter);
75+
Add(JsonMetadataServices.TimeSpanConverter);
7576
Add(JsonMetadataServices.UInt16Converter);
7677
Add(JsonMetadataServices.UInt32Converter);
7778
Add(JsonMetadataServices.UInt64Converter);

src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Converters.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,12 @@ public static partial class JsonMetadataServices
110110
public static JsonConverter<string> StringConverter => s_stringConverter ??= new StringConverter();
111111
private static JsonConverter<string>? s_stringConverter;
112112

113+
/// <summary>
114+
/// Returns a <see cref="JsonConverter{T}"/> instance that converts <see cref="TimeSpan"/> values.
115+
/// </summary>
116+
public static JsonConverter<TimeSpan> TimeSpanConverter => s_timeSpanConverter ??= new TimeSpanConverter();
117+
private static JsonConverter<TimeSpan>? s_timeSpanConverter;
118+
113119
/// <summary>
114120
/// Returns a <see cref="JsonConverter{T}"/> instance that converts <see cref="ushort"/> values.
115121
/// </summary>

src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,9 @@ public static FormatException GetFormatException(DataType dateType)
636636
case DataType.DateTimeOffset:
637637
message = SR.FormatDateTimeOffset;
638638
break;
639+
case DataType.TimeSpan:
640+
message = SR.FormatTimeSpan;
641+
break;
639642
case DataType.Base64String:
640643
message = SR.CannotDecodeInvalidBase64;
641644
break;
@@ -723,6 +726,7 @@ internal enum DataType
723726
Boolean,
724727
DateTime,
725728
DateTimeOffset,
729+
TimeSpan,
726730
Base64String,
727731
Guid,
728732
}

src/libraries/System.Text.Json/tests/System.Text.Json.Tests/JsonDateTimeTestData.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,6 @@ public static IEnumerable<object[]> InvalidISO8601Tests()
189189
yield return new object[] { "\"1997-07-16T19:20:30.4555555+1400\"" };
190190
yield return new object[] { "\"1997-07-16T19:20:30.4555555-1400\"" };
191191

192-
193192
// Proper format but invalid calendar date, time, or time zone designator fields
194193
yield return new object[] { "\"1997-00-16T19:20:30.4555555\"" };
195194
yield return new object[] { "\"1997-07-16T25:20:30.4555555\"" };
@@ -215,6 +214,7 @@ public static IEnumerable<object[]> InvalidISO8601Tests()
215214
yield return new object[] { "\"1997-07-16T19:20:30.45555555550000000\"" };
216215
yield return new object[] { "\"1997-07-16T19:20:30.45555555555555555\"" };
217216
yield return new object[] { "\"1997-07-16T19:20:30.45555555555555555555\"" };
217+
yield return new object[] { "\"1997-07-16T19:20:30.4555555555555555+01:300\"" };
218218

219219
// Hex strings
220220

0 commit comments

Comments
 (0)