Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Deserialization with an open generic base type (e.g. `Base<>`) now closes the generic subtype correctly (e.g. `Nested1<int>` for `Base<int>`) instead of failing. #177
- Errors and exceptions raised while deserializing a subtype now carry the fully qualified JSON path (e.g. `Property2.Value` instead of `Value`), matching stock Newtonsoft.Json error handling. #182

### JsonSubTypes.Text.Json
#### Changed
- The converter caches the resolved `IJsonSubtypes` converter list per `JsonSerializerOptions` (frozen on first use) and resolves single-level hierarchies without per-object allocations.
- Deserialization reads the resolved subtype from the already-parsed `JsonElement` instead of re-reading the raw bytes through a second materialization.
- The discriminator write path streams the payload as UTF-8 (`ArrayBufferWriter` + `Utf8JsonWriter`) instead of round-tripping through a UTF-16 string.

## [1.0.0-rc.3] - 2026-08-12

### JsonSubTypes.Text.Json
Expand Down
32 changes: 32 additions & 0 deletions JsonSubTypes.Benchmarks/BenchmarkGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System;
using System.Text.Json;

namespace JsonSubTypes.Benchmarks
{
// The converter, resolver and Newtonsoft benchmarks rely on reflection, which the
// Native AOT job disables. Route their options through these guards so those benchmarks
// fail loudly under the Native AOT job instead of silently running against the default
// options (which would produce plausible-looking numbers that measure nothing).
internal static class BenchmarkGuard
{
public static JsonSerializerOptions ReflectionOptions(JsonSerializerOptions? options)
{
if (!JsonSerializer.IsReflectionEnabledByDefault)
{
throw new NotSupportedException(
"This engine relies on reflection, which the Native AOT host disables; the benchmark is not measured under that job.");
}

return options ?? throw new NotSupportedException("Benchmark options were not initialized.");
}

public static void RequireReflection()
{
if (!JsonSerializer.IsReflectionEnabledByDefault)
{
throw new NotSupportedException(
"This engine relies on reflection, which the Native AOT host disables; the benchmark is not measured under that job.");
}
}
}
}
119 changes: 119 additions & 0 deletions JsonSubTypes.Benchmarks/CollectionBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using BenchmarkDotNet.Attributes;
using JsonSubTypes.Text.Json;
using JsonSubTypes.Text.Json.Aot.Generated;
using StjBuilder = JsonSubTypes.Text.Json.JsonSubtypesConverterBuilder;

