diff --git a/Dockerfile b/Dockerfile index b7b7dcf6f..c659b79aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,13 +40,11 @@ COPY . . RUN dotnet build -c Release --framework net47 YamlDotNet/YamlDotNet.csproj -o /output/net47 RUN dotnet build -c Release --framework netstandard2.0 YamlDotNet/YamlDotNet.csproj -o /output/netstandard2.0 RUN dotnet build -c Release --framework netstandard2.1 YamlDotNet/YamlDotNet.csproj -o /output/netstandard2.1 -RUN dotnet build -c Release --framework net6.0 YamlDotNet/YamlDotNet.csproj -o /output/net6.0 RUN dotnet build -c Release --framework net8.0 YamlDotNet/YamlDotNet.csproj -o /output/net8.0 RUN dotnet pack -c Release YamlDotNet/YamlDotNet.csproj -o /output/package /p:Version=$PACKAGE_VERSION RUN dotnet test -c Release YamlDotNet.Test/YamlDotNet.Test.csproj --framework net8.0 --logger:"trx;LogFileName=/output/tests-net8.0.trx" --logger:"console;Verbosity=detailed" -RUN dotnet test -c Release YamlDotNet.Test/YamlDotNet.Test.csproj --framework net6.0 --logger:"trx;LogFileName=/output/tests-net6.0.trx" --logger:"console;Verbosity=detailed" FROM alpine VOLUME /output diff --git a/Dockerfile.NonEnglish b/Dockerfile.NonEnglish index 1d0c648e4..53e007910 100644 --- a/Dockerfile.NonEnglish +++ b/Dockerfile.NonEnglish @@ -42,7 +42,6 @@ COPY . . RUN dotnet build -c Release --framework net47 YamlDotNet/YamlDotNet.csproj -o /output/net47 RUN dotnet build -c Release --framework netstandard2.0 YamlDotNet/YamlDotNet.csproj -o /output/netstandard2.0 RUN dotnet build -c Release --framework netstandard2.1 YamlDotNet/YamlDotNet.csproj -o /output/netstandard2.1 -RUN dotnet build -c Release --framework net6.0 YamlDotNet/YamlDotNet.csproj -o /output/net6.0 RUN dotnet build -c Release --framework net8.0 YamlDotNet/YamlDotNet.csproj -o /output/net8.0 RUN dotnet pack -c Release YamlDotNet/YamlDotNet.csproj -o /output/package /p:Version=$PACKAGE_VERSION @@ -62,8 +61,6 @@ RUN echo -n "${LC_ALL}" > /etc/locale.gen && \ locale-gen RUN dotnet test -c Release YamlDotNet.Test/YamlDotNet.Test.csproj --framework net8.0 --logger:"trx;LogFileName=/output/tests-net8.0.trx" --logger:"console;Verbosity=detailed" -RUN dotnet test -c Release YamlDotNet.Test/YamlDotNet.Test.csproj --framework net6.0 --logger:"trx;LogFileName=/output/tests-net6.0.trx" --logger:"console;Verbosity=detailed" -RUN dotnet test -c Release YamlDotNet.Test/YamlDotNet.Test.csproj --framework netcoreapp3.1 --logger:"trx;LogFileName=/output/tests-netcoreapp3.1.trx" --logger:"console;Verbosity=detailed" FROM alpine VOLUME /output diff --git a/README.md b/README.md index a8487f69e..50687a4a1 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ The library has now been successfully used in multiple projects and is considere * netstandard 2.0 * netstandard 2.1 -* .NET 6.0 * .NET 8.0 +* .NET 10.0 * .NET Framework 4.7 ## Quick start diff --git a/YamlDotNet.Analyzers.StaticGenerator/SerializableSyntaxReceiver.cs b/YamlDotNet.Analyzers.StaticGenerator/SerializableSyntaxReceiver.cs index 7b209777d..4ae3add3f 100644 --- a/YamlDotNet.Analyzers.StaticGenerator/SerializableSyntaxReceiver.cs +++ b/YamlDotNet.Analyzers.StaticGenerator/SerializableSyntaxReceiver.cs @@ -172,6 +172,11 @@ private void CheckForSupportedGeneric(ITypeSymbol type) Classes.Add(sanitizedTypeName, new ClassObject(sanitizedTypeName, (INamedTypeSymbol)type, true)); CheckForSupportedGeneric(((INamedTypeSymbol)type).TypeArguments[1]); } + else if (typeName.StartsWith("System.Collections.Generic.OrderedDictionary")) + { + Classes.Add(sanitizedTypeName, new ClassObject(sanitizedTypeName, (INamedTypeSymbol)type, true)); + CheckForSupportedGeneric(((INamedTypeSymbol)type).TypeArguments[1]); + } else if (typeName.StartsWith("System.Collections.Generic.List")) { Classes.Add(sanitizedTypeName, new ClassObject(sanitizedTypeName, (INamedTypeSymbol)type, isList: true)); diff --git a/YamlDotNet.Analyzers.StaticGenerator/StaticObjectFactoryFile.cs b/YamlDotNet.Analyzers.StaticGenerator/StaticObjectFactoryFile.cs index 677d6c9b3..bb367933f 100644 --- a/YamlDotNet.Analyzers.StaticGenerator/StaticObjectFactoryFile.cs +++ b/YamlDotNet.Analyzers.StaticGenerator/StaticObjectFactoryFile.cs @@ -57,7 +57,31 @@ public override void Write(SerializableSyntaxReceiver syntaxReceiver) } else { - Write($"if (type == typeof({classObject.ModuleSymbol.GetFullName().Replace("?", string.Empty)})) return new {classObject.ModuleSymbol.GetFullName().Replace("?", string.Empty)}();"); + var fullName = classObject.ModuleSymbol.GetFullName().Replace("?", string.Empty); + var requiredMembers = new System.Collections.Generic.List(); + foreach (var prop in classObject.PropertySymbols) + { + if (prop.IsRequired) + { + requiredMembers.Add($"{prop.Name} = default!"); + } + } + foreach (var field in classObject.FieldSymbols) + { + if (field.IsRequired) + { + requiredMembers.Add($"{field.Name} = default!"); + } + } + if (requiredMembers.Count > 0) + { + var initializer = string.Join(", ", requiredMembers); + Write($"if (type == typeof({fullName})) return new {fullName}() {{ {initializer} }};"); + } + else + { + Write($"if (type == typeof({fullName})) return new {fullName}();"); + } } //always support a list and dictionary of the type Write($"if (type == typeof(System.Collections.Generic.List<{classObject.ModuleSymbol.GetFullName().Replace("?", string.Empty)}>)) return new System.Collections.Generic.List<{classObject.ModuleSymbol.GetFullName().Replace("?", string.Empty)}>();"); @@ -65,7 +89,7 @@ public override void Write(SerializableSyntaxReceiver syntaxReceiver) } // always support dictionary when deserializing object Write("if (type == typeof(System.Collections.Generic.Dictionary)) return new System.Collections.Generic.Dictionary();"); - Write($"throw new ArgumentOutOfRangeException(\"Unknown type: \" + type.ToString());"); + Write($"throw new InvalidOperationException($\"Type '{{type.FullName}}' is not registered in the YamlDotNet static context. Add [YamlSerializable(typeof({{type.Name}}))] to your static context class.\");"); UnIndent(); Write("}"); Write("public override Array CreateArray(Type type, int count)"); @@ -82,7 +106,7 @@ public override void Write(SerializableSyntaxReceiver syntaxReceiver) Write($"if (type == typeof({classObject.ModuleSymbol.GetFullName().Replace("?", string.Empty)}[])) return new {classObject.ModuleSymbol.GetFullName(false).Replace("?", string.Empty)}[count];"); } } - Write($"throw new ArgumentOutOfRangeException(\"Unknown type: \" + type.ToString());"); + Write($"throw new InvalidOperationException($\"Type '{{type.FullName}}' is not registered in the YamlDotNet static context. Add [YamlSerializable(typeof({{type.Name}}))] to your static context class.\");"); UnIndent(); Write("}"); Write("public override bool IsDictionary(Type type)"); @@ -170,7 +194,7 @@ public override void Write(SerializableSyntaxReceiver syntaxReceiver) // always support dictionary object Write("if (type == typeof(System.Collections.Generic.Dictionary)) return typeof(object);"); - Write("throw new ArgumentOutOfRangeException(\"Unknown type: \" + type.ToString());"); + Write($"throw new InvalidOperationException($\"Type '{{type.FullName}}' is not registered in the YamlDotNet static context. Add [YamlSerializable(typeof({{type.Name}}))] to your static context class.\");"); UnIndent(); Write("}"); Write("public override Type GetValueType(Type type)"); @@ -211,7 +235,7 @@ public override void Write(SerializableSyntaxReceiver syntaxReceiver) } Write("if (type == typeof(System.Collections.Generic.Dictionary)) return typeof(object);"); - Write("throw new ArgumentOutOfRangeException(\"Unknown type: \" + type.ToString());"); + Write($"throw new InvalidOperationException($\"Type '{{type.FullName}}' is not registered in the YamlDotNet static context. Add [YamlSerializable(typeof({{type.Name}}))] to your static context class.\");"); UnIndent(); Write("}"); WriteExecuteMethod(syntaxReceiver, "ExecuteOnDeserializing", (c) => c.OnDeserializingMethods); WriteExecuteMethod(syntaxReceiver, "ExecuteOnDeserialized", (c) => c.OnDeserializedMethods); diff --git a/YamlDotNet.Analyzers.StaticGenerator/TypeFactoryGenerator.cs b/YamlDotNet.Analyzers.StaticGenerator/TypeFactoryGenerator.cs index f0090dcbe..6a88128dd 100644 --- a/YamlDotNet.Analyzers.StaticGenerator/TypeFactoryGenerator.cs +++ b/YamlDotNet.Analyzers.StaticGenerator/TypeFactoryGenerator.cs @@ -20,6 +20,8 @@ // SOFTWARE. using System; +using System.Collections.Generic; +using System.Linq; using System.Text; using Microsoft.CodeAnalysis; @@ -30,6 +32,14 @@ public class TypeFactoryGenerator : ISourceGenerator { private GeneratorExecutionContext _context; + private static readonly DiagnosticDescriptor UnregisteredTypeDescriptor = new DiagnosticDescriptor( + id: "YDNG001", + title: "Unregistered type in YamlDotNet static context", + messageFormat: "Property '{0}' on type '{1}' uses type '{2}' which is not registered in the YamlDotNet static context. Add [YamlSerializable(typeof({3}))] to your static context class.", + category: "YamlDotNet.StaticGenerator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + public void Execute(GeneratorExecutionContext context) { if (!(context.SyntaxContextReceiver is SerializableSyntaxReceiver receiver)) @@ -50,6 +60,8 @@ public void Execute(GeneratorExecutionContext context) } context.AddSource("YamlDotNetAutoGraph.g.cs", GenerateSource(receiver)); + + ReportUnregisteredTypes(context, receiver); } public void Initialize(GeneratorInitializationContext context) @@ -118,5 +130,158 @@ private string GenerateSource(SerializableSyntaxReceiver syntaxReceiver) } return result.ToString(); } + + private static void ReportUnregisteredTypes(GeneratorExecutionContext context, SerializableSyntaxReceiver receiver) + { + // Build a set of all registered type full names (including collections auto-registered) + var registeredTypeNames = new HashSet(receiver.Classes.Keys); + + // Walk each registered class's properties and fields + foreach (var entry in receiver.Classes) + { + var classObject = entry.Value; + + // Skip collection/array entries — they are auto-registered wrappers, not user-defined types + if (classObject.IsArray || classObject.IsList || classObject.IsDictionary + || classObject.IsListOverride || classObject.IsDictionaryOverride) + { + continue; + } + + foreach (var property in classObject.PropertySymbols) + { + CheckTypeRegistration(context, receiver, registeredTypeNames, + property.Type, property.Name, classObject.FullName, property.Locations); + } + + foreach (var field in classObject.FieldSymbols) + { + CheckTypeRegistration(context, receiver, registeredTypeNames, + field.Type, field.Name, classObject.FullName, field.Locations); + } + } + } + + private static void CheckTypeRegistration( + GeneratorExecutionContext context, + SerializableSyntaxReceiver receiver, + HashSet registeredTypeNames, + ITypeSymbol type, + string memberName, + string containingTypeName, + System.Collections.Immutable.ImmutableArray locations) + { + // Unwrap nullable value types + if (type is INamedTypeSymbol namedType + && namedType.IsGenericType + && namedType.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T) + { + type = namedType.TypeArguments[0]; + } + + // Skip types that don't need registration + if (IsWellKnownType(type)) + { + return; + } + + // Check generic collection types — verify their element types + var fullName = type.GetFullName().TrimEnd('?'); + if (fullName.StartsWith("System.Collections.Generic.")) + { + if (type is INamedTypeSymbol genericType && genericType.IsGenericType) + { + foreach (var typeArg in genericType.TypeArguments) + { + CheckTypeRegistration(context, receiver, registeredTypeNames, + typeArg, memberName, containingTypeName, locations); + } + } + return; + } + + // Check array element types + if (type is IArrayTypeSymbol arrayType) + { + CheckTypeRegistration(context, receiver, registeredTypeNames, + arrayType.ElementType, memberName, containingTypeName, locations); + return; + } + + // Check if the type is registered + var sanitizedName = SanitizeName(fullName.TrimEnd('?')); + if (!registeredTypeNames.Contains(sanitizedName)) + { + var shortName = type.Name; + var location = locations.FirstOrDefault() ?? Location.None; + context.ReportDiagnostic(Diagnostic.Create( + UnregisteredTypeDescriptor, + location, + memberName, + containingTypeName, + fullName, + shortName)); + } + } + + private static bool IsWellKnownType(ITypeSymbol type) + { + // Enums are handled by the enum mapping system + if (type.TypeKind == TypeKind.Enum) + { + return true; + } + + // Check for special types that don't need registration + switch (type.SpecialType) + { + case SpecialType.System_Boolean: + case SpecialType.System_Byte: + case SpecialType.System_SByte: + case SpecialType.System_Int16: + case SpecialType.System_UInt16: + case SpecialType.System_Int32: + case SpecialType.System_UInt32: + case SpecialType.System_Int64: + case SpecialType.System_UInt64: + case SpecialType.System_Single: + case SpecialType.System_Double: + case SpecialType.System_Decimal: + case SpecialType.System_Char: + case SpecialType.System_String: + case SpecialType.System_Object: + case SpecialType.System_DateTime: + return true; + } + + // Check for other well-known BCL types that are handled + // by built-in converters or the scalar deserializer + var fullName = type.GetFullName().TrimEnd('?'); + switch (fullName) + { + case "System.Guid": + case "System.TimeSpan": + case "System.DateTimeOffset": + case "System.Uri": + case "System.Type": +#if NET6_0_OR_GREATER + case "System.DateOnly": + case "System.TimeOnly": +#endif + return true; + } + + // DateOnly and TimeOnly may not be caught by the #if above since the + // source generator itself targets netstandard2.0, so check by string + if (fullName == "System.DateOnly" || fullName == "System.TimeOnly") + { + return true; + } + + return false; + } + + private static string SanitizeName(string name) => + new string(name.Select(c => char.IsLetterOrDigit(c) ? c : '_').ToArray()); } } diff --git a/YamlDotNet.Fsharp.Test/YamlDotNet.Fsharp.Test.fsproj b/YamlDotNet.Fsharp.Test/YamlDotNet.Fsharp.Test.fsproj index 9c61a02e3..7fc790f9a 100644 --- a/YamlDotNet.Fsharp.Test/YamlDotNet.Fsharp.Test.fsproj +++ b/YamlDotNet.Fsharp.Test/YamlDotNet.Fsharp.Test.fsproj @@ -1,6 +1,6 @@  - net8.0;net6.0;net47 + net8.0;net47 false ..\YamlDotNet.snk true diff --git a/YamlDotNet.Test/Analyzers/StaticGenerator/ObjectTests.cs b/YamlDotNet.Test/Analyzers/StaticGenerator/ObjectTests.cs index 0fd73e8c8..9624ce19d 100644 --- a/YamlDotNet.Test/Analyzers/StaticGenerator/ObjectTests.cs +++ b/YamlDotNet.Test/Analyzers/StaticGenerator/ObjectTests.cs @@ -191,6 +191,24 @@ public void ReadOnlyDictionariesAreTreatedAsDictionaries() Assert.Equal("d", dictionary["c"]); } +#if NET8_0_OR_GREATER + [Fact] + public void RequiredMembersWork() + { + var deserializer = new StaticDeserializerBuilder(new StaticContext()).Build(); + var yaml = @"Name: hello +Value: 42 +"; + var actual = deserializer.Deserialize(yaml); + Assert.Equal("hello", actual.Name); + Assert.Equal(42, actual.Value); + + var serializer = new StaticSerializerBuilder(new StaticContext()).Build(); + var actualYaml = serializer.Serialize(actual); + Assert.Equal(yaml.NormalizeNewLines().TrimNewLines(), actualYaml.NormalizeNewLines().TrimNewLines()); + } +#endif + #if NET6_0_OR_GREATER [Fact] public void EnumDeserializationUsesEnumMemberAttribute() @@ -399,6 +417,16 @@ private void ExecuteListOverrideTest() where TClass : InterfaceLists Assert.Equal("value2", ((List)actual.TestValue)[1]); } + [Fact] + public void UnregisteredTypeThrowsDescriptiveException() + { + var context = new StaticContext(); + var factory = context.GetFactory(); + var ex = Assert.Throws(() => factory.Create(typeof(UnregisteredType))); + Assert.Contains("is not registered in the YamlDotNet static context", ex.Message); + Assert.Contains("YamlSerializable", ex.Message); + } + [YamlSerializable] public class TestState { @@ -516,4 +544,19 @@ public interface InterfaceLists { object TestValue { get; } } + + // Not decorated with [YamlSerializable] — intentionally unregistered + public class UnregisteredType + { + public string Value { get; set; } + } + +#if NET8_0_OR_GREATER + [YamlSerializable] + public class RequiredMemberClass + { + public required string Name { get; set; } + public required int Value { get; set; } + } +#endif } diff --git a/YamlDotNet.Test/Helpers/OrderedDictionaryTests.cs b/YamlDotNet.Test/Helpers/OrderedDictionaryTests.cs index 4951295a6..d05ffdcfa 100644 --- a/YamlDotNet.Test/Helpers/OrderedDictionaryTests.cs +++ b/YamlDotNet.Test/Helpers/OrderedDictionaryTests.cs @@ -22,7 +22,6 @@ using System.Collections.Generic; using System.Linq; using Xunit; -using YamlDotNet.Helpers; namespace YamlDotNet.Test.Helpers { @@ -31,7 +30,7 @@ public class OrderedDictionaryTests [Fact] public void OrderOfElementsIsMainted() { - var dict = (IDictionary)new OrderedDictionary + var dict = (IDictionary)new YamlDotNet.Helpers.OrderedDictionary { { 3, "First" }, { 2, "Temporary" }, @@ -52,7 +51,7 @@ public void OrderOfElementsIsMainted() [Fact] public void KeysContainsWorks() { - var dict = new OrderedDictionary + var dict = new YamlDotNet.Helpers.OrderedDictionary { { 3, "First item" }, { 2, "Second item" }, @@ -69,7 +68,7 @@ public void KeysContainsWorks() [Fact] public void ValuesContainsWorks() { - var dict = new OrderedDictionary + var dict = new YamlDotNet.Helpers.OrderedDictionary { { 3, "First item" }, { 2, "Second item" }, @@ -86,7 +85,7 @@ public void ValuesContainsWorks() [Fact] public void CanInsertAndRemoveAtIndex() { - var dict = new OrderedDictionary + var dict = new YamlDotNet.Helpers.OrderedDictionary { { 3, "First" }, { 2, "Temporary" }, diff --git a/YamlDotNet.Test/Serialization/IParsableDeserializationTests.cs b/YamlDotNet.Test/Serialization/IParsableDeserializationTests.cs new file mode 100644 index 000000000..25e646020 --- /dev/null +++ b/YamlDotNet.Test/Serialization/IParsableDeserializationTests.cs @@ -0,0 +1,103 @@ +// This file is part of YamlDotNet - A .NET library for YAML. +// Copyright (c) Antoine Aubry and contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; +using FluentAssertions; +using Xunit; +using YamlDotNet.Serialization; + +namespace YamlDotNet.Test.Serialization +{ + /// + /// Tests that types with a static Parse(string, IFormatProvider) method + /// (i.e. types implementing IParsable<T> on .NET 7+) are correctly + /// deserialized from YAML scalars without needing a custom IYamlTypeConverter. + /// + public class IParsableDeserializationTests + { + [Fact] + public void TimeSpan_IsDeserialized() + { + var yaml = "Duration: 01:30:00\n"; + var deserializer = new DeserializerBuilder().Build(); + var result = deserializer.Deserialize(yaml); + result.Duration.Should().Be(new TimeSpan(1, 30, 0)); + } + + [Fact] + public void TimeSpan_WithDays_IsDeserialized() + { + var yaml = "Duration: 1.02:03:04\n"; + var deserializer = new DeserializerBuilder().Build(); + var result = deserializer.Deserialize(yaml); + result.Duration.Should().Be(new TimeSpan(1, 2, 3, 4)); + } + + [Fact] + public void TimeSpan_RoundTrips() + { + var serializer = new SerializerBuilder().Build(); + var deserializer = new DeserializerBuilder().Build(); + + var original = new TimeSpanHolder { Duration = new TimeSpan(1, 2, 3, 4, 5) }; + var yaml = serializer.Serialize(original); + var result = deserializer.Deserialize(yaml); + + result.Duration.Should().Be(original.Duration); + } + + [Fact] + public void DateTimeOffset_IsDeserialized() + { + var yaml = "Stamp: 2025-01-02T03:04:05+00:00\n"; + var deserializer = new DeserializerBuilder().Build(); + var result = deserializer.Deserialize(yaml); + result.Stamp.Should().Be(new DateTimeOffset(2025, 1, 2, 3, 4, 5, TimeSpan.Zero)); + } + +#if NET6_0_OR_GREATER + [Fact] + public void DateOnly_IsDeserialized() + { + var yaml = "Day: 2025-06-15\n"; + var deserializer = new DeserializerBuilder().Build(); + var result = deserializer.Deserialize(yaml); + result.Day.Should().Be(new DateOnly(2025, 6, 15)); + } + + [Fact] + public void TimeOnly_IsDeserialized() + { + var yaml = "At: 14:30:00\n"; + var deserializer = new DeserializerBuilder().Build(); + var result = deserializer.Deserialize(yaml); + result.At.Should().Be(new TimeOnly(14, 30, 0)); + } +#endif + + private class TimeSpanHolder { public TimeSpan Duration { get; set; } } + private class DateTimeOffsetHolder { public DateTimeOffset Stamp { get; set; } } +#if NET6_0_OR_GREATER + private class DateOnlyHolder { public DateOnly Day { get; set; } } + private class TimeOnlyHolder { public TimeOnly At { get; set; } } +#endif + } +} diff --git a/YamlDotNet.Test/Serialization/NullableTypeConverterTests.cs b/YamlDotNet.Test/Serialization/NullableTypeConverterTests.cs new file mode 100644 index 000000000..44bd71c0e --- /dev/null +++ b/YamlDotNet.Test/Serialization/NullableTypeConverterTests.cs @@ -0,0 +1,227 @@ +// This file is part of YamlDotNet - A .NET library for YAML. +// Copyright (c) Antoine Aubry and contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; +using FluentAssertions; +using Xunit; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.Converters; + +namespace YamlDotNet.Test.Serialization +{ + public class NullableTypeConverterTests + { + // -- Guid --------------------------------------------------------------- + + [Fact] + public void GuidConverter_Accepts_NullableGuid() + { + new GuidConverter(false).Accepts(typeof(Guid?)).Should().BeTrue(); + } + + [Fact] + public void NullableGuid_WithValue_RoundTrips() + { + var obj = new NullableGuidHolder { Id = Guid.NewGuid() }; + Roundtrip(obj).Id.Should().Be(obj.Id); + } + + [Fact] + public void NullableGuid_WithNull_RoundTrips() + { + var obj = new NullableGuidHolder { Id = null }; + Roundtrip(obj).Id.Should().BeNull(); + } + + // -- TimeSpan ----------------------------------------------------------- + + [Fact] + public void TimeSpanConverter_Accepts_NullableTimeSpan() + { + new TimeSpanConverter().Accepts(typeof(TimeSpan?)).Should().BeTrue(); + } + + [Fact] + public void NullableTimeSpan_WithValue_RoundTrips() + { + var obj = new NullableTimeSpanHolder { Duration = new TimeSpan(1, 2, 3) }; + Roundtrip(obj).Duration.Should().Be(obj.Duration); + } + + [Fact] + public void NullableTimeSpan_WithNull_RoundTrips() + { + var obj = new NullableTimeSpanHolder { Duration = null }; + Roundtrip(obj).Duration.Should().BeNull(); + } + + // -- Uri ---------------------------------------------------------------- + + [Fact] + public void NullableUri_WithValue_RoundTrips() + { + var obj = new NullableUriHolder { Endpoint = new Uri("https://example.com") }; + Roundtrip(obj).Endpoint.Should().Be(obj.Endpoint); + } + + [Fact] + public void NullableUri_WithNull_RoundTrips() + { + var obj = new NullableUriHolder { Endpoint = null }; + Roundtrip(obj).Endpoint.Should().BeNull(); + } + + // -- DateTimeOffset ----------------------------------------------------- + + [Fact] + public void DateTimeOffsetConverter_Accepts_NullableDateTimeOffset() + { + new DateTimeOffsetConverter().Accepts(typeof(DateTimeOffset?)).Should().BeTrue(); + } + + [Fact] + public void NullableDateTimeOffset_WithValue_RoundTrips() + { + var converter = new DateTimeOffsetConverter(); + var obj = new NullableDateTimeOffsetHolder { Stamp = new DateTimeOffset(2025, 1, 2, 3, 4, 5, TimeSpan.FromHours(-6)) }; + + var serializer = new SerializerBuilder().WithTypeConverter(converter).Build(); + var deserializer = new DeserializerBuilder().WithTypeConverter(converter).Build(); + + var yaml = serializer.Serialize(obj); + var result = deserializer.Deserialize(yaml); + result.Stamp.Should().Be(obj.Stamp); + } + + [Fact] + public void NullableDateTimeOffset_WithNull_RoundTrips() + { + var converter = new DateTimeOffsetConverter(); + var obj = new NullableDateTimeOffsetHolder { Stamp = null }; + + var serializer = new SerializerBuilder().WithTypeConverter(converter).Build(); + var deserializer = new DeserializerBuilder().WithTypeConverter(converter).Build(); + + var yaml = serializer.Serialize(obj); + var result = deserializer.Deserialize(yaml); + result.Stamp.Should().BeNull(); + } + + // -- DateTime (via DateTimeConverter) ------------------------------------ + + [Fact] + public void DateTimeConverter_Accepts_NullableDateTime() + { + new DateTimeConverter().Accepts(typeof(DateTime?)).Should().BeTrue(); + } + + [Fact] + public void NullableDateTime_WithNull_RoundTrips() + { + var converter = new DateTimeConverter(); + var obj = new NullableDateTimeHolder { When = null }; + + var serializer = new SerializerBuilder().WithTypeConverter(converter).Build(); + var deserializer = new DeserializerBuilder().WithTypeConverter(converter).Build(); + + var yaml = serializer.Serialize(obj); + var result = deserializer.Deserialize(yaml); + result.When.Should().BeNull(); + } + + // -- DateTime (via DateTime8601Converter) -------------------------------- + + [Fact] + public void DateTime8601Converter_Accepts_NullableDateTime() + { + new DateTime8601Converter().Accepts(typeof(DateTime?)).Should().BeTrue(); + } + +#if NET6_0_OR_GREATER + // -- DateOnly ----------------------------------------------------------- + + [Fact] + public void DateOnlyConverter_Accepts_NullableDateOnly() + { + new DateOnlyConverter().Accepts(typeof(DateOnly?)).Should().BeTrue(); + } + + [Fact] + public void NullableDateOnly_WithNull_RoundTrips() + { + var converter = new DateOnlyConverter(); + var obj = new NullableDateOnlyHolder { Day = null }; + + var serializer = new SerializerBuilder().WithTypeConverter(converter).Build(); + var deserializer = new DeserializerBuilder().WithTypeConverter(converter).Build(); + + var yaml = serializer.Serialize(obj); + var result = deserializer.Deserialize(yaml); + result.Day.Should().BeNull(); + } + + // -- TimeOnly ----------------------------------------------------------- + + [Fact] + public void TimeOnlyConverter_Accepts_NullableTimeOnly() + { + new TimeOnlyConverter().Accepts(typeof(TimeOnly?)).Should().BeTrue(); + } + + [Fact] + public void NullableTimeOnly_WithNull_RoundTrips() + { + var converter = new TimeOnlyConverter(); + var obj = new NullableTimeOnlyHolder { At = null }; + + var serializer = new SerializerBuilder().WithTypeConverter(converter).Build(); + var deserializer = new DeserializerBuilder().WithTypeConverter(converter).Build(); + + var yaml = serializer.Serialize(obj); + var result = deserializer.Deserialize(yaml); + result.At.Should().BeNull(); + } +#endif + + // -- Helpers ------------------------------------------------------------ + + private static T Roundtrip(T obj) + { + var serializer = new SerializerBuilder().Build(); + var deserializer = new DeserializerBuilder().Build(); + var yaml = serializer.Serialize(obj!); + return deserializer.Deserialize(yaml); + } + + private class NullableGuidHolder { public Guid? Id { get; set; } } + private class NullableTimeSpanHolder { public TimeSpan? Duration { get; set; } } + private class NullableDateTimeHolder { public DateTime? When { get; set; } } + private class NullableDateTimeOffsetHolder { public DateTimeOffset? Stamp { get; set; } } +#if NET6_0_OR_GREATER + private class NullableDateOnlyHolder { public DateOnly? Day { get; set; } } + private class NullableTimeOnlyHolder { public TimeOnly? At { get; set; } } +#endif + +#nullable enable + private class NullableUriHolder { public Uri? Endpoint { get; set; } } +#nullable restore + } +} diff --git a/YamlDotNet.Test/Serialization/TimeSpanConverterTests.cs b/YamlDotNet.Test/Serialization/TimeSpanConverterTests.cs new file mode 100644 index 000000000..a529a597a --- /dev/null +++ b/YamlDotNet.Test/Serialization/TimeSpanConverterTests.cs @@ -0,0 +1,108 @@ +// This file is part of YamlDotNet - A .NET library for YAML. +// Copyright (c) Antoine Aubry and contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; +using FakeItEasy; +using FluentAssertions; +using Xunit; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.Converters; + +namespace YamlDotNet.Test.Serialization +{ + public class TimeSpanConverterTests + { + [Theory] + [InlineData(typeof(TimeSpan), true)] + [InlineData(typeof(string), false)] + [InlineData(typeof(int), false)] + public void Accepts_ShouldReturn_ExpectedResult(Type type, bool expected) + { + var converter = new TimeSpanConverter(); + converter.Accepts(type).Should().Be(expected); + } + + [Theory] + [InlineData("01:30:00", 1, 30, 0)] + [InlineData("1.02:03:04", 26, 3, 4)] + [InlineData("00:00:00", 0, 0, 0)] + [InlineData("12:34:56.7890000", 12, 34, 56)] + public void ReadYaml_ShouldReturn_TimeSpan(string yamlValue, int hours, int minutes, int seconds) + { + var parser = A.Fake(); + A.CallTo(() => parser.Current).Returns(new Scalar(yamlValue)); + A.CallTo(() => parser.MoveNext()).Returns(true); + + var converter = new TimeSpanConverter(); + var result = converter.ReadYaml(parser, typeof(TimeSpan), null!); + + result.Should().BeOfType(); + var ts = (TimeSpan)result; + ts.Hours.Should().Be(hours % 24); + ts.Minutes.Should().Be(minutes); + ts.Seconds.Should().Be(seconds); + } + + [Fact] + public void WriteYaml_ShouldWrite_TimeSpan() + { + var emitter = A.Fake(); + var converter = new TimeSpanConverter(); + var timeSpan = new TimeSpan(1, 2, 3, 4, 5); + + converter.WriteYaml(emitter, timeSpan, typeof(TimeSpan), null!); + + A.CallTo(() => emitter.Emit(A.That.Matches(s => s.Value == "1.02:03:04.0050000"))).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void WriteYaml_JsonCompatible_ShouldUseDoubleQuotes() + { + var emitter = A.Fake(); + var converter = new TimeSpanConverter(jsonCompatible: true); + var timeSpan = new TimeSpan(1, 30, 0); + + converter.WriteYaml(emitter, timeSpan, typeof(TimeSpan), null!); + + A.CallTo(() => emitter.Emit(A.That.Matches(s => s.Style == ScalarStyle.DoubleQuoted))).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void RoundTrip_ShouldPreserveValue() + { + var serializer = new SerializerBuilder().Build(); + var deserializer = new DeserializerBuilder().Build(); + + var original = new TimeSpan(1, 2, 3, 4, 5); + var yaml = serializer.Serialize(new { Duration = original }); + var result = deserializer.Deserialize(yaml); + + result.Duration.Should().Be(original); + } + + private class TimeSpanContainer + { + public TimeSpan Duration { get; set; } + } + } +} diff --git a/YamlDotNet.Test/Serialization/UriConverterTests.cs b/YamlDotNet.Test/Serialization/UriConverterTests.cs new file mode 100644 index 000000000..07a971094 --- /dev/null +++ b/YamlDotNet.Test/Serialization/UriConverterTests.cs @@ -0,0 +1,105 @@ +// This file is part of YamlDotNet - A .NET library for YAML. +// Copyright (c) Antoine Aubry and contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; +using FakeItEasy; +using FluentAssertions; +using Xunit; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.Converters; + +namespace YamlDotNet.Test.Serialization +{ + public class UriConverterTests + { + [Theory] + [InlineData(typeof(Uri), true)] + [InlineData(typeof(string), false)] + [InlineData(typeof(int), false)] + public void Accepts_ShouldReturn_ExpectedResult(Type type, bool expected) + { + var converter = new UriConverter(); + converter.Accepts(type).Should().Be(expected); + } + + [Theory] + [InlineData("https://example.com")] + [InlineData("http://localhost:8080/path?query=1")] + [InlineData("/relative/path")] + public void ReadYaml_ShouldReturn_Uri(string yamlValue) + { + var parser = A.Fake(); + A.CallTo(() => parser.Current).Returns(new Scalar(yamlValue)); + A.CallTo(() => parser.MoveNext()).Returns(true); + + var converter = new UriConverter(); + var result = converter.ReadYaml(parser, typeof(Uri), null!); + + result.Should().BeOfType(); + var uri = (Uri)result; + uri.OriginalString.Should().Be(yamlValue); + } + + [Fact] + public void WriteYaml_ShouldWrite_AbsoluteUri() + { + var emitter = A.Fake(); + var converter = new UriConverter(); + var uri = new Uri("https://example.com/path"); + + converter.WriteYaml(emitter, uri, typeof(Uri), null!); + + A.CallTo(() => emitter.Emit(A.That.Matches(s => s.Value == "https://example.com/path"))).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void WriteYaml_JsonCompatible_ShouldUseDoubleQuotes() + { + var emitter = A.Fake(); + var converter = new UriConverter(jsonCompatible: true); + var uri = new Uri("https://example.com"); + + converter.WriteYaml(emitter, uri, typeof(Uri), null!); + + A.CallTo(() => emitter.Emit(A.That.Matches(s => s.Style == ScalarStyle.DoubleQuoted))).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void RoundTrip_ShouldPreserveValue() + { + var serializer = new SerializerBuilder().Build(); + var deserializer = new DeserializerBuilder().Build(); + + var original = new Uri("https://example.com/test?q=1&r=2"); + var yaml = serializer.Serialize(new { Endpoint = original }); + var result = deserializer.Deserialize(yaml); + + result.Endpoint.Should().Be(original); + } + + private class UriContainer + { + public Uri Endpoint { get; set; } + } + } +} diff --git a/YamlDotNet.Test/YamlDotNet.Test.csproj b/YamlDotNet.Test/YamlDotNet.Test.csproj index 50a464709..f1958fe7c 100644 --- a/YamlDotNet.Test/YamlDotNet.Test.csproj +++ b/YamlDotNet.Test/YamlDotNet.Test.csproj @@ -1,6 +1,6 @@  - net8.0;net6.0;net47 + net10.0;net8.0;net47 false ..\YamlDotNet.snk true diff --git a/YamlDotNet/RepresentationModel/YamlMappingNode.cs b/YamlDotNet/RepresentationModel/YamlMappingNode.cs index 6401cdc12..012830f06 100644 --- a/YamlDotNet/RepresentationModel/YamlMappingNode.cs +++ b/YamlDotNet/RepresentationModel/YamlMappingNode.cs @@ -36,7 +36,7 @@ namespace YamlDotNet.RepresentationModel /// public sealed class YamlMappingNode : YamlNode, IEnumerable>, IYamlConvertible { - private readonly OrderedDictionary children = []; + private readonly Helpers.OrderedDictionary children = []; /// /// Gets the children of the current node. diff --git a/YamlDotNet/Serialization/BuilderSkeleton.cs b/YamlDotNet/Serialization/BuilderSkeleton.cs index a19d24146..591d61ae8 100755 --- a/YamlDotNet/Serialization/BuilderSkeleton.cs +++ b/YamlDotNet/Serialization/BuilderSkeleton.cs @@ -55,7 +55,9 @@ internal BuilderSkeleton(ITypeResolver typeResolver) typeConverterFactories = new LazyComponentRegistrationList { { typeof(GuidConverter), _ => new GuidConverter(false) }, - { typeof(SystemTypeConverter), _ => new SystemTypeConverter() } + { typeof(SystemTypeConverter), _ => new SystemTypeConverter() }, + { typeof(TimeSpanConverter), _ => new TimeSpanConverter(false) }, + { typeof(UriConverter), _ => new UriConverter(false) } }; typeInspectorFactories = []; diff --git a/YamlDotNet/Serialization/Converters/DateOnlyConverter.cs b/YamlDotNet/Serialization/Converters/DateOnlyConverter.cs index 16bafc395..409c35487 100644 --- a/YamlDotNet/Serialization/Converters/DateOnlyConverter.cs +++ b/YamlDotNet/Serialization/Converters/DateOnlyConverter.cs @@ -63,6 +63,10 @@ public DateOnlyConverter(IFormatProvider? provider = null, bool doubleQuotes = f public override object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { var value = ConsumeScalarValue(parser); + if (string.IsNullOrEmpty(value)) + { + return null!; + } var dateOnly = DateOnly.ParseExact(value, this.formats, this.provider); return dateOnly; @@ -78,7 +82,12 @@ public override object ReadYaml(IParser parser, Type type, ObjectDeserializer ro /// On serializing, the first format in the list is used. public override void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) { - var dateOnly = (DateOnly)value!; + if (value == null) + { + emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, "", ScalarStyle.Plain, true, false)); + return; + } + var dateOnly = (DateOnly)value; var formatted = dateOnly.ToString(this.formats.First(), this.provider); EmitScalar(emitter, formatted, doubleQuotes ? ScalarStyle.DoubleQuoted : ScalarStyle.Any); diff --git a/YamlDotNet/Serialization/Converters/DateTime8601Converter.cs b/YamlDotNet/Serialization/Converters/DateTime8601Converter.cs index 17f57614c..2a5b87ab6 100644 --- a/YamlDotNet/Serialization/Converters/DateTime8601Converter.cs +++ b/YamlDotNet/Serialization/Converters/DateTime8601Converter.cs @@ -60,6 +60,10 @@ public DateTime8601Converter(ScalarStyle scalarStyle) public override object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { var value = ConsumeScalarValue(parser); + if (string.IsNullOrEmpty(value)) + { + return null!; + } var result = DateTime.ParseExact(value, "O", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); return result; @@ -75,7 +79,12 @@ public override object ReadYaml(IParser parser, Type type, ObjectDeserializer ro /// On serializing, the first format in the list is used. public override void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) { - var formatted = ((DateTime)value!).ToString("O", CultureInfo.InvariantCulture); + if (value == null) + { + emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, "", ScalarStyle.Plain, true, false)); + return; + } + var formatted = ((DateTime)value).ToString("O", CultureInfo.InvariantCulture); EmitScalar(emitter, formatted, scalarStyle); } diff --git a/YamlDotNet/Serialization/Converters/DateTimeConverter.cs b/YamlDotNet/Serialization/Converters/DateTimeConverter.cs index ea04f381e..ff30c2ec2 100644 --- a/YamlDotNet/Serialization/Converters/DateTimeConverter.cs +++ b/YamlDotNet/Serialization/Converters/DateTimeConverter.cs @@ -65,6 +65,10 @@ public DateTimeConverter(DateTimeKind kind = DateTimeKind.Utc, IFormatProvider? public override object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { var value = ConsumeScalarValue(parser); + if (string.IsNullOrEmpty(value)) + { + return null!; + } var style = this.kind == DateTimeKind.Local ? DateTimeStyles.AssumeLocal : DateTimeStyles.AssumeUniversal; var dt = DateTime.ParseExact(value, this.formats, this.provider, style); @@ -82,7 +86,12 @@ public override object ReadYaml(IParser parser, Type type, ObjectDeserializer ro /// On serializing, the first format in the list is used. public override void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) { - var dt = (DateTime)value!; + if (value == null) + { + emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, "", ScalarStyle.Plain, true, false)); + return; + } + var dt = (DateTime)value; var adjusted = this.kind == DateTimeKind.Local ? dt.ToLocalTime() : dt.ToUniversalTime(); var formatted = adjusted.ToString(this.formats.First(), this.provider); diff --git a/YamlDotNet/Serialization/Converters/DateTimeOffsetConverter.cs b/YamlDotNet/Serialization/Converters/DateTimeOffsetConverter.cs index 57090683f..4d9c84b43 100644 --- a/YamlDotNet/Serialization/Converters/DateTimeOffsetConverter.cs +++ b/YamlDotNet/Serialization/Converters/DateTimeOffsetConverter.cs @@ -70,6 +70,10 @@ public DateTimeOffsetConverter( public override object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { var value = ConsumeScalarValue(parser); + if (string.IsNullOrEmpty(value)) + { + return null!; + } var result = DateTimeOffset.ParseExact(value, formats, provider, dateStyle); return result; @@ -85,7 +89,12 @@ public override object ReadYaml(IParser parser, Type type, ObjectDeserializer ro /// On serializing, the first format in the list is used. public override void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) { - var dt = (DateTimeOffset)value!; + if (value == null) + { + emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, "", ScalarStyle.Plain, true, false)); + return; + } + var dt = (DateTimeOffset)value; var formatted = dt.ToString(formats.First(), this.provider); EmitScalar(emitter, formatted, style); diff --git a/YamlDotNet/Serialization/Converters/GuidConverter.cs b/YamlDotNet/Serialization/Converters/GuidConverter.cs index 7b38ecfb6..1c9e26445 100644 --- a/YamlDotNet/Serialization/Converters/GuidConverter.cs +++ b/YamlDotNet/Serialization/Converters/GuidConverter.cs @@ -39,18 +39,27 @@ public GuidConverter(bool jsonCompatible) public bool Accepts(Type type) { - return type == typeof(Guid); + return type == typeof(Guid) || type == typeof(Guid?); } public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { var value = parser.Consume().Value; + if (string.IsNullOrEmpty(value)) + { + return null!; + } return new Guid(value); } public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) { - var guid = (Guid)value!; + if (value == null) + { + emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, "", ScalarStyle.Plain, true, false)); + return; + } + var guid = (Guid)value; emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, guid.ToString("D"), jsonCompatible ? ScalarStyle.DoubleQuoted : ScalarStyle.Any, true, false)); } } diff --git a/YamlDotNet/Serialization/Converters/ScalarConverterBase.cs b/YamlDotNet/Serialization/Converters/ScalarConverterBase.cs index dace2c696..5c2e4b3a9 100644 --- a/YamlDotNet/Serialization/Converters/ScalarConverterBase.cs +++ b/YamlDotNet/Serialization/Converters/ScalarConverterBase.cs @@ -35,7 +35,7 @@ public abstract class ScalarConverterBase : IYamlTypeConverter /// public bool Accepts(Type type) { - return type == typeof(T); + return type == typeof(T) || Nullable.GetUnderlyingType(type) == typeof(T); } /// diff --git a/YamlDotNet/Serialization/Converters/TimeOnlyConverter.cs b/YamlDotNet/Serialization/Converters/TimeOnlyConverter.cs index dd8bbc3de..cf09b3ee4 100644 --- a/YamlDotNet/Serialization/Converters/TimeOnlyConverter.cs +++ b/YamlDotNet/Serialization/Converters/TimeOnlyConverter.cs @@ -63,6 +63,10 @@ public TimeOnlyConverter(IFormatProvider? provider = null, bool doubleQuotes = f public override object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { var value = ConsumeScalarValue(parser); + if (string.IsNullOrEmpty(value)) + { + return null!; + } var timeOnly = TimeOnly.ParseExact(value, this.formats, this.provider); return timeOnly; @@ -78,7 +82,12 @@ public override object ReadYaml(IParser parser, Type type, ObjectDeserializer ro /// On serializing, the first format in the list is used. public override void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) { - var timeOnly = (TimeOnly)value!; + if (value == null) + { + emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, "", ScalarStyle.Plain, true, false)); + return; + } + var timeOnly = (TimeOnly)value; var formatted = timeOnly.ToString(this.formats.First(), this.provider); EmitScalar(emitter, formatted, doubleQuotes ? ScalarStyle.DoubleQuoted : ScalarStyle.Any); diff --git a/YamlDotNet/Serialization/Converters/TimeSpanConverter.cs b/YamlDotNet/Serialization/Converters/TimeSpanConverter.cs new file mode 100644 index 000000000..7508e6712 --- /dev/null +++ b/YamlDotNet/Serialization/Converters/TimeSpanConverter.cs @@ -0,0 +1,69 @@ +// This file is part of YamlDotNet - A .NET library for YAML. +// Copyright (c) Antoine Aubry and contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; +using System.Globalization; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; + +namespace YamlDotNet.Serialization.Converters +{ + /// + /// Converter for . + /// Serializes TimeSpan values using the invariant culture round-trip format ("c"). + /// + public class TimeSpanConverter : IYamlTypeConverter + { + private readonly bool jsonCompatible; + + public TimeSpanConverter(bool jsonCompatible = false) + { + this.jsonCompatible = jsonCompatible; + } + + public bool Accepts(Type type) + { + return type == typeof(TimeSpan) || type == typeof(TimeSpan?); + } + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + var value = parser.Consume().Value; + if (string.IsNullOrEmpty(value)) + { + return null!; + } + return TimeSpan.Parse(value, CultureInfo.InvariantCulture); + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) + { + if (value == null) + { + emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, "", ScalarStyle.Plain, true, false)); + return; + } + var timeSpan = (TimeSpan)value; + var formatted = timeSpan.ToString("c", CultureInfo.InvariantCulture); + emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, formatted, jsonCompatible ? ScalarStyle.DoubleQuoted : ScalarStyle.Any, true, false)); + } + } +} diff --git a/YamlDotNet/Serialization/Converters/UriConverter.cs b/YamlDotNet/Serialization/Converters/UriConverter.cs new file mode 100644 index 000000000..e0ff5dffc --- /dev/null +++ b/YamlDotNet/Serialization/Converters/UriConverter.cs @@ -0,0 +1,66 @@ +// This file is part of YamlDotNet - A .NET library for YAML. +// Copyright (c) Antoine Aubry and contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; + +namespace YamlDotNet.Serialization.Converters +{ + /// + /// Converter for . + /// + public class UriConverter : IYamlTypeConverter + { + private readonly bool jsonCompatible; + + public UriConverter(bool jsonCompatible = false) + { + this.jsonCompatible = jsonCompatible; + } + + public bool Accepts(Type type) + { + return type == typeof(Uri); + } + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + var value = parser.Consume().Value; + if (string.IsNullOrEmpty(value)) + { + return null!; + } + return new Uri(value, UriKind.RelativeOrAbsolute); + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) + { + if (value == null) + { + emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, "", ScalarStyle.Plain, true, false)); + return; + } + var uri = (Uri)value; + emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, uri.OriginalString, jsonCompatible ? ScalarStyle.DoubleQuoted : ScalarStyle.Any, true, false)); + } + } +} diff --git a/YamlDotNet/Serialization/NodeDeserializers/ScalarNodeDeserializer.cs b/YamlDotNet/Serialization/NodeDeserializers/ScalarNodeDeserializer.cs index 2abcae9fc..5c08e4648 100644 --- a/YamlDotNet/Serialization/NodeDeserializers/ScalarNodeDeserializer.cs +++ b/YamlDotNet/Serialization/NodeDeserializers/ScalarNodeDeserializer.cs @@ -142,7 +142,27 @@ public bool Deserialize(IParser parser, Type expectedType, Func (.NET 7+) such as + // TimeSpan, DateTimeOffset, Guid, IPAddress, and others. + // This is especially important for the static/AOT deserialization + // path where the typeConverter is a NullTypeConverter. + var parseMethod = underlyingType.GetPublicStaticMethod("Parse", typeof(string), typeof(IFormatProvider)); + if (parseMethod != null) + { + try + { + value = parseMethod.Invoke(null, new object[] { scalar.Value, CultureInfo.InvariantCulture }); + } + catch (System.Reflection.TargetInvocationException ex) when (ex.InnerException != null) + { + throw ex.InnerException; + } + } + else + { + value = typeConverter.ChangeType(scalar.Value, expectedType, enumNamingConvention, typeInspector); + } } break; } diff --git a/YamlDotNet/Serialization/SerializerBuilder.cs b/YamlDotNet/Serialization/SerializerBuilder.cs index 85c62656c..0adf84213 100755 --- a/YamlDotNet/Serialization/SerializerBuilder.cs +++ b/YamlDotNet/Serialization/SerializerBuilder.cs @@ -371,6 +371,8 @@ public SerializerBuilder JsonCompatible() return this .WithTypeConverter(new GuidConverter(true), w => w.InsteadOf()) + .WithTypeConverter(new TimeSpanConverter(true), w => w.InsteadOf()) + .WithTypeConverter(new UriConverter(true), w => w.InsteadOf()) .WithTypeConverter(new DateTime8601Converter(ScalarStyle.DoubleQuoted)) #if NET6_0_OR_GREATER .WithTypeConverter(new DateOnlyConverter(doubleQuotes: true)) diff --git a/YamlDotNet/Serialization/StaticBuilderSkeleton.cs b/YamlDotNet/Serialization/StaticBuilderSkeleton.cs index 628753599..65b621f9a 100644 --- a/YamlDotNet/Serialization/StaticBuilderSkeleton.cs +++ b/YamlDotNet/Serialization/StaticBuilderSkeleton.cs @@ -48,6 +48,8 @@ internal StaticBuilderSkeleton(ITypeResolver typeResolver) typeConverterFactories = new LazyComponentRegistrationList { { typeof(GuidConverter), _ => new GuidConverter(false) }, + { typeof(TimeSpanConverter), _ => new TimeSpanConverter(false) }, + { typeof(UriConverter), _ => new UriConverter(false) }, }; typeInspectorFactories = []; diff --git a/YamlDotNet/Serialization/StaticSerializerBuilder.cs b/YamlDotNet/Serialization/StaticSerializerBuilder.cs index c30e219e7..1e63e6fb5 100644 --- a/YamlDotNet/Serialization/StaticSerializerBuilder.cs +++ b/YamlDotNet/Serialization/StaticSerializerBuilder.cs @@ -375,6 +375,8 @@ public StaticSerializerBuilder JsonCompatible() return this .WithTypeConverter(new GuidConverter(true), w => w.InsteadOf()) + .WithTypeConverter(new TimeSpanConverter(true), w => w.InsteadOf()) + .WithTypeConverter(new UriConverter(true), w => w.InsteadOf()) .WithTypeConverter(new DateTime8601Converter(ScalarStyle.DoubleQuoted)) #if NET6_0_OR_GREATER .WithTypeConverter(new DateOnlyConverter(doubleQuotes: true)) diff --git a/YamlDotNet/YamlDotNet.csproj b/YamlDotNet/YamlDotNet.csproj index 999463e64..a799f5792 100644 --- a/YamlDotNet/YamlDotNet.csproj +++ b/YamlDotNet/YamlDotNet.csproj @@ -1,7 +1,7 @@  - net8.0;net6.0;netstandard2.0;netstandard2.1;net47 + net10.0;net8.0;netstandard2.0;netstandard2.1;net47 Debug;Release ..\YamlDotNet.snk diff --git a/appveyor.yml b/appveyor.yml index ad94058ba..9ae2264c3 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -23,6 +23,9 @@ environment: install: - cmd: git submodule update --init + - ps: | + Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' -OutFile 'dotnet-install.ps1' + .\dotnet-install.ps1 -Channel 10.0 -InstallDir "$env:ProgramFiles\dotnet" - cmd: | dotnet tool install --global GitVersion.Tool --version 6.5.1 dotnet tool restore @@ -40,12 +43,12 @@ artifacts: - path: YamlDotNet\bin\Release\net47 name: Release-Net47 - - path: YamlDotNet\bin\Release\net6.0 - name: Release-Net60 - - path: YamlDotNet\bin\Release\net8.0 name: Release-Net80 + - path: YamlDotNet\bin\Release\net10.0 + name: Release-Net100 + - path: YamlDotNet\bin\*.nupkg - path: YamlDotNet\bin\*.snupkg