diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..8c144a7 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## Description + + + +## Checklist + +Please make sure the following points are addressed before the PR is reviewed: + +- [ ] **Changelog**: an entry was added to the `[Unreleased]` section of `CHANGELOG.md` describing the change (bug fix, feature, breaking change), with the related issue number when applicable. +- [ ] **Tests**: the change is covered by tests, and the test suite passes. +- [ ] **Documentation**: README or XML docs were updated when the public API or behavior changed. diff --git a/CHANGELOG.md b/CHANGELOG.md index 50372c7..cbc4e68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### JsonSubTypes +#### Fixed +- Deserialization with an open generic base type (e.g. `Base<>`) now closes the generic subtype correctly (e.g. `Nested1` for `Base`) instead of failing. #177 + +## [1.0.0-rc.2] - 2026-08-10 +### Changed +- Rebuilt with Source Link, deterministic builds and `.snupkg` symbol packages so symbols validate against the published package. + +## [1.0.0-rc.1] - 2026-08-10 ### Added -- New package `JsonSubTypes.Text.Json` (1.0.0-rc.1) bringing polymorphic subtype serialization to `System.Text.Json` (.NET 8+). +- New package `JsonSubTypes.Text.Json` bringing polymorphic subtype serialization to `System.Text.Json` (.NET 8+). - Attribute-based and builder-based subtype registration. - Discriminator mapping by property presence (`KnownSubTypeWithProperty`). - Fallback subtype support (`FallBackSubType`). diff --git a/JsonSubTypes.Tests/GenericEdgeCaseTests.cs b/JsonSubTypes.Tests/GenericEdgeCaseTests.cs new file mode 100644 index 0000000..d70af92 --- /dev/null +++ b/JsonSubTypes.Tests/GenericEdgeCaseTests.cs @@ -0,0 +1,156 @@ +using System; +using Newtonsoft.Json; +using NUnit.Framework; + +namespace JsonSubTypes.Tests +{ + [TestFixture] + public class GenericEdgeCaseTests + { + public abstract class ArityBase + { + public abstract string Kind { get; } + } + + public class TwoParam : ArityBase + { + public override string Kind => "1"; + } + + [Test] + public void ArityMismatchThrowsCleanJsonSerializationException() + { + var settings = new JsonSerializerSettings(); + settings.Converters.Add(JsonSubtypesConverterBuilder + .Of(typeof(ArityBase<>), "Kind") + .RegisterSubtype(typeof(TwoParam<,>), "1") + .Build()); + + var json = "{\"Kind\":\"1\"}"; + + var exception = Assert.Throws( + () => JsonConvert.DeserializeObject>(json, settings)); + Assert.That(exception.Message, Does.Contain("generic").IgnoreCase); + } + + public interface IShape + { + T Value { get; set; } + } + + public abstract class ShapeBase : IShape + { + public T Value { get; set; } + public abstract string Kind { get; } + } + + public class Circle : ShapeBase + { + public override string Kind => "circle"; + } + + public class UnrelatedShape : IShape + { + public T Value { get; set; } + } + + [Test] + public void UnrelatedInterfaceImplementorIsNotClaimed() + { + var converter = JsonSubtypesConverterBuilder + .Of(typeof(IShape<>), "Kind") + .RegisterSubtype(typeof(Circle<>), "circle") + .Build(); + + Assert.IsFalse(converter.CanConvert(typeof(UnrelatedShape))); + } + + [Test] + public void UnrelatedInterfaceImplementorSerializesNormallyWithDiscriminator() + { + var settings = new JsonSerializerSettings(); + settings.Converters.Add(JsonSubtypesConverterBuilder + .Of(typeof(IShape<>), "Kind") + .SerializeDiscriminatorProperty() + .RegisterSubtype(typeof(Circle<>), "circle") + .Build()); + + var json = JsonConvert.SerializeObject(new UnrelatedShape { Value = 5 }, settings); + + Assert.AreEqual("{\"Value\":5}", json); + } + + public abstract class MultiBase + { + public abstract string Kind { get; } + } + + public abstract class MultiMid : MultiBase + { + } + + public class MultiLeaf : MultiMid + { + public override string Kind => "leaf"; + } + + [Test] + public void MultiLevelGenericHierarchyDeserializes() + { + var settings = new JsonSerializerSettings(); + settings.Converters.Add(JsonSubtypesConverterBuilder + .Of(typeof(MultiBase<>), "Kind") + .RegisterSubtype(typeof(MultiLeaf<>), "leaf") + .Build()); + + var result = JsonConvert.DeserializeObject>("{\"Kind\":\"leaf\"}", settings); + + Assert.IsInstanceOf>(result); + } + + public class BareBase + { + } + + public class BareOne : BareBase + { + } + + [Test] + public void ExplicitClosedFormRegistrationTakesPrecedence() + { + var settings = new JsonSerializerSettings(); + settings.Converters.Add(JsonSubtypesConverterBuilder + .Of(typeof(BareBase<>), "Kind") + .SerializeDiscriminatorProperty() + .RegisterSubtype(typeof(BareOne<>), "1") + .RegisterSubtype(typeof(BareOne), "5") + .Build()); + + var jsonInt = JsonConvert.SerializeObject(new BareOne(), settings); + StringAssert.Contains("\"Kind\":\"5\"", jsonInt); + + var jsonString = JsonConvert.SerializeObject(new BareOne(), settings); + StringAssert.Contains("\"Kind\":\"1\"", jsonString); + } + + public class GenericFallback : BareBase + { + } + + [Test] + public void GenericFallbackSubtypeIsClosed() + { + var settings = new JsonSerializerSettings(); + settings.Converters.Add(JsonSubtypesConverterBuilder + .Of(typeof(BareBase<>), "Kind") + .RegisterSubtype(typeof(BareOne<>), "1") + .SetFallbackSubtype(typeof(GenericFallback<>)) + .Build()); + + var result = JsonConvert.DeserializeObject>("{\"Kind\":\"zzz\"}", settings); + + Assert.IsInstanceOf>(result); + } + } +} diff --git a/JsonSubTypes.Tests/GenericTests.cs b/JsonSubTypes.Tests/GenericTests.cs index 9eaf8f4..f56f6d6 100644 --- a/JsonSubTypes.Tests/GenericTests.cs +++ b/JsonSubTypes.Tests/GenericTests.cs @@ -3,7 +3,8 @@ using NUnit.Framework; namespace JsonSubTypes.Tests -{[JsonConverter(typeof(JsonSubtypes), "Type")] +{ + [JsonConverter(typeof(JsonSubtypes), "Type")] [JsonSubtypes.KnownSubType(typeof(Some<>), "Some")] public interface IResult { @@ -49,4 +50,77 @@ public void DeserializingSubTypeWithDateParsesCorrectly() Console.WriteLine(result); } } + + + + [TestFixture] + public class GenericBaseTests + { + interface IShape + { + T Value { get; set; } + + string Kind { get; } + } + + abstract class ShapeBase : IShape + { + public T Value { get; set; } + + public abstract string Kind { get; } + } + + class Square : ShapeBase + { + public override string Kind => "square"; + } + + class Circle : ShapeBase + { + public override string Kind => "circle"; + } + + [Test] + public void Deserialize_BaseConcreteSubtype_WithJsonSubtypes_OnAbstractBase_ReturnsSquare() + { + var settings = new JsonSerializerSettings(); + settings.Converters.Add(JsonSubtypesConverterBuilder + .Of(typeof(ShapeBase<>), "Kind") + .RegisterSubtype(typeof(Square<>), "square") + .RegisterSubtype(typeof(Circle<>), "circle") + .Build()); + + var json = JsonConvert.SerializeObject(new Square + { + Value = 42, + }, settings); + + var shape = JsonConvert.DeserializeObject>(json, settings); + + Assert.IsInstanceOf>(shape); + Assert.AreEqual(42, shape.Value); + } + + [Test] + public void Deserialize_InterfaceConcreteSubtype_WithJsonSubtypes_OnInterface_ReturnsSquare() + { + var settings = new JsonSerializerSettings(); + settings.Converters.Add(JsonSubtypesConverterBuilder + .Of(typeof(IShape<>), "Kind") + .RegisterSubtype(typeof(Square<>), "square") + .RegisterSubtype(typeof(Circle<>), "circle") + .Build()); + + var json = JsonConvert.SerializeObject(new Square + { + Value = 42, + }, settings); + + var shape = JsonConvert.DeserializeObject>(json, settings); + + Assert.IsInstanceOf>(shape); + Assert.AreEqual(42, shape.Value); + } + } + } diff --git a/JsonSubTypes/JsonSubtypes.cs b/JsonSubTypes/JsonSubtypes.cs index 41b04b1..e1be1ec 100644 --- a/JsonSubTypes/JsonSubtypes.cs +++ b/JsonSubTypes/JsonSubtypes.cs @@ -274,6 +274,19 @@ private Type GetType(JObject jObject, Type parentType, JsonSerializer serializer currentTypeResolver = GetTypeResolver(ToTypeInfo(targetType), jsonConverterCollection); } + if (targetType != null && ToTypeInfo(targetType).IsGenericTypeDefinition && !ToTypeInfo(parentType).IsGenericTypeDefinition) + { + Type[] parentTypeArguments = GetGenericTypeArguments(parentType).ToArray(); + if (GetGenericTypeParameterCount(targetType) != parentTypeArguments.Length) + { + throw new JsonSerializationException( + "Could not close generic subtype " + targetType.FullName + " with the generic arguments of " + + parentType.FullName + ": different number of generic parameters."); + } + + return ToTypeInfo(targetType).MakeGenericType(parentTypeArguments); + } + return targetType; } @@ -523,6 +536,15 @@ private static IEnumerable GetGenericTypeArguments(Type type) #endif } + private static int GetGenericTypeParameterCount(Type type) + { +#if (NETSTANDARD1_3) + return type.GetTypeInfo().GenericTypeParameters.Length; +#else + return type.GetGenericArguments().Length; +#endif + } + internal static TypeInfo ToTypeInfo(Type type) { #if (!NETSTANDARD1_3) diff --git a/JsonSubTypes/JsonSubtypesByDiscriminatorValueConverter.cs b/JsonSubTypes/JsonSubtypesByDiscriminatorValueConverter.cs index 42af58f..9d72500 100644 --- a/JsonSubTypes/JsonSubtypesByDiscriminatorValueConverter.cs +++ b/JsonSubTypes/JsonSubtypesByDiscriminatorValueConverter.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -69,7 +70,25 @@ internal override NullableDictionary GetSubTypeMapping(Type type) public override bool CanConvert(Type objectType) { - return base.CanConvert(objectType) || _supportedTypes.ContainsKey(objectType); + if (base.CanConvert(objectType) || _supportedTypes.ContainsKey(objectType)) + { + return true; + } + + // Claim closed forms of registered generic subtypes only, e.g. + // Nested1 when Nested1<> is registered. Matching against the + // base type would also claim unrelated types that merely implement + // the generic base interface. + foreach (Type registeredType in _supportedTypes.Keys) + { + if (ToTypeInfo(registeredType).IsGenericTypeDefinition && + IsClosedGenericFormOf(objectType, registeredType)) + { + return true; + } + } + + return false; } public override bool CanWrite @@ -109,7 +128,15 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s if (!_supportedTypes.TryGetValue(value.GetType(), out object supportedType)) { - throw new JsonSerializationException("Impossible to serialize type: " + value.GetType().FullName + " because there is no registered mapping for the discriminator property"); + var matchingGenericSupportedType = _supportedTypes.Keys + .FirstOrDefault(x => IsClosedGenericFormOf(value.GetType(), x)); + + if (matchingGenericSupportedType == null || + !_supportedTypes.TryGetValue(matchingGenericSupportedType, out supportedType)) + { + throw new JsonSerializationException("Impossible to serialize type: " + value.GetType().FullName + + " because there is no registered mapping for the discriminator property"); + } } JToken typeMappingPropertyValue = JToken.FromObject(supportedType, serializer); if (_addDiscriminatorFirst) diff --git a/JsonSubTypes/JsonSubtypesConverter.cs b/JsonSubTypes/JsonSubtypesConverter.cs index 8765c98..dfa2284 100644 --- a/JsonSubTypes/JsonSubtypesConverter.cs +++ b/JsonSubTypes/JsonSubtypesConverter.cs @@ -1,5 +1,4 @@ using System; -using System.Reflection; namespace JsonSubTypes { @@ -48,7 +47,42 @@ internal override Type GetFallbackSubType(Type type) public override bool CanConvert(Type objectType) { - return objectType == _baseType || ToTypeInfo(_baseType).IsAssignableFrom(ToTypeInfo(objectType)); + return objectType == _baseType || + ToTypeInfo(_baseType).IsAssignableFrom(ToTypeInfo(objectType)) || + IsClosedGenericFormOf(objectType, _baseType); + } + + /// + /// Returns true when is a constructed form of the + /// open generic (e.g. Base<int> for Base<>), + /// or inherits one (e.g. Square<int> : ShapeBase<int>). Interfaces are + /// intentionally not matched: registering an interface as a subtype cannot work + /// end-to-end (deserialization would resolve to a non-instantiable interface). + /// + protected static bool IsClosedGenericFormOf(Type objectType, Type genericType) + { + if (!ToTypeInfo(genericType).IsGenericTypeDefinition || !ToTypeInfo(objectType).IsGenericType) + { + return false; + } + + if (objectType.GetGenericTypeDefinition() == genericType) + { + return true; + } + + var current = ToTypeInfo(objectType).BaseType; + while (current != null) + { + if (ToTypeInfo(current).IsGenericType && current.GetGenericTypeDefinition() == genericType) + { + return true; + } + + current = ToTypeInfo(current).BaseType; + } + + return false; } } }