Add DateOnly and TimeOnly support to System.Text.Json (#69160)
authorEirik Tsarpalis <eirik.tsarpalis@gmail.com>
Fri, 13 May 2022 17:31:28 +0000 (18:31 +0100)
committerGitHub <noreply@github.com>
Fri, 13 May 2022 17:31:28 +0000 (18:31 +0100)
* Add DateOnly and TimeOnly support to System.Text.Json

* Constrain DateOnly supported formats to calendar dates only.

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

Co-authored-by: Layomi Akinrinade <layomia@gmail.com>
* Replace NETx_OR_GREATER with NETCOREAPP

Co-authored-by: Layomi Akinrinade <layomia@gmail.com>
24 files changed:
src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs
src/libraries/System.Text.Json/ref/System.Text.Json.csproj
src/libraries/System.Text.Json/ref/System.Text.Json.netcoreapp.cs [new file with mode: 0644]
src/libraries/System.Text.Json/src/Resources/Strings.resx
src/libraries/System.Text.Json/src/System.Text.Json.csproj
src/libraries/System.Text.Json/src/System/Text/Json/JsonHelpers.Date.cs
src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/DateOnlyConverter.cs [new file with mode: 0644]
src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs
src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/TimeOnlyConverter.cs [new file with mode: 0644]
src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/UnsupportedTypeConverterFactory.cs
src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.WriteCore.cs
src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.Converters.cs
src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Converters.cs
src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.cs
src/libraries/System.Text.Json/tests/Common/UnsupportedTypesTests.cs
src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/ContextClasses.cs
src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/MetadataAndSerializationContextTests.cs
src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/MetadataContextTests.cs
src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/MixedModeContextTests.cs
src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/RealWorldContextTests.cs
src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnsupportedTypesTests.cs
src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/SerializationContextTests.cs
src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/Value.ReadTests.cs
src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/Value.WriteTests.cs

index 4ae90f7..54bfa77 100644 (file)
@@ -85,6 +85,8 @@ namespace System.Text.Json.SourceGeneration
 
             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;
@@ -103,10 +105,6 @@ namespace System.Text.Json.SourceGeneration
             private readonly Type _intPtrType;
             private readonly Type _uIntPtrType;
 
-            // Unsupported types that may not resolve
-            private readonly Type? _dateOnlyType;
-            private readonly Type? _timeOnlyType;
-
             // Needed for converter validation
             private readonly Type _jsonConverterOfTType;
 
@@ -1551,6 +1549,8 @@ namespace System.Text.Json.SourceGeneration
                 AddTypeIfNotNull(_knownTypes, _byteArrayType);
                 AddTypeIfNotNull(_knownTypes, _timeSpanType);
                 AddTypeIfNotNull(_knownTypes, _dateTimeOffsetType);
+                AddTypeIfNotNull(_knownTypes, _dateOnlyType);
+                AddTypeIfNotNull(_knownTypes, _timeOnlyType);
                 AddTypeIfNotNull(_knownTypes, _guidType);
                 AddTypeIfNotNull(_knownTypes, _uriType);
                 AddTypeIfNotNull(_knownTypes, _versionType);
@@ -1566,9 +1566,6 @@ namespace System.Text.Json.SourceGeneration
                 _knownUnsupportedTypes.Add(_intPtrType);
                 _knownUnsupportedTypes.Add(_uIntPtrType);
 
-                AddTypeIfNotNull(_knownUnsupportedTypes, _dateOnlyType);
-                AddTypeIfNotNull(_knownUnsupportedTypes, _timeOnlyType);
-
                 static void AddTypeIfNotNull(HashSet<Type> types, Type? type)
                 {
                     if (type != null)
index 2fb7e39..5da1eef 100644 (file)
@@ -10,6 +10,7 @@
 
   <ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
     <Compile Include="System.Text.Json.Typeforwards.netcoreapp.cs" />
+    <Compile Include="System.Text.Json.netcoreapp.cs" />
   </ItemGroup>
 
   <ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
diff --git a/src/libraries/System.Text.Json/ref/System.Text.Json.netcoreapp.cs b/src/libraries/System.Text.Json/ref/System.Text.Json.netcoreapp.cs
new file mode 100644 (file)
index 0000000..8c26294
--- /dev/null
@@ -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; } }
+    }
+}
index f7383fc..142cc46 100644 (file)
   <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>
   <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>
