From 1eca2a9a0fb177ba159ac8de7cbc7b3107b046e1 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 10:21:06 +0200 Subject: [PATCH 1/8] Add a migration guide (Newtonsoft -> STJ and between STJ engines) MIGRATION.md is an actionable before/after recipe: the mechanical renames, the behaviors that actually differ between the Newtonsoft and STJ packages, and the capability ceilings when moving between the converter, resolver and generator. Also fix the outdated test count in the STJ status note and link the guide from the README. --- MIGRATION.md | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 4 +- 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 MIGRATION.md diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..3b5b336 --- /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(typeof(JsonSubtypes), "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(typeof(JsonSubtypes), "Kind")]` — the STJ converter is generic over the base type. +- `[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/README.md b/README.md index 39f6f9a..b3539a8 100644 --- a/README.md +++ b/README.md @@ -237,10 +237,12 @@ 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 (196 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 From c3f526bfe102410d5f32c836e5b7bdbaaad7c46a Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 10:27:40 +0200 Subject: [PATCH 2/8] Rename FallBackSubTypeAttribute and FallBackToNearestAncestor in JsonSubTypes.Text.Json The STJ package is still a release candidate, so now is the time to fix the capitalization inherited from the Newtonsoft API: FallBackSubTypeAttribute -> FallbackSubTypeAttribute and FallBackToNearestAncestor() -> FallbackToNearestAncestor(). The Newtonsoft package keeps its historical names. The generator matches attributes by namespace + short name, so it now looks up FallbackSubTypeAttribute; the native JsonUnknownDerivedTypeHandling enum member is untouched. All STJ (196), AOT (79) and Newtonsoft (153) tests pass. --- CHANGELOG.md | 4 ++++ .../TestDomain.cs | 2 +- JsonSubTypes.Text.Json.Aot.Sample/Program.cs | 2 +- .../JsonSubTypesGenerator.cs | 8 +++---- .../DemoAlternativeTypePropertyNameTests.cs | 4 ++-- .../DemoKnownSubTypeWithProperties.cs | 2 +- .../JsonSubtypesResolverNativeOptionsTests.cs | 24 +++++++++---------- JsonSubTypes.Text.Json/JsonSubtypes.cs | 4 ++-- .../JsonSubtypesConverterBuilder.cs | 12 +++++----- .../JsonSubtypesResolver.cs | 4 ++-- README.md | 8 +++---- 11 files changed, 39 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30ee87c..878ca6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### JsonSubTypes.Text.Json +#### 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. + ### 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.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/JsonSubTypesGenerator.cs b/JsonSubTypes.Text.Json.Aot/JsonSubTypesGenerator.cs index 8cd45aa..2550ab8 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) { 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..cfc3fa9 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypes.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypes.cs @@ -49,7 +49,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; } @@ -932,7 +932,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/README.md b/README.md index b3539a8..cc0f004 100644 --- a/README.md +++ b/README.md @@ -318,9 +318,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. @@ -357,7 +357,7 @@ public class Person { } ```csharp [JsonSubTypeConverter(typeof(JsonSubtypes), "Type")] [KnownSubType(typeof(ConstantExpression), "Constant")] -[FallBackSubType(typeof(UnknownExpression))] +[FallbackSubType(typeof(UnknownExpression))] public interface IExpression { } ``` @@ -392,7 +392,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"`) | ❌ | ❌ | ✅ | ✅ | From 8cd067eb03e59f6ed7c9b3136e92b2976a0740b5 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 11:33:31 +0200 Subject: [PATCH 3/8] Fix duplicate discriminators in the AOT generator's nested-chain write path When a nested hierarchy's discriminators are also native properties of the type (e.g. [JsonPropertyName("$PayloadKind")]), the generated nested-chain writer emitted every payload property without excluding the discriminator names, so the injected discriminators were written twice. The runtime converter already excluded them; the generator now skips any property whose name matches a discriminator in the chain. Adds a dedicated test fixture pinning the single-write and the round-trip to the deepest subtype. --- .../GeneratedConverterAdvancedTests.cs | 85 +++++++++++++++++++ .../JsonSubTypesGenerator.cs | 9 +- 2 files changed, 92 insertions(+), 2 deletions(-) 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 2550ab8..1999d47 100644 --- a/JsonSubTypes.Text.Json.Aot/JsonSubTypesGenerator.cs +++ b/JsonSubTypes.Text.Json.Aot/JsonSubTypesGenerator.cs @@ -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; From 364858a7db289f02245bc87dba79170ac5c9cf3f Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 11:33:46 +0200 Subject: [PATCH 4/8] Prefix every benchmark method with its scenario for unambiguous results Converter_Serialize/Generated_Deserialize etc. collided across the single-object and base-as-leaf classes, so a full-suite run produced ambiguous rows. Each class now uses a unique scenario prefix (Single_, Col_, Nested_, Pres_, Leaf_, Nw_) and PERFORMANCE.md documents the mapping and how to filter a scenario. --- JsonSubTypes.Benchmarks/BaseAsLeafBenchmarks.cs | 8 ++++---- .../NestedHierarchyBenchmarks.cs | 6 +++++- JsonSubTypes.Benchmarks/NewtonsoftBenchmarks.cs | 8 ++++---- JsonSubTypes.Benchmarks/Program.cs | 12 ++++++------ PERFORMANCE.md | 16 ++++++++++------ 5 files changed, 29 insertions(+), 21 deletions(-) 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/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 From 27de4d8ea4dcc470f14b73d339988f0ec76e8464 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 11:52:07 +0200 Subject: [PATCH 5/8] Eliminate the second JsonDocument parse on the base-as-leaf read path ReadObject parsed the JSON once to resolve the type, then ReadPlainObject parsed it again from the reader to materialize the base object. Reuse the already-parsed RootElement instead, matching how the subtype path deserializes. Measured (BenchmarkDotNet, net10, DefaultJob): Leaf_Converter_Deserialize 1.58us / 560 B before, 1.16us / 360 B after. All STJ and AOT tests pass. --- JsonSubTypes.Text.Json/JsonSubtypes.cs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/JsonSubTypes.Text.Json/JsonSubtypes.cs b/JsonSubTypes.Text.Json/JsonSubtypes.cs index cfc3fa9..15343e4 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypes.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypes.cs @@ -428,9 +428,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 +439,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 +548,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 +559,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); From 9fa4963e22166c5ac3c9f60983d67ef4de5f74cf Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sun, 16 Aug 2026 23:07:53 +0200 Subject: [PATCH 6/8] Regenerate the golden master for the nested-chain write-path change The nested-chain writer now skips properties whose name matches a discriminator in the chain; the committed PayloadJsonSubTypesConverter still carried the old output without the guard, failing the golden-master test. --- .../PayloadJsonSubTypesConverter.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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; From 8227c749a39259be80c5bd6f1ee421761b781174 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sun, 16 Aug 2026 23:08:16 +0200 Subject: [PATCH 7/8] Close the generic converter over the annotated type and write the attribute-based discriminator The JsonSubTypeConverterAttribute kept passing a closed converter type to JsonConverterAttribute, so System.Text.Json built the converter through its parameterless constructor and the CreateConverter override was never called. The attribute now leaves ConverterType null for the JsonSubtypes forms and routes through CreateConverter, which closes the generic over the annotated type (new [JsonSubTypeConverter("Kind")] convenience constructors) and passes the discriminator to the converter. As a result the attribute-based write path now injects the discriminator for registered subtypes, matching the behaviour the README and MIGRATION.md already documented. GetTypeResolver and CreateTypeResolver close JsonSubtypes<> over the target type when the attribute carries no converter type, so the resolver dance keeps working for nested hierarchies. --- CHANGELOG.md | 4 + .../ConvenienceAttributeTests.cs | 100 ++++++++++++++++++ JsonSubTypes.Text.Json/JsonSubtypes.cs | 63 ++++++++--- MIGRATION.md | 4 +- README.md | 8 +- 5 files changed, 163 insertions(+), 16 deletions(-) create mode 100644 JsonSubTypes.Text.Json.Tests/ConvenienceAttributeTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 878ca6e..b6ea8a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,12 @@ 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 diff --git a/JsonSubTypes.Text.Json.Tests/ConvenienceAttributeTests.cs b/JsonSubTypes.Text.Json.Tests/ConvenienceAttributeTests.cs new file mode 100644 index 0000000..20c1dc5 --- /dev/null +++ b/JsonSubTypes.Text.Json.Tests/ConvenienceAttributeTests.cs @@ -0,0 +1,100 @@ +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" }); + + 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/JsonSubtypes.cs b/JsonSubTypes.Text.Json/JsonSubtypes.cs index 15343e4..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(converterType) + 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() : 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)!; } } @@ -638,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)); @@ -656,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)!; } diff --git a/MIGRATION.md b/MIGRATION.md index 3b5b336..9c3f71f 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -24,7 +24,7 @@ JsonConvert.DeserializeObject("{\"Kind\":\"Dog\",\"Breed\":\"Rex\"}"); **After (System.Text.Json):** ```csharp -[JsonSubTypeConverter(typeof(JsonSubtypes), "Kind")] +[JsonSubTypeConverter("Kind")] [KnownSubType(typeof(Dog), "Dog")] [KnownSubType(typeof(Cat), "Cat")] public class Animal @@ -41,7 +41,7 @@ JsonSerializer.Deserialize("{\"Kind\":\"Dog\",\"Breed\":\"Rex\"}", optio Mechanical differences: -- `[JsonConverter(typeof(JsonSubtypes), "Kind")]` becomes `[JsonSubTypeConverter(typeof(JsonSubtypes), "Kind")]` — the STJ converter is generic over the base type. +- `[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. diff --git a/README.md b/README.md index cc0f004..a7f63b6 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ 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 (196 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. @@ -248,7 +248,7 @@ If you are migrating an existing Newtonsoft.Json code base, or deciding between ```csharp using JsonSubTypes.Text.Json; -[JsonSubTypeConverter(typeof(JsonSubtypes), "Sound")] +[JsonSubTypeConverter(nameof(Animal.Sound))] [KnownSubType(typeof(Dog), "Bark")] [KnownSubType(typeof(Cat), "Meow")] public class Animal @@ -270,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); From 1884389fa8dd559af9d4f49499c66685f2d1a5c2 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sun, 16 Aug 2026 23:21:11 +0200 Subject: [PATCH 8/8] Pin the property-presence serialization guarantee across engines Property-presence has no discriminator to inject, so serialization writes the plain runtime contract. Add a parity test pinning that the runtime converter and the generated converter emit the same output for a property-presence subtype (all properties once, nothing injected), and strengthen the attribute-based runtime test to assert the exact property set so migrating never loses a property nor duplicates the discriminator. --- .../EngineParityTests.cs | 14 ++++++++++++++ .../ConvenienceAttributeTests.cs | 7 +++++++ 2 files changed, 21 insertions(+) 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.Tests/ConvenienceAttributeTests.cs b/JsonSubTypes.Text.Json.Tests/ConvenienceAttributeTests.cs index 20c1dc5..fcbfb01 100644 --- a/JsonSubTypes.Text.Json.Tests/ConvenienceAttributeTests.cs +++ b/JsonSubTypes.Text.Json.Tests/ConvenienceAttributeTests.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using System.Linq; using System.Text.Json; using JsonSubTypes.Text.Json; using NUnit.Framework; @@ -82,6 +84,11 @@ 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);