Skip to content
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### JsonSubTypes.Text.Json
#### Added
- New `JsonSubTypeConverterAttribute` convenience constructors that close the generic `JsonSubtypes<T>` converter over the annotated type, so the base type does not need to be repeated: `[JsonSubTypeConverter("Kind")]` instead of `[JsonSubTypeConverter(typeof(JsonSubtypes<Animal>), "Kind")]`.
#### Changed
- Renamed `FallBackSubTypeAttribute` to `FallbackSubTypeAttribute` and `FallBackToNearestAncestor()` to `FallbackToNearestAncestor()` for consistent capitalization. The `FallBack*` names still work in `JsonSubTypes` (Newtonsoft), which keeps its historical API.
#### Fixed
- The attribute-based converter now writes the discriminator on serialization, as documented: the attribute's `CreateConverter` override was previously bypassed by `System.Text.Json` (the converter was built through its parameterless constructor), so the discriminator was only read, never written. The attribute now routes through `CreateConverter`, which also activates the discriminator-injection write path for registered subtypes.

### JsonSubTypes
#### Fixed
- 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
Expand Down
8 changes: 4 additions & 4 deletions JsonSubTypes.Benchmarks/BaseAsLeafBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,16 @@ public BaseAsLeafBenchmarks()
}

[Benchmark]
public string Converter_Serialize() => JsonSerializer.Serialize<ConvBaseAnimal>(_convBase, _converterOptions!);
public string Leaf_Converter_Serialize() => JsonSerializer.Serialize<ConvBaseAnimal>(_convBase, _converterOptions!);

[Benchmark]
public string Generated_Serialize() => JsonSerializer.Serialize<BaseLeafAnimal>(_generatedBase, _generatedOptions);
public string Leaf_Generated_Serialize() => JsonSerializer.Serialize<BaseLeafAnimal>(_generatedBase, _generatedOptions);

[Benchmark]
public ConvBaseAnimal? Converter_Deserialize() => JsonSerializer.Deserialize<ConvBaseAnimal>(_converterJson!, _converterOptions!);
public ConvBaseAnimal? Leaf_Converter_Deserialize() => JsonSerializer.Deserialize<ConvBaseAnimal>(_converterJson!, _converterOptions!);

[Benchmark]
public BaseLeafAnimal? Generated_Deserialize() => JsonSerializer.Deserialize<BaseLeafAnimal>(_generatedJson, _generatedOptions);
public BaseLeafAnimal? Leaf_Generated_Deserialize() => JsonSerializer.Deserialize<BaseLeafAnimal>(_generatedJson, _generatedOptions);
}

public class ConvBaseAnimal { public int Age { get; set; } }
Expand Down
6 changes: 5 additions & 1 deletion JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ public class NestedHierarchyBenchmarks
private readonly JsonSerializerOptions _generatedOptions = new JsonSerializerOptions
{
TypeInfoResolver = NestedContext.Default,
Converters = { JsonSubTypesAotConverters.NestedPayload }
Converters =
{
JsonSubTypesAotConverters.NestedPayload,
JsonSubTypesAotConverters.NestedGame
}
};

private readonly ConvRun _convRun = new ConvRun();
Expand Down
8 changes: 4 additions & 4 deletions JsonSubTypes.Benchmarks/NewtonsoftBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,16 @@ public NewtonsoftBenchmarks()
}

[Benchmark]
public string Single_Serialize() => JsonConvert.SerializeObject(_animal, _settings);
public string Nw_Single_Serialize() => JsonConvert.SerializeObject(_animal, _settings);

[Benchmark]
public NwAnimal? Single_Deserialize() => JsonConvert.DeserializeObject<NwAnimal>(_singleJson, _settings);
public NwAnimal? Nw_Single_Deserialize() => JsonConvert.DeserializeObject<NwAnimal>(_singleJson, _settings);

[Benchmark]
public string Collection_Serialize() => JsonConvert.SerializeObject(_animals, _settings);
public string Nw_Collection_Serialize() => JsonConvert.SerializeObject(_animals, _settings);

[Benchmark]
public List<NwAnimal>? Collection_Deserialize() => JsonConvert.DeserializeObject<List<NwAnimal>>(_collectionJson, _settings);
public List<NwAnimal>? Nw_Collection_Deserialize() => JsonConvert.DeserializeObject<List<NwAnimal>>(_collectionJson, _settings);
}

public class NwAnimal { public int Age { get; set; } }
Expand Down
12 changes: 6 additions & 6 deletions JsonSubTypes.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,22 +78,22 @@ public PolymorphismBenchmarks()
}