index fcfaa53..8ecdb51 100644 (file)
@@ -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>
@@ -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'">
index 9ef24e2..e792f3f 100644 (file)
@@ -13,6 +13,7 @@ namespace System.Text.Json
             public int Year;
             public int Month;
             public int Day;
+            public bool IsCalendarDateOnly;
             public int Hour;
             public int Minute;
             public int Second;
@@ -23,94 +24,6 @@ namespace System.Text.Json
             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)
         {
@@ -180,6 +93,22 @@ namespace System.Text.Json
             return TryCreateDateTimeOffsetInterpretingDataAsLocalTime(parseData, out value);
         }
 
+#if NETCOREAPP
+        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>
@@ -251,7 +180,7 @@ namespace System.Text.Json
             // We now have YYYY-MM-DD [dateX]
             if (source.Length == 10)
             {
-                // Just a calendar date
+                parseData.IsCalendarDateOnly = true;
                 return true;
             }
 
diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/DateOnlyConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/DateOnlyConverter.cs
new file mode 100644 (file)
index 0000000..8a4a4cf
--- /dev/null
@@ -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>
+    {
+        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)
+        {
+            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);
+        }
+    }
+}
index fc76a9f..8091200 100644 (file)
@@ -47,7 +47,7 @@ namespace System.Text.Json.Serialization.Converters
             _namingPolicy = namingPolicy;
             _nameCache = new ConcurrentDictionary<ulong, JsonEncodedText>();
 
-#if NET6_0_OR_GREATER
+#if NETCOREAPP
             string[] names = Enum.GetNames<T>();
             T[] values = Enum.GetValues<T>();
 #else
@@ -65,7 +65,7 @@ namespace System.Text.Json.Serialization.Converters
                     break;
                 }
 
-#if NET6_0_OR_GREATER
+#if NETCOREAPP
                 T value = values[i];
 #else
                 T value = (T)values.GetValue(i)!;
diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/TimeOnlyConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/TimeOnlyConverter.cs
new file mode 100644 (file)
index 0000000..3e66dbb
--- /dev/null
@@ -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>
+    {
+        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);
+
+            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);
+        }
+    }
+}
index 71abc44..c11725b 100644 (file)
@@ -26,20 +26,7 @@ namespace System.Text.Json.Serialization.Converters
                 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)
