Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>.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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ public sealed class PersonJsonSubTypesConverter : JsonSubTypesAotConverterBase<g
protected override Type SelectType(JsonElement root, JsonSerializerOptions options)
{
System.Collections.Generic.HashSet<Type> matches = new System.Collections.Generic.HashSet<Type>();
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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
34 changes: 33 additions & 1 deletion JsonSubTypes.Text.Json.Aot.Generator.Tests/GoldenMasterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<MultiPropBase>("{\"jobtitle\":\"Dev\",\"FirstName\":\"A\"}", Options(caseInsensitive: true, namingPolicy: null));

Assert.IsInstanceOf<PEmployee>(result);
}

[Test]
public void PresenceMatching_HonorsNamingPolicy()
{
var result = JsonSerializer.Deserialize<MultiPropBase>("{\"jobTitle\":\"Dev\",\"firstName\":\"A\"}", Options(caseInsensitive: false, namingPolicy: JsonNamingPolicy.CamelCase));

Assert.IsInstanceOf<PEmployee>(result);
}

[Test]
public void PresenceMatching_ExactName_WithoutCaseInsensitiveOrPolicy()
{
var result = JsonSerializer.Deserialize<MultiPropBase>("{\"JobTitle\":\"Dev\",\"FirstName\":\"A\"}", Options(caseInsensitive: false, namingPolicy: null));

Assert.IsInstanceOf<PEmployee>(result);
}
}

public enum EAnimalKind
{
Cat,
Expand Down
4 changes: 2 additions & 2 deletions JsonSubTypes.Text.Json.Aot/JsonSubTypesGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SelfDeclaredBase>)JsonSubtypesConverterBuilder
.Of<SelfDeclaredBase>("Kind")
.SerializeDiscriminatorProperty()
.Build();
converter.RegisterDynamicSubtype("dog", typeof(SelfDeclaredDog));

var options = new JsonSerializerOptions();
options.Converters.Add(converter);

var json = JsonSerializer.Serialize<SelfDeclaredBase>(new SelfDeclaredDog { CanBark = true }, options);

StringAssert.Contains("\"Kind\":\"dog\"", json);
StringAssert.Contains("\"CanBark\":true", json);
}

[Test]
public void RegisterDynamicSubtypeRejectsMixedDiscriminatorTypes()
{
Expand Down
4 changes: 2 additions & 2 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
30 changes: 16 additions & 14 deletions PERFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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.
Expand All @@ -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.

Expand All @@ -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).

Expand All @@ -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.
Loading