[Benchmark]
public string Generated_Serialize() => JsonSerializer.Serialize<BenchAnimal>(_benchCat, _generatedOptions);
public string Single_Generated_Serialize() => JsonSerializer.Serialize<BenchAnimal>(_benchCat, _generatedOptions);

[Benchmark]
public string Resolver_Serialize() => JsonSerializer.Serialize<ResAnimal>(_resCat, _resolverOptions!);
public string Single_Resolver_Serialize() => JsonSerializer.Serialize<ResAnimal>(_resCat, _resolverOptions!);

[Benchmark]
public string Converter_Serialize() => JsonSerializer.Serialize<ConvAnimal>(new ConvCat { Age = 3, Lives = 9 }, _converterOptions!);
public string Single_Converter_Serialize() => JsonSerializer.Serialize<ConvAnimal>(new ConvCat { Age = 3, Lives = 9 }, _converterOptions!);

[Benchmark]
public ConvAnimal? Converter_Deserialize() => JsonSerializer.Deserialize<ConvAnimal>(_converterJson!, _converterOptions!);
public ConvAnimal? Single_Converter_Deserialize() => JsonSerializer.Deserialize<ConvAnimal>(_converterJson!, _converterOptions!);

[Benchmark]
public ResAnimal? Resolver_Deserialize() => JsonSerializer.Deserialize<ResAnimal>(_resolverJson!, _resolverOptions!);
public ResAnimal? Single_Resolver_Deserialize() => JsonSerializer.Deserialize<ResAnimal>(_resolverJson!, _resolverOptions!);

[Benchmark]
public BenchAnimal? Generated_Deserialize() => JsonSerializer.Deserialize<BenchAnimal>(_generatedJson, _generatedOptions);
public BenchAnimal? Single_Generated_Deserialize() => JsonSerializer.Deserialize<BenchAnimal>(_generatedJson, _generatedOptions);
}

public class ConvAnimal { public int Age { get; set; } }
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
2 changes: 1 addition & 1 deletion JsonSubTypes.Text.Json.Aot.Generated/TestDomain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public class Owl : Animal
[JsonSubTypesAotConverter]
[KnownSubTypeWithProperty(typeof(Employee), "JobTitle")]
[KnownSubTypeWithProperty(typeof(Artist), "Skill")]
[FallBackSubType(typeof(Person))]
[FallbackSubType(typeof(Person))]
public class Person
{
public string? FirstName { get; set; }
Expand Down
2 changes: 1 addition & 1 deletion JsonSubTypes.Text.Json.Aot.Sample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ public class Dog : Animal
[JsonSubTypesAotConverter]
[KnownSubTypeWithProperty(typeof(Employee), "JobTitle")]
[KnownSubTypeWithProperty(typeof(Artist), "Skill")]
[FallBackSubType(typeof(Person))]
[FallbackSubType(typeof(Person))]
public class Person
{
public string? FirstName { get; set; }
Expand Down
14 changes: 14 additions & 0 deletions JsonSubTypes.Text.Json.Aot.Tests/EngineParityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,20 @@ public void MultiplePropertiesForSameSubtype()
Assert.IsInstanceOf<PEmployee>(employee);
}

[Test]
public void PresenceSerialize_WritesAllPropertiesOnce()
{
Requires(ParityCapabilities.Presence);
var employee = new PEmployee { FirstName = "Ann", JobTitle = "Dev", Department = "R&D" };

string json = JsonSerializer.Serialize<MultiPropBase>(employee, CreateOptions());

Assert.AreEqual("{\"JobTitle\":\"Dev\",\"Department\":\"R\\u0026D\",\"FirstName\":\"Ann\"}", json);

var back = JsonSerializer.Deserialize<MultiPropBase>(json, CreateOptions());
Assert.IsInstanceOf<PEmployee>(back);
}

[Test]
public void FallbackReadWithParameterizedConstructor()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,44 @@ public void Deserialize_NestedDiscriminator_StillWorksForIntermediate()
}
}