index c1449f2..98fbbcb 100644 (file)
@@ -12,7 +12,7 @@ namespace System.Text.Json.Serialization
             ref WriteStack state)
         {
             if (
-#if NET5_0_OR_GREATER
+#if NETCOREAPP
                 // Short-circuit the check against "is not null"; treated as a constant by recent versions of the JIT.
                 typeof(T).IsValueType)
 #else
index 58e81d7..d41325d 100644 (file)
@@ -98,7 +98,7 @@ namespace System.Text.Json
 
         private static Dictionary<Type, JsonConverter> GetDefaultSimpleConverters()
         {
-            const int NumberOfSimpleConverters = 24;
+            const int NumberOfSimpleConverters = 26;
             var converters = new Dictionary<Type, JsonConverter>(NumberOfSimpleConverters);
 
             // Use a dictionary for simple converters.
@@ -109,6 +109,10 @@ namespace System.Text.Json
             Add(JsonMetadataServices.CharConverter);
             Add(JsonMetadataServices.DateTimeConverter);
             Add(JsonMetadataServices.DateTimeOffsetConverter);
+#if NETCOREAPP
+            Add(JsonMetadataServices.DateOnlyConverter);
+            Add(JsonMetadataServices.TimeOnlyConverter);
+#endif
             Add(JsonMetadataServices.DoubleConverter);
             Add(JsonMetadataServices.DecimalConverter);
             Add(JsonMetadataServices.GuidConverter);
@@ -128,7 +132,7 @@ namespace System.Text.Json
             Add(JsonMetadataServices.UriConverter);
             Add(JsonMetadataServices.VersionConverter);
 
-            Debug.Assert(NumberOfSimpleConverters == converters.Count);
+            Debug.Assert(converters.Count <= NumberOfSimpleConverters);
 
             return converters;
 
index 035d4c7..4b605b7 100644 (file)
@@ -50,6 +50,22 @@ namespace System.Text.Json.Serialization.Metadata
         public static JsonConverter<DateTimeOffset> DateTimeOffsetConverter => s_dateTimeOffsetConverter ??= new DateTimeOffsetConverter();
         private static JsonConverter<DateTimeOffset>? s_dateTimeOffsetConverter;
 
+#if NETCOREAPP
+        /// <summary>
+        /// Returns a <see cref="JsonConverter{T}"/> instance that converts <see cref="DateOnly"/> values.
+        /// </summary>
+        /// <remarks>This API is for use by the output of the System.Text.Json source generator and should not be called directly.</remarks>
+        public static JsonConverter<DateOnly> DateOnlyConverter => s_dateOnlyConverter ??= new DateOnlyConverter();
+        private static JsonConverter<DateOnly>? s_dateOnlyConverter;
+
+        /// <summary>
+        /// Returns a <see cref="JsonConverter{T}"/> instance that converts <see cref="TimeOnly"/> values.
+        /// </summary>
+        /// <remarks>This API is for use by the output of the System.Text.Json source generator and should not be called directly.</remarks>
+        public static JsonConverter<TimeOnly> TimeOnlyConverter => s_timeOnlyConverter ??= new TimeOnlyConverter();
+        private static JsonConverter<TimeOnly>? s_timeOnlyConverter;
+#endif
+
         /// <summary>
         /// Returns a <see cref="JsonConverter{T}"/> instance that converts <see cref="decimal"/> values.
         /// </summary>
index 9462951..59ec139 100644 (file)
@@ -612,35 +612,26 @@ namespace System.Text.Json
         }
 
         [DoesNotReturn]
-        public static void ThrowFormatException(DataType dateType)
+        public static void ThrowFormatException(DataType dataType)
         {
             string message = "";
 
-            switch (dateType)
+            switch (dataType)
             {
                 case DataType.Boolean:
-                    message = SR.FormatBoolean;
-                    break;
+                case DataType.DateOnly:
                 case DataType.DateTime:
-                    message = SR.FormatDateTime;
-                    break;
                 case DataType.DateTimeOffset:
-                    message = SR.FormatDateTimeOffset;
-                    break;
                 case DataType.TimeSpan:
-                    message = SR.FormatTimeSpan;
+                case DataType.Guid:
+                case DataType.Version:
+                    message = SR.Format(SR.UnsupportedFormat, dataType);
                     break;
                 case DataType.Base64String:
                     message = SR.CannotDecodeInvalidBase64;
                     break;
-                case DataType.Guid:
-                    message = SR.FormatGuid;
-                    break;
-                case DataType.Version:
-                    message = SR.FormatVersion;
-                    break;
                 default:
-                    Debug.Fail($"The DateType enum value: {dateType} is not part of the switch. Add the appropriate case and exception message.");
+                    Debug.Fail($"The DataType enum value: {dataType} is not part of the switch. Add the appropriate case and exception message.");
                     break;
             }
 
@@ -723,6 +714,7 @@ namespace System.Text.Json
     internal enum DataType
     {
         Boolean,
+        DateOnly,
         DateTime,
         DateTimeOffset,
         TimeSpan,
index f93e708..738f339 100644 (file)
@@ -31,10 +31,6 @@ namespace System.Text.Json.Serialization.Tests
             await RunTest<IntPtr>(json);
             await RunTest<IntPtr?>(json); // One nullable variation.
             await RunTest<UIntPtr>(json);
-#if NETCOREAPP
-            await RunTest<DateOnly>(json);
-            await RunTest<TimeOnly>(json);
-#endif
 
             async Task RunTest<T>(string json)
             {
@@ -74,10 +70,6 @@ namespace System.Text.Json.Serialization.Tests
             await RunTest((IntPtr)123);
             await RunTest<IntPtr?>(new IntPtr(123)); // One nullable variation.
             await RunTest((UIntPtr)123);
-#if NETCOREAPP
-            await RunTest(DateOnly.MaxValue);
-            await RunTest(TimeOnly.MinValue);
-#endif
 
             async Task RunTest<T>(T value)
             {
index 496fd29..71a7e4b 100644 (file)
@@ -38,6 +38,9 @@ namespace System.Text.Json.SourceGeneration.Tests
         public JsonTypeInfo<JsonElement> JsonElement { get; }
         public JsonTypeInfo<RealWorldContextTests.ClassWithEnumAndNullable> ClassWithEnumAndNullable { get; }
         public JsonTypeInfo<RealWorldContextTests.ClassWithNullableProperties> ClassWithNullableProperties { get; }
+#if NETCOREAPP
+        public JsonTypeInfo<RealWorldContextTests.ClassWithDateOnlyAndTimeOnlyValues> ClassWithDateOnlyAndTimeOnlyValues { get; }
+#endif
         public JsonTypeInfo<ClassWithCustomConverter> ClassWithCustomConverter { get; }
         public JsonTypeInfo<StructWithCustomConverter> StructWithCustomConverter { get; }
         public JsonTypeInfo<ClassWithCustomConverterFactory> ClassWithCustomConverterFactory { get; }
index 077e9e6..a1fd660 100644 (file)
@@ -34,6 +34,9 @@ namespace System.Text.Json.SourceGeneration.Tests
     [JsonSerializable(typeof(JsonElement))]
     [JsonSerializable(typeof(RealWorldContextTests.ClassWithEnumAndNullable))]
     [JsonSerializable(typeof(RealWorldContextTests.ClassWithNullableProperties))]
+#if NETCOREAPP
+    [JsonSerializable(typeof(RealWorldContextTests.ClassWithDateOnlyAndTimeOnlyValues))]
+#endif
     [JsonSerializable(typeof(ClassWithCustomConverter))]
     [JsonSerializable(typeof(StructWithCustomConverter))]
     [JsonSerializable(typeof(ClassWithCustomConverterFactory))]
index daea2f1..ffb5ca6 100644 (file)
@@ -33,6 +33,9 @@ namespace System.Text.Json.SourceGeneration.Tests
     [JsonSerializable(typeof(JsonElement), GenerationMode = JsonSourceGenerationMode.Metadata)]
     [JsonSerializable(typeof(RealWorldContextTests.ClassWithEnumAndNullable), GenerationMode = JsonSourceGenerationMode.Metadata)]
     [JsonSerializable(typeof(RealWorldContextTests.ClassWithNullableProperties), GenerationMode = JsonSourceGenerationMode.Metadata)]
+#if NETCOREAPP
+    [JsonSerializable(typeof(RealWorldContextTests.ClassWithDateOnlyAndTimeOnlyValues), GenerationMode = JsonSourceGenerationMode.Metadata)]
+#endif
     [JsonSerializable(typeof(ClassWithCustomConverter), GenerationMode = JsonSourceGenerationMode.Metadata)]
     [JsonSerializable(typeof(StructWithCustomConverter), GenerationMode = JsonSourceGenerationMode.Metadata)]
     [JsonSerializable(typeof(ClassWithCustomConverterFactory), GenerationMode = JsonSourceGenerationMode.Metadata)]
@@ -130,6 +133,9 @@ namespace System.Text.Json.SourceGeneration.Tests
     [JsonSerializable(typeof(JsonElement))]
     [JsonSerializable(typeof(RealWorldContextTests.ClassWithEnumAndNullable))]
     [JsonSerializable(typeof(RealWorldContextTests.ClassWithNullableProperties))]
+#if NETCOREAPP
+    [JsonSerializable(typeof(RealWorldContextTests.ClassWithDateOnlyAndTimeOnlyValues))]
+#endif
     [JsonSerializable(typeof(ClassWithCustomConverter))]
     [JsonSerializable(typeof(StructWithCustomConverter))]
     [JsonSerializable(typeof(ClassWithCustomConverterFactory))]
index d1cacf7..2affcb3 100644 (file)
@@ -34,6 +34,9 @@ namespace System.Text.Json.SourceGeneration.Tests
     [JsonSerializable(typeof(JsonElement), GenerationMode = JsonSourceGenerationMode.Metadata | JsonSourceGenerationMode.Serialization)]
     [JsonSerializable(typeof(RealWorldContextTests.ClassWithEnumAndNullable), GenerationMode = JsonSourceGenerationMode.Metadata | JsonSourceGenerationMode.Serialization)]
     [JsonSerializable(typeof(RealWorldContextTests.ClassWithNullableProperties), GenerationMode = JsonSourceGenerationMode.Metadata | JsonSourceGenerationMode.Serialization)]
+#if NETCOREAPP
+    [JsonSerializable(typeof(RealWorldContextTests.ClassWithDateOnlyAndTimeOnlyValues), GenerationMode = JsonSourceGenerationMode.Metadata | JsonSourceGenerationMode.Serialization)]
+#endif
     [JsonSerializable(typeof(ClassWithCustomConverter), GenerationMode = JsonSourceGenerationMode.Metadata | JsonSourceGenerationMode.Serialization)]
     [JsonSerializable(typeof(StructWithCustomConverter), GenerationMode = JsonSourceGenerationMode.Metadata | JsonSourceGenerationMode.Serialization)]
     [JsonSerializable(typeof(ClassWithCustomConverterFactory), GenerationMode = JsonSourceGenerationMode.Metadata | JsonSourceGenerationMode.Serialization)]
index 6ce8882..1f791fc 100644 (file)
@@ -838,6 +838,44 @@ namespace System.Text.Json.SourceGeneration.Tests
             }
         }
 
+#if NETCOREAPP
+        [Fact]
+        public virtual void ClassWithDateOnlyAndTimeOnlyValues_Roundtrip()
+        {
+            RunTest(new ClassWithDateOnlyAndTimeOnlyValues
+            {
+                DateOnly = DateOnly.Parse("2022-05-10"),
+                NullableDateOnly = DateOnly.Parse("2022-05-10"),
+
+                TimeOnly = TimeOnly.Parse("21:51:51"),
+                NullableTimeOnly = TimeOnly.Parse("21:51:51"),
+            });
+
+            RunTest(new ClassWithDateOnlyAndTimeOnlyValues());
+
+            void RunTest(ClassWithDateOnlyAndTimeOnlyValues expected)
+            {
+                string json = JsonSerializer.Serialize(expected, DefaultContext.ClassWithDateOnlyAndTimeOnlyValues);
+                ClassWithDateOnlyAndTimeOnlyValues actual = JsonSerializer.Deserialize(json, DefaultContext.ClassWithDateOnlyAndTimeOnlyValues);
+
+                Assert.Equal(expected.DateOnly, actual.DateOnly);
+                Assert.Equal(expected.NullableDateOnly, actual.NullableDateOnly);
+
+                Assert.Equal(expected.TimeOnly, actual.TimeOnly);
+                Assert.Equal(expected.NullableTimeOnly, actual.NullableTimeOnly);
+            }
+        }
+
+        public class ClassWithDateOnlyAndTimeOnlyValues
+        {
+            public DateOnly DateOnly { get; set; }
+            public DateOnly? NullableDateOnly { get; set; }
+
+            public TimeOnly TimeOnly { get; set; }
+            public TimeOnly? NullableTimeOnly { get; set; }
+        }
+#endif
+
         public class ClassWithNullableProperties
         {
             public Uri Uri { get; set; }
index fbe343e..6483a5e 100644 (file)
@@ -39,13 +39,6 @@ namespace System.Text.Json.SourceGeneration.Tests
         [JsonSerializable(typeof(ClassThatImplementsIAsyncEnumerable))]
         [JsonSerializable(typeof(ClassWithType<ClassThatImplementsIAsyncEnumerable>))]
         [JsonSerializable(typeof(ClassWithAsyncEnumerableConverter))]
-
-#if NETCOREAPP
-        [JsonSerializable(typeof(DateOnly))]
-        [JsonSerializable(typeof(ClassWithType<DateOnly>))]
-        [JsonSerializable(typeof(TimeOnly))]
-        [JsonSerializable(typeof(ClassWithType<TimeOnly>))]
-#endif
         internal sealed partial class UnsupportedTypesTestsContext_Metadata : JsonSerializerContext
         {
         }
index c8b8f0f..9917cd3 100644 (file)
@@ -34,6 +34,9 @@ namespace System.Text.Json.SourceGeneration.Tests
     [JsonSerializable(typeof(JsonElement))]
     [JsonSerializable(typeof(RealWorldContextTests.ClassWithEnumAndNullable))]
     [JsonSerializable(typeof(RealWorldContextTests.ClassWithNullableProperties))]
+#if NETCOREAPP
+    [JsonSerializable(typeof(RealWorldContextTests.ClassWithDateOnlyAndTimeOnlyValues))]
+#endif
     [JsonSerializable(typeof(ClassWithCustomConverter))]
     [JsonSerializable(typeof(StructWithCustomConverter))]
     [JsonSerializable(typeof(ClassWithCustomConverterFactory))]
@@ -80,6 +83,9 @@ namespace System.Text.Json.SourceGeneration.Tests
     [JsonSerializable(typeof(JsonElement), GenerationMode = JsonSourceGenerationMode.Serialization)]
     [JsonSerializable(typeof(RealWorldContextTests.ClassWithEnumAndNullable), GenerationMode = JsonSourceGenerationMode.Serialization)]
     [JsonSerializable(typeof(RealWorldContextTests.ClassWithNullableProperties), GenerationMode = JsonSourceGenerationMode.Serialization)]
+#if NETCOREAPP
+    [JsonSerializable(typeof(RealWorldContextTests.ClassWithDateOnlyAndTimeOnlyValues), GenerationMode = JsonSourceGenerationMode.Serialization)]
+#endif
     [JsonSerializable(typeof(ClassWithCustomConverter), GenerationMode = JsonSourceGenerationMode.Serialization)]
     [JsonSerializable(typeof(StructWithCustomConverter), GenerationMode = JsonSourceGenerationMode.Serialization)]
     [JsonSerializable(typeof(ClassWithCustomConverterFactory), GenerationMode = JsonSourceGenerationMode.Serialization)]
@@ -127,6 +133,9 @@ namespace System.Text.Json.SourceGeneration.Tests
     [JsonSerializable(typeof(JsonElement), GenerationMode = JsonSourceGenerationMode.Serialization)]
     [JsonSerializable(typeof(RealWorldContextTests.ClassWithEnumAndNullable), GenerationMode = JsonSourceGenerationMode.Serialization)]
     [JsonSerializable(typeof(RealWorldContextTests.ClassWithNullableProperties), GenerationMode = JsonSourceGenerationMode.Serialization)]
+#if NETCOREAPP
+    [JsonSerializable(typeof(RealWorldContextTests.ClassWithDateOnlyAndTimeOnlyValues), GenerationMode = JsonSourceGenerationMode.Serialization)]
+#endif
     [JsonSerializable(typeof(ClassWithCustomConverter), GenerationMode = JsonSourceGenerationMode.Serialization)]
     [JsonSerializable(typeof(StructWithCustomConverter), GenerationMode = JsonSourceGenerationMode.Serialization)]
     [JsonSerializable(typeof(ClassWithCustomConverterFactory), GenerationMode = JsonSourceGenerationMode.Serialization)]
@@ -446,6 +455,35 @@ namespace System.Text.Json.SourceGeneration.Tests
             }
         }
 
+#if NETCOREAPP
+        [Fact]
+        public override void ClassWithDateOnlyAndTimeOnlyValues_Roundtrip()
+        {
+            RunTest(new ClassWithDateOnlyAndTimeOnlyValues
+            {
+                DateOnly = DateOnly.Parse("2022-05-10"),
+                NullableDateOnly = DateOnly.Parse("2022-05-10"),
+
+                TimeOnly = TimeOnly.Parse("21:51:51"),
+                NullableTimeOnly = TimeOnly.Parse("21:51:51"),
+            });
+
+            RunTest(new ClassWithDateOnlyAndTimeOnlyValues());
+
+            void RunTest(ClassWithDateOnlyAndTimeOnlyValues expected)
+            {
+                string json = JsonSerializer.Serialize(expected, DefaultContext.ClassWithDateOnlyAndTimeOnlyValues);
+                ClassWithDateOnlyAndTimeOnlyValues actual = JsonSerializer.Deserialize(json, ((ITestContext)MetadataWithPerTypeAttributeContext.Default).ClassWithDateOnlyAndTimeOnlyValues);
+
+                Assert.Equal(expected.DateOnly, actual.DateOnly);
+                Assert.Equal(expected.NullableDateOnly, actual.NullableDateOnly);
+
+                Assert.Equal(expected.TimeOnly, actual.TimeOnly);
+                Assert.Equal(expected.NullableTimeOnly, actual.NullableTimeOnly);
+            }
+        }
+#endif
+
         [Fact]
         public override void ParameterizedConstructor()
         {
index de711c2..257d354 100644 (file)
@@ -1,6 +1,7 @@
 // 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;
 using System.Globalization;
 using System.Runtime.CompilerServices;
 using Newtonsoft.Json;
@@ -558,5 +559,140 @@ namespace System.Text.Json.Serialization.Tests
 
             Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<TimeSpan>(json));
         }
+
+#if NETCOREAPP
+        [Theory]
+        [InlineData("1970-01-01")]
+        [InlineData("2002-02-13")]
+        [InlineData("2022-05-10")]
+        [InlineData("\\u0032\\u0030\\u0032\\u0032\\u002D\\u0030\\u0035\\u002D\\u0031\\u0030", "2022-05-10")]
+        [InlineData("0001-01-01")] // DateOnly.MinValue
+        [InlineData("9999-12-31")] // DateOnly.MaxValue
+        public static void DateOnly_Read_Success(string json, string? actual = null)
+        {
+            DateOnly value = JsonSerializer.Deserialize<DateOnly>($"\"{json}\"");
+            Assert.Equal(DateOnly.Parse(actual ?? json), value);
+        }
+
+        [Theory]
+        [InlineData("1970-01-01")]
+        [InlineData("2002-02-13")]
+        [InlineData("2022-05-10")]
+        [InlineData("\\u0032\\u0030\\u0032\\u0032\\u002D\\u0030\\u0035\\u002D\\u0031\\u0030", "2022-05-10")]
+        [InlineData("0001-01-01")] // DateOnly.MinValue
+        [InlineData("9999-12-31")] // DateOnly.MaxValue
+        public static void DateOnly_ReadDictionaryKey_Success(string json, string? actual = null)
+        {
+            Dictionary<DateOnly, int> expectedDict = new() { [DateOnly.Parse(actual ?? json)] = 0 };
+            Dictionary<DateOnly, int> actualDict = JsonSerializer.Deserialize<Dictionary<DateOnly, int>>($@"{{""{json}"":0}}");
+            Assert.Equal(expectedDict, actualDict);
+        }
+
+        [Fact]
+        public static void DateOnly_Read_Nullable_Tests()
+        {
+            DateOnly? value = JsonSerializer.Deserialize<DateOnly?>("null");
+            Assert.False(value.HasValue);
+
+            value = JsonSerializer.Deserialize<DateOnly?>("\"2022-05-10\"");
+            Assert.True(value.HasValue);
+            Assert.Equal(DateOnly.Parse("2022-05-10"), value);
+            Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<DateOnly>("null"));
+        }
+
+        [Theory]
+        [InlineData("05/10/2022")] // 'd' Format
+        [InlineData("Tue, 10 May 2022")] // 'r' Format
+        [InlineData("\t2022-05-10")] // Otherwise valid but has invalid json character
+        [InlineData("\\t2022-05-10")] // Otherwise valid but has leading whitespace
+        [InlineData("2022-05-10   ")] // Otherwise valid but has trailing whitespace
+        // Fail on arbitrary ISO dates
+        [InlineData("2022-05-10T20:53:01")]
+        [InlineData("2022-05-10T20:53:01.3552286")]
+        [InlineData("2022-05-10T20:53:01.3552286+01:00")]
+        [InlineData("2022-05-10T20:53Z")]
+        [InlineData("\\u0030\\u0035\\u002F\\u0031\\u0030\\u002F\\u0032\\u0030\\u0032\\u0032")]
+        [InlineData("00:00:01")]
+        [InlineData("23:59:59")]
+        [InlineData("23:59:59.00000009")]
+        [InlineData("1.00:00:00")]
+        [InlineData("1:2:00:00")]
+        [InlineData("+00:00:00")]
+        [InlineData("1$")]
+        [InlineData("-2020-05-10")]
+        [InlineData("0000-12-31")] // DateOnly.MinValue - 1
+        [InlineData("10000-01-01")] // DateOnly.MaxValue + 1
+        [InlineData("1234", false)]
+        [InlineData("{}", false)]
+        [InlineData("[]", false)]
+        [InlineData("true", false)]
+        [InlineData("null", false)]
+        public static void DateOnly_Read_Failure(string json, bool addQuotes = true)
+        {
+            if (addQuotes)
+                json = $"\"{json}\"";
+
+            Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<DateOnly>(json));
+        }
+
+        [Theory]
+        [InlineData("23:59:59")]
+        [InlineData("23:59:59.9", "23:59:59.9000000")]
+        [InlineData("02:48:05.4775807")]
+        [InlineData("02:48:05.4775808")]
+        [InlineData("\\u0032\\u0033\\u003A\\u0035\\u0039\\u003A\\u0035\\u0039", "23:59:59")]
+        [InlineData("00:00:00.0000000", "00:00:00")] // TimeOnly.MinValue
+        [InlineData("23:59:59.9999999")] // TimeOnly.MaxValue
+        public static void TimeOnly_Read_Success(string json, string? actual = null)
+        {
+            TimeOnly value = JsonSerializer.Deserialize<TimeOnly>($"\"{json}\"");
+            Assert.Equal(TimeOnly.Parse(actual ?? json), value);
+        }
+
+        [Fact]
+        public static void TimeOnly_Read_Nullable_Tests()
+        {
+            TimeOnly? value = JsonSerializer.Deserialize<TimeOnly?>("null");
+            Assert.False(value.HasValue);
+
+            value = JsonSerializer.Deserialize<TimeOnly?>("\"23:59:59\"");
+            Assert.True(value.HasValue);
+            Assert.Equal(TimeOnly.Parse("23:59:59"), value);
+            Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<TimeOnly>("null"));
+        }
+
+        [Theory]
+        [InlineData("00:00")]
+        [InlineData("23:59")]
+        [InlineData("\t23:59:59")] // Otherwise valid but has invalid json character
+        [InlineData("\\t23:59:59")] // Otherwise valid but has leading whitespace
+        [InlineData("23:59:59   ")] // Otherwise valid but has trailing whitespace
+        [InlineData("\\u0032\\u0034\\u003A\\u0030\\u0030\\u003A\\u0030\\u0030")]
+        [InlineData("00:60:00")]
+        [InlineData("00:00:60")]
+        [InlineData("00:00:00.00000009")]
+        [InlineData("900000000.00:00:00")]
+        [InlineData("1:00:00")] // 'g' Format
+        [InlineData("1:2:00:00")] // 'g' Format
+        [InlineData("+00:00:00")]
+        [InlineData("2021-06-18")]
+        [InlineData("1$")]
+        [InlineData("-00:00:00.0000001")] // TimeOnly.MinValue - 1
+        [InlineData("24:00:00.0000000")] // TimeOnly.MaxValue + 1
+        [InlineData("10675199.02:48:05.4775807")] // TimeSpan.MaxValue
+        [InlineData("-10675199.02:48:05.4775808")] // TimeSpan.MinValue
+        [InlineData("1234", false)]
+        [InlineData("{}", false)]
+        [InlineData("[]", false)]
+        [InlineData("true", false)]
+        [InlineData("null", false)]
+        public static void TimeOnly_Read_Failure(string json, bool addQuotes = true)
+        {
+            if (addQuotes)
+                json = $"\"{json}\"";
+
+            Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<TimeOnly>(json));
+        }
+#endif
     }
 }
