From cb631ac358a76d16921facff207815c2f788e4d4 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 23:43:50 +0200 Subject: [PATCH 1/7] State in the README that the STJ API is not frozen before 1.0.0 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cb776df..dd75f60 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 (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. 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. From 6161e73e02251c0207c50a07ca775e033d6ae64b Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sun, 16 Aug 2026 00:40:03 +0200 Subject: [PATCH 2/7] Test the dynamic subtype write path for RegisterDynamicSubtype --- .../PayloadJsonSubTypesConverter.cs | 10 ++++-- .../GoldenMasterTests.cs | 34 ++++++++++++++++++- .../ReviewBugTests.cs | 20 +++++++++++ 3 files changed, 61 insertions(+), 3 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; 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.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() { From 76a1d194ae9080fd55e89ecaad88f509a26151d8 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sun, 16 Aug 2026 00:41:24 +0200 Subject: [PATCH 3/7] Document the runtime plugin pattern (KnownSubTypeOf, RegisterSubtypeAssembly, RegisterDynamicSubtype) The audits flagged these public features as entirely undocumented. Add a README section with a runnable example, a MIGRATION pointer, and a CHANGELOG entry. Also document the name-based security implication for plugin assemblies. --- CHANGELOG.md | 2 ++ MIGRATION.md | 2 +- README.md | 26 ++++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) 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/MIGRATION.md b/MIGRATION.md index da5eb56..ed21fee 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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/README.md b/README.md index dd75f60..3f0a753 100644 --- a/README.md +++ b/README.md @@ -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`: From 5bdb6c464ed39701f341159345413e0d84368f8b Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sun, 16 Aug 2026 00:44:04 +0200 Subject: [PATCH 4/7] Attribute the JsonDocument round-trip to the generator, not the converter The STJ converter write path is streamed since the Utf8JsonReader rework; only the AOT generator still round-trips through a JsonDocument. The MaxDepth +1 note, the 2-3x memory note and the engine comparisons claimed the round-trip for both. Scope them to the generator and refresh the STJ test count (202). --- MIGRATION.md | 2 +- PERFORMANCE.md | 4 ++-- README.md | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index ed21fee..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. diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 8236e3d..704bdc9 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -98,5 +98,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 3f0a753..c832eea 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. Until then the public API is not frozen: it can still change between releases. +> **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 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. 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. @@ -393,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). @@ -459,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`. From d3da2deefd141a13fd29dff7dadfb455cf233c0a Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sun, 16 Aug 2026 07:49:57 +0200 Subject: [PATCH 5/7] Refresh the converter performance numbers after the streamed write path Re-measured the converter scenarios on the same machine: the write side no longer round-trips through a JsonDocument, so serialization allocations dropped (Single 856B->784B, Collection 3288B->2.93KB). Runtime updated to .NET 10.0.10 and a note explains which engine still carries the JsonDocument cost. --- PERFORMANCE.md | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 704bdc9..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). From 4f6585724323885cc06e2839ee0ef6ac72c11ce5 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sun, 16 Aug 2026 07:50:23 +0200 Subject: [PATCH 6/7] Honor JsonNamingPolicy and PropertyNameCaseInsensitive in the generated presence mode The generated SelectType matched presence properties with the literal name, ignoring both options that the runtime converter honors. Route the lookup through the shared TryGetProperty helper (which already applies the naming policy and case-insensitive fallback), regenerate the golden master and cover the three cases with tests. --- .../PersonJsonSubTypesConverter.cs | 4 +- .../GeneratorDriverTests.cs | 4 +- .../GeneratedConverterAdvancedTests.cs | 38 +++++++++++++++++++ .../JsonSubTypesGenerator.cs | 4 +- 4 files changed, 44 insertions(+), 6 deletions(-) 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.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); From 9aac76dc1262135be8fc30be3093ea81635bb284 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sun, 16 Aug 2026 07:56:51 +0200 Subject: [PATCH 7/7] Clarify the test coverage in the STJ status note The '202 unit tests' count covered only the converter suite. Spell out the whole STJ family: 202 converter tests, 84 generated-converter tests plus 14 Native-AOT-only, 74 generator tests. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c832eea..a03646a 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 (202 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. Until then the public API is not frozen: it can still change between releases. +> **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.