Skip to content

Commit

Permalink
Add Either helper class & serialization (#5190)
Browse files Browse the repository at this point in the history
Fixes #5189
  • Loading branch information
1 parent c038b4a commit 17940a8
Show file tree
Hide file tree
Showing 3 changed files with 361 additions and 0 deletions.
205 changes: 205 additions & 0 deletions src/SLCore.UnitTests/Protocol/EitherJsonConverterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/*
* SonarLint for Visual Studio
* Copyright (C) 2016-2024 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

using System;
using Newtonsoft.Json;
using SonarLint.VisualStudio.SLCore.Protocol;

namespace SonarLint.VisualStudio.SLCore.UnitTests.Protocol;

[TestClass]
public class EitherJsonConverterTests
{
[DataTestMethod]
[DataRow(typeof(object), false)]
[DataRow(typeof(Either<ConflictingObject.ConflictingLeft, ConflictingObject.ConflictingRight>), false)]
[DataRow(typeof(Either<SimpleObject.LeftOption, ConflictingObject.ConflictingRight>), false)]
[DataRow(typeof(Either<SimpleObject.LeftOption, SimpleObject.RightOption>), true)]
public void CanConvert_TypeHasToMatch(Type typeToCheck, bool isSupported)
{
var testSubject = new EitherJsonConverter<SimpleObject.LeftOption, SimpleObject.RightOption>();

testSubject.CanConvert(typeToCheck).Should().Be(isSupported);
}

[TestMethod]
public void SerializeObject_SerializesEitherAsSingleObject()
{
var left = new SimpleObject
{
Property = Either<SimpleObject.LeftOption, SimpleObject.RightOption>.CreateLeft(
new SimpleObject.LeftOption
{ Left = "lll" })
};
var right = new SimpleObject
{
Property = Either<SimpleObject.LeftOption, SimpleObject.RightOption>.CreateRight(
new SimpleObject.RightOption
{ Right = 10 })
};

JsonConvert.SerializeObject(left).Should().BeEquivalentTo("""{"Property":{"Left":"lll"}}""");
JsonConvert.SerializeObject(right).Should().BeEquivalentTo("""{"Property":{"Right":10}}""");
}

[TestMethod]
public void DeserializeObject_PrimitiveNotAnObject_Throws()
{
var str = """
{
"Property" : "ThisIsExpectedToBeAnObjectButItIsAString"
}
""";

Action act = () => JsonConvert.DeserializeObject<SimpleObject>(str);

act.Should().ThrowExactly<InvalidOperationException>().WithMessage("Expected Object, found String");
}

[TestMethod]
public void DeserializeObject_CollectionNotAnObject_Throws()
{
var str = """
{
"Property" : [1, 2, 3]
}
""";

Action act = () => JsonConvert.DeserializeObject<SimpleObject>(str);

act.Should().ThrowExactly<InvalidOperationException>().WithMessage("Expected Object, found Array");
}

[TestMethod]
public void DeserializeObject_UnableToMatch_Throws()
{
var str = """
{
"Property" :
{
"unknown" : "value"
}
}
""";

Action act = () => JsonConvert.DeserializeObject<SimpleObject>(str);

act.Should().ThrowExactly<InvalidOperationException>().WithMessage("Unable to make a definitive choice between Either options");
}

[TestMethod]
public void DeserializeObject_UnresolvableTypes_Throws()
{
var str = """
{
"Property" :
{
"SameProperty" : "value"
}
}
""";


Action act = () => JsonConvert.DeserializeObject<ConflictingObject>(str);

act
.Should()
.ThrowExactly<JsonException>()
.WithInnerExceptionExactly<ArgumentException>()
.WithMessage(
"Types SonarLint.VisualStudio.SLCore.UnitTests.Protocol.EitherJsonConverterTests+ConflictingObject+ConflictingLeft and SonarLint.VisualStudio.SLCore.UnitTests.Protocol.EitherJsonConverterTests+ConflictingObject+ConflictingRight have equivalent sets of properties and fields");
}

[TestMethod]
public void SerializeDeserializeObject_ComplexObjects_DeserializesToCorrectEitherVariant()
{
var left = new ComplexObject
{
dto = Either<ComplexObject.LeftOption, ComplexObject.RightOption>.CreateLeft(new ComplexObject.LeftOption())
};
var right = new ComplexObject
{
dto = Either<ComplexObject.LeftOption, ComplexObject.RightOption>.CreateRight(
new ComplexObject.RightOption())
};

JsonConvert.DeserializeObject<ComplexObject>(JsonConvert.SerializeObject(left)).Should().BeEquivalentTo(left);
JsonConvert.DeserializeObject<ComplexObject>(JsonConvert.SerializeObject(right)).Should().BeEquivalentTo(right);
}

public class SimpleObject
{
[JsonConverter(typeof(EitherJsonConverter<LeftOption, RightOption>))]
public Either<LeftOption, RightOption> Property { get; set; }

public class LeftOption
{
public string Left { get; set; }
}

public class RightOption
{
public int Right;
}
}

public class ConflictingObject
{
[JsonConverter(typeof(EitherJsonConverter<ConflictingLeft, ConflictingRight>))]
public Either<ConflictingLeft, ConflictingRight> Property { get; set; }

public class ConflictingLeft
{
public string SameProperty { get; set; }
}

public class ConflictingRight
{
public string SameProperty { get; set; }
}
}


public class ComplexObject
{
public string str = "aaaaa";

[JsonConverter(typeof(EitherJsonConverter<LeftOption, RightOption>))]
public Either<LeftOption, RightOption> dto { get; set; }

public class LeftOption
{
public object ACommon { get; set; } = new { lala = 10 };
public object ACommon2 = new { lala = 10 };
public object V1Obj { get; set; } = new { dada = 20 };
public object V1Str = "strstrstr";
public object XCommon { get; set; } = new { lala = 10 };
}

public class RightOption
{
public object ACommon { get; set; } = new { lala = 20 };
public object ACommon2 = new { lala = 20 };
public object V2Obj { get; set; } = new { dada = 40 };
public object V2Str = "strstrstr2";
public object XCommon { get; set; } = new { lala = 20 };
}
}
}
40 changes: 40 additions & 0 deletions src/SLCore/Protocol/Either.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* SonarLint for Visual Studio
* Copyright (C) 2016-2024 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

namespace SonarLint.VisualStudio.SLCore.Protocol
{
/// <summary>
/// Represents an option class where only one of the properties <see cref="Left"/> or <see cref="Right"/> is not null
/// </summary>
public sealed class Either<TLeft, TRight>
where TLeft : class
where TRight : class
{
private Either()
{
}

public TLeft Left { get; private set; }
public TRight Right { get; private set; }

public static Either<TLeft, TRight> CreateLeft(TLeft left) => new Either<TLeft, TRight> { Left = left };
public static Either<TLeft, TRight> CreateRight(TRight right) => new Either<TLeft, TRight> { Right = right };
}
}
116 changes: 116 additions & 0 deletions src/SLCore/Protocol/EitherJsonConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* SonarLint for Visual Studio
* Copyright (C) 2016-2024 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace SonarLint.VisualStudio.SLCore.Protocol
{
/// <summary>
/// <see cref="JsonConverter" /> for <see cref="Either{TLeft,TRight}" /> type.
/// Left and Right are serialized to and deserialize from the same json property.
/// </summary>
/// <typeparam name="TLeft">A class that converts to json object. Primitive types and Arrays are not supported</typeparam>
/// <typeparam name="TRight">A class that converts to json object. Primitive types and Arrays are not supported</typeparam>
/// <remarks>
/// <list type="bullet">
/// <item><a href="https://www.newtonsoft.com/json/help/html/serializationguide.htm#PrimitiveTypes">Primitive types</a></item>
/// <item><a href="https://www.newtonsoft.com/json/help/html/serializationguide.htm#ComplexTypes">Arrays (IList, IEnumerable, IList&lt;T&gt;, Array)</a></item>
/// </list>
/// </remarks>
public class EitherJsonConverter<TLeft, TRight> : JsonConverter
where TLeft : class
where TRight : class
{
private readonly HashSet<string> leftProperties;
private readonly HashSet<string> rightProperties;

public EitherJsonConverter()
{
leftProperties = GetAllPropertyAndFieldNames(typeof(TLeft));
rightProperties = GetAllPropertyAndFieldNames(typeof(TRight));
var intersection = leftProperties.Intersect(rightProperties).ToArray();
leftProperties.ExceptWith(intersection);
rightProperties.ExceptWith(intersection);

if (!rightProperties.Any() && !leftProperties.Any())
{
throw new ArgumentException(
$"Types {typeof(TLeft)} and {typeof(TRight)} have equivalent sets of properties and fields");
}
}

private static HashSet<string> GetAllPropertyAndFieldNames(Type type)
{
const BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Public;
return type.GetProperties(bindingFlags).Select(x => x.Name)
.Concat(type.GetFields(bindingFlags).Select(x => x.Name))
.ToHashSet();
}

public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
var either = (Either<TLeft, TRight>)value;

if (either.Left != null)
{
serializer.Serialize(writer, either.Left);
return;
}

serializer.Serialize(writer, either.Right);
}

public override bool CanConvert(Type objectType)
{
return objectType == typeof(Either<TLeft, TRight>);
}

public override object ReadJson(JsonReader reader, Type objectType, object existingValue,
JsonSerializer serializer)
{
var jToken = JToken.ReadFrom(reader);

if (jToken.Type != JTokenType.Object)
{
throw new InvalidOperationException($"Expected {JTokenType.Object}, found {jToken.Type}");
}

foreach (var jsonProperty in jToken.Children().Select(x => x.Path))
{
if (leftProperties.Contains(jsonProperty))
{
return Either<TLeft, TRight>.CreateLeft(jToken.ToObject<TLeft>());
}

if (rightProperties.Contains(jsonProperty))
{
return Either<TLeft, TRight>.CreateRight(jToken.ToObject<TRight>());
}
}

throw new InvalidOperationException("Unable to make a definitive choice between Either options");
}
}
}

0 comments on commit 17940a8

Please sign in to comment.