index 4f1121a..257ec5b 100644 (file)
@@ -1,6 +1,7 @@
 // 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;
 using System.Text.Encodings.Web;
 using Newtonsoft.Json;
 using Xunit;
@@ -146,5 +147,46 @@ namespace System.Text.Json.Serialization.Tests
             Assert.Equal($"\"{expectedValue ?? value}\"", json);
             Assert.Equal(json, JsonConvert.SerializeObject(ts));
         }
+
+#if NETCOREAPP
+        [Theory]
+        [InlineData("1970-01-01")]
+        [InlineData("2002-02-13")]
+        [InlineData("2022-05-10")]
+        [InlineData("0001-01-01")] // DateOnly.MinValue
+        [InlineData("9999-12-31")] // DateOnly.MaxValue
+        public static void DateOnly_Write_Success(string value)
+        {
+            DateOnly ts = DateOnly.Parse(value);
+            string json = JsonSerializer.Serialize(ts);
+            Assert.Equal($"\"{value}\"", json);
+        }
+
+        [Theory]
+        [InlineData("1970-01-01")]
+        [InlineData("2002-02-13")]
+        [InlineData("2022-05-10")]
+        [InlineData("0001-01-01")] // DateOnly.MinValue
+        [InlineData("9999-12-31")] // DateOnly.MaxValue
+        public static void DateOnly_WriteDictionaryKey_Success(string value)
+        {
+            var dict = new Dictionary<DateOnly, int> { [DateOnly.Parse(value)] = 0 };
+            string json = JsonSerializer.Serialize(dict);
+            Assert.Equal($@"{{""{value}"":0}}", json);
+        }
+
+        [Theory]
+        [InlineData("1:59:59", "01:59:59")]
+        [InlineData("23:59:59")]
+        [InlineData("23:59:59.9", "23:59:59.9000000")]
+        [InlineData("00:00:00")] // TimeOnly.MinValue
+        [InlineData("23:59:59.9999999")] // TimeOnly.MaxValue
+        public static void TimeOnly_Write_Success(string value, string? expectedValue = null)
+        {
+            TimeOnly ts = TimeOnly.Parse(value);
+            string json = JsonSerializer.Serialize(ts);
+            Assert.Equal($"\"{expectedValue ?? value}\"", json);
+        }
+#endif
     }
 }