From cb1c48ab56ddb2994b21761c905802a38dfdf578 Mon Sep 17 00:00:00 2001 From: MrLuje Date: Sat, 15 Jun 2024 11:42:16 +0000 Subject: [PATCH] feat: add FsharpOption support --- YamlDotNet.Fsharp.Test/DeserializerTests.fs | 87 ++++++++++ YamlDotNet.Fsharp.Test/SerializerTests.fs | 152 ++++++++++++++++++ .../YamlDotNet.Fsharp.Test.fsproj | 25 +++ YamlDotNet.sln | 6 + YamlDotNet/Helpers/FsharpHelper.cs | 38 +++++ .../ObjectNodeDeserializer.cs | 7 +- .../ScalarNodeDeserializer.cs | 6 +- .../FullObjectGraphTraversalStrategy.cs | 25 ++- .../Serialization/Utilities/TypeConverter.cs | 5 +- 9 files changed, 342 insertions(+), 9 deletions(-) create mode 100644 YamlDotNet.Fsharp.Test/DeserializerTests.fs create mode 100644 YamlDotNet.Fsharp.Test/SerializerTests.fs create mode 100644 YamlDotNet.Fsharp.Test/YamlDotNet.Fsharp.Test.fsproj create mode 100644 YamlDotNet/Helpers/FsharpHelper.cs diff --git a/YamlDotNet.Fsharp.Test/DeserializerTests.fs b/YamlDotNet.Fsharp.Test/DeserializerTests.fs new file mode 100644 index 000000000..3f48a4ff7 --- /dev/null +++ b/YamlDotNet.Fsharp.Test/DeserializerTests.fs @@ -0,0 +1,87 @@ +module DeserializerTests + +open System +open Xunit +open YamlDotNet.Serialization +open YamlDotNet.Serialization.NamingConventions +open FsUnit.Xunit +open System.ComponentModel + +[] +type Spec = { + EngineType: string + DriveType: string +} + +[] +type Car = { + Name: string + Year: int + Spec: Spec option + Nickname: string option +} + +[] +type Person = { + Name: string + MomentOfBirth: DateTime + Cars: Car array +} + +[] +let Deserialize_YamlWithScalarOptions() = + let yaml = """ +name: Jack +momentOfBirth: 1983-04-21T20:21:03.0041599Z +cars: +- name: Mercedes + year: 2018 + nickname: Jessy +- name: Honda + year: 2021 +""" + let sut = DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build() + + let person = sut.Deserialize(yaml) + person.Name |> should equal "Jack" + person.Cars |> should haveLength 2 + person.Cars[0].Name |> should equal "Mercedes" + person.Cars[0].Nickname |> should equal (Some "Jessy") + person.Cars[1].Name |> should equal "Honda" + person.Cars[1].Nickname |> should equal None + + +[] +let Deserialize_YamlWithObjectOptions() = + let yaml = """ +name: Jack +momentOfBirth: 1983-04-21T20:21:03.0041599Z +cars: +- name: Mercedes + year: 2018 + spec: + engineType: V6 + driveType: AWD +- name: Honda + year: 2021 +""" + let sut = DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build() + + let person = sut.Deserialize(yaml) + person.Name |> should equal "Jack" + person.Cars |> should haveLength 2 + + person.Cars[0].Name |> should equal "Mercedes" + person.Cars[0].Spec |> should not' (be null) + person.Cars[0].Spec |> Option.isSome |> should equal true + person.Cars[0].Spec.Value.EngineType |> should equal "V6" + person.Cars[0].Spec.Value.DriveType |> should equal "AWD" + + person.Cars[1].Name |> should equal "Honda" + person.Cars[1].Spec |> should be null + person.Cars[1].Spec |> should equal None + person.Cars[1].Nickname |> should equal None diff --git a/YamlDotNet.Fsharp.Test/SerializerTests.fs b/YamlDotNet.Fsharp.Test/SerializerTests.fs new file mode 100644 index 000000000..d612647cc --- /dev/null +++ b/YamlDotNet.Fsharp.Test/SerializerTests.fs @@ -0,0 +1,152 @@ +module SerializerTests + +open System +open Xunit +open YamlDotNet.Serialization +open YamlDotNet.Serialization.NamingConventions +open FsUnit.Xunit +open YamlDotNet.Core + +[] +type Spec = { + EngineType: string + DriveType: string +} + +[] +type Car = { + Name: string + Year: int + Spec: Spec option + Nickname: string option +} + +[] +type Person = { + Name: string + MomentOfBirth: DateTime + KidsSeat: int option + Cars: Car array +} + +[] +let Serialize_YamlWithScalarOptions() = + let jackTheDriver = { + Name = "Jack" + MomentOfBirth = DateTime(1983, 4, 21, 20, 21, 03, 4) + KidsSeat = Some 1 + Cars = [| + { Name = "Mercedes" + Year = 2018 + Nickname = Some "Jessy" + Spec = None }; + { Name = "Honda" + Year = 2021 + Nickname = None + Spec = None } + |] + } + + let yaml = """name: Jack +momentOfBirth: 1983-04-21T20:21:03.0040000 +kidsSeat: 1 +cars: +- name: Mercedes + year: 2018 + spec: + nickname: Jessy +- name: Honda + year: 2021 + spec: + nickname: +""" + let sut = SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build() + + let person = sut.Serialize(jackTheDriver) + person |> should equal yaml + + +[] +let Serialize_YamlWithScalarOptions_OmitNull() = + let jackTheDriver = { + Name = "Jack" + MomentOfBirth = DateTime(1983, 4, 21, 20, 21, 03, 4) + KidsSeat = Some 1 + Cars = [| + { Name = "Mercedes" + Year = 2018 + Nickname = Some "Jessy" + Spec = None }; + { Name = "Honda" + Year = 2021 + Nickname = None + Spec = None } + |] + } + + let yaml = """name: Jack +momentOfBirth: 1983-04-21T20:21:03.0040000 +kidsSeat: 1 +cars: +- name: Mercedes + year: 2018 + nickname: Jessy +- name: Honda + year: 2021 +""" + let sut = SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull) + .Build() + + let person = sut.Serialize(jackTheDriver) + person |> should equal yaml + + +[] +let Serialize_YamlWithObjectOptions_OmitNull() = + let jackTheDriver = { + Name = "Jack" + MomentOfBirth = DateTime(1983, 4, 21, 20, 21, 03, 4) + KidsSeat = Some 1 + Cars = [| + { Name = "Mercedes" + Year = 2018 + Nickname = None + Spec = Some { + EngineType = "V6" + DriveType = "AWD" + } }; + { Name = "Honda" + Year = 2021 + Nickname = None + Spec = None } + |] + } + + let yaml = """name: Jack +momentOfBirth: 1983-04-21T20:21:03.0040000 +kidsSeat: 1 +cars: +- name: Mercedes + year: 2018 + spec: + engineType: V6 + driveType: AWD +- name: Honda + year: 2021 +""" + let sut = SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull) + .Build() + + let person = sut.Serialize(jackTheDriver) + person |> should equal yaml + +type TestOmit = { + name: string + plop: int option +} diff --git a/YamlDotNet.Fsharp.Test/YamlDotNet.Fsharp.Test.fsproj b/YamlDotNet.Fsharp.Test/YamlDotNet.Fsharp.Test.fsproj new file mode 100644 index 000000000..300cec1f9 --- /dev/null +++ b/YamlDotNet.Fsharp.Test/YamlDotNet.Fsharp.Test.fsproj @@ -0,0 +1,25 @@ + + + net8.0;net7.0;net6.0;net47 + false + ..\YamlDotNet.snk + true + 8.0 + true + + + + + + + + + + + + + + + + + diff --git a/YamlDotNet.sln b/YamlDotNet.sln index e7d7d763e..b88747fc1 100644 --- a/YamlDotNet.sln +++ b/YamlDotNet.sln @@ -31,6 +31,8 @@ Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "YamlDotNet.Samples.Fsharp", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "YamlDotNet.Core7AoTCompileTest.Model", "YamlDotNet.Core7AoTCompileTest.Model\YamlDotNet.Core7AoTCompileTest.Model.csproj", "{BFE15564-7C2C-47DA-8302-9BCB39B6864B}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "YamlDotNet.Fsharp.Test", "YamlDotNet.Fsharp.Test\YamlDotNet.Fsharp.Test.fsproj", "{294EFEB3-4DC2-4105-ADE7-E429F5522419}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -69,6 +71,10 @@ Global {BFE15564-7C2C-47DA-8302-9BCB39B6864B}.Debug|Any CPU.Build.0 = Debug|Any CPU {BFE15564-7C2C-47DA-8302-9BCB39B6864B}.Release|Any CPU.ActiveCfg = Release|Any CPU {BFE15564-7C2C-47DA-8302-9BCB39B6864B}.Release|Any CPU.Build.0 = Release|Any CPU + {294EFEB3-4DC2-4105-ADE7-E429F5522419}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {294EFEB3-4DC2-4105-ADE7-E429F5522419}.Debug|Any CPU.Build.0 = Debug|Any CPU + {294EFEB3-4DC2-4105-ADE7-E429F5522419}.Release|Any CPU.ActiveCfg = Release|Any CPU + {294EFEB3-4DC2-4105-ADE7-E429F5522419}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/YamlDotNet/Helpers/FsharpHelper.cs b/YamlDotNet/Helpers/FsharpHelper.cs new file mode 100644 index 000000000..304164b5f --- /dev/null +++ b/YamlDotNet/Helpers/FsharpHelper.cs @@ -0,0 +1,38 @@ +using System; +using YamlDotNet.Serialization; + +namespace YamlDotNet.Helpers +{ + public static class FsharpHelper + { + private static bool IsFsharp(Type t) + { + return t.Namespace == "Microsoft.FSharp.Core"; + } + + public static bool IsOptionType(Type t) + { + return IsFsharp(t) && t.Name == "FSharpOption`1"; + } + + public static Type? GetOptionUnderlyingType(Type t) + { + return t.IsGenericType && IsOptionType(t) ? t.GenericTypeArguments[0] : null; + } + + public static object? GetValue(IObjectDescriptor objectDescriptor) + { + if (!IsOptionType(objectDescriptor.Type)) + { + throw new InvalidOperationException("Should not be called on non-Option<> type"); + } + + if (objectDescriptor.Value is null) + { + return null; + } + + return objectDescriptor.Type.GetProperty("Value").GetValue(objectDescriptor.Value); + } + } +} diff --git a/YamlDotNet/Serialization/NodeDeserializers/ObjectNodeDeserializer.cs b/YamlDotNet/Serialization/NodeDeserializers/ObjectNodeDeserializer.cs index da882c50d..06dbb6b0e 100644 --- a/YamlDotNet/Serialization/NodeDeserializers/ObjectNodeDeserializer.cs +++ b/YamlDotNet/Serialization/NodeDeserializers/ObjectNodeDeserializer.cs @@ -24,6 +24,7 @@ using System.Runtime.Serialization; using YamlDotNet.Core; using YamlDotNet.Core.Events; +using YamlDotNet.Helpers; using YamlDotNet.Serialization.Utilities; namespace YamlDotNet.Serialization.NodeDeserializers @@ -60,8 +61,10 @@ public bool Deserialize(IParser parser, Type expectedType, Func(object name, IObjectDescriptor value, break; } - var underlyingType = Nullable.GetUnderlyingType(value.Type); - if (underlyingType != null) + var nullableUnderlyingType = Nullable.GetUnderlyingType(value.Type); + var optionUnderlyingType = nullableUnderlyingType ?? FsharpHelper.GetOptionUnderlyingType(value.Type); + var optionValue = optionUnderlyingType != null ? FsharpHelper.GetValue(value) : null; + + if (nullableUnderlyingType != null) { // This is a nullable type, recursively handle it with its underlying type. // Note that if it contains null, the condition above already took care of it - Traverse("Value", new ObjectDescriptor(value.Value, underlyingType, value.Type, value.ScalarStyle), visitor, context, path); + Traverse( + "Value", + new ObjectDescriptor(value.Value, nullableUnderlyingType, value.Type, value.ScalarStyle), + visitor, + context, + path + ); + } + else if (optionUnderlyingType != null && optionValue != null) + { + Traverse( + "Value", + new ObjectDescriptor(FsharpHelper.GetValue(value), optionUnderlyingType, value.Type, value.ScalarStyle), + visitor, + context, + path + ); } else { diff --git a/YamlDotNet/Serialization/Utilities/TypeConverter.cs b/YamlDotNet/Serialization/Utilities/TypeConverter.cs index 7bd79065c..d8efd0f17 100644 --- a/YamlDotNet/Serialization/Utilities/TypeConverter.cs +++ b/YamlDotNet/Serialization/Utilities/TypeConverter.cs @@ -27,6 +27,7 @@ using System.Globalization; using System.Reflection; using System.ComponentModel; +using YamlDotNet.Helpers; namespace YamlDotNet.Serialization.Utilities { @@ -122,11 +123,11 @@ public static T ChangeType(object? value, CultureInfo culture, INamingConvent return value; } - // Nullable types get a special treatment + // Nullable & fsharp option types get a special treatment if (destinationType.IsGenericType()) { var genericTypeDefinition = destinationType.GetGenericTypeDefinition(); - if (genericTypeDefinition == typeof(Nullable<>)) + if (genericTypeDefinition == typeof(Nullable<>) || FsharpHelper.IsOptionType(genericTypeDefinition)) { var innerType = destinationType.GetGenericArguments()[0]; var convertedValue = ChangeType(value, innerType, culture, enumNamingConvention);