diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d430df..2f174dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### JsonSubTypes.Text.Json +#### Added +- Runtime plugin registration: subtypes in another assembly loaded at runtime can declare themselves with `[KnownSubTypeOf]` / `[KnownSubTypeWithPropertyOf]`, and the host registers the assembly with `JsonSubtypesConverterBuilder.RegisterSubtypeAssembly`. `JsonSubtypes.RegisterDynamicSubtype` registers a single subtype directly after the converter is built. #### Changed - Replaced the global `JsonSubTypesTypeResolution.AddAssembly` registry with a declarative `[KnownSubTypeOtherAssembly("AssemblyName")]` attribute on the base type. Resolution is now per-type instead of process-wide, so it no longer leaks across serialization profiles. The attribute takes an assembly name, keeping the base type free of a compile-time reference to the plugin. - Renamed `FallBackSubTypeAttribute` to `FallbackSubTypeAttribute` and `FallBackToNearestAncestor()` to `FallbackToNearestAncestor()` for consistent capitalization. The `FallBack*` names still work in `JsonSubTypes` (Newtonsoft), which keeps its historical API. 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/GoldenMaster/JsonSubTypes.Text.Json.Aot/JsonSubTypes.Text.Json.Aot.JsonSubTypesGenerator/PersonJsonSubTypesConverter.cs b/JsonSubTypes.Text.Json.Aot.Generated/GoldenMaster/JsonSubTypes.Text.Json.Aot/JsonSubTypes.Text.Json.Aot.JsonSubTypesGenerator/PersonJsonSubTypesConverter.cs index 40b1f1b..b06d791 100644 --- a/JsonSubTypes.Text.Json.Aot.Generated/GoldenMaster/JsonSubTypes.Text.Json.Aot/JsonSubTypes.Text.Json.Aot.JsonSubTypesGenerator/PersonJsonSubTypesConverter.cs +++ b/JsonSubTypes.Text.Json.Aot.Generated/GoldenMaster/JsonSubTypes.Text.Json.Aot/JsonSubTypes.Text.Json.Aot.JsonSubTypesGenerator/PersonJsonSubTypesConverter.cs @@ -12,11 +12,11 @@ public sealed class PersonJsonSubTypesConverter : JsonSubTypesAotConverterBase matches = new System.Collections.Generic.HashSet(); - if (root.TryGetProperty("JobTitle", out _)) + if (TryGetProperty(root, "JobTitle", options, out _)) { matches.Add(typeof(global::JsonSubTypes.Text.Json.Aot.Generated.TestDomain.Employee)); } - if (root.TryGetProperty("Skill", out _)) + if (TryGetProperty(root, "Skill", options, out _)) { matches.Add(typeof(global::JsonSubTypes.Text.Json.Aot.Generated.TestDomain.Artist)); } diff --git a/JsonSubTypes.Text.Json.Aot.Generator.Tests/GeneratorDriverTests.cs b/JsonSubTypes.Text.Json.Aot.Generator.Tests/GeneratorDriverTests.cs index bc64956..f29c75e 100644 --- a/JsonSubTypes.Text.Json.Aot.Generator.Tests/GeneratorDriverTests.cs +++ b/JsonSubTypes.Text.Json.Aot.Generator.Tests/GeneratorDriverTests.cs @@ -88,8 +88,8 @@ public class Artist : Person { public string? Skill { get; set; } } string? text = GeneratorDriverRunner.GetGeneratedSource(run, "PersonJsonSubTypesConverter.g.cs"); Assert.That(text, Is.Not.Null); - StringAssert.Contains("TryGetProperty(\"JobTitle\"", text!); - StringAssert.Contains("TryGetProperty(\"Skill\"", text!); + StringAssert.Contains("TryGetProperty(root, \"JobTitle\", options", text!); + StringAssert.Contains("TryGetProperty(root, \"Skill\", options", text!); } [Test] diff --git a/JsonSubTypes.Text.Json.Aot.Generator.Tests/GoldenMasterTests.cs b/JsonSubTypes.Text.Json.Aot.Generator.Tests/GoldenMasterTests.cs index 8a17635..4336bd6 100644 --- a/JsonSubTypes.Text.Json.Aot.Generator.Tests/GoldenMasterTests.cs +++ b/JsonSubTypes.Text.Json.Aot.Generator.Tests/GoldenMasterTests.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.Linq; +using Microsoft.CodeAnalysis; using NUnit.Framework; namespace JsonSubTypes.Text.Json.Aot.Generator.Tests @@ -60,7 +61,7 @@ public void GeneratedFiles_MatchCurrentGeneratorOutput() string producedText = NormalizeGeneratedCode(produced[hintName]); Assert.That(producedText, Is.EqualTo(committedText), "Generator output differs from committed " + committedName + - ".\nRegenerate with: dotnet build JsonSubTypes.Text.Json.Aot.Generated -p:EmitCompilerGeneratedFiles=true"); + ".\nRegenerate with: dotnet test --filter RegenerateGoldenMaster"); } // No extra files produced that are not committed. @@ -72,6 +73,37 @@ public void GeneratedFiles_MatchCurrentGeneratorOutput() } } + // Rewrites the golden-master files from the generator's current output. Run explicitly + // after a deliberate generator change: dotnet test --filter RegenerateGoldenMaster + [Test] + [Explicit] + public void RegenerateGoldenMaster() + { + string root = FindRepoRoot(); + string domain = File.ReadAllText(Path.Combine(root, DomainPath)); + // Committed files live under the generator's nested hint path (same shape the + // EmitCompilerGeneratedFiles build produces), minus the .g.cs suffix. + string generatedDir = Path.Combine(root, GeneratedDir, + "JsonSubTypes.Text.Json.Aot", "JsonSubTypes.Text.Json.Aot.JsonSubTypesGenerator"); + + GeneratorRun run = GeneratorDriverRunner.GetRun(domain); + var produced = run.DriverResults.Results + .SelectMany(r => r.GeneratedSources); + + foreach (GeneratedSourceResult source in produced) + { + string committedName = source.HintName.EndsWith(".g.cs") + ? source.HintName.Replace(".g.cs", ".cs") + : source.HintName; + string targetPath = Path.Combine(generatedDir, committedName); + // UTF-8 without BOM, and without the generated-code markers: the committed files + // drop those (so Sonar analyzes them), and NormalizeGeneratedCode ignores them. + string text = NormalizeGeneratedCode(source.SourceText.ToString()); + File.WriteAllText(targetPath, text, new System.Text.UTF8Encoding(false)); + TestContext.WriteLine("Wrote " + targetPath); + } + } + // Compare the generated text without the "generated code" markers, so a change // in the marker lines alone does not break the golden master. private static string NormalizeGeneratedCode(string text) diff --git a/JsonSubTypes.Text.Json.Aot.Tests/GeneratedConverterAdvancedTests.cs b/JsonSubTypes.Text.Json.Aot.Tests/GeneratedConverterAdvancedTests.cs index c03d8d9..bd7b927 100644 --- a/JsonSubTypes.Text.Json.Aot.Tests/GeneratedConverterAdvancedTests.cs +++ b/JsonSubTypes.Text.Json.Aot.Tests/GeneratedConverterAdvancedTests.cs @@ -256,6 +256,44 @@ public void RoundTrip_ReturnsDeepestSubtype() // ---- domain types ---- + [TestFixture] + public class GeneratedPresenceModeOptionsTests + { + private static JsonSerializerOptions Options(bool caseInsensitive, JsonNamingPolicy? namingPolicy) + { + return new JsonSerializerOptions + { + PropertyNameCaseInsensitive = caseInsensitive, + PropertyNamingPolicy = namingPolicy, + Converters = { JsonSubTypesAotConverters.MultiPropBase } + }; + } + + [Test] + public void PresenceMatching_HonorsCaseInsensitive() + { + var result = JsonSerializer.Deserialize("{\"jobtitle\":\"Dev\",\"FirstName\":\"A\"}", Options(caseInsensitive: true, namingPolicy: null)); + + Assert.IsInstanceOf(result); + } + + [Test] + public void PresenceMatching_HonorsNamingPolicy() + { + var result = JsonSerializer.Deserialize("{\"jobTitle\":\"Dev\",\"firstName\":\"A\"}", Options(caseInsensitive: false, namingPolicy: JsonNamingPolicy.CamelCase)); + + Assert.IsInstanceOf(result); + } + + [Test] + public void PresenceMatching_ExactName_WithoutCaseInsensitiveOrPolicy() + { + var result = JsonSerializer.Deserialize("{\"JobTitle\":\"Dev\",\"FirstName\":\"A\"}", Options(caseInsensitive: false, namingPolicy: null)); + + Assert.IsInstanceOf(result); + } + } + public enum EAnimalKind { Cat, diff --git a/JsonSubTypes.Text.Json.Aot/JsonSubTypesGenerator.cs b/JsonSubTypes.Text.Json.Aot/JsonSubTypesGenerator.cs index 1999d47..5579bb7 100644 --- a/JsonSubTypes.Text.Json.Aot/JsonSubTypesGenerator.cs +++ b/JsonSubTypes.Text.Json.Aot/JsonSubTypesGenerator.cs @@ -1111,11 +1111,11 @@ private static string EmitPresenceModeSelectType(BaseTypeInfo info) foreach (PropertyPresenceRegistration reg in info.PropertyPresences) { checks.Add(reg.StopLookupOnMatch - ? " if (root.TryGetProperty(" + SymbolDisplay.FormatLiteral(reg.PropertyName, quote: true) + ", out _))\n" + + ? " if (TryGetProperty(root, " + SymbolDisplay.FormatLiteral(reg.PropertyName, quote: true) + ", options, out _))\n" + MemberOpenBrace + " return typeof(" + reg.FullyQualifiedName + ");\n" + MemberCloseBrace - : " if (root.TryGetProperty(" + SymbolDisplay.FormatLiteral(reg.PropertyName, quote: true) + ", out _))\n" + + : " if (TryGetProperty(root, " + SymbolDisplay.FormatLiteral(reg.PropertyName, quote: true) + ", options, out _))\n" + MemberOpenBrace + " matches.Add(typeof(" + reg.FullyQualifiedName + "));\n" + MemberCloseBrace); diff --git a/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs b/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs index 79ee8b0..4e696d1 100644 --- a/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs +++ b/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs @@ -287,6 +287,26 @@ public void DynamicSubtypeRegisteredAtRuntime() Assert.IsTrue((dog as SelfDeclaredDog)?.CanBark == true); } + [Test] + public void DynamicSubtypeWritesDiscriminatorAtRuntime() + { + // Serialization counterpart: the reverse map (_runtimeTypeToDiscriminator) must write + // the discriminator for a subtype registered after the converter was built. + var converter = (JsonSubtypes)JsonSubtypesConverterBuilder + .Of("Kind") + .SerializeDiscriminatorProperty() + .Build(); + converter.RegisterDynamicSubtype("dog", typeof(SelfDeclaredDog)); + + var options = new JsonSerializerOptions(); + options.Converters.Add(converter); + + var json = JsonSerializer.Serialize(new SelfDeclaredDog { CanBark = true }, options); + + StringAssert.Contains("\"Kind\":\"dog\"", json); + StringAssert.Contains("\"CanBark\":true", json); + } + [Test] public void RegisterDynamicSubtypeRejectsMixedDiscriminatorTypes() { diff --git a/MIGRATION.md b/MIGRATION.md index da5eb56..0c69039 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -51,7 +51,7 @@ 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`. +- **`MaxDepth` needs one more level with the generator**: its write path round-trips through a `JsonDocument`. The converter's write path is streamed and does not. - **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: `[KnownSubTypeOtherAssembly("AssemblyName")]` on the base type; Newtonsoft never supported them. - **Security**: the name-based resolution warning in the README applies to both; see the [security section](./#security) there. @@ -115,7 +115,7 @@ var options = new JsonSerializerOptions 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). +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. Register the plugin's assembly at runtime with `RegisterSubtypeAssembly(assembly)` and declare the subtypes with `[KnownSubTypeOf(typeof(Base), "value")]` (see the README's plugin section), or register single subtypes directly with `RegisterDynamicSubtype("value", typeof(Sub))`. ### Resolver → Generator diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 8236e3d..2bbecd0 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -22,6 +22,8 @@ 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 converter numbers reflect the streamed write path (`Utf8JsonReader`): the write side no longer materializes a `JsonDocument`, which is why the converter's serialization allocations are lower than in earlier revisions. The generator numbers still reflect its `JsonDocument` round-trip (see the README). + Each benchmark class uses a scenario prefix on its method names, so the result rows are unambiguous when the whole suite runs: - **`Single_`** (`PolymorphismBenchmarks`): a `Cat` declared as its `Animal` base (two `int` properties). @@ -39,7 +41,7 @@ The numbers were measured on the machine BenchmarkDotNet reported in that run: - **CPU**: Intel Core i7-4790 @ 3.60 GHz (Haswell), 8 logical / 4 physical cores - **OS**: Linux (Manjaro) -- **Runtime**: .NET 10.0.9 +- **Runtime**: .NET 10.0.10 - **BenchmarkDotNet**: 0.15.8 Numbers vary across machines and runs; treat them as a relative ordering, not as absolute figures for your hardware. @@ -50,24 +52,24 @@ Numbers vary across machines and runs; treat them as a relative ordering, not as | Benchmark | Converter (`Build()`) | Resolver (`BuildResolver()`) | Generator (`JsonSubTypes.Text.Json.Aot`) | | :--- | ---: | ---: | ---: | -| Serialize | 1.14 µs / 856 B | 0.33 µs / 400 B | 1.18 µs / 656 B | -| Deserialize | 1.50 µs / 648 B | 0.43 µs / 56 B | 0.98 µs / 152 B | +| Serialize | 1.08 µs / 784 B | 0.33 µs / 400 B | 1.18 µs / 656 B | +| Deserialize | 1.51 µs / 424 B | 0.43 µs / 56 B | 0.98 µs / 152 B | ### Collection of 4 objects | Benchmark | Converter (`Build()`) | Resolver (`BuildResolver()`) | Generator (`JsonSubTypes.Text.Json.Aot`) | | :--- | ---: | ---: | ---: | -| Serialize | 4.16 µs / 3288 B | 0.99 µs / 624 B | 4.29 µs / 2600 B | -| Deserialize | 5.64 µs / 2744 B | 1.87 µs / 784 B | 4.54 µs / 696 B | +| Serialize | 3.20 µs / 2.93 KB | 0.99 µs / 624 B | 4.29 µs / 2600 B | +| Deserialize | 5.53 µs / 1.8 KB | 1.87 µs / 784 B | 4.54 µs / 696 B | ### Nested hierarchy and property presence | Benchmark | Converter (`Build()`) | Generator (`JsonSubTypes.Text.Json.Aot`) | | :--- | ---: | ---: | -| Nested deserialize | 1.73 µs / 1152 B | 1.15 µs / 144 B | +| Nested deserialize | 1.93 µs / 752 B | 1.15 µs / 144 B | | Nested serialize | — (no discriminator written) | 1.54 µs / 1016 B | -| Property-presence deserialize | 1.21 µs / 776 B | 1.00 µs / 312 B | -| Property-presence serialize | 0.27 µs / 96 B | 0.27 µs / 96 B | +| Property-presence deserialize | 1.20 µs / 592 B | 1.00 µs / 312 B | +| Property-presence serialize | 0.28 µs / 96 B | 0.27 µs / 96 B | Nested serialization is measured on the generated engine only: the converter falls back to the plain runtime-type contract when the leaf is registered on an intermediate base (see the README), so its write path does not inject a discriminator there. @@ -86,10 +88,10 @@ The original `JsonSubTypes` package, through `JsonConvert`. It is a different ru | Benchmark | Newtonsoft (`JsonSubTypes`) | STJ Converter (`Build()`) | | :--- | ---: | ---: | -| Single serialize | 1.41 µs / 2.99 KB | 1.14 µs / 856 B | -| Single deserialize | 2.03 µs / 4.82 KB | 1.50 µs / 648 B | -| Collection serialize (4) | 5.25 µs / 7.22 KB | 4.16 µs / 3288 B | -| Collection deserialize (4) | 8.31 µs / 11.21 KB | 5.64 µs / 2744 B | +| Single serialize | 1.41 µs / 2.99 KB | 1.08 µs / 784 B | +| Single deserialize | 2.03 µs / 4.82 KB | 1.51 µs / 424 B | +| Collection serialize (4) | 5.25 µs / 7.22 KB | 3.20 µs / 2.93 KB | +| Collection deserialize (4) | 8.31 µs / 11.21 KB | 5.53 µs / 1.8 KB | The Newtonsoft package received the same fast-path treatment as the STJ converter: single-level type resolution without the multi-level walk, direct string/int discriminator lookup instead of `ToObject` reflection, and a plain `JValue` discriminator write when no converter applies. Its remaining cost is structural — Newtonsoft loads the payload into a `JObject` and re-deserializes through a `JTokenReader`, a double parse we deliberately kept rather than rewrite the read architecture (date parsing, error paths and `Error` events depend on it). @@ -98,5 +100,5 @@ The Newtonsoft package received the same fast-path treatment as the STJ converte The ordering is structural, not a tuning artifact: - The **resolver** is fastest because it delegates to `System.Text.Json` native polymorphism: the runtime routes the type during streaming, with no `JsonDocument` round-trip and no reflection per call. -- The **generated converter** eliminates reflection (compiled routing) but still round-trips the payload through a `JsonDocument` to inject the discriminator on write and to read it on deserialize, which is why it sits between the resolver and the converter. -- The **converter** keeps the same `JsonDocument` round-trip and adds runtime type resolution (converter scans, mapping lookups), making it the slowest of the three — and the only engine for hierarchies whose subtypes are only known at runtime. +- The **generated converter** eliminates reflection (compiled routing) but still round-trips the payload through a `JsonDocument` on both the write path (injecting the discriminator) and deserialization, which is why it sits between the resolver and the converter. +- The **converter** streams the write path (`Utf8JsonReader`) and adds runtime type resolution (converter scans, mapping lookups), making it the slowest of the three — and the only engine for hierarchies whose subtypes are only known at runtime. diff --git a/README.md b/README.md index cb776df..a03646a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ __JsonSubTypes__ is a discriminated Json sub-type Converter implementation for . `JsonSubTypes` exists in two packages that share the same API and registration model (attributes and `JsonSubtypesConverterBuilder`): - **`JsonSubTypes`** — for `Newtonsoft.Json`, the original and stable package. -- **`JsonSubTypes.Text.Json`** (`.NET 8+`) — for `System.Text.Json`. **Experimental**: the API is complete and the code fully tested, but the stable `1.0.0` release is still pending. +- **`JsonSubTypes.Text.Json`** (`.NET 8+`) — for `System.Text.Json`. **Experimental**: the API is complete and the code fully tested, but the stable `1.0.0` release is still pending, and the public API may still change until then. Pin to a specific package version if you rely on it. The examples below use the Newtonsoft.Json package; the API is the same for `System.Text.Json`, so read them either way. If you are targeting `System.Text.Json`, then after these examples jump to the [System.Text.Json variant](#systemtextjson-variant) section, which explains the engines available there (`Build()` converter, `BuildResolver()`, AOT generator) and their differences and limitations. @@ -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 (202 converter tests, 84 generated-converter tests plus 14 run only under Native AOT, 74 generator 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. Until then the public API is not frozen: it can still change between releases. 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. @@ -300,6 +300,32 @@ var result = JsonSerializer.Deserialize("{\"catLives\":6,\"type\":2,\"ag Assert.AreEqual(typeof(Cat), result.GetType()); ``` +### Plugins: subtypes registered at runtime + +For hierarchies whose subtypes live in another assembly loaded at runtime (plugins, config-driven types), the subtype declares itself and the host registers the assembly: + +```csharp +// In the plugin assembly: +[KnownSubTypeOf(typeof(Animal), "dog")] +public class PluginDog : Animal { } + +// In the host, after loading the plugin assembly: +var options = new JsonSerializerOptions(); +options.Converters.Add(JsonSubtypesConverterBuilder + .Of("type") + .RegisterSubtypeAssembly(pluginAssembly) + .Build()); + +var dog = JsonSerializer.Deserialize("{\"type\":\"dog\",\"CanBark\":true}", options); +// dog is a PluginDog +``` + +- `[KnownSubTypeOf(typeof(Base), "value")]` on the subtype declares it; with a value it is mapped to that discriminator, without one it is resolved by type name in the registered assembly. `[KnownSubTypeWithPropertyOf]` is the property-presence equivalent. +- `RegisterSubtypeAssembly(assembly)` scans the assembly for those declarations, so the base type does not need to reference the plugin. It mirrors `[KnownSubTypeOtherAssembly]` but accepts an assembly loaded at runtime, whose name is not known at compile time. +- `RegisterDynamicSubtype(object discriminator, Type type)` on a builder-built converter registers one subtype directly, without scanning: call it during setup, before the converter is used, as it mutates the mapping. + +The security note about name-based resolution applies here too: only types assignable from the base are instantiated, but a plugin assembly declares its own subtypes, so only register assemblies you trust. + ### Native resolver via `BuildResolver()` `JsonSubtypesConverterBuilder` also exposes the native `System.Text.Json` polymorphic contract model (`JsonPolymorphismOptions`) as an alternative to `Build()`. Assign the result to `JsonSerializerOptions.TypeInfoResolver` instead of `Converters`: @@ -367,13 +393,13 @@ public interface IExpression { } - With `System.Text.Json`, the converter is only applied when the static type is the polymorphic base type (or a base-typed property/collection), matching the native `[JsonDerivedType]` behavior. The Newtonsoft version also applies converters when serializing a value whose static type is a concrete subtype. - A property declared with a base class or interface type is serialized using the **declared type's contract**: subtype members are omitted unless a converter that claims the declared type is applied (attribute on the type, or builder registered in `JsonSerializerOptions`). The Newtonsoft version serialized the runtime type by default. - Property order differs: `System.Text.Json` emits properties most-derived-first, while the Newtonsoft version honored `[JsonProperty(Order = N)]`. There is no `Order` support in `System.Text.Json`. -- Deeply nested graphs need `MaxDepth` about one level higher than with the Newtonsoft/plain serialization: the discriminator write path round-trips through a `JsonDocument`, which consumes one depth level. (A 64-level chain requires `MaxDepth = 66` instead of 65.) +- Deeply nested graphs need `MaxDepth` about one level higher than with the Newtonsoft/plain serialization when using the **generator**: its write path round-trips the payload through a `JsonDocument` to inject the discriminator, which consumes one depth level. (A 64-level chain requires `MaxDepth = 66` instead of 65.) The converter's write path is streamed (`Utf8JsonReader`) and does not need the extra level. - Name-based type resolution stays scoped to the base type's assembly by default. Cross-assembly subtypes require an explicit opt-in: `[KnownSubTypeOtherAssembly("AssemblyName")]` on the base type, a capability the Newtonsoft version does not have. - `JsonNamingPolicy` and `PropertyNameCaseInsensitive` are respected when matching the discriminator property, and `JsonStringEnumConverter` is respected when mapping discriminator values. Note that `JsonStringEnumConverter` (.NET 8) does **not** honor `[EnumMember(Value = ...)]` — use enum names or `[JsonStringEnumMemberName]` (.NET 9+). - The discriminator is read from anywhere in the object. Native `[JsonDerivedType]` polymorphism requires its `$type` property first, unless you opt into `JsonSerializerOptions.AllowOutOfOrderMetadataProperties`. - Dotted or nested discriminator property paths (e.g. `"nested.property"`) are supported. - **Fallback paths**: serializing the base type itself (rather than a subtype) and deserializing an unknown discriminator back to the base use a reflection-based writer/reader, because the base type's contract is owned by the converter (`System.Text.Json` exposes no property metadata for converter-owned types). `[JsonPropertyName]`, `[JsonIgnore]` (including `JsonIgnoreCondition`), the naming policy and `DefaultIgnoreCondition` are honored; per-property `[JsonConverter]`, `[JsonInclude]` fields, `required` members and parameterized constructors are not supported on these two paths. -- **Performance**: writing an object with a discriminator serializes it once, then re-parses the JSON (`JsonDocument`) to inject the discriminator property, so payloads spend roughly 2-3x their size in temporary memory on the write path. This is the cost of the converter architecture and of the `MaxDepth + 1` note above. +- **Performance**: the generator writes an object with a discriminator by serializing it once and re-parsing the JSON (`JsonDocument`) to inject the discriminator property, so payloads spend roughly 2-3x their size in temporary memory on the write path. This is the cost of the generator's architecture and of the `MaxDepth + 1` note above. The converter's write path is streamed instead. - **Security**: see the [security section](#security) at the bottom of this section. It applies to both packages; the only difference is the set of assemblies searched for a name-based hit. - The property-presence builder (`JsonSubtypesWithPropertyConverterBuilder`) registers subtypes by property name, so two subtypes cannot share the same property name through the builder (use `[KnownSubTypeWithProperty]` attributes for that case). @@ -433,7 +459,7 @@ Benchmarked with BenchmarkDotNet (`JsonSubTypes.Benchmarks`, .NET 10); the metho - **Resolver (`BuildResolver()`)** is the fastest: it delegates to `System.Text.Json` native polymorphism, with no `JsonDocument` round-trip and no reflection per call. - **Generator (`JsonSubTypes.Text.Json.Aot`)** beats the runtime converter on deserialization and allocates far less (compiled routing instead of per-call converter scans). Its Native AOT steady state is comparable to (slightly slower than) JIT; its real advantage is trimming compatibility and startup time. -- **Converter (`Build()`)** is the slowest of the three: it keeps the `JsonDocument` round-trip and adds runtime type resolution. It is the only engine for hierarchies whose subtypes are only known at runtime. +- **Converter (`Build()`)** is the slowest of the three: it adds runtime type resolution (converter scans, mapping lookups) to a streamed write path. It is the only engine for hierarchies whose subtypes are only known at runtime. - **Newtonsoft.Json (`JsonSubTypes`)** is slower and allocates several times more than the STJ converter on the same scenarios. Reproduce the measurements yourself with `dotnet run -c Release --project JsonSubTypes.Benchmarks`.