namespace JsonSubTypes.Benchmarks
{
[MemoryDiagnoser]
public class CollectionBenchmarks
{
private readonly JsonSerializerOptions? _converterOptions;
private readonly JsonSerializerOptions? _resolverOptions;
private readonly JsonSerializerOptions _generatedOptions = new JsonSerializerOptions
{
TypeInfoResolver = ColContext.Default,
Converters = { JsonSubTypesAotConverters.ColAnimal }
};

private readonly List<ColConvAnimal> _convAnimals;
private readonly List<ColResAnimal> _resAnimals;
private readonly List<ColAnimal> _generatedAnimals;

private readonly string? _converterJson;
private readonly string? _resolverJson;
private readonly string _generatedJson;

public CollectionBenchmarks()
{
_convAnimals = new List<ColConvAnimal>
{
new ColConvCat { Age = 3, Lives = 9 },
new ColConvDog { Age = 5, CanHunt = true },
new ColConvCat { Age = 7, Lives = 7 },
new ColConvDog { Age = 1, CanHunt = false }
};
_resAnimals = new List<ColResAnimal>
{
new ColResCat { Age = 3, Lives = 9 },
new ColResDog { Age = 5, CanHunt = true },
new ColResCat { Age = 7, Lives = 7 },
new ColResDog { Age = 1, CanHunt = false }
};
_generatedAnimals = new List<ColAnimal>
{
new ColCat { Age = 3, Lives = 9 },
new ColDog { Age = 5, CanHunt = true },
new ColCat { Age = 7, Lives = 7 },
new ColDog { Age = 1, CanHunt = false }
};

if (JsonSerializer.IsReflectionEnabledByDefault)
{
_converterOptions = new JsonSerializerOptions();
_converterOptions.Converters.Add(StjBuilder.Of<ColConvAnimal>("type")
.RegisterSubtype<ColConvCat>("cat")
.RegisterSubtype<ColConvDog>("dog")
.SerializeDiscriminatorProperty()
.Build());
_resolverOptions = new JsonSerializerOptions
{
TypeInfoResolver = StjBuilder.Of<ColResAnimal>("type")
.RegisterSubtype<ColResCat>("cat")
.RegisterSubtype<ColResDog>("dog")
.SerializeDiscriminatorProperty()
.BuildResolver()
};

_converterJson = JsonSerializer.Serialize(_convAnimals, _converterOptions);
_resolverJson = JsonSerializer.Serialize(_resAnimals, _resolverOptions);
}

_generatedJson = JsonSerializer.Serialize(_generatedAnimals, _generatedOptions);
}

[Benchmark]
public string Col_Converter_Serialize() => JsonSerializer.Serialize(_convAnimals, BenchmarkGuard.ReflectionOptions(_converterOptions));

[Benchmark]
public string Col_Resolver_Serialize() => JsonSerializer.Serialize(_resAnimals, BenchmarkGuard.ReflectionOptions(_resolverOptions));

[Benchmark]
public string Col_Generated_Serialize() => JsonSerializer.Serialize(_generatedAnimals, _generatedOptions);

[Benchmark]
public List<ColConvAnimal>? Col_Converter_Deserialize() => JsonSerializer.Deserialize<List<ColConvAnimal>>(_converterJson!, BenchmarkGuard.ReflectionOptions(_converterOptions));

[Benchmark]
public List<ColResAnimal>? Col_Resolver_Deserialize() => JsonSerializer.Deserialize<List<ColResAnimal>>(_resolverJson!, BenchmarkGuard.ReflectionOptions(_resolverOptions));

[Benchmark]
public List<ColAnimal>? Col_Generated_Deserialize() => JsonSerializer.Deserialize<List<ColAnimal>>(_generatedJson, _generatedOptions);
}

public class ColConvAnimal { public int Age { get; set; } }
public class ColConvCat : ColConvAnimal { public int Lives { get; set; } }
public class ColConvDog : ColConvAnimal { public bool CanHunt { get; set; } }

public class ColResAnimal { public int Age { get; set; } }
public class ColResCat : ColResAnimal { public int Lives { get; set; } }
public class ColResDog : ColResAnimal { public bool CanHunt { get; set; } }

[JsonSubTypesAotConverter("type")]
[KnownSubType(typeof(ColCat), "cat")]
[KnownSubType(typeof(ColDog), "dog")]
public class ColAnimal { public int Age { get; set; } }
public class ColCat : ColAnimal { public int Lives { get; set; } }
public class ColDog : ColAnimal { public bool CanHunt { get; set; } }

[JsonSerializable(typeof(ColAnimal))]
[JsonSerializable(typeof(ColCat))]
[JsonSerializable(typeof(ColDog))]
[JsonSerializable(typeof(List<ColAnimal>))]
public partial class ColContext : JsonSerializerContext
{
}
}
9 changes: 5 additions & 4 deletions JsonSubTypes.Benchmarks/JsonSubTypes.Benchmarks.csproj
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- the converter/resolver benchmarks are JIT-only reference points; only the generated
engine is measured under Native AOT -->
<!-- the default build is JIT so the reflection-based converter/resolver benchmarks can run;
the generated engine is additionally measured under Native AOT via the BenchmarkDotNet
NativeAOT toolchain job (no PublishAot here: it would disable reflection for the whole host) -->
<NoWarn>$(NoWarn);IL2026;IL3050</NoWarn>
</PropertyGroup>
<ItemGroup>
Expand All @@ -18,5 +18,6 @@
<ProjectReference Include="..\JsonSubTypes.Text.Json.Aot\JsonSubTypes.Text.Json.Aot.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<ProjectReference Include="..\JsonSubTypes\JsonSubTypes.csproj" />
</ItemGroup>
</Project>
142 changes: 142 additions & 0 deletions JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using BenchmarkDotNet.Attributes;
using JsonSubTypes.Text.Json;
using JsonSubTypes.Text.Json.Aot.Generated;
using StjBuilder = JsonSubTypes.Text.Json.JsonSubtypesConverterBuilder;

namespace JsonSubTypes.Benchmarks
{
[MemoryDiagnoser]
public class NestedHierarchyBenchmarks
{
private readonly JsonSerializerOptions? _converterOptions;
private readonly JsonSerializerOptions _generatedOptions = new JsonSerializerOptions
{
TypeInfoResolver = NestedContext.Default,
Converters = { JsonSubTypesAotConverters.NestedPayload }
};

private readonly ConvRun _convRun = new ConvRun();
private readonly NestedRun _nestedRun = new NestedRun();

private readonly string? _converterJson;
private readonly string _generatedJson;

public NestedHierarchyBenchmarks()
{
if (JsonSerializer.IsReflectionEnabledByDefault)
{
_converterOptions = new JsonSerializerOptions();
_converterOptions.Converters.Add(StjBuilder
.Of(typeof(ConvPayload), "$PayloadKind")
.RegisterSubtype(typeof(ConvGame), PayloadDiscriminator.GAME)
.RegisterSubtype(typeof(ConvCom), PayloadDiscriminator.COM)
.Build());
_converterOptions.Converters.Add(StjBuilder
.Of(typeof(ConvGame), "$GameKind")
.RegisterSubtype(typeof(ConvRun), GameDiscriminator.RUN)
.RegisterSubtype(typeof(ConvWalk), GameDiscriminator.WALK)
.Build());

_converterJson = JsonSerializer.Serialize<ConvPayload>(_convRun, _converterOptions);
}

_generatedJson = JsonSerializer.Serialize<NestedPayload>(_nestedRun, _generatedOptions);
}

[Benchmark]
public string Nested_Generated_Serialize() => JsonSerializer.Serialize<NestedPayload>(_nestedRun, _generatedOptions);

[Benchmark]
public ConvPayload? Nested_Converter_Deserialize() => JsonSerializer.Deserialize<ConvPayload>(_converterJson!, BenchmarkGuard.ReflectionOptions(_converterOptions));

[Benchmark]
public NestedPayload? Nested_Generated_Deserialize() => JsonSerializer.Deserialize<NestedPayload>(_generatedJson, _generatedOptions);
}

public enum PayloadDiscriminator
{
COM = 0,
GAME = 1
}

public enum GameDiscriminator
{
RUN = 0,
WALK = 1
}

public class ConvPayload
{
[JsonPropertyName("$PayloadKind")]
public PayloadDiscriminator PayloadKind { get; set; } = PayloadDiscriminator.GAME;
}

public class ConvGame : ConvPayload
{
[JsonPropertyName("$GameKind")]
public GameDiscriminator GameKind { get; set; } = GameDiscriminator.WALK;
}

public class ConvRun : ConvGame
{
public ConvRun()
{
PayloadKind = PayloadDiscriminator.GAME;
GameKind = GameDiscriminator.RUN;
}
}

public class ConvWalk : ConvGame
{
}

public class ConvCom : ConvPayload
{
}

[JsonSubTypesAotConverter("$PayloadKind")]
[KnownSubType(typeof(NestedGame), PayloadDiscriminator.GAME)]
[KnownSubType(typeof(NestedCom), PayloadDiscriminator.COM)]
public class NestedPayload
{
[JsonPropertyName("$PayloadKind")]
public PayloadDiscriminator PayloadKind { get; set; } = PayloadDiscriminator.GAME;
}

[JsonSubTypesAotConverter("$GameKind")]
[KnownSubType(typeof(NestedRun), GameDiscriminator.RUN)]
[KnownSubType(typeof(NestedWalk), GameDiscriminator.WALK)]
public class NestedGame : NestedPayload
{
[JsonPropertyName("$GameKind")]
public GameDiscriminator GameKind { get; set; } = GameDiscriminator.WALK;
}

public class NestedRun : NestedGame
{
public NestedRun()
{
PayloadKind = PayloadDiscriminator.GAME;
GameKind = GameDiscriminator.RUN;
}
}

public class NestedWalk : NestedGame
{
}

public class NestedCom : NestedPayload
{
}

[JsonSerializable(typeof(NestedPayload))]
[JsonSerializable(typeof(NestedGame))]
[JsonSerializable(typeof(NestedRun))]
[JsonSerializable(typeof(NestedWalk))]
[JsonSerializable(typeof(NestedCom))]
public partial class NestedContext : JsonSerializerContext
{
}
}
Loading
Loading