[TestFixture]
public class GeneratedNestedWithNativeDiscriminatorPropertyTests
{
// A nested hierarchy whose discriminators are also native properties of the type
// ([JsonPropertyName("$PayloadKind")], ...). The write path must not duplicate them:
// the injected discriminator replaces the native property of the same name.

private static JsonSerializerOptions Options()
{
return new JsonSerializerOptions
{
Converters =
{
JsonSubTypesAotConverters.PPPayload,
JsonSubTypesAotConverters.PPGame
}
};
}

[Test]
public void Serialize_WritesEachDiscriminatorOnce()
{
string json = JsonSerializer.Serialize<PPPayload>(new PPRun(), Options());

Assert.AreEqual("{\"$PayloadKind\":1,\"$GameKind\":0}", json);
}

[Test]
public void RoundTrip_ReturnsDeepestSubtype()
{
var options = Options();
string json = JsonSerializer.Serialize<PPPayload>(new PPRun(), options);
var back = JsonSerializer.Deserialize<PPPayload>(json, options);

Assert.IsInstanceOf<PPRun>(back);
}
}

// ---- domain types ----

public enum EAnimalKind
Expand Down Expand Up @@ -562,3 +600,50 @@ public class DNLeaf : DNMid
public int Mark { get; set; }
}

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

[JsonSubTypesAotConverter("$GameKind")]
[KnownSubType(typeof(PPRun), GameDiscriminator.RUN)]
[KnownSubType(typeof(PPWalk), GameDiscriminator.WALK)]
public class PPGame : PPPayload
{
[JsonPropertyName("$GameKind")]
public GameDiscriminator GameKind { get; set; } = GameDiscriminator.WALK;
}

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

public class PPWalk : PPGame
{
}

public class PPCom : PPPayload
{
}

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

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

17 changes: 11 additions & 6 deletions JsonSubTypes.Text.Json.Aot/JsonSubTypesGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public sealed class JsonSubTypesGenerator : IIncrementalGenerator
private const string JsonSubTypesAotConverterAttributeName = "JsonSubTypesAotConverterAttribute";
private const string KnownSubTypeAttributeName = "KnownSubTypeAttribute";
private const string KnownSubTypeWithPropertyAttributeName = "KnownSubTypeWithPropertyAttribute";
private const string FallBackSubTypeAttributeName = "FallBackSubTypeAttribute";
private const string FallbackSubTypeAttributeName = "FallbackSubTypeAttribute";
private const string SystemTextJsonSerializationNamespace = "System.Text.Json.Serialization";
private const string DiagnosticId = "JSTAOT001";
private const string DuplicateDiscriminatorDiagnosticId = "JSTAOT002";
Expand Down Expand Up @@ -228,8 +228,8 @@ private static void ProcessRegistrationAttributes(INamedTypeSymbol baseType, Bas
case KnownSubTypeWithPropertyAttributeName:
ProcessKnownSubTypeWithProperty(attr, info);
break;
case FallBackSubTypeAttributeName:
ProcessFallBackSubType(attr, info);
case FallbackSubTypeAttributeName:
ProcessFallbackSubType(attr, info);
break;
}
}
Expand Down Expand Up @@ -304,7 +304,7 @@ private static void ProcessKnownSubTypeWithProperty(AttributeData attr, BaseType
});
}

private static void ProcessFallBackSubType(AttributeData attr, BaseTypeInfo info)
private static void ProcessFallbackSubType(AttributeData attr, BaseTypeInfo info)
{
if (attr.ConstructorArguments[0].Value is ITypeSymbol fallback)
{
Expand Down Expand Up @@ -1176,20 +1176,25 @@ private static string EmitNestedCases(BaseTypeInfo info)
foreach (NestedChain nested in info.NestedTypes)
{
List<string> discLines = [];
List<string> discriminatorNames = [];
foreach (ChainEntry entry in nested.Chain)
{
string discriminatorName = entry.DiscriminatorName == info.DiscriminatorPropertyName
? "DiscriminatorPropertyNameValue"
: SymbolDisplay.FormatLiteral(entry.DiscriminatorName, quote: true);
discLines.Add($" writer.WritePropertyName({discriminatorName});");
discLines.Add($" writer.WritePropertyName({discriminatorName});");
discLines.Add($" {EmitDiscriminatorValueStatement(entry.Discriminator)}");
discriminatorNames.Add(discriminatorName);
}
string payload = $$"""
string payload = JsonSerializer.Serialize(value, options.GetTypeInfo(runtimeType));
using JsonDocument payloadDocument = JsonDocument.Parse(payload);
foreach (JsonProperty property in payloadDocument.RootElement.EnumerateObject())
{
property.WriteTo(writer);
if ({{string.Join(" && ", discriminatorNames.Select(n => $"!property.NameEquals({n})"))}})
{
property.WriteTo(writer);
}
}
writer.WriteEndObject();
return true;
Expand Down
Loading