diff --git a/CHANGELOG.md b/CHANGELOG.md index 30ee87c..b6ea8a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### JsonSubTypes.Text.Json +#### Added +- New `JsonSubTypeConverterAttribute` convenience constructors that close the generic `JsonSubtypes` converter over the annotated type, so the base type does not need to be repeated: `[JsonSubTypeConverter("Kind")]` instead of `[JsonSubTypeConverter(typeof(JsonSubtypes), "Kind")]`. +#### Changed +- Renamed `FallBackSubTypeAttribute` to `FallbackSubTypeAttribute` and `FallBackToNearestAncestor()` to `FallbackToNearestAncestor()` for consistent capitalization. The `FallBack*` names still work in `JsonSubTypes` (Newtonsoft), which keeps its historical API. +#### Fixed +- The attribute-based converter now writes the discriminator on serialization, as documented: the attribute's `CreateConverter` override was previously bypassed by `System.Text.Json` (the converter was built through its parameterless constructor), so the discriminator was only read, never written. The attribute now routes through `CreateConverter`, which also activates the discriminator-injection write path for registered subtypes. + ### 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 diff --git a/JsonSubTypes.Benchmarks/BaseAsLeafBenchmarks.cs b/JsonSubTypes.Benchmarks/BaseAsLeafBenchmarks.cs index 3093f28..6b06352 100644 --- a/JsonSubTypes.Benchmarks/BaseAsLeafBenchmarks.cs +++ b/JsonSubTypes.Benchmarks/BaseAsLeafBenchmarks.cs @@ -45,16 +45,16 @@ public BaseAsLeafBenchmarks() } [Benchmark] - public string Converter_Serialize() => JsonSerializer.Serialize(_convBase, _converterOptions!); + public string Leaf_Converter_Serialize() => JsonSerializer.Serialize(_convBase, _converterOptions!); [Benchmark] - public string Generated_Serialize() => JsonSerializer.Serialize(_generatedBase, _generatedOptions); + public string Leaf_Generated_Serialize() => JsonSerializer.Serialize(_generatedBase, _generatedOptions); [Benchmark] - public ConvBaseAnimal? Converter_Deserialize() => JsonSerializer.Deserialize(_converterJson!, _converterOptions!); + public ConvBaseAnimal? Leaf_Converter_Deserialize() => JsonSerializer.Deserialize(_converterJson!, _converterOptions!); [Benchmark] - public BaseLeafAnimal? Generated_Deserialize() => JsonSerializer.Deserialize(_generatedJson, _generatedOptions); + public BaseLeafAnimal? Leaf_Generated_Deserialize() => JsonSerializer.Deserialize(_generatedJson, _generatedOptions); } public class ConvBaseAnimal { public int Age { get; set; } } diff --git a/JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs b/JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs index decc504..913c744 100644 --- a/JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs +++ b/JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs @@ -14,7 +14,11 @@ public class NestedHierarchyBenchmarks private readonly JsonSerializerOptions _generatedOptions = new JsonSerializerOptions { TypeInfoResolver = NestedContext.Default, - Converters = { JsonSubTypesAotConverters.NestedPayload } + Converters = + { + JsonSubTypesAotConverters.NestedPayload, + JsonSubTypesAotConverters.NestedGame + } }; private readonly ConvRun _convRun = new ConvRun(); diff --git a/JsonSubTypes.Benchmarks/NewtonsoftBenchmarks.cs b/JsonSubTypes.Benchmarks/NewtonsoftBenchmarks.cs index b9c678d..559abed 100644 --- a/JsonSubTypes.Benchmarks/NewtonsoftBenchmarks.cs +++ b/JsonSubTypes.Benchmarks/NewtonsoftBenchmarks.cs @@ -51,16 +51,16 @@ public NewtonsoftBenchmarks() } [Benchmark] - public string Single_Serialize() => JsonConvert.SerializeObject(_animal, _settings); + public string Nw_Single_Serialize() => JsonConvert.SerializeObject(_animal, _settings); [Benchmark] - public NwAnimal? Single_Deserialize() => JsonConvert.DeserializeObject(_singleJson, _settings); + public NwAnimal? Nw_Single_Deserialize() => JsonConvert.DeserializeObject(_singleJson, _settings); [Benchmark] - public string Collection_Serialize() => JsonConvert.SerializeObject(_animals, _settings); + public string Nw_Collection_Serialize() => JsonConvert.SerializeObject(_animals, _settings); [Benchmark] - public List? Collection_Deserialize() => JsonConvert.DeserializeObject>(_collectionJson, _settings); + public List? Nw_Collection_Deserialize() => JsonConvert.DeserializeObject>(_collectionJson, _settings); } public class NwAnimal { public int Age { get; set; } } diff --git a/JsonSubTypes.Benchmarks/Program.cs b/JsonSubTypes.Benchmarks/Program.cs index b393de8..4223a41 100644 --- a/JsonSubTypes.Benchmarks/Program.cs +++ b/JsonSubTypes.Benchmarks/Program.cs @@ -78,22 +78,22 @@ public PolymorphismBenchmarks() } [Benchmark] - public string Generated_Serialize() => JsonSerializer.Serialize(_benchCat, _generatedOptions); + public string Single_Generated_Serialize() => JsonSerializer.Serialize(_benchCat, _generatedOptions); [Benchmark] - public string Resolver_Serialize() => JsonSerializer.Serialize(_resCat, _resolverOptions!); + public string Single_Resolver_Serialize() => JsonSerializer.Serialize(_resCat, _resolverOptions!); [Benchmark] - public string Converter_Serialize() => JsonSerializer.Serialize(new ConvCat { Age = 3, Lives = 9 }, _converterOptions!); + public string Single_Converter_Serialize() => JsonSerializer.Serialize(new ConvCat { Age = 3, Lives = 9 }, _converterOptions!); [Benchmark] - public ConvAnimal? Converter_Deserialize() => JsonSerializer.Deserialize(_converterJson!, _converterOptions!); + public ConvAnimal? Single_Converter_Deserialize() => JsonSerializer.Deserialize(_converterJson!, _converterOptions!); [Benchmark] - public ResAnimal? Resolver_Deserialize() => JsonSerializer.Deserialize(_resolverJson!, _resolverOptions!); + public ResAnimal? Single_Resolver_Deserialize() => JsonSerializer.Deserialize(_resolverJson!, _resolverOptions!); [Benchmark] - public BenchAnimal? Generated_Deserialize() => JsonSerializer.Deserialize(_generatedJson, _generatedOptions); + public BenchAnimal? Single_Generated_Deserialize() => JsonSerializer.Deserialize(_generatedJson, _generatedOptions); } public class ConvAnimal { public int Age { get; set; } } diff --git a/JsonSubTypes.Text.Json.Aot.Generated/GoldenMaster/JsonSubTypes.Text.Json.Aot/JsonSubTypes.Text.Json.Aot.JsonSubTypesGenerator/PayloadJsonSubTypesConverter.cs b/JsonSubTypes.Text.Json.Aot.Generated/GoldenMaster/JsonSubTypes.Text.Json.Aot/JsonSubTypes.Text.Json.Aot.JsonSubTypesGenerator/PayloadJsonSubTypesConverter.cs index b2d5759..d9ff998 100644 --- a/JsonSubTypes.Text.Json.Aot.Generated/GoldenMaster/JsonSubTypes.Text.Json.Aot/JsonSubTypes.Text.Json.Aot.JsonSubTypesGenerator/PayloadJsonSubTypesConverter.cs +++ b/JsonSubTypes.Text.Json.Aot.Generated/GoldenMaster/JsonSubTypes.Text.Json.Aot/JsonSubTypes.Text.Json.Aot.JsonSubTypesGenerator/PayloadJsonSubTypesConverter.cs @@ -90,7 +90,10 @@ protected override bool TryWriteNestedObject(Utf8JsonWriter writer, global::Json using JsonDocument payloadDocument = JsonDocument.Parse(payload); foreach (JsonProperty property in payloadDocument.RootElement.EnumerateObject()) { - property.WriteTo(writer); + if (!property.NameEquals(DiscriminatorPropertyNameValue) && !property.NameEquals("$GameKind")) + { + property.WriteTo(writer); + } } writer.WriteEndObject(); return true; @@ -106,7 +109,10 @@ protected override bool TryWriteNestedObject(Utf8JsonWriter writer, global::Json using JsonDocument payloadDocument = JsonDocument.Parse(payload); foreach (JsonProperty property in payloadDocument.RootElement.EnumerateObject()) { - property.WriteTo(writer); + if (!property.NameEquals(DiscriminatorPropertyNameValue) && !property.NameEquals("$GameKind")) + { + property.WriteTo(writer); + } } writer.WriteEndObject(); return true; diff --git a/JsonSubTypes.Text.Json.Aot.Generated/TestDomain.cs b/JsonSubTypes.Text.Json.Aot.Generated/TestDomain.cs index edb1e6d..b606a8c 100644 --- a/JsonSubTypes.Text.Json.Aot.Generated/TestDomain.cs +++ b/JsonSubTypes.Text.Json.Aot.Generated/TestDomain.cs @@ -35,7 +35,7 @@ public class Owl : Animal [JsonSubTypesAotConverter] [KnownSubTypeWithProperty(typeof(Employee), "JobTitle")] [KnownSubTypeWithProperty(typeof(Artist), "Skill")] - [FallBackSubType(typeof(Person))] + [FallbackSubType(typeof(Person))] public class Person { public string? FirstName { get; set; } diff --git a/JsonSubTypes.Text.Json.Aot.Sample/Program.cs b/JsonSubTypes.Text.Json.Aot.Sample/Program.cs index 7568561..96cab38 100644 --- a/JsonSubTypes.Text.Json.Aot.Sample/Program.cs +++ b/JsonSubTypes.Text.Json.Aot.Sample/Program.cs @@ -96,7 +96,7 @@ public class Dog : Animal [JsonSubTypesAotConverter] [KnownSubTypeWithProperty(typeof(Employee), "JobTitle")] [KnownSubTypeWithProperty(typeof(Artist), "Skill")] -[FallBackSubType(typeof(Person))] +[FallbackSubType(typeof(Person))] public class Person { public string? FirstName { get; set; } diff --git a/JsonSubTypes.Text.Json.Aot.Tests/EngineParityTests.cs b/JsonSubTypes.Text.Json.Aot.Tests/EngineParityTests.cs index dcfefa2..6f2418d 100644 --- a/JsonSubTypes.Text.Json.Aot.Tests/EngineParityTests.cs +++ b/JsonSubTypes.Text.Json.Aot.Tests/EngineParityTests.cs @@ -232,6 +232,20 @@ public void MultiplePropertiesForSameSubtype() Assert.IsInstanceOf(employee); } + [Test] + public void PresenceSerialize_WritesAllPropertiesOnce() + { + Requires(ParityCapabilities.Presence); + var employee = new PEmployee { FirstName = "Ann", JobTitle = "Dev", Department = "R&D" }; + + string json = JsonSerializer.Serialize(employee, CreateOptions()); + + Assert.AreEqual("{\"JobTitle\":\"Dev\",\"Department\":\"R\\u0026D\",\"FirstName\":\"Ann\"}", json); + + var back = JsonSerializer.Deserialize(json, CreateOptions()); + Assert.IsInstanceOf(back); + } + [Test] public void FallbackReadWithParameterizedConstructor() { diff --git a/JsonSubTypes.Text.Json.Aot.Tests/GeneratedConverterAdvancedTests.cs b/JsonSubTypes.Text.Json.Aot.Tests/GeneratedConverterAdvancedTests.cs index ba79777..c03d8d9 100644 --- a/JsonSubTypes.Text.Json.Aot.Tests/GeneratedConverterAdvancedTests.cs +++ b/JsonSubTypes.Text.Json.Aot.Tests/GeneratedConverterAdvancedTests.cs @@ -216,6 +216,44 @@ public void Deserialize_NestedDiscriminator_StillWorksForIntermediate() } } + [TestFixture] + public class GeneratedNestedWithNativeDiscriminatorPropertyTests + { + // A nested hierarchy whose discriminators are also native properties of the type + // ([JsonPropertyName("$PayloadKind")], ...). The write path must not duplicate them: + // the injected discriminator replaces the native property of the same name. + + private static JsonSerializerOptions Options() + { + return new JsonSerializerOptions + { + Converters = + { + JsonSubTypesAotConverters.PPPayload, + JsonSubTypesAotConverters.PPGame + } + }; + } + + [Test] + public void Serialize_WritesEachDiscriminatorOnce() + { + string json = JsonSerializer.Serialize(new PPRun(), Options()); + + Assert.AreEqual("{\"$PayloadKind\":1,\"$GameKind\":0}", json); + } + + [Test] + public void RoundTrip_ReturnsDeepestSubtype() + { + var options = Options(); + string json = JsonSerializer.Serialize(new PPRun(), options); + var back = JsonSerializer.Deserialize(json, options); + + Assert.IsInstanceOf(back); + } + } + // ---- domain types ---- public enum EAnimalKind @@ -562,3 +600,50 @@ public class DNLeaf : DNMid public int Mark { get; set; } } +[JsonSubTypesAotConverter("$PayloadKind")] +[KnownSubType(typeof(PPGame), PayloadDiscriminator.GAME)] +[KnownSubType(typeof(PPCom), PayloadDiscriminator.COM)] +public class PPPayload +{ + [JsonPropertyName("$PayloadKind")] + public PayloadDiscriminator PayloadKind { get; set; } = PayloadDiscriminator.GAME; +} + +[JsonSubTypesAotConverter("$GameKind")] +[KnownSubType(typeof(PPRun), GameDiscriminator.RUN)] +[KnownSubType(typeof(PPWalk), GameDiscriminator.WALK)] +public class PPGame : PPPayload +{ + [JsonPropertyName("$GameKind")] + public GameDiscriminator GameKind { get; set; } = GameDiscriminator.WALK; +} + +public class PPRun : PPGame +{ + public PPRun() + { + PayloadKind = PayloadDiscriminator.GAME; + GameKind = GameDiscriminator.RUN; + } +} + +public class PPWalk : PPGame +{ +} + +public class PPCom : PPPayload +{ +} + +public enum PayloadDiscriminator +{ + COM = 0, + GAME = 1 +} + +public enum GameDiscriminator +{ + RUN = 0, + WALK = 1 +} + diff --git a/JsonSubTypes.Text.Json.Aot/JsonSubTypesGenerator.cs b/JsonSubTypes.Text.Json.Aot/JsonSubTypesGenerator.cs index 8cd45aa..1999d47 100644 --- a/JsonSubTypes.Text.Json.Aot/JsonSubTypesGenerator.cs +++ b/JsonSubTypes.Text.Json.Aot/JsonSubTypesGenerator.cs @@ -15,7 +15,7 @@ public sealed class JsonSubTypesGenerator : IIncrementalGenerator private const string JsonSubTypesAotConverterAttributeName = "JsonSubTypesAotConverterAttribute"; private const string KnownSubTypeAttributeName = "KnownSubTypeAttribute"; private const string KnownSubTypeWithPropertyAttributeName = "KnownSubTypeWithPropertyAttribute"; - private const string FallBackSubTypeAttributeName = "FallBackSubTypeAttribute"; + private const string FallbackSubTypeAttributeName = "FallbackSubTypeAttribute"; private const string SystemTextJsonSerializationNamespace = "System.Text.Json.Serialization"; private const string DiagnosticId = "JSTAOT001"; private const string DuplicateDiscriminatorDiagnosticId = "JSTAOT002"; @@ -228,8 +228,8 @@ private static void ProcessRegistrationAttributes(INamedTypeSymbol baseType, Bas case KnownSubTypeWithPropertyAttributeName: ProcessKnownSubTypeWithProperty(attr, info); break; - case FallBackSubTypeAttributeName: - ProcessFallBackSubType(attr, info); + case FallbackSubTypeAttributeName: + ProcessFallbackSubType(attr, info); break; } } @@ -304,7 +304,7 @@ private static void ProcessKnownSubTypeWithProperty(AttributeData attr, BaseType }); } - private static void ProcessFallBackSubType(AttributeData attr, BaseTypeInfo info) + private static void ProcessFallbackSubType(AttributeData attr, BaseTypeInfo info) { if (attr.ConstructorArguments[0].Value is ITypeSymbol fallback) { @@ -1176,20 +1176,25 @@ private static string EmitNestedCases(BaseTypeInfo info) foreach (NestedChain nested in info.NestedTypes) { List discLines = []; + List discriminatorNames = []; foreach (ChainEntry entry in nested.Chain) { string discriminatorName = entry.DiscriminatorName == info.DiscriminatorPropertyName ? "DiscriminatorPropertyNameValue" : SymbolDisplay.FormatLiteral(entry.DiscriminatorName, quote: true); - discLines.Add($" writer.WritePropertyName({discriminatorName});"); + discLines.Add($" writer.WritePropertyName({discriminatorName});"); discLines.Add($" {EmitDiscriminatorValueStatement(entry.Discriminator)}"); + discriminatorNames.Add(discriminatorName); } string payload = $$""" string payload = JsonSerializer.Serialize(value, options.GetTypeInfo(runtimeType)); using JsonDocument payloadDocument = JsonDocument.Parse(payload); foreach (JsonProperty property in payloadDocument.RootElement.EnumerateObject()) { - property.WriteTo(writer); + if ({{string.Join(" && ", discriminatorNames.Select(n => $"!property.NameEquals({n})"))}}) + { + property.WriteTo(writer); + } } writer.WriteEndObject(); return true; diff --git a/JsonSubTypes.Text.Json.Tests/ConvenienceAttributeTests.cs b/JsonSubTypes.Text.Json.Tests/ConvenienceAttributeTests.cs new file mode 100644 index 0000000..fcbfb01 --- /dev/null +++ b/JsonSubTypes.Text.Json.Tests/ConvenienceAttributeTests.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using JsonSubTypes.Text.Json; +using NUnit.Framework; + +namespace JsonSubTypes.Tests +{ + // The convenience JsonSubTypeConverterAttribute constructors close the generic + // JsonSubtypes converter over the annotated type, so the base type is not + // repeated on the attribute: [JsonSubTypeConverter("Kind")] instead of + // [JsonSubTypeConverter(typeof(JsonSubtypes), "Kind")]. + [TestFixture] + public class ConvenienceAttributeTests + { + [JsonSubTypeConverter("Kind")] + [KnownSubType(typeof(ConvDog), "Dog")] + [KnownSubType(typeof(ConvCat), "Cat")] + public class ConvAnimal + { + public string Kind { get; set; } + public int Age { get; set; } + } + + public class ConvDog : ConvAnimal + { + public string Breed { get; set; } + } + + public class ConvCat : ConvAnimal + { + public bool Declawed { get; set; } + } + + [JsonSubTypeConverter] + [KnownSubTypeWithProperty(typeof(PresEmployee), "JobTitle")] + public class PresencePerson + { + public string FirstName { get; set; } + } + + public class PresEmployee : PresencePerson + { + public string JobTitle { get; set; } + } + + [JsonSubTypeConverter("Type")] + [KnownSubType(typeof(ImplExpression), "impl")] + public interface IExpression + { + string Type { get; } + } + + public class ImplExpression : IExpression + { + public string Type { get; } = "impl"; + public int Value { get; set; } + } + + [Test] + public void ValueBased_WritesDiscriminatorAndRoundTrips() + { + string json = JsonSerializer.Serialize(new ConvDog { Kind = "Dog", Age = 3, Breed = "Rex" }); + + StringAssert.Contains("\"Kind\":\"Dog\"", json); + StringAssert.Contains("\"Breed\":\"Rex\"", json); + Assert.AreEqual(1, System.Text.RegularExpressions.Regex.Matches(json, "\"Kind\"").Count); + + var back = JsonSerializer.Deserialize(json); + Assert.IsInstanceOf(back); + Assert.AreEqual("Rex", (back as ConvDog)?.Breed); + } + + [Test] + public void ValueBased_DiscriminatorNotANativeProperty_IsInjected() + { + string json = JsonSerializer.Serialize(new ConvDog { Age = 3, Breed = "Rex" }); + + StringAssert.Contains("\"Kind\":\"Dog\"", json); + } + + [Test] + public void PropertyPresence_RoundTrips() + { + string json = JsonSerializer.Serialize(new PresEmployee { FirstName = "Ann", JobTitle = "Dev" }); + + using JsonDocument doc = JsonDocument.Parse(json); + Assert.AreEqual(2, doc.RootElement.EnumerateObject().Count()); + CollectionAssert.AreEquivalent( + new[] { "FirstName", "JobTitle" }, doc.RootElement.EnumerateObject().Select(p => p.Name).ToList()); + + var back = JsonSerializer.Deserialize(json); + Assert.IsInstanceOf(back); + Assert.AreEqual("Dev", (back as PresEmployee)?.JobTitle); + } + + [Test] + public void InterfaceBase_RoundTrips() + { + string json = JsonSerializer.Serialize(new ImplExpression { Value = 7 }); + + var back = JsonSerializer.Deserialize(json); + Assert.IsInstanceOf(back); + Assert.AreEqual(7, (back as ImplExpression)?.Value); + } + } +} diff --git a/JsonSubTypes.Text.Json.Tests/DemoAlternativeTypePropertyNameTests.cs b/JsonSubTypes.Text.Json.Tests/DemoAlternativeTypePropertyNameTests.cs index 0bd6913..37d5fcd 100644 --- a/JsonSubTypes.Text.Json.Tests/DemoAlternativeTypePropertyNameTests.cs +++ b/JsonSubTypes.Text.Json.Tests/DemoAlternativeTypePropertyNameTests.cs @@ -173,7 +173,7 @@ namespace FallBackSubType public class DemoAlternativeTypePropertyNameTests { [JsonSubTypeConverter(typeof(JsonSubtypes), "Kind")] - [FallBackSubType(typeof(UnknownAnimal))] + [FallbackSubType(typeof(UnknownAnimal))] public interface IAnimal { string Kind { get; } @@ -225,7 +225,7 @@ public class DemoAlternativeTypePropertyNameTests { [JsonSubTypeConverter(typeof(JsonSubtypes), "Kind")] [KnownSubType(typeof(Dog), null)] - [FallBackSubType(typeof(UnknownAnimal))] + [FallbackSubType(typeof(UnknownAnimal))] public interface IAnimal { string Kind { get; } diff --git a/JsonSubTypes.Text.Json.Tests/DemoKnownSubTypeWithProperties.cs b/JsonSubTypes.Text.Json.Tests/DemoKnownSubTypeWithProperties.cs index 7bf7de3..532ea09 100644 --- a/JsonSubTypes.Text.Json.Tests/DemoKnownSubTypeWithProperties.cs +++ b/JsonSubTypes.Text.Json.Tests/DemoKnownSubTypeWithProperties.cs @@ -80,7 +80,7 @@ public void ThrowIfManyMatches() [JsonSubTypeConverter(typeof(JsonSubtypes))] [KnownSubTypeWithProperty(typeof(ClassC), nameof(ClassC.Other), StopLookupOnMatch = true)] [KnownSubTypeWithProperty(typeof(ClassB), nameof(ClassB.Optional))] - [FallBackSubType(typeof(ClassB))] + [FallbackSubType(typeof(ClassB))] public class ClassA { public string CommonProp { get; set; } diff --git a/JsonSubTypes.Text.Json.Tests/JsonSubtypesResolverNativeOptionsTests.cs b/JsonSubTypes.Text.Json.Tests/JsonSubtypesResolverNativeOptionsTests.cs index 98accc7..3bf41eb 100644 --- a/JsonSubTypes.Text.Json.Tests/JsonSubtypesResolverNativeOptionsTests.cs +++ b/JsonSubTypes.Text.Json.Tests/JsonSubtypesResolverNativeOptionsTests.cs @@ -14,16 +14,16 @@ private static JsonSerializerOptions Options(JsonSubtypesResolver resolver) return new JsonSerializerOptions { TypeInfoResolver = resolver }; } - // ---- FallBackToNearestAncestor ---- + // ---- FallbackToNearestAncestor ---- [Test] - public void FallBackToNearestAncestor_SerializesUnregisteredDerivedAsNearestAncestor() + public void FallbackToNearestAncestor_SerializesUnregisteredDerivedAsNearestAncestor() { var options = Options(JsonSubtypesConverterBuilder.Of("$type") .RegisterSubtype("widget") .RegisterSubtype("round") .SerializeDiscriminatorProperty() - .FallBackToNearestAncestor() + .FallbackToNearestAncestor() .BuildResolver()); string json = JsonSerializer.Serialize(new FancyRoundWidget { Diameter = 4, Finish = "matte" }, options); @@ -32,13 +32,13 @@ public void FallBackToNearestAncestor_SerializesUnregisteredDerivedAsNearestAnce } [Test] - public void FallBackToNearestAncestor_RegisteredTypesAreUnchanged() + public void FallbackToNearestAncestor_RegisteredTypesAreUnchanged() { var options = Options(JsonSubtypesConverterBuilder.Of("$type") .RegisterSubtype("widget") .RegisterSubtype("round") .SerializeDiscriminatorProperty() - .FallBackToNearestAncestor() + .FallbackToNearestAncestor() .BuildResolver()); string json = JsonSerializer.Serialize(new RoundWidget { Diameter = 4 }, options); @@ -47,7 +47,7 @@ public void FallBackToNearestAncestor_RegisteredTypesAreUnchanged() } [Test] - public void WithoutFallBackToNearestAncestor_UnregisteredDerivedThrows() + public void WithoutFallbackToNearestAncestor_UnregisteredDerivedThrows() { var options = Options(JsonSubtypesConverterBuilder.Of("$type") .RegisterSubtype("widget") @@ -60,12 +60,12 @@ public void WithoutFallBackToNearestAncestor_UnregisteredDerivedThrows() } [Test] - public void Build_WithFallBackToNearestAncestor_Throws() + public void Build_WithFallbackToNearestAncestor_Throws() { var builder = JsonSubtypesConverterBuilder.Of("$type") .RegisterSubtype("round") .SerializeDiscriminatorProperty() - .FallBackToNearestAncestor(); + .FallbackToNearestAncestor(); var exception = Assert.Throws(() => builder.Build()); StringAssert.Contains("only supported by BuildResolver", exception?.Message); @@ -177,7 +177,7 @@ public void KnownSubTypeAttributes_WithEnumDiscriminator_Throw() } [Test] - public void FallBackSubTypeAttributeEqualToBase_IsHonored() + public void FallbackSubTypeAttributeEqualToBase_IsHonored() { var options = Options(JsonSubtypesConverterBuilder.Of("$type") .SerializeDiscriminatorProperty() @@ -189,7 +189,7 @@ public void FallBackSubTypeAttributeEqualToBase_IsHonored() } [Test] - public void FallBackSubTypeAttributeNonBase_Throws() + public void FallbackSubTypeAttributeNonBase_Throws() { var builder = JsonSubtypesConverterBuilder.Of("$type") .SerializeDiscriminatorProperty(); @@ -296,7 +296,7 @@ public class AttrCircle : AttrShapeBase } [KnownSubType(typeof(FbCar), "car")] - [FallBackSubType(typeof(FbVehicle))] + [FallbackSubType(typeof(FbVehicle))] public class FbVehicle { public int Wheels { get; set; } @@ -308,7 +308,7 @@ public class FbCar : FbVehicle } [KnownSubType(typeof(CbCar), "car")] - [FallBackSubType(typeof(CbBike))] + [FallbackSubType(typeof(CbBike))] public class CbVehicle { } diff --git a/JsonSubTypes.Text.Json/JsonSubtypes.cs b/JsonSubTypes.Text.Json/JsonSubtypes.cs index ec8d035..c9b79f7 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypes.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypes.cs @@ -20,24 +20,50 @@ namespace JsonSubTypes.Text.Json; AttributeTargets.Property, AllowMultiple = false)] public class JsonSubTypeConverterAttribute : JsonConverterAttribute { + private readonly Type? _converterType; + public string? DiscriminatorPropertyName { get; } - public JsonSubTypeConverterAttribute(Type converterType, string? discriminatorPropertyName) : base(converterType) + public JsonSubTypeConverterAttribute(Type converterType, string? discriminatorPropertyName) : base(IsJsonSubtypes(converterType) ? null! : converterType) + { + _converterType = IsJsonSubtypes(converterType) ? converterType : null; + DiscriminatorPropertyName = discriminatorPropertyName; + } + + public JsonSubTypeConverterAttribute(Type converterType) : base(IsJsonSubtypes(converterType) ? null! : converterType) + { + _converterType = IsJsonSubtypes(converterType) ? converterType : null; + } + + // Convenience constructors: they close the generic JsonSubtypes converter over the + // annotated type (CreateConverter receives it), so the base type is not repeated on the attribute. + public JsonSubTypeConverterAttribute(string? discriminatorPropertyName) : base() { DiscriminatorPropertyName = discriminatorPropertyName; } - public JsonSubTypeConverterAttribute(Type converterType) : base(converterType) + public JsonSubTypeConverterAttribute() : base() + { + } + + private static bool IsJsonSubtypes(Type type) { + return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(JsonSubtypes<>); } [RequiresUnreferencedCode("JsonSubTypes.Text.Json uses reflection to create and invoke subtype converters.")] [RequiresDynamicCode("JsonSubTypes.Text.Json uses reflection to create subtype converters.")] public override JsonConverter CreateConverter(Type typeToConvert) { + Type converterType = _converterType ?? typeof(JsonSubtypes<>); + if (converterType.IsGenericTypeDefinition) + { + converterType = converterType.MakeGenericType(typeToConvert); + } + return DiscriminatorPropertyName == null - ? (JsonConverter)Activator.CreateInstance(ConverterType!)! - : (JsonConverter)Activator.CreateInstance(ConverterType!, DiscriminatorPropertyName)!; + ? (JsonConverter)Activator.CreateInstance(converterType)! + : (JsonConverter)Activator.CreateInstance(converterType, DiscriminatorPropertyName)!; } } @@ -49,7 +75,7 @@ public class KnownSubTypeAttribute(Type subType, object? associatedValue) : Attr } [AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface)] -public class FallBackSubTypeAttribute(Type subType) : Attribute +public class FallbackSubTypeAttribute(Type subType) : Attribute { public Type SubType { get; } = subType; } @@ -428,9 +454,8 @@ private static bool IsDefaultValue(object? value, object? defaultValue) private static readonly ConcurrentDictionary> BaseTypeFactoryCache = new(); - private static T? ReadPlainObject(ref Utf8JsonReader reader, Type targetType, JsonSerializerOptions serializer) + private static T? ReadPlainObject(JsonElement jObject, Type targetType, JsonSerializerOptions serializer) { - JsonDocument jObject = JsonDocument.ParseValue(ref reader); object instance; try { @@ -440,12 +465,12 @@ private static readonly ConcurrentDictionary> catch (MissingMethodException) { throw new JsonException( - $"Could not create an instance of type {targetType.FullName}: a parameterless constructor is required to fall back to the base type. Position: {reader.Position.GetInteger()}."); + $"Could not create an instance of type {targetType.FullName}: a parameterless constructor is required to fall back to the base type."); } Action readerFn = BaseTypeObjectReaderCache.GetOrAdd(targetType, static type => BuildBaseTypeObjectReader(type)); - readerFn(instance, jObject.RootElement, serializer); + readerFn(instance, jObject, serializer); return (T)instance; } @@ -549,8 +574,6 @@ private static IList CreateCompatibleList(Type targetContainerType, Type element private T? ReadObject(ref Utf8JsonReader reader, Type objectType, JsonSerializerOptions serializer) { - Utf8JsonReader readerAtStart = reader; - JsonDocument jObject = JsonDocument.ParseValue(ref reader); Type targetType = GetType(jObject, objectType, serializer); @@ -562,7 +585,7 @@ private static IList CreateCompatibleList(Type targetContainerType, Type element if (targetType == objectType) { - return ReadPlainObject(ref readerAtStart, targetType, serializer); + return ReadPlainObject(jObject.RootElement, targetType, serializer); } return (T?)JsonSerializer.Deserialize(jObject.RootElement, targetType, serializer); @@ -641,14 +664,21 @@ private Type GetType(JsonDocument jObject, Type parentType, JsonSerializerOption JsonSubTypeConverterAttribute? jsonConverterAttribute = ConverterAttributeCache.GetOrAdd(target, static type => GetAttribute(type.GetTypeInfo())); - if (jsonConverterAttribute != null && - jsonConverterAttribute.ConverterType != null && - jsonConverterAttribute.ConverterType.IsGenericType && - jsonConverterAttribute.ConverterType.GenericTypeArguments.Length > 0 && - typeof(T).IsAssignableFrom(jsonConverterAttribute.ConverterType.GenericTypeArguments[0])) + if (jsonConverterAttribute != null) { - return AttributeResolverCache.GetOrAdd((typeof(T), target), - static key => CreateTypeResolver(key.Item2)); + Type? converterType = jsonConverterAttribute.ConverterType ?? typeof(JsonSubtypes<>); + if (converterType.IsGenericTypeDefinition) + { + converterType = converterType.MakeGenericType(target); + } + + if (converterType.IsGenericType && + converterType.GenericTypeArguments.Length > 0 && + typeof(T).IsAssignableFrom(converterType.GenericTypeArguments[0])) + { + return AttributeResolverCache.GetOrAdd((typeof(T), target), + static key => CreateTypeResolver(key.Item2)); + } } return jsonConverterCollection.FirstOrDefault(c => c.CanConvert(target)); @@ -659,7 +689,13 @@ private static IJsonSubtypes CreateTypeResolver(Type targetType) JsonSubTypeConverterAttribute? attribute = ConverterAttributeCache.GetOrAdd(targetType, static type => GetAttribute(type.GetTypeInfo())); - return (IJsonSubtypes)Activator.CreateInstance(attribute!.ConverterType!, + Type converterType = attribute!.ConverterType ?? typeof(JsonSubtypes<>); + if (converterType.IsGenericTypeDefinition) + { + converterType = converterType.MakeGenericType(targetType); + } + + return (IJsonSubtypes)Activator.CreateInstance(converterType, attribute.DiscriminatorPropertyName)!; } @@ -932,7 +968,7 @@ private static NullableDictionary BuildAttributeSubTypeMapping(Typ internal virtual Type? GetFallbackSubType(Type type) { - return _fallbackType ?? GetAttribute(type.GetTypeInfo())?.SubType; + return _fallbackType ?? GetAttribute(type.GetTypeInfo())?.SubType; } private static IEnumerable GetAttributes(TypeInfo typeInfo) where TAttribute : Attribute diff --git a/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs b/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs index b9bd47e..75cbe5d 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs @@ -89,7 +89,7 @@ public JsonSubtypesConverterBuilder IgnoreUnrecognizedTypeDiscriminators() /// System.Text.Json supports it (.NET 8 and later). Only supported by /// ; throws if this is set. /// - public JsonSubtypesConverterBuilder FallBackToNearestAncestor() + public JsonSubtypesConverterBuilder FallbackToNearestAncestor() { _fallBackToNearestAncestor = true; return this; @@ -102,7 +102,7 @@ public JsonConverter Build() if (_ignoreUnrecognizedTypeDiscriminators || _fallBackToNearestAncestor) { throw new NotSupportedException( - "IgnoreUnrecognizedTypeDiscriminators and FallBackToNearestAncestor are only supported by BuildResolver(). Use Build() to obtain the JsonSubtypes converter without these options."); + "IgnoreUnrecognizedTypeDiscriminators and FallbackToNearestAncestor are only supported by BuildResolver(). Use Build() to obtain the JsonSubtypes converter without these options."); } if (_serializeDiscriminatorProperty) @@ -150,13 +150,13 @@ public JsonConverter Build() /// . A fallback to a subtype other than /// the base type is not supported; /// when no subtype is registered explicitly, KnownSubType and - /// FallBackSubType attributes on the base type are honored; + /// FallbackSubType attributes on the base type are honored; /// must have been called and the /// discriminator must be written first (the native resolver always writes the discriminator /// property, always first, and only for runtime types that are registered as subtypes, /// including the base type when it is registered). An unregistered derived type can be /// serialized as its nearest registered ancestor with - /// ; + /// ; /// only a single level of hierarchy is resolved per base type. To handle several base /// type hierarchies, combine builders with . Combining /// resolvers through does not work, @@ -286,11 +286,11 @@ private static NullableDictionary BuildAttributeSubTypeMapping(Typ return dictionary; } - private static FallBackSubTypeAttribute? GetFallbackSubTypeAttribute(Type type) + private static FallbackSubTypeAttribute? GetFallbackSubTypeAttribute(Type type) { foreach (object attribute in type.GetTypeInfo().GetCustomAttributes(false)) { - if (attribute is FallBackSubTypeAttribute fallback) + if (attribute is FallbackSubTypeAttribute fallback) { return fallback; } diff --git a/JsonSubTypes.Text.Json/JsonSubtypesResolver.cs b/JsonSubTypes.Text.Json/JsonSubtypesResolver.cs index 808109d..d044af5 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypesResolver.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypesResolver.cs @@ -35,12 +35,12 @@ namespace JsonSubTypes.Text.Json; /// unregistered runtime type is never silently handled: an unregistered base type is /// serialized without a discriminator, while an unregistered derived type throws /// unless -/// is enabled, in which +/// is enabled, in which /// case it is serialized as its nearest registered ancestor. With discriminator serialization /// enabled, the converter instead throws /// for any runtime type that has no registered mapping; /// only a single level of hierarchy is resolved per base type: intermediate resolvers are -/// not chained. KnownSubType and FallBackSubType attributes are honored when no +/// not chained. KnownSubType and FallbackSubType attributes are honored when no /// subtype is registered explicitly, but JsonSubTypeConverter is not (the resolver must /// be built explicitly); /// is not applied to the diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..9c3f71f --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,127 @@ +# Migration guide + +How to move between the packages and engines. Each section shows the same scenario before and after, and lists what actually changes in behaviour — not what would be nice to change. + +## Newtonsoft.Json → JsonSubTypes.Text.Json (converter) + +The two packages share the same configuration model (attributes and `JsonSubtypesConverterBuilder`), so most code moves over line by line. + +**Before (Newtonsoft.Json):** + +```csharp +[JsonConverter(typeof(JsonSubtypes), "Kind")] +[JsonSubtypes.KnownSubType(typeof(Dog), "Dog")] +[JsonSubtypes.KnownSubType(typeof(Cat), "Cat")] +public class Animal +{ + public virtual string Kind { get; } +} + +// registration / usage +JsonConvert.DeserializeObject("{\"Kind\":\"Dog\",\"Breed\":\"Rex\"}"); +``` + +**After (System.Text.Json):** + +```csharp +[JsonSubTypeConverter("Kind")] +[KnownSubType(typeof(Dog), "Dog")] +[KnownSubType(typeof(Cat), "Cat")] +public class Animal +{ + public virtual string Kind { get; } +} + +// registration / usage: options are required (STJ has no DefaultSettings) +var options = new JsonSerializerOptions(); +options.Converters.Add(JsonSubtypesConverterBuilder.Of("Kind").Build()); + +JsonSerializer.Deserialize("{\"Kind\":\"Dog\",\"Breed\":\"Rex\"}", options); +``` + +Mechanical differences: + +- `[JsonConverter(typeof(JsonSubtypes), "Kind")]` becomes `[JsonSubTypeConverter("Kind")]` — the converter is `JsonSubtypes`, closed over the annotated type. The explicit `[JsonSubTypeConverter(typeof(JsonSubtypes), "Kind")]` form is equivalent. +- `[JsonSubtypes.KnownSubType]` becomes `[KnownSubType]` (import `JsonSubTypes.Text.Json`). +- `JsonConvert.SerializeObject`/`DeserializeObject` become `JsonSerializer.Serialize`/`Deserialize`, and you must pass a `JsonSerializerOptions` (there is no equivalent of `DefaultSettings`). +- The builder (`JsonSubtypesConverterBuilder.Of(...)`, `RegisterSubtype`, `SerializeDiscriminatorProperty`) is the same shape. `JsonSubtypesWithPropertyConverterBuilder` likewise. + +Behaviour that actually differs — check your tests against these: + +- **The attribute-based STJ converter writes the discriminator by default**; the Newtonsoft one never does from attributes (`CanWrite = false`). If you relied on attributes for read-only, the JSON shape changes. +- **The converter applies only when the static type is the base type.** A property declared with a base/interface type serializes with the declared type's contract; subtype members are omitted unless a converter claims the declared type. Newtonsoft serialized the runtime type by default. +- **Property order differs.** STJ emits most-derived-first; there is no `[JsonProperty(Order = N)]` support. +- **`MaxDepth` needs one more level** because the write path round-trips through a `JsonDocument`. +- **Fallback paths are narrower**: serializing the base type directly or an unknown discriminator uses a reflection-based path that honors `[JsonIgnore]`, `[JsonPropertyName]`, naming policy and `DefaultIgnoreCondition`, but not per-property `[JsonConverter]`, `[JsonInclude]` fields, `required` members or parameterized constructors. +- **Cross-assembly subtypes** require opt-in (`JsonSubTypesTypeResolution.AddAssembly`); Newtonsoft never supported them. +- **Security**: the name-based resolution warning in the README applies to both; see the [security section](./#security) there. + +## Between the System.Text.Json engines + +The three engines share the configuration layer (attributes + builder), but they do not support the same feature set. Moving between them is usually a change of *capability*, not just a change of call. + +### Converter (`Build()`) → Resolver (`BuildResolver()`) + +The resolver delegates to `System.Text.Json` native polymorphism. It is faster but refuses configuration it cannot express — at build time (`NotSupportedException`), not silently. Before moving, check that your scenario only uses: + +- string or int discriminator values (no enum, no `null`, no other value types), +- a single level of hierarchy per base type (no nested multi-level chains), +- the discriminator always written first, +- a fallback only to the base type (`SetFallbackSubtype(baseType)` maps to ignore-unrecognized; anything else throws). + +```csharp +// before +var options = new JsonSerializerOptions(); +options.Converters.Add(JsonSubtypesConverterBuilder + .Of("Kind").RegisterSubtype("Dog").Build()); + +// after +var options = new JsonSerializerOptions +{ + TypeInfoResolver = JsonSubtypesConverterBuilder + .Of("Kind").RegisterSubtype("Dog").BuildResolver() +}; +``` + +Property-presence matching, dotted discriminator paths, enum/`null` discriminators and multi-level hierarchies are not supported by the resolver — keep the converter for those. + +### Converter (`Build()`) → Generator (`JsonSubTypes.Text.Json.Aot`) + +The generator reads its registrations from attributes at compile time and can only route types visible to the compilation. Move to it when the hierarchy is fixed and known at build time (or you publish with Native AOT / trimming), and reference **both** packages: + +```bash +dotnet add package JsonSubTypes.Text.Json.Aot +dotnet add package JsonSubTypes.Text.Json +``` + +```csharp +// before (builder) +var options = new JsonSerializerOptions(); +options.Converters.Add(JsonSubtypesConverterBuilder + .Of("Kind").RegisterSubtype("Dog").Build()); + +// after (attributes + generated converter) +[JsonSubTypesAotConverter("Kind")] +[KnownSubType(typeof(Dog), "Dog")] +public class Animal { } + +var options = new JsonSerializerOptions +{ + TypeInfoResolver = MyContext.Default, + Converters = { JsonSubTypesAotConverters.Animal } +}; +[JsonSerializable(typeof(Animal))] +[JsonSerializable(typeof(Dog))] +public partial class MyContext : JsonSerializerContext { } +``` + +If you do not own the types (plugins, third-party assemblies) or the subtypes are only known at runtime, the generator cannot see them — keep the converter (or use `RegisterDynamicSubtype` where supported). + +### Resolver → Generator + +Both are compile-time friendly, but the generator supports strictly more features (enums, `null`, property presence, fallback subtypes, discriminator written last, nested hierarchies). If you outgrow the resolver's subset, move to the generator rather than back to the converter — the configuration layer (attributes) is the same, so the change is mostly the call site. + +## What is not covered by this guide + +- `System.Text.Json` native polymorphism (`[JsonDerivedType]` + `[JsonPolymorphic]`) — that is a different API entirely, covered in the README's [System.Text.Json variant](./#systemtextjson-variant) section. +- Serialization of dates, GUIDs, number formats and other STJ/Newtonsoft type-level differences that are unrelated to polymorphism. diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 53994f3..8236e3d 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -22,12 +22,16 @@ The command runs every benchmark twice: once under the JIT (`DefaultJob`) and on Each scenario is a micro-benchmark of serializing/deserializing a small object graph, declared as its polymorphic base type. The numbers below are **mean** values from a single representative run, with allocations per operation. -The scenarios: +Each benchmark class uses a scenario prefix on its method names, so the result rows are unambiguous when the whole suite runs: -- **Single object**: a `Cat` declared as its `Animal` base (two `int` properties). -- **Collection**: a list of four mixed animals (`Cat`/`Dog`), the common API payload shape. -- **Nested hierarchy**: a two-level hierarchy (`Payload → Game → Run`), discriminated by two properties. -- **Property presence**: discrimination by property presence (`KnownSubTypeWithProperty`) instead of a discriminator value. +- **`Single_`** (`PolymorphismBenchmarks`): a `Cat` declared as its `Animal` base (two `int` properties). +- **`Col_`** (`CollectionBenchmarks`): a list of four mixed animals (`Cat`/`Dog`), the common API payload shape. +- **`Nested_`** (`NestedHierarchyBenchmarks`): a two-level hierarchy (`Payload → Game → Run`), discriminated by two properties. +- **`Pres_`** (`PropertyPresenceBenchmarks`): discrimination by property presence (`KnownSubTypeWithProperty`) instead of a discriminator value. +- **`Leaf_`** (`BaseAsLeafBenchmarks`): serializing/deserializing the polymorphic base type itself, exercising the converter's reflection-based fallback path. +- **`Nw_`** (`NewtonsoftBenchmarks`): the Newtonsoft.Json package through `JsonConvert`. + +Filter a scenario with the class name: `dotnet run -c Release --project JsonSubTypes.Benchmarks --filter '*CollectionBenchmarks*'`. ## Machine @@ -73,7 +77,7 @@ The generated engine is the only one compatible with Native AOT. The native buil | Benchmark | JIT | Native AOT | | :--- | ---: | ---: | -| Generated_Serialize (single) | 1.18 µs / 656 B | 1.43 µs / 640 B | +| Single_Generated_Serialize | 1.18 µs / 656 B | 1.43 µs / 640 B | | Generated_Deserialize (single) | 0.98 µs / 152 B | 1.29 µs / 152 B | ## Newtonsoft.Json comparison diff --git a/README.md b/README.md index 39f6f9a..a7f63b6 100644 --- a/README.md +++ b/README.md @@ -237,16 +237,18 @@ settings.Converters.Add(JsonSubtypesWithPropertyConverterBuilder ## System.Text.Json variant -> **Status: experimental.** The `JsonSubTypes.Text.Json` package is a **release candidate** (`1.0.0-rc.x`) and not yet part of the project's stable offering. The code is fully tested (133 unit tests) and the API is complete, but the stable `1.0.0` release will follow once the package has been exercised in more real-world projects. +> **Status: experimental.** The `JsonSubTypes.Text.Json` package is a **release candidate** (`1.0.0-rc.x`) and not yet part of the project's stable offering. The code is fully tested (200 unit tests) and the API is complete, but the stable `1.0.0` release will follow once the package has been exercised in more real-world projects. A variant of the library for `System.Text.Json` (.NET 8+) is available in the `JsonSubTypes.Text.Json` namespace and package. It supports the same attribute-driven and builder-driven API, adapted to `System.Text.Json` idioms. +If you are migrating an existing Newtonsoft.Json code base, or deciding between the engines, see [MIGRATION.md](MIGRATION). + ### Attribute based discriminator ```csharp using JsonSubTypes.Text.Json; -[JsonSubTypeConverter(typeof(JsonSubtypes), "Sound")] +[JsonSubTypeConverter(nameof(Animal.Sound))] [KnownSubType(typeof(Dog), "Bark")] [KnownSubType(typeof(Cat), "Meow")] public class Animal @@ -268,6 +270,10 @@ public class Cat : Animal } ``` +The converter is `JsonSubtypes`, closed over the annotated type — so the attribute does not repeat the base type. The explicit `[JsonSubTypeConverter(typeof(JsonSubtypes), "Sound")]` form is equivalent and still supported (needed only when the converter is not `JsonSubtypes`). + +N.B. The discriminator is usually a property of the class (use `nameof(...)` so it stays in sync); if it is not, the converter writes it as an injected field instead — a string literal such as `[JsonSubTypeConverter("type")]`. + ```csharp var animal = JsonSerializer.Deserialize("{\"Sound\":\"Bark\",\"Breed\":\"Jack Russell Terrier\"}"); Assert.AreEqual("Jack Russell Terrier", (animal as Dog)?.Breed); @@ -316,9 +322,9 @@ var options = new JsonSerializerOptions The resolver delegates all serialization work to `System.Text.Json`, so it only supports a subset of the converter configuration and throws at build time otherwise: `string` or `int` discriminator values, a single level of hierarchy per base type, and the discriminator always written first. The following native behaviors are exposed as opt-in builder methods: -- `FallBackToNearestAncestor()`: an unregistered derived type is serialized as its nearest registered ancestor instead of throwing. +- `FallbackToNearestAncestor()`: an unregistered derived type is serialized as its nearest registered ancestor instead of throwing. - `IgnoreUnrecognizedTypeDiscriminators()`: an unknown type discriminator falls back to the base type instead of throwing. `SetFallbackSubtype(baseType)` enables the same behavior. -- When no subtype is registered explicitly, `[KnownSubType]` and `[FallBackSubType]` attributes on the base type are honored. +- When no subtype is registered explicitly, `[KnownSubType]` and `[FallbackSubType]` attributes on the base type are honored. For several base type hierarchies, combine builders with `JsonSubtypesConverterBuilder.BuildResolvers(...)`. Combining resolvers through `JsonSerializerOptions.TypeInfoResolverChain` does not work, because each resolver answers for every type and only the first one would be applied. @@ -355,7 +361,7 @@ public class Person { } ```csharp [JsonSubTypeConverter(typeof(JsonSubtypes), "Type")] [KnownSubType(typeof(ConstantExpression), "Constant")] -[FallBackSubType(typeof(UnknownExpression))] +[FallbackSubType(typeof(UnknownExpression))] public interface IExpression { } ``` @@ -390,7 +396,7 @@ Only types assignable from the polymorphic base type can be resolved, but any su | Enum / `null` discriminator values | ❌ | ❌ | ✅ | ✅ | | Custom discriminator property name | ✅ | ✅ | ✅ | ✅ | | Property presence matching (`KnownSubTypeWithProperty`) | ❌ | ❌ | ✅ | ✅ | -| Fallback subtype (`FallBackSubType`) | ❌ | base only | ✅ | ✅ | +| Fallback subtype (`FallbackSubType`) | ❌ | base only | ✅ | ✅ | | Discriminator written last | ❌ | ❌ | ✅ | ✅ | | Naming policy / case-insensitive on the discriminator name | ❌ | ⚠️ | ✅ | ✅ | | Dotted / nested discriminator path (`"nested.type"`) | ❌ | ❌ | ✅ | ✅ |