diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e3d81fa..865f76e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,6 +44,9 @@ jobs: - name: Test Aot with coverage (net10) run: dotnet test JsonSubTypes.Aot.Tests/JsonSubTypes.Aot.Tests.csproj -c Release -f net10.0 --collect:"XPlat Code Coverage" --results-directory TestResults/aot + - name: Test Aot generator with coverage (net10) + run: dotnet test JsonSubTypes.Aot.Generator.Tests/JsonSubTypes.Aot.Generator.Tests.csproj -c Release -f net10.0 --collect:"XPlat Code Coverage" --results-directory TestResults/generator + - name: Install gpg (required by codecov-action v5) run: sudo apt-get install -y gpg # codecov-action v5 verifies the Codecov binary signature and requires @@ -57,6 +60,7 @@ jobs: TestResults/newtonsoft/**/coverage.cobertura.xml TestResults/textjson/**/coverage.cobertura.xml TestResults/aot/**/coverage.cobertura.xml + TestResults/generator/**/coverage.cobertura.xml fail_ci_if_error: true # The Aot sample is the only top-level-statements consumer of the generator: @@ -95,11 +99,15 @@ jobs: run: dotnet test JsonSubTypes.Tests/JsonSubTypes.Tests.csproj -c Release -f net46 analysis: - # SonarCloud runs on master only. The job-level `if` avoids referencing the - # secret in a step-level `if`, which broke workflow startup before (secrets are - # not allowed in `if:` conditions). + # SonarCloud analyzes both master pushes and pull requests. For PRs the scanner + # needs the pull-request parameters plus GITHUB_TOKEN (used to decorate the PR + # with the quality gate). The job-level `if` avoids referencing the secret in a + # step-level `if`, which broke workflow startup before (secrets are not allowed + # in `if:` conditions). runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/master' + permissions: + contents: read + pull-requests: write steps: - uses: actions/checkout@v5 with: @@ -122,9 +130,17 @@ jobs: # job builds and tests the solution itself rather than reusing modern's # reports (whose relative file paths would not resolve here). - name: Begin SonarCloud analysis - run: dotnet-sonarscanner begin /k:manuc66_JsonSubTypes /o:manuc66-github /d:sonar.token="$SONAR_TOKEN" /d:sonar.host.url=https://sonarcloud.io /d:sonar.cs.cobertura.reportsPaths=TestResults/**/coverage.cobertura.xml /d:sonar.exclusions=**/*Tests*/**/*,**/JsonSubTypes.Benchmarks/**/*,**/JsonSubTypes.Aot.Sample/**/* env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + PR_KEY: ${{ github.event.pull_request.number }} + PR_BRANCH: ${{ github.head_ref }} + PR_BASE: ${{ github.base_ref }} + run: | + pr_args="" + if [ "${{ github.event_name }}" = "pull_request" ]; then + pr_args="/d:sonar.pullrequest.key=$PR_KEY /d:sonar.pullrequest.branch=$PR_BRANCH /d:sonar.pullrequest.base=$PR_BASE" + fi + dotnet-sonarscanner begin /k:manuc66_JsonSubTypes /o:manuc66-github /d:sonar.token="$SONAR_TOKEN" /d:sonar.host.url=https://sonarcloud.io /d:sonar.cs.cobertura.reportsPaths=TestResults/**/coverage.cobertura.xml /d:sonar.exclusions=**/*Tests*/**/*,**/JsonSubTypes.Benchmarks/**/*,**/JsonSubTypes.Aot.Sample/**/* /d:sonar.cpd.exclusions=**/*.g.cs $pr_args - name: Build for SonarCloud analysis run: dotnet build JsonSubTypes.sln -c Release @@ -134,8 +150,10 @@ jobs: dotnet test JsonSubTypes.Tests/JsonSubTypes.Tests.csproj -c Release -f net10.0 --collect:"XPlat Code Coverage" --results-directory TestResults/newtonsoft dotnet test JsonSubTypes.Text.Json.Tests/JsonSubTypes.Text.Json.Tests.csproj -c Release -f net10.0 --collect:"XPlat Code Coverage" --results-directory TestResults/textjson dotnet test JsonSubTypes.Aot.Tests/JsonSubTypes.Aot.Tests.csproj -c Release -f net10.0 --collect:"XPlat Code Coverage" --results-directory TestResults/aot + dotnet test JsonSubTypes.Aot.Generator.Tests/JsonSubTypes.Aot.Generator.Tests.csproj -c Release -f net10.0 --collect:"XPlat Code Coverage" --results-directory TestResults/generator - name: End SonarCloud analysis run: dotnet-sonarscanner end /d:sonar.token="$SONAR_TOKEN" env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/AnimalJsonSubTypesConverter.cs b/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/AnimalJsonSubTypesConverter.cs new file mode 100644 index 0000000..e99e608 --- /dev/null +++ b/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/AnimalJsonSubTypesConverter.cs @@ -0,0 +1,309 @@ +// +#nullable enable +using System; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JsonSubTypes.Aot.Generated +{ + /// Generated by JsonSubTypes.Aot. Compiled converter for global::JsonSubTypes.Aot.Generated.TestDomain.Animal. + [global::System.CodeDom.Compiler.GeneratedCode("JsonSubTypes.Aot", "1.0.0")] + public sealed class AnimalJsonSubTypesConverter : JsonConverter + { + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Animal); + + public override void Write(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.Animal value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + Type runtimeType = value.GetType(); + string payload; + if (runtimeType == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Animal)) + { + if (IsRegistered(runtimeType)) + { + payload = SerializeBasePayload((global::JsonSubTypes.Aot.Generated.TestDomain.Animal)value, options); + } + else + { + WriteBaseObject(writer, (global::JsonSubTypes.Aot.Generated.TestDomain.Animal)value, options); + return; + } + } + else if (IsRegistered(runtimeType)) + { + payload = JsonSerializer.Serialize(value, options.GetTypeInfo(runtimeType)); + } + else if (TryWriteNestedObject(writer, value, runtimeType, options)) + { + return; + } + else if (TryWriteDynamic(writer, value, runtimeType, options)) + { + return; + } + else + { + JsonSerializer.Serialize(writer, value, options.GetTypeInfo(runtimeType)); + return; + } + using JsonDocument payloadDocument = JsonDocument.Parse(payload); + string discriminatorName = "type"; + if (options.PropertyNamingPolicy != null) + { + discriminatorName = options.PropertyNamingPolicy.ConvertName(discriminatorName); + } + writer.WriteStartObject(); + writer.WritePropertyName(discriminatorName); + WriteDiscriminatorValue(writer, runtimeType, options); + foreach (JsonProperty property in payloadDocument.RootElement.EnumerateObject()) + { + if (!property.NameEquals(discriminatorName)) + { + property.WriteTo(writer); + } + } + writer.WriteEndObject(); + } + + public override global::JsonSubTypes.Aot.Generated.TestDomain.Animal? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Unrecognized token: " + reader.TokenType); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + JsonElement root = document.RootElement; + Type target = SelectType(root, options); + if (target == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Animal)) + { + return DeserializeBase(root, options); + } + return (global::JsonSubTypes.Aot.Generated.TestDomain.Animal?)JsonSerializer.Deserialize(root.GetRawText(), options.GetTypeInfo(target)); + } + + private Type SelectType(JsonElement root, JsonSerializerOptions options) + { + if (TryGetValueInJson(root, "type", options, out JsonElement discriminator)) + { + + if (discriminator.ValueKind == JsonValueKind.String) + { + switch (discriminator.GetString()) + { + case "cat": return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Cat); + } + } + if (discriminator.ValueKind == JsonValueKind.Number) + { + switch (discriminator.GetRawText()) + { + case "2": return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Dog); + } + } + if (TryGetDynamicType(discriminator, out Type? dynamicType)) + { + return dynamicType!; + } + if (CustomTypeNameResolver is not null) + { + Type? customType = CustomTypeNameResolver(GetDiscriminatorKey(discriminator)); + if (customType != null) + { + return customType; + } + } + return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Animal); + } + return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Animal); + } + + private static readonly System.Collections.Generic.Dictionary> DiscriminatorWriters = new System.Collections.Generic.Dictionary> + { + [typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Cat)] = static (writer, options) => writer.WriteStringValue("cat"), + [typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Dog)] = static (writer, options) => writer.WriteNumberValue(2) + }; + + private static bool IsRegistered(Type runtimeType) + { + return DiscriminatorWriters.ContainsKey(runtimeType); + } + + private static void WriteDiscriminatorValue(Utf8JsonWriter writer, Type runtimeType, JsonSerializerOptions options) + { + if (DiscriminatorWriters.TryGetValue(runtimeType, out System.Action? write)) + { + write(writer, options); + return; + } + throw new JsonException("Impossible to serialize type: " + runtimeType.FullName + " because there is no registered mapping for the discriminator property"); + } + + public readonly System.Collections.Concurrent.ConcurrentDictionary DynamicSubtypes = new System.Collections.Concurrent.ConcurrentDictionary(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _dynamicReverse = new System.Collections.Concurrent.ConcurrentDictionary(); + + public void RegisterDynamicSubtype(object discriminator, Type type) + { + DynamicSubtypes[discriminator] = type; + _dynamicReverse[type] = discriminator; // last registration wins, like the builder + } + + /// + /// Custom discriminator-to-type resolution hook, invoked after the static + /// registrations and DynamicSubtypes. Assign it to implement your own + /// name-based lookup (e.g. assembly scanning, a DI registry). The resolved + /// type must be resolvable by the TypeInfoResolver (in the source-gen context + /// for Native AOT). + /// + public Func? CustomTypeNameResolver { get; set; } + + private static object? GetDiscriminatorKey(JsonElement discriminator) + { + switch (discriminator.ValueKind) + { + case JsonValueKind.String: + return discriminator.GetString(); + case JsonValueKind.Number when int.TryParse(discriminator.GetRawText(), out int keyInt): + return keyInt; + default: + return discriminator.GetRawText(); + } + } + + private bool TryGetDynamicType(JsonElement discriminator, out Type? dynamicType) + { + switch (discriminator.ValueKind) + { + case JsonValueKind.String: + return DynamicSubtypes.TryGetValue(discriminator.GetString()!, out dynamicType); + case JsonValueKind.Number: + if (int.TryParse(discriminator.GetRawText(), out int dynamicInt) && DynamicSubtypes.TryGetValue(dynamicInt, out dynamicType)) + { + return true; + } + return DynamicSubtypes.TryGetValue(discriminator.GetRawText(), out dynamicType); + default: + return DynamicSubtypes.TryGetValue(discriminator.GetRawText(), out dynamicType); + } + } + + private static bool TryWriteNestedObject(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.Animal value, Type runtimeType, JsonSerializerOptions options) + { + + return false; + } + + private bool TryWriteDynamic(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.Animal value, Type runtimeType, JsonSerializerOptions options) + { + if (_dynamicReverse.TryGetValue(runtimeType, out object? dynamicDiscriminator)) + { + writer.WriteStartObject(); + string dynamicDiscriminatorName = "type"; + if (options.PropertyNamingPolicy != null) + { + dynamicDiscriminatorName = options.PropertyNamingPolicy.ConvertName(dynamicDiscriminatorName); + } + writer.WritePropertyName(dynamicDiscriminatorName); + writer.WriteRawValue(JsonSerializer.Serialize(dynamicDiscriminator, options.GetTypeInfo(dynamicDiscriminator.GetType()))); + string payload = JsonSerializer.Serialize(value, options.GetTypeInfo(runtimeType)); + using JsonDocument payloadDocument = JsonDocument.Parse(payload); + foreach (JsonProperty property in payloadDocument.RootElement.EnumerateObject()) + { + property.WriteTo(writer); + } + writer.WriteEndObject(); + return true; + } + return false; + } + private static string SerializeBasePayload(global::JsonSubTypes.Aot.Generated.TestDomain.Animal value, JsonSerializerOptions options) + { + using System.IO.MemoryStream stream = new System.IO.MemoryStream(); + using (Utf8JsonWriter writer = new Utf8JsonWriter(stream)) + { + WriteBaseObject(writer, value, options); + } + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); + } + + private static void WriteBaseObject(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.Animal value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + string nameAge = "Age"; + if (options.PropertyNamingPolicy != null && true) + { + nameAge = options.PropertyNamingPolicy.ConvertName(nameAge); + } + writer.WritePropertyName(nameAge); + JsonSerializer.Serialize(writer, value.Age, options.GetTypeInfo(typeof(int))); + writer.WriteEndObject(); + } + + private static global::JsonSubTypes.Aot.Generated.TestDomain.Animal DeserializeBase(JsonElement root, JsonSerializerOptions options) + { + global::JsonSubTypes.Aot.Generated.TestDomain.Animal instance = new global::JsonSubTypes.Aot.Generated.TestDomain.Animal(); + if (TryGetProperty(root, "Age", options, out JsonElement AgeValue)) + { + instance.Age = (int)JsonSerializer.Deserialize(AgeValue.GetRawText(), options.GetTypeInfo(typeof(int)))!; + } + return instance; + } + + private static bool TryGetProperty(JsonElement root, string name, JsonSerializerOptions options, out JsonElement value) + { + if (root.TryGetProperty(name, out value)) + { + return true; + } + string? convertedName = options.PropertyNamingPolicy?.ConvertName(name); + if (convertedName != null && convertedName != name && root.TryGetProperty(convertedName, out value)) + { + return true; + } + if (options.PropertyNameCaseInsensitive) + { + foreach (JsonProperty property in root.EnumerateObject()) + { + if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + } + return false; + } + + private static bool TryGetValueInJson(JsonElement root, string propertyName, JsonSerializerOptions options, out JsonElement value) + { + if (TryGetProperty(root, propertyName, options, out value)) + { + return true; + } + if (propertyName.IndexOf('.') >= 0) + { + string[] segments = propertyName.Split('.'); + JsonElement current = root; + foreach (string segment in segments) + { + if (!TryGetProperty(current, segment, options, out current)) + { + value = default; + return false; + } + } + value = current; + return true; + } + return false; + } + } +} \ No newline at end of file diff --git a/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/DottedGadgetJsonSubTypesConverter.cs b/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/DottedGadgetJsonSubTypesConverter.cs new file mode 100644 index 0000000..8b0feef --- /dev/null +++ b/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/DottedGadgetJsonSubTypesConverter.cs @@ -0,0 +1,302 @@ +// +#nullable enable +using System; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JsonSubTypes.Aot.Generated +{ + /// Generated by JsonSubTypes.Aot. Compiled converter for global::JsonSubTypes.Aot.Generated.TestDomain.DottedGadget. + [global::System.CodeDom.Compiler.GeneratedCode("JsonSubTypes.Aot", "1.0.0")] + public sealed class DottedGadgetJsonSubTypesConverter : JsonConverter + { + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.DottedGadget); + + public override void Write(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.DottedGadget value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + Type runtimeType = value.GetType(); + string payload; + if (runtimeType == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.DottedGadget)) + { + if (IsRegistered(runtimeType)) + { + payload = SerializeBasePayload((global::JsonSubTypes.Aot.Generated.TestDomain.DottedGadget)value, options); + } + else + { + WriteBaseObject(writer, (global::JsonSubTypes.Aot.Generated.TestDomain.DottedGadget)value, options); + return; + } + } + else if (IsRegistered(runtimeType)) + { + payload = JsonSerializer.Serialize(value, options.GetTypeInfo(runtimeType)); + } + else if (TryWriteNestedObject(writer, value, runtimeType, options)) + { + return; + } + else if (TryWriteDynamic(writer, value, runtimeType, options)) + { + return; + } + else + { + JsonSerializer.Serialize(writer, value, options.GetTypeInfo(runtimeType)); + return; + } + using JsonDocument payloadDocument = JsonDocument.Parse(payload); + string discriminatorName = "nested.type"; + if (options.PropertyNamingPolicy != null) + { + discriminatorName = options.PropertyNamingPolicy.ConvertName(discriminatorName); + } + writer.WriteStartObject(); + writer.WritePropertyName(discriminatorName); + WriteDiscriminatorValue(writer, runtimeType, options); + foreach (JsonProperty property in payloadDocument.RootElement.EnumerateObject()) + { + if (!property.NameEquals(discriminatorName)) + { + property.WriteTo(writer); + } + } + writer.WriteEndObject(); + } + + public override global::JsonSubTypes.Aot.Generated.TestDomain.DottedGadget? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Unrecognized token: " + reader.TokenType); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + JsonElement root = document.RootElement; + Type target = SelectType(root, options); + if (target == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.DottedGadget)) + { + return DeserializeBase(root, options); + } + return (global::JsonSubTypes.Aot.Generated.TestDomain.DottedGadget?)JsonSerializer.Deserialize(root.GetRawText(), options.GetTypeInfo(target)); + } + + private Type SelectType(JsonElement root, JsonSerializerOptions options) + { + if (TryGetValueInJson(root, "nested.type", options, out JsonElement discriminator)) + { + + if (discriminator.ValueKind == JsonValueKind.String) + { + switch (discriminator.GetString()) + { + case "electronic": return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.DottedElectronic); + } + } + + if (TryGetDynamicType(discriminator, out Type? dynamicType)) + { + return dynamicType!; + } + if (CustomTypeNameResolver is not null) + { + Type? customType = CustomTypeNameResolver(GetDiscriminatorKey(discriminator)); + if (customType != null) + { + return customType; + } + } + return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.DottedGadget); + } + return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.DottedGadget); + } + + private static readonly System.Collections.Generic.Dictionary> DiscriminatorWriters = new System.Collections.Generic.Dictionary> + { + [typeof(global::JsonSubTypes.Aot.Generated.TestDomain.DottedElectronic)] = static (writer, options) => writer.WriteStringValue("electronic") + }; + + private static bool IsRegistered(Type runtimeType) + { + return DiscriminatorWriters.ContainsKey(runtimeType); + } + + private static void WriteDiscriminatorValue(Utf8JsonWriter writer, Type runtimeType, JsonSerializerOptions options) + { + if (DiscriminatorWriters.TryGetValue(runtimeType, out System.Action? write)) + { + write(writer, options); + return; + } + throw new JsonException("Impossible to serialize type: " + runtimeType.FullName + " because there is no registered mapping for the discriminator property"); + } + + public readonly System.Collections.Concurrent.ConcurrentDictionary DynamicSubtypes = new System.Collections.Concurrent.ConcurrentDictionary(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _dynamicReverse = new System.Collections.Concurrent.ConcurrentDictionary(); + + public void RegisterDynamicSubtype(object discriminator, Type type) + { + DynamicSubtypes[discriminator] = type; + _dynamicReverse[type] = discriminator; // last registration wins, like the builder + } + + /// + /// Custom discriminator-to-type resolution hook, invoked after the static + /// registrations and DynamicSubtypes. Assign it to implement your own + /// name-based lookup (e.g. assembly scanning, a DI registry). The resolved + /// type must be resolvable by the TypeInfoResolver (in the source-gen context + /// for Native AOT). + /// + public Func? CustomTypeNameResolver { get; set; } + + private static object? GetDiscriminatorKey(JsonElement discriminator) + { + switch (discriminator.ValueKind) + { + case JsonValueKind.String: + return discriminator.GetString(); + case JsonValueKind.Number when int.TryParse(discriminator.GetRawText(), out int keyInt): + return keyInt; + default: + return discriminator.GetRawText(); + } + } + + private bool TryGetDynamicType(JsonElement discriminator, out Type? dynamicType) + { + switch (discriminator.ValueKind) + { + case JsonValueKind.String: + return DynamicSubtypes.TryGetValue(discriminator.GetString()!, out dynamicType); + case JsonValueKind.Number: + if (int.TryParse(discriminator.GetRawText(), out int dynamicInt) && DynamicSubtypes.TryGetValue(dynamicInt, out dynamicType)) + { + return true; + } + return DynamicSubtypes.TryGetValue(discriminator.GetRawText(), out dynamicType); + default: + return DynamicSubtypes.TryGetValue(discriminator.GetRawText(), out dynamicType); + } + } + + private static bool TryWriteNestedObject(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.DottedGadget value, Type runtimeType, JsonSerializerOptions options) + { + + return false; + } + + private bool TryWriteDynamic(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.DottedGadget value, Type runtimeType, JsonSerializerOptions options) + { + if (_dynamicReverse.TryGetValue(runtimeType, out object? dynamicDiscriminator)) + { + writer.WriteStartObject(); + string dynamicDiscriminatorName = "nested.type"; + if (options.PropertyNamingPolicy != null) + { + dynamicDiscriminatorName = options.PropertyNamingPolicy.ConvertName(dynamicDiscriminatorName); + } + writer.WritePropertyName(dynamicDiscriminatorName); + writer.WriteRawValue(JsonSerializer.Serialize(dynamicDiscriminator, options.GetTypeInfo(dynamicDiscriminator.GetType()))); + string payload = JsonSerializer.Serialize(value, options.GetTypeInfo(runtimeType)); + using JsonDocument payloadDocument = JsonDocument.Parse(payload); + foreach (JsonProperty property in payloadDocument.RootElement.EnumerateObject()) + { + property.WriteTo(writer); + } + writer.WriteEndObject(); + return true; + } + return false; + } + private static string SerializeBasePayload(global::JsonSubTypes.Aot.Generated.TestDomain.DottedGadget value, JsonSerializerOptions options) + { + using System.IO.MemoryStream stream = new System.IO.MemoryStream(); + using (Utf8JsonWriter writer = new Utf8JsonWriter(stream)) + { + WriteBaseObject(writer, value, options); + } + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); + } + + private static void WriteBaseObject(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.DottedGadget value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + string nameAge = "Age"; + if (options.PropertyNamingPolicy != null && true) + { + nameAge = options.PropertyNamingPolicy.ConvertName(nameAge); + } + writer.WritePropertyName(nameAge); + JsonSerializer.Serialize(writer, value.Age, options.GetTypeInfo(typeof(int))); + writer.WriteEndObject(); + } + + private static global::JsonSubTypes.Aot.Generated.TestDomain.DottedGadget DeserializeBase(JsonElement root, JsonSerializerOptions options) + { + global::JsonSubTypes.Aot.Generated.TestDomain.DottedGadget instance = new global::JsonSubTypes.Aot.Generated.TestDomain.DottedGadget(); + if (TryGetProperty(root, "Age", options, out JsonElement AgeValue)) + { + instance.Age = (int)JsonSerializer.Deserialize(AgeValue.GetRawText(), options.GetTypeInfo(typeof(int)))!; + } + return instance; + } + + private static bool TryGetProperty(JsonElement root, string name, JsonSerializerOptions options, out JsonElement value) + { + if (root.TryGetProperty(name, out value)) + { + return true; + } + string? convertedName = options.PropertyNamingPolicy?.ConvertName(name); + if (convertedName != null && convertedName != name && root.TryGetProperty(convertedName, out value)) + { + return true; + } + if (options.PropertyNameCaseInsensitive) + { + foreach (JsonProperty property in root.EnumerateObject()) + { + if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + } + return false; + } + + private static bool TryGetValueInJson(JsonElement root, string propertyName, JsonSerializerOptions options, out JsonElement value) + { + if (TryGetProperty(root, propertyName, options, out value)) + { + return true; + } + if (propertyName.IndexOf('.') >= 0) + { + string[] segments = propertyName.Split('.'); + JsonElement current = root; + foreach (string segment in segments) + { + if (!TryGetProperty(current, segment, options, out current)) + { + value = default; + return false; + } + } + value = current; + return true; + } + return false; + } + } +} \ No newline at end of file diff --git a/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/GadgetJsonSubTypesConverter.cs b/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/GadgetJsonSubTypesConverter.cs new file mode 100644 index 0000000..d02eb7d --- /dev/null +++ b/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/GadgetJsonSubTypesConverter.cs @@ -0,0 +1,308 @@ +// +#nullable enable +using System; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JsonSubTypes.Aot.Generated +{ + /// Generated by JsonSubTypes.Aot. Compiled converter for global::JsonSubTypes.Aot.Generated.TestDomain.Gadget. + [global::System.CodeDom.Compiler.GeneratedCode("JsonSubTypes.Aot", "1.0.0")] + public sealed class GadgetJsonSubTypesConverter : JsonConverter + { + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Gadget); + + public override void Write(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.Gadget value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + Type runtimeType = value.GetType(); + string payload; + if (runtimeType == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Gadget)) + { + if (IsRegistered(runtimeType)) + { + payload = SerializeBasePayload((global::JsonSubTypes.Aot.Generated.TestDomain.Gadget)value, options); + } + else + { + WriteBaseObject(writer, (global::JsonSubTypes.Aot.Generated.TestDomain.Gadget)value, options); + return; + } + } + else if (IsRegistered(runtimeType)) + { + payload = JsonSerializer.Serialize(value, options.GetTypeInfo(runtimeType)); + } + else if (TryWriteNestedObject(writer, value, runtimeType, options)) + { + return; + } + else if (TryWriteDynamic(writer, value, runtimeType, options)) + { + return; + } + else + { + JsonSerializer.Serialize(writer, value, options.GetTypeInfo(runtimeType)); + return; + } + using JsonDocument payloadDocument = JsonDocument.Parse(payload); + string discriminatorName = "kind"; + if (options.PropertyNamingPolicy != null) + { + discriminatorName = options.PropertyNamingPolicy.ConvertName(discriminatorName); + } + writer.WriteStartObject(); + writer.WritePropertyName(discriminatorName); + WriteDiscriminatorValue(writer, runtimeType, options); + foreach (JsonProperty property in payloadDocument.RootElement.EnumerateObject()) + { + if (!property.NameEquals(discriminatorName)) + { + property.WriteTo(writer); + } + } + writer.WriteEndObject(); + } + + public override global::JsonSubTypes.Aot.Generated.TestDomain.Gadget? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Unrecognized token: " + reader.TokenType); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + JsonElement root = document.RootElement; + Type target = SelectType(root, options); + if (target == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Gadget)) + { + return DeserializeBase(root, options); + } + return (global::JsonSubTypes.Aot.Generated.TestDomain.Gadget?)JsonSerializer.Deserialize(root.GetRawText(), options.GetTypeInfo(target)); + } + + private Type SelectType(JsonElement root, JsonSerializerOptions options) + { + if (TryGetValueInJson(root, "kind", options, out JsonElement discriminator)) + { + + if (discriminator.ValueKind == JsonValueKind.String) + { + switch (discriminator.GetString()) + { + case "ElectronicCat": return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.ElectronicCat); + } + } + if (discriminator.ValueKind == JsonValueKind.Number) + { + switch (discriminator.GetRawText()) + { + case "0": return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.ElectronicCat); + } + } + if (TryGetDynamicType(discriminator, out Type? dynamicType)) + { + return dynamicType!; + } + if (CustomTypeNameResolver is not null) + { + Type? customType = CustomTypeNameResolver(GetDiscriminatorKey(discriminator)); + if (customType != null) + { + return customType; + } + } + return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Gadget); + } + return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Gadget); + } + + private static readonly System.Collections.Generic.Dictionary> DiscriminatorWriters = new System.Collections.Generic.Dictionary> + { + [typeof(global::JsonSubTypes.Aot.Generated.TestDomain.ElectronicCat)] = static (writer, options) => writer.WriteRawValue(JsonSerializer.Serialize(global::JsonSubTypes.Aot.Generated.TestDomain.GadgetKind.ElectronicCat, options.GetTypeInfo(typeof(global::JsonSubTypes.Aot.Generated.TestDomain.GadgetKind)))) + }; + + private static bool IsRegistered(Type runtimeType) + { + return DiscriminatorWriters.ContainsKey(runtimeType); + } + + private static void WriteDiscriminatorValue(Utf8JsonWriter writer, Type runtimeType, JsonSerializerOptions options) + { + if (DiscriminatorWriters.TryGetValue(runtimeType, out System.Action? write)) + { + write(writer, options); + return; + } + throw new JsonException("Impossible to serialize type: " + runtimeType.FullName + " because there is no registered mapping for the discriminator property"); + } + + public readonly System.Collections.Concurrent.ConcurrentDictionary DynamicSubtypes = new System.Collections.Concurrent.ConcurrentDictionary(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _dynamicReverse = new System.Collections.Concurrent.ConcurrentDictionary(); + + public void RegisterDynamicSubtype(object discriminator, Type type) + { + DynamicSubtypes[discriminator] = type; + _dynamicReverse[type] = discriminator; // last registration wins, like the builder + } + + /// + /// Custom discriminator-to-type resolution hook, invoked after the static + /// registrations and DynamicSubtypes. Assign it to implement your own + /// name-based lookup (e.g. assembly scanning, a DI registry). The resolved + /// type must be resolvable by the TypeInfoResolver (in the source-gen context + /// for Native AOT). + /// + public Func? CustomTypeNameResolver { get; set; } + + private static object? GetDiscriminatorKey(JsonElement discriminator) + { + switch (discriminator.ValueKind) + { + case JsonValueKind.String: + return discriminator.GetString(); + case JsonValueKind.Number when int.TryParse(discriminator.GetRawText(), out int keyInt): + return keyInt; + default: + return discriminator.GetRawText(); + } + } + + private bool TryGetDynamicType(JsonElement discriminator, out Type? dynamicType) + { + switch (discriminator.ValueKind) + { + case JsonValueKind.String: + return DynamicSubtypes.TryGetValue(discriminator.GetString()!, out dynamicType); + case JsonValueKind.Number: + if (int.TryParse(discriminator.GetRawText(), out int dynamicInt) && DynamicSubtypes.TryGetValue(dynamicInt, out dynamicType)) + { + return true; + } + return DynamicSubtypes.TryGetValue(discriminator.GetRawText(), out dynamicType); + default: + return DynamicSubtypes.TryGetValue(discriminator.GetRawText(), out dynamicType); + } + } + + private static bool TryWriteNestedObject(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.Gadget value, Type runtimeType, JsonSerializerOptions options) + { + + return false; + } + + private bool TryWriteDynamic(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.Gadget value, Type runtimeType, JsonSerializerOptions options) + { + if (_dynamicReverse.TryGetValue(runtimeType, out object? dynamicDiscriminator)) + { + writer.WriteStartObject(); + string dynamicDiscriminatorName = "kind"; + if (options.PropertyNamingPolicy != null) + { + dynamicDiscriminatorName = options.PropertyNamingPolicy.ConvertName(dynamicDiscriminatorName); + } + writer.WritePropertyName(dynamicDiscriminatorName); + writer.WriteRawValue(JsonSerializer.Serialize(dynamicDiscriminator, options.GetTypeInfo(dynamicDiscriminator.GetType()))); + string payload = JsonSerializer.Serialize(value, options.GetTypeInfo(runtimeType)); + using JsonDocument payloadDocument = JsonDocument.Parse(payload); + foreach (JsonProperty property in payloadDocument.RootElement.EnumerateObject()) + { + property.WriteTo(writer); + } + writer.WriteEndObject(); + return true; + } + return false; + } + private static string SerializeBasePayload(global::JsonSubTypes.Aot.Generated.TestDomain.Gadget value, JsonSerializerOptions options) + { + using System.IO.MemoryStream stream = new System.IO.MemoryStream(); + using (Utf8JsonWriter writer = new Utf8JsonWriter(stream)) + { + WriteBaseObject(writer, value, options); + } + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); + } + + private static void WriteBaseObject(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.Gadget value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + string nameAge = "Age"; + if (options.PropertyNamingPolicy != null && true) + { + nameAge = options.PropertyNamingPolicy.ConvertName(nameAge); + } + writer.WritePropertyName(nameAge); + JsonSerializer.Serialize(writer, value.Age, options.GetTypeInfo(typeof(int))); + writer.WriteEndObject(); + } + + private static global::JsonSubTypes.Aot.Generated.TestDomain.Gadget DeserializeBase(JsonElement root, JsonSerializerOptions options) + { + global::JsonSubTypes.Aot.Generated.TestDomain.Gadget instance = new global::JsonSubTypes.Aot.Generated.TestDomain.Gadget(); + if (TryGetProperty(root, "Age", options, out JsonElement AgeValue)) + { + instance.Age = (int)JsonSerializer.Deserialize(AgeValue.GetRawText(), options.GetTypeInfo(typeof(int)))!; + } + return instance; + } + + private static bool TryGetProperty(JsonElement root, string name, JsonSerializerOptions options, out JsonElement value) + { + if (root.TryGetProperty(name, out value)) + { + return true; + } + string? convertedName = options.PropertyNamingPolicy?.ConvertName(name); + if (convertedName != null && convertedName != name && root.TryGetProperty(convertedName, out value)) + { + return true; + } + if (options.PropertyNameCaseInsensitive) + { + foreach (JsonProperty property in root.EnumerateObject()) + { + if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + } + return false; + } + + private static bool TryGetValueInJson(JsonElement root, string propertyName, JsonSerializerOptions options, out JsonElement value) + { + if (TryGetProperty(root, propertyName, options, out value)) + { + return true; + } + if (propertyName.IndexOf('.') >= 0) + { + string[] segments = propertyName.Split('.'); + JsonElement current = root; + foreach (string segment in segments) + { + if (!TryGetProperty(current, segment, options, out current)) + { + value = default; + return false; + } + } + value = current; + return true; + } + return false; + } + } +} \ No newline at end of file diff --git a/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/GameJsonSubTypesConverter.cs b/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/GameJsonSubTypesConverter.cs new file mode 100644 index 0000000..4a8dd6e --- /dev/null +++ b/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/GameJsonSubTypesConverter.cs @@ -0,0 +1,302 @@ +// +#nullable enable +using System; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JsonSubTypes.Aot.Generated +{ + /// Generated by JsonSubTypes.Aot. Compiled converter for global::JsonSubTypes.Aot.Generated.TestDomain.Game. + [global::System.CodeDom.Compiler.GeneratedCode("JsonSubTypes.Aot", "1.0.0")] + public sealed class GameJsonSubTypesConverter : JsonConverter + { + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Game); + + public override void Write(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.Game value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + Type runtimeType = value.GetType(); + string payload; + if (runtimeType == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Game)) + { + if (IsRegistered(runtimeType)) + { + payload = SerializeBasePayload((global::JsonSubTypes.Aot.Generated.TestDomain.Game)value, options); + } + else + { + WriteBaseObject(writer, (global::JsonSubTypes.Aot.Generated.TestDomain.Game)value, options); + return; + } + } + else if (IsRegistered(runtimeType)) + { + payload = JsonSerializer.Serialize(value, options.GetTypeInfo(runtimeType)); + } + else if (TryWriteNestedObject(writer, value, runtimeType, options)) + { + return; + } + else if (TryWriteDynamic(writer, value, runtimeType, options)) + { + return; + } + else + { + JsonSerializer.Serialize(writer, value, options.GetTypeInfo(runtimeType)); + return; + } + using JsonDocument payloadDocument = JsonDocument.Parse(payload); + string discriminatorName = "$GameKind"; + if (options.PropertyNamingPolicy != null) + { + discriminatorName = options.PropertyNamingPolicy.ConvertName(discriminatorName); + } + writer.WriteStartObject(); + writer.WritePropertyName(discriminatorName); + WriteDiscriminatorValue(writer, runtimeType, options); + foreach (JsonProperty property in payloadDocument.RootElement.EnumerateObject()) + { + if (!property.NameEquals(discriminatorName)) + { + property.WriteTo(writer); + } + } + writer.WriteEndObject(); + } + + public override global::JsonSubTypes.Aot.Generated.TestDomain.Game? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Unrecognized token: " + reader.TokenType); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + JsonElement root = document.RootElement; + Type target = SelectType(root, options); + if (target == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Game)) + { + return DeserializeBase(root, options); + } + return (global::JsonSubTypes.Aot.Generated.TestDomain.Game?)JsonSerializer.Deserialize(root.GetRawText(), options.GetTypeInfo(target)); + } + + private Type SelectType(JsonElement root, JsonSerializerOptions options) + { + if (TryGetValueInJson(root, "$GameKind", options, out JsonElement discriminator)) + { + + if (discriminator.ValueKind == JsonValueKind.String) + { + switch (discriminator.GetString()) + { + case "RUN": return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Run); + case "WALK": return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Walk); + } + } + if (discriminator.ValueKind == JsonValueKind.Number) + { + switch (discriminator.GetRawText()) + { + case "0": return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Run); + case "1": return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Walk); + } + } + if (TryGetDynamicType(discriminator, out Type? dynamicType)) + { + return dynamicType!; + } + if (CustomTypeNameResolver is not null) + { + Type? customType = CustomTypeNameResolver(GetDiscriminatorKey(discriminator)); + if (customType != null) + { + return customType; + } + } + return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Game); + } + return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Game); + } + + private static readonly System.Collections.Generic.Dictionary> DiscriminatorWriters = new System.Collections.Generic.Dictionary> + { + [typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Run)] = static (writer, options) => writer.WriteRawValue(JsonSerializer.Serialize(global::JsonSubTypes.Aot.Generated.TestDomain.GameDiscriminator.RUN, options.GetTypeInfo(typeof(global::JsonSubTypes.Aot.Generated.TestDomain.GameDiscriminator)))), + [typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Walk)] = static (writer, options) => writer.WriteRawValue(JsonSerializer.Serialize(global::JsonSubTypes.Aot.Generated.TestDomain.GameDiscriminator.WALK, options.GetTypeInfo(typeof(global::JsonSubTypes.Aot.Generated.TestDomain.GameDiscriminator)))) + }; + + private static bool IsRegistered(Type runtimeType) + { + return DiscriminatorWriters.ContainsKey(runtimeType); + } + + private static void WriteDiscriminatorValue(Utf8JsonWriter writer, Type runtimeType, JsonSerializerOptions options) + { + if (DiscriminatorWriters.TryGetValue(runtimeType, out System.Action? write)) + { + write(writer, options); + return; + } + throw new JsonException("Impossible to serialize type: " + runtimeType.FullName + " because there is no registered mapping for the discriminator property"); + } + + public readonly System.Collections.Concurrent.ConcurrentDictionary DynamicSubtypes = new System.Collections.Concurrent.ConcurrentDictionary(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _dynamicReverse = new System.Collections.Concurrent.ConcurrentDictionary(); + + public void RegisterDynamicSubtype(object discriminator, Type type) + { + DynamicSubtypes[discriminator] = type; + _dynamicReverse[type] = discriminator; // last registration wins, like the builder + } + + /// + /// Custom discriminator-to-type resolution hook, invoked after the static + /// registrations and DynamicSubtypes. Assign it to implement your own + /// name-based lookup (e.g. assembly scanning, a DI registry). The resolved + /// type must be resolvable by the TypeInfoResolver (in the source-gen context + /// for Native AOT). + /// + public Func? CustomTypeNameResolver { get; set; } + + private static object? GetDiscriminatorKey(JsonElement discriminator) + { + switch (discriminator.ValueKind) + { + case JsonValueKind.String: + return discriminator.GetString(); + case JsonValueKind.Number when int.TryParse(discriminator.GetRawText(), out int keyInt): + return keyInt; + default: + return discriminator.GetRawText(); + } + } + + private bool TryGetDynamicType(JsonElement discriminator, out Type? dynamicType) + { + switch (discriminator.ValueKind) + { + case JsonValueKind.String: + return DynamicSubtypes.TryGetValue(discriminator.GetString()!, out dynamicType); + case JsonValueKind.Number: + if (int.TryParse(discriminator.GetRawText(), out int dynamicInt) && DynamicSubtypes.TryGetValue(dynamicInt, out dynamicType)) + { + return true; + } + return DynamicSubtypes.TryGetValue(discriminator.GetRawText(), out dynamicType); + default: + return DynamicSubtypes.TryGetValue(discriminator.GetRawText(), out dynamicType); + } + } + + private static bool TryWriteNestedObject(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.Game value, Type runtimeType, JsonSerializerOptions options) + { + + return false; + } + + private bool TryWriteDynamic(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.Game value, Type runtimeType, JsonSerializerOptions options) + { + if (_dynamicReverse.TryGetValue(runtimeType, out object? dynamicDiscriminator)) + { + writer.WriteStartObject(); + string dynamicDiscriminatorName = "$GameKind"; + if (options.PropertyNamingPolicy != null) + { + dynamicDiscriminatorName = options.PropertyNamingPolicy.ConvertName(dynamicDiscriminatorName); + } + writer.WritePropertyName(dynamicDiscriminatorName); + writer.WriteRawValue(JsonSerializer.Serialize(dynamicDiscriminator, options.GetTypeInfo(dynamicDiscriminator.GetType()))); + string payload = JsonSerializer.Serialize(value, options.GetTypeInfo(runtimeType)); + using JsonDocument payloadDocument = JsonDocument.Parse(payload); + foreach (JsonProperty property in payloadDocument.RootElement.EnumerateObject()) + { + property.WriteTo(writer); + } + writer.WriteEndObject(); + return true; + } + return false; + } + private static string SerializeBasePayload(global::JsonSubTypes.Aot.Generated.TestDomain.Game value, JsonSerializerOptions options) + { + using System.IO.MemoryStream stream = new System.IO.MemoryStream(); + using (Utf8JsonWriter writer = new Utf8JsonWriter(stream)) + { + WriteBaseObject(writer, value, options); + } + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); + } + + private static void WriteBaseObject(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.Game value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + writer.WriteEndObject(); + } + + private static global::JsonSubTypes.Aot.Generated.TestDomain.Game DeserializeBase(JsonElement root, JsonSerializerOptions options) + { + global::JsonSubTypes.Aot.Generated.TestDomain.Game instance = new global::JsonSubTypes.Aot.Generated.TestDomain.Game(); + + return instance; + } + + private static bool TryGetProperty(JsonElement root, string name, JsonSerializerOptions options, out JsonElement value) + { + if (root.TryGetProperty(name, out value)) + { + return true; + } + string? convertedName = options.PropertyNamingPolicy?.ConvertName(name); + if (convertedName != null && convertedName != name && root.TryGetProperty(convertedName, out value)) + { + return true; + } + if (options.PropertyNameCaseInsensitive) + { + foreach (JsonProperty property in root.EnumerateObject()) + { + if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + } + return false; + } + + private static bool TryGetValueInJson(JsonElement root, string propertyName, JsonSerializerOptions options, out JsonElement value) + { + if (TryGetProperty(root, propertyName, options, out value)) + { + return true; + } + if (propertyName.IndexOf('.') >= 0) + { + string[] segments = propertyName.Split('.'); + JsonElement current = root; + foreach (string segment in segments) + { + if (!TryGetProperty(current, segment, options, out current)) + { + value = default; + return false; + } + } + value = current; + return true; + } + return false; + } + } +} \ No newline at end of file diff --git a/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/JsonSubTypesAotConverters.cs b/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/JsonSubTypesAotConverters.cs new file mode 100644 index 0000000..b6d1b10 --- /dev/null +++ b/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/JsonSubTypesAotConverters.cs @@ -0,0 +1,17 @@ +// +#nullable enable + +namespace JsonSubTypes.Aot.Generated +{ + /// Generated by JsonSubTypes.Aot. Shared converter instances to add to JsonSerializerOptions.Converters. + [global::System.CodeDom.Compiler.GeneratedCode("JsonSubTypes.Aot", "1.0.0")] + public static class JsonSubTypesAotConverters + { + public static readonly AnimalJsonSubTypesConverter Animal = new AnimalJsonSubTypesConverter(); + public static readonly PersonJsonSubTypesConverter Person = new PersonJsonSubTypesConverter(); + public static readonly GadgetJsonSubTypesConverter Gadget = new GadgetJsonSubTypesConverter(); + public static readonly DottedGadgetJsonSubTypesConverter DottedGadget = new DottedGadgetJsonSubTypesConverter(); + public static readonly PayloadJsonSubTypesConverter Payload = new PayloadJsonSubTypesConverter(); + public static readonly GameJsonSubTypesConverter Game = new GameJsonSubTypesConverter(); + } +} \ No newline at end of file diff --git a/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/PayloadJsonSubTypesConverter.cs b/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/PayloadJsonSubTypesConverter.cs new file mode 100644 index 0000000..51daae9 --- /dev/null +++ b/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/PayloadJsonSubTypesConverter.cs @@ -0,0 +1,333 @@ +// +#nullable enable +using System; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JsonSubTypes.Aot.Generated +{ + /// Generated by JsonSubTypes.Aot. Compiled converter for global::JsonSubTypes.Aot.Generated.TestDomain.Payload. + [global::System.CodeDom.Compiler.GeneratedCode("JsonSubTypes.Aot", "1.0.0")] + public sealed class PayloadJsonSubTypesConverter : JsonConverter + { + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Payload); + + public override void Write(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.Payload value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + Type runtimeType = value.GetType(); + string payload; + if (runtimeType == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Payload)) + { + if (IsRegistered(runtimeType)) + { + payload = SerializeBasePayload((global::JsonSubTypes.Aot.Generated.TestDomain.Payload)value, options); + } + else + { + WriteBaseObject(writer, (global::JsonSubTypes.Aot.Generated.TestDomain.Payload)value, options); + return; + } + } + else if (IsRegistered(runtimeType)) + { + payload = JsonSerializer.Serialize(value, options.GetTypeInfo(runtimeType)); + } + else if (TryWriteNestedObject(writer, value, runtimeType, options)) + { + return; + } + else if (TryWriteDynamic(writer, value, runtimeType, options)) + { + return; + } + else + { + JsonSerializer.Serialize(writer, value, options.GetTypeInfo(runtimeType)); + return; + } + using JsonDocument payloadDocument = JsonDocument.Parse(payload); + string discriminatorName = "$PayloadKind"; + if (options.PropertyNamingPolicy != null) + { + discriminatorName = options.PropertyNamingPolicy.ConvertName(discriminatorName); + } + writer.WriteStartObject(); + writer.WritePropertyName(discriminatorName); + WriteDiscriminatorValue(writer, runtimeType, options); + foreach (JsonProperty property in payloadDocument.RootElement.EnumerateObject()) + { + if (!property.NameEquals(discriminatorName)) + { + property.WriteTo(writer); + } + } + writer.WriteEndObject(); + } + + public override global::JsonSubTypes.Aot.Generated.TestDomain.Payload? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Unrecognized token: " + reader.TokenType); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + JsonElement root = document.RootElement; + Type target = SelectType(root, options); + if (target == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Payload)) + { + return DeserializeBase(root, options); + } + return (global::JsonSubTypes.Aot.Generated.TestDomain.Payload?)JsonSerializer.Deserialize(root.GetRawText(), options.GetTypeInfo(target)); + } + + private Type SelectType(JsonElement root, JsonSerializerOptions options) + { + if (TryGetValueInJson(root, "$PayloadKind", options, out JsonElement discriminator)) + { + + if (discriminator.ValueKind == JsonValueKind.String) + { + switch (discriminator.GetString()) + { + case "GAME": return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Game); + case "COM": return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Com); + } + } + if (discriminator.ValueKind == JsonValueKind.Number) + { + switch (discriminator.GetRawText()) + { + case "0": return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Game); + case "1": return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Com); + } + } + if (TryGetDynamicType(discriminator, out Type? dynamicType)) + { + return dynamicType!; + } + if (CustomTypeNameResolver is not null) + { + Type? customType = CustomTypeNameResolver(GetDiscriminatorKey(discriminator)); + if (customType != null) + { + return customType; + } + } + return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Payload); + } + return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Payload); + } + + private static readonly System.Collections.Generic.Dictionary> DiscriminatorWriters = new System.Collections.Generic.Dictionary> + { + [typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Game)] = static (writer, options) => writer.WriteRawValue(JsonSerializer.Serialize(global::JsonSubTypes.Aot.Generated.TestDomain.PayloadDiscriminator.GAME, options.GetTypeInfo(typeof(global::JsonSubTypes.Aot.Generated.TestDomain.PayloadDiscriminator)))), + [typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Com)] = static (writer, options) => writer.WriteRawValue(JsonSerializer.Serialize(global::JsonSubTypes.Aot.Generated.TestDomain.PayloadDiscriminator.COM, options.GetTypeInfo(typeof(global::JsonSubTypes.Aot.Generated.TestDomain.PayloadDiscriminator)))) + }; + + private static bool IsRegistered(Type runtimeType) + { + return DiscriminatorWriters.ContainsKey(runtimeType); + } + + private static void WriteDiscriminatorValue(Utf8JsonWriter writer, Type runtimeType, JsonSerializerOptions options) + { + if (DiscriminatorWriters.TryGetValue(runtimeType, out System.Action? write)) + { + write(writer, options); + return; + } + throw new JsonException("Impossible to serialize type: " + runtimeType.FullName + " because there is no registered mapping for the discriminator property"); + } + + public readonly System.Collections.Concurrent.ConcurrentDictionary DynamicSubtypes = new System.Collections.Concurrent.ConcurrentDictionary(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _dynamicReverse = new System.Collections.Concurrent.ConcurrentDictionary(); + + public void RegisterDynamicSubtype(object discriminator, Type type) + { + DynamicSubtypes[discriminator] = type; + _dynamicReverse[type] = discriminator; // last registration wins, like the builder + } + + /// + /// Custom discriminator-to-type resolution hook, invoked after the static + /// registrations and DynamicSubtypes. Assign it to implement your own + /// name-based lookup (e.g. assembly scanning, a DI registry). The resolved + /// type must be resolvable by the TypeInfoResolver (in the source-gen context + /// for Native AOT). + /// + public Func? CustomTypeNameResolver { get; set; } + + private static object? GetDiscriminatorKey(JsonElement discriminator) + { + switch (discriminator.ValueKind) + { + case JsonValueKind.String: + return discriminator.GetString(); + case JsonValueKind.Number when int.TryParse(discriminator.GetRawText(), out int keyInt): + return keyInt; + default: + return discriminator.GetRawText(); + } + } + + private bool TryGetDynamicType(JsonElement discriminator, out Type? dynamicType) + { + switch (discriminator.ValueKind) + { + case JsonValueKind.String: + return DynamicSubtypes.TryGetValue(discriminator.GetString()!, out dynamicType); + case JsonValueKind.Number: + if (int.TryParse(discriminator.GetRawText(), out int dynamicInt) && DynamicSubtypes.TryGetValue(dynamicInt, out dynamicType)) + { + return true; + } + return DynamicSubtypes.TryGetValue(discriminator.GetRawText(), out dynamicType); + default: + return DynamicSubtypes.TryGetValue(discriminator.GetRawText(), out dynamicType); + } + } + + private static bool TryWriteNestedObject(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.Payload value, Type runtimeType, JsonSerializerOptions options) + { + if (runtimeType == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Run)) + { + writer.WriteStartObject(); + writer.WritePropertyName("$PayloadKind"); + writer.WriteRawValue(JsonSerializer.Serialize(global::JsonSubTypes.Aot.Generated.TestDomain.PayloadDiscriminator.GAME, options.GetTypeInfo(typeof(global::JsonSubTypes.Aot.Generated.TestDomain.PayloadDiscriminator)))); + writer.WritePropertyName("$GameKind"); + writer.WriteRawValue(JsonSerializer.Serialize(global::JsonSubTypes.Aot.Generated.TestDomain.GameDiscriminator.RUN, options.GetTypeInfo(typeof(global::JsonSubTypes.Aot.Generated.TestDomain.GameDiscriminator)))); + string payload = JsonSerializer.Serialize(value, options.GetTypeInfo(runtimeType)); + using JsonDocument payloadDocument = JsonDocument.Parse(payload); + foreach (JsonProperty property in payloadDocument.RootElement.EnumerateObject()) + { + property.WriteTo(writer); + } + writer.WriteEndObject(); + return true; + } + if (runtimeType == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Walk)) + { + writer.WriteStartObject(); + writer.WritePropertyName("$PayloadKind"); + writer.WriteRawValue(JsonSerializer.Serialize(global::JsonSubTypes.Aot.Generated.TestDomain.PayloadDiscriminator.GAME, options.GetTypeInfo(typeof(global::JsonSubTypes.Aot.Generated.TestDomain.PayloadDiscriminator)))); + writer.WritePropertyName("$GameKind"); + writer.WriteRawValue(JsonSerializer.Serialize(global::JsonSubTypes.Aot.Generated.TestDomain.GameDiscriminator.WALK, options.GetTypeInfo(typeof(global::JsonSubTypes.Aot.Generated.TestDomain.GameDiscriminator)))); + string payload = JsonSerializer.Serialize(value, options.GetTypeInfo(runtimeType)); + using JsonDocument payloadDocument = JsonDocument.Parse(payload); + foreach (JsonProperty property in payloadDocument.RootElement.EnumerateObject()) + { + property.WriteTo(writer); + } + writer.WriteEndObject(); + return true; + } + return false; + } + + private bool TryWriteDynamic(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.Payload value, Type runtimeType, JsonSerializerOptions options) + { + if (_dynamicReverse.TryGetValue(runtimeType, out object? dynamicDiscriminator)) + { + writer.WriteStartObject(); + string dynamicDiscriminatorName = "$PayloadKind"; + if (options.PropertyNamingPolicy != null) + { + dynamicDiscriminatorName = options.PropertyNamingPolicy.ConvertName(dynamicDiscriminatorName); + } + writer.WritePropertyName(dynamicDiscriminatorName); + writer.WriteRawValue(JsonSerializer.Serialize(dynamicDiscriminator, options.GetTypeInfo(dynamicDiscriminator.GetType()))); + string payload = JsonSerializer.Serialize(value, options.GetTypeInfo(runtimeType)); + using JsonDocument payloadDocument = JsonDocument.Parse(payload); + foreach (JsonProperty property in payloadDocument.RootElement.EnumerateObject()) + { + property.WriteTo(writer); + } + writer.WriteEndObject(); + return true; + } + return false; + } + private static string SerializeBasePayload(global::JsonSubTypes.Aot.Generated.TestDomain.Payload value, JsonSerializerOptions options) + { + using System.IO.MemoryStream stream = new System.IO.MemoryStream(); + using (Utf8JsonWriter writer = new Utf8JsonWriter(stream)) + { + WriteBaseObject(writer, value, options); + } + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); + } + + private static void WriteBaseObject(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.Payload value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + writer.WriteEndObject(); + } + + private static global::JsonSubTypes.Aot.Generated.TestDomain.Payload DeserializeBase(JsonElement root, JsonSerializerOptions options) + { + global::JsonSubTypes.Aot.Generated.TestDomain.Payload instance = new global::JsonSubTypes.Aot.Generated.TestDomain.Payload(); + + return instance; + } + + private static bool TryGetProperty(JsonElement root, string name, JsonSerializerOptions options, out JsonElement value) + { + if (root.TryGetProperty(name, out value)) + { + return true; + } + string? convertedName = options.PropertyNamingPolicy?.ConvertName(name); + if (convertedName != null && convertedName != name && root.TryGetProperty(convertedName, out value)) + { + return true; + } + if (options.PropertyNameCaseInsensitive) + { + foreach (JsonProperty property in root.EnumerateObject()) + { + if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + } + return false; + } + + private static bool TryGetValueInJson(JsonElement root, string propertyName, JsonSerializerOptions options, out JsonElement value) + { + if (TryGetProperty(root, propertyName, options, out value)) + { + return true; + } + if (propertyName.IndexOf('.') >= 0) + { + string[] segments = propertyName.Split('.'); + JsonElement current = root; + foreach (string segment in segments) + { + if (!TryGetProperty(current, segment, options, out current)) + { + value = default; + return false; + } + } + value = current; + return true; + } + return false; + } + } +} \ No newline at end of file diff --git a/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/PersonJsonSubTypesConverter.cs b/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/PersonJsonSubTypesConverter.cs new file mode 100644 index 0000000..285d564 --- /dev/null +++ b/JsonSubTypes.Aot.Generated/Generated/JsonSubTypes.Aot/JsonSubTypes.Aot.JsonSubTypesGenerator/PersonJsonSubTypesConverter.cs @@ -0,0 +1,157 @@ +// +#nullable enable +using System; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JsonSubTypes.Aot.Generated +{ + /// Generated by JsonSubTypes.Aot. Compiled converter for global::JsonSubTypes.Aot.Generated.TestDomain.Person. + [global::System.CodeDom.Compiler.GeneratedCode("JsonSubTypes.Aot", "1.0.0")] + public sealed class PersonJsonSubTypesConverter : JsonConverter + { + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Person); + + public override void Write(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.Person value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + Type runtimeType = value.GetType(); + if (runtimeType == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Person)) + { + WriteBaseObject(writer, (global::JsonSubTypes.Aot.Generated.TestDomain.Person)value, options); + return; + } + JsonSerializer.Serialize(writer, value, options.GetTypeInfo(runtimeType)); + } + + public override global::JsonSubTypes.Aot.Generated.TestDomain.Person? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Unrecognized token: " + reader.TokenType); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + JsonElement root = document.RootElement; + Type target = SelectType(root, options); + if (target == typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Person)) + { + return DeserializeBase(root, options); + } + return (global::JsonSubTypes.Aot.Generated.TestDomain.Person?)JsonSerializer.Deserialize(root.GetRawText(), options.GetTypeInfo(target)); + } + + private Type SelectType(JsonElement root, JsonSerializerOptions options) + { + System.Collections.Generic.HashSet matches = new System.Collections.Generic.HashSet(); +if (root.TryGetProperty("JobTitle", out _)) +{ + matches.Add(typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Employee)); +} +if (root.TryGetProperty("Skill", out _)) +{ + matches.Add(typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Artist)); +} + if (matches.Count == 1) + { + return matches.First(); + } + if (matches.Count > 1) + { + throw new JsonException("Ambiguous type resolution, expected only one type but got: " + string.Join(", ", matches.Select(t => t.FullName))); + } + return typeof(global::JsonSubTypes.Aot.Generated.TestDomain.Person); + } + + + private static string SerializeBasePayload(global::JsonSubTypes.Aot.Generated.TestDomain.Person value, JsonSerializerOptions options) + { + using System.IO.MemoryStream stream = new System.IO.MemoryStream(); + using (Utf8JsonWriter writer = new Utf8JsonWriter(stream)) + { + WriteBaseObject(writer, value, options); + } + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); + } + + private static void WriteBaseObject(Utf8JsonWriter writer, global::JsonSubTypes.Aot.Generated.TestDomain.Person value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + string nameFirstName = "FirstName"; + if (options.PropertyNamingPolicy != null && true) + { + nameFirstName = options.PropertyNamingPolicy.ConvertName(nameFirstName); + } + writer.WritePropertyName(nameFirstName); + JsonSerializer.Serialize(writer, value.FirstName, options.GetTypeInfo(typeof(string))); + writer.WriteEndObject(); + } + + private static global::JsonSubTypes.Aot.Generated.TestDomain.Person DeserializeBase(JsonElement root, JsonSerializerOptions options) + { + global::JsonSubTypes.Aot.Generated.TestDomain.Person instance = new global::JsonSubTypes.Aot.Generated.TestDomain.Person(); + if (TryGetProperty(root, "FirstName", options, out JsonElement FirstNameValue)) + { + instance.FirstName = (string)JsonSerializer.Deserialize(FirstNameValue.GetRawText(), options.GetTypeInfo(typeof(string)))!; + } + return instance; + } + + private static bool TryGetProperty(JsonElement root, string name, JsonSerializerOptions options, out JsonElement value) + { + if (root.TryGetProperty(name, out value)) + { + return true; + } + string? convertedName = options.PropertyNamingPolicy?.ConvertName(name); + if (convertedName != null && convertedName != name && root.TryGetProperty(convertedName, out value)) + { + return true; + } + if (options.PropertyNameCaseInsensitive) + { + foreach (JsonProperty property in root.EnumerateObject()) + { + if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + } + return false; + } + + private static bool TryGetValueInJson(JsonElement root, string propertyName, JsonSerializerOptions options, out JsonElement value) + { + if (TryGetProperty(root, propertyName, options, out value)) + { + return true; + } + if (propertyName.IndexOf('.') >= 0) + { + string[] segments = propertyName.Split('.'); + JsonElement current = root; + foreach (string segment in segments) + { + if (!TryGetProperty(current, segment, options, out current)) + { + value = default; + return false; + } + } + value = current; + return true; + } + return false; + } + } +} \ No newline at end of file diff --git a/JsonSubTypes.Aot.Generated/JsonSubTypes.Aot.Generated.csproj b/JsonSubTypes.Aot.Generated/JsonSubTypes.Aot.Generated.csproj new file mode 100644 index 0000000..1501231 --- /dev/null +++ b/JsonSubTypes.Aot.Generated/JsonSubTypes.Aot.Generated.csproj @@ -0,0 +1,21 @@ + + + + net8.0;net10.0 + enable + latest + false + + + + + + + + diff --git a/JsonSubTypes.Aot.Generated/TestDomain.cs b/JsonSubTypes.Aot.Generated/TestDomain.cs new file mode 100644 index 0000000..50bf9c3 --- /dev/null +++ b/JsonSubTypes.Aot.Generated/TestDomain.cs @@ -0,0 +1,129 @@ +#nullable enable +using System.Text.Json.Serialization; +using JsonSubTypes.Text.Json; + +namespace JsonSubTypes.Aot.Generated.TestDomain +{ + [JsonSubTypesAotConverter("type")] + [KnownSubType(typeof(Cat), "cat")] + [KnownSubType(typeof(Dog), 2)] + public class Animal + { + public int Age { get; set; } + } + + public class Cat : Animal + { + public int Lives { get; set; } + } + + public class Dog : Animal + { + public bool CanHunt { get; set; } + } + + [JsonSubTypesAotConverter] + [KnownSubTypeWithProperty(typeof(Employee), "JobTitle")] + [KnownSubTypeWithProperty(typeof(Artist), "Skill")] + [FallBackSubType(typeof(Person))] + public class Person + { + public string? FirstName { get; set; } + } + + public class Employee : Person + { + public string? JobTitle { get; set; } + } + + public class Artist : Person + { + public string? Skill { get; set; } + } + + [JsonSubTypesAotConverter("kind")] + [KnownSubType(typeof(ElectronicCat), GadgetKind.ElectronicCat)] + public class Gadget + { + public int Age { get; set; } + } + + public class ElectronicCat : Gadget + { + public int Lives { get; set; } + } + + public enum GadgetKind + { + ElectronicCat + } + + [JsonSubTypesAotConverter("nested.type")] + [KnownSubType(typeof(DottedElectronic), "electronic")] + public class DottedGadget + { + public int Age { get; set; } + } + + public class DottedElectronic : DottedGadget + { + public int Lives { get; set; } + } + + public enum PayloadDiscriminator + { + GAME = 0, + COM = 1 + } + + public enum GameDiscriminator + { + RUN = 0, + WALK = 1 + } + + [JsonSubTypesAotConverter("$PayloadKind")] + [KnownSubType(typeof(Game), PayloadDiscriminator.GAME)] + [KnownSubType(typeof(Com), PayloadDiscriminator.COM)] + public class Payload + { + } + + public class Com : Payload + { + } + + [JsonSubTypesAotConverter("$GameKind")] + [KnownSubType(typeof(Run), GameDiscriminator.RUN)] + [KnownSubType(typeof(Walk), GameDiscriminator.WALK)] + public class Game : Payload + { + } + + public class Run : Game + { + } + + public class Walk : Game + { + } + + [JsonSerializable(typeof(Animal))] + [JsonSerializable(typeof(Cat))] + [JsonSerializable(typeof(Dog))] + [JsonSerializable(typeof(Person))] + [JsonSerializable(typeof(Employee))] + [JsonSerializable(typeof(Artist))] + [JsonSerializable(typeof(Gadget))] + [JsonSerializable(typeof(ElectronicCat))] + [JsonSerializable(typeof(DottedGadget))] + [JsonSerializable(typeof(DottedElectronic))] + [JsonSerializable(typeof(Payload))] + [JsonSerializable(typeof(Com))] + [JsonSerializable(typeof(Game))] + [JsonSerializable(typeof(Run))] + [JsonSerializable(typeof(Walk))] + public partial class TestDomainJsonContext : JsonSerializerContext + { + } +} diff --git a/JsonSubTypes.Aot.Generator.Tests/CommittedGeneratedConverterTests.cs b/JsonSubTypes.Aot.Generator.Tests/CommittedGeneratedConverterTests.cs new file mode 100644 index 0000000..a1ab4d7 --- /dev/null +++ b/JsonSubTypes.Aot.Generator.Tests/CommittedGeneratedConverterTests.cs @@ -0,0 +1,146 @@ +#nullable enable +using System.Text.Json; +using System.Text.Json.Serialization; +using JsonSubTypes.Aot.Generated; +using JsonSubTypes.Aot.Generated.TestDomain; +using NUnit.Framework; + +namespace JsonSubTypes.Aot.Generator.Tests +{ + // Exercises the committed golden-master converters (the Generated/ files in + // JsonSubTypes.Aot.Generated). Because those files are compiled as real sources, + // coverlet measures them here and Sonar analyzes them. Every converter must be + // exercised on both serialize and deserialize so its Write/Read/SelectType paths + // are covered. + [TestFixture] + public class CommittedGeneratedConverterTests + { + private static JsonSerializerOptions Options() + { + return new JsonSerializerOptions { Converters = { GetConverter() } }; + } + + private static JsonConverter GetConverter() + { + // resolve the converter instance from the committed registry + return (JsonConverter)RegistryConverter(typeof(T)); + } + + private static object RegistryConverter(System.Type baseType) + { + var registry = typeof(JsonSubTypesAotConverters); + string memberName = baseType.Name; + return registry.GetField(memberName)?.GetValue(null) + ?? throw new System.InvalidOperationException("No committed converter for " + baseType.Name); + } + + // ---- Animal (string + int discriminators) ---- + + [Test] + public void Animal_SerializeCat_WritesDiscriminator() + { + string json = JsonSerializer.Serialize(new Cat { Age = 3, Lives = 9 }, Options()); + Assert.That(json, Does.Contain("\"type\":\"cat\"")); + } + + [Test] + public void Animal_DeserializeCatDiscriminator_ReturnsCat() + { + Animal? result = JsonSerializer.Deserialize("{\"type\":\"cat\",\"Lives\":9,\"Age\":3}", Options()); + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void Animal_DeserializeIntDogDiscriminator_ReturnsDog() + { + Animal? result = JsonSerializer.Deserialize("{\"type\":2,\"CanHunt\":true,\"Age\":3}", Options()); + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void Animal_DeserializeUnknownDiscriminator_FallsBackToBase() + { + Animal? result = JsonSerializer.Deserialize("{\"type\":\"fish\",\"Age\":3}", Options()); + Assert.That(result, Is.InstanceOf()); + Assert.That(result, Is.Not.InstanceOf()); + } + + // ---- Person (property presence + fallback) ---- + + [Test] + public void Person_SerializeArtist_NoDiscriminatorWritten() + { + string json = JsonSerializer.Serialize(new Artist { Skill = "Painter", FirstName = "A" }, Options()); + Assert.That(json, Does.Contain("\"Skill\"")); + } + + [Test] + public void Person_DeserializeBySkill_ReturnsArtist() + { + Person? result = JsonSerializer.Deserialize("{\"Skill\":\"Painter\",\"FirstName\":\"A\"}", Options()); + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void Person_DeserializeUnknown_FallsBackToBase() + { + Person? result = JsonSerializer.Deserialize("{\"FirstName\":\"A\"}", Options()); + Assert.That(result, Is.InstanceOf()); + Assert.That(result, Is.Not.InstanceOf()); + } + + // ---- Gadget (enum discriminator) ---- + + [Test] + public void Gadget_SerializeElectronicCat_WritesEnumDiscriminator() + { + string json = JsonSerializer.Serialize(new ElectronicCat { Age = 3, Lives = 9 }, Options()); + Assert.That(json, Does.Contain("\"kind\":")); + } + + [Test] + public void Gadget_DeserializeElectronicCat_ReturnsSubtype() + { + Gadget? result = JsonSerializer.Deserialize("{\"kind\":0,\"Lives\":9,\"Age\":3}", Options()); + Assert.That(result, Is.InstanceOf()); + } + + // ---- DottedGadget (nested discriminator path) ---- + + [Test] + public void DottedGadget_DeserializeNestedDiscriminator_ReturnsSubtype() + { + DottedGadget? result = JsonSerializer.Deserialize( + "{\"nested\":{\"type\":\"electronic\"},\"Lives\":9,\"Age\":3}", Options()); + Assert.That(result, Is.InstanceOf()); + } + + // ---- Payload / Game (nested multi-level hierarchy) ---- + + [Test] + public void Payload_DeserializeNestedGameKind_ReturnsRun() + { + var options = new JsonSerializerOptions + { + TypeInfoResolver = TestDomainJsonContext.Default, + Converters = { (JsonConverter)RegistryConverter(typeof(Payload)), (JsonConverter)RegistryConverter(typeof(Game)) } + }; + Payload? result = JsonSerializer.Deserialize( + "{\"$PayloadKind\":0,\"$GameKind\":0}", options); + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void Payload_DeserializeCom_ReturnsCom() + { + var options = new JsonSerializerOptions + { + TypeInfoResolver = TestDomainJsonContext.Default, + Converters = { (JsonConverter)RegistryConverter(typeof(Payload)) } + }; + Payload? result = JsonSerializer.Deserialize( + "{\"$PayloadKind\":1}", options); + Assert.That(result, Is.InstanceOf()); + } + } +} diff --git a/JsonSubTypes.Aot.Generator.Tests/GeneratedCodeExecutionTests.cs b/JsonSubTypes.Aot.Generator.Tests/GeneratedCodeExecutionTests.cs new file mode 100644 index 0000000..bce3f94 --- /dev/null +++ b/JsonSubTypes.Aot.Generator.Tests/GeneratedCodeExecutionTests.cs @@ -0,0 +1,95 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using NUnit.Framework; + +namespace JsonSubTypes.Aot.Generator.Tests +{ + // Compiles the driver output (source + generated converters) into a real + // assembly and runs the generated converters through reflection. This is what + // gives coverlet visibility into the *generated* code paths. + [TestFixture] + public class GeneratedCodeExecutionTests + { + private const string Hierarchy = @" +using System.Text.Json.Serialization; +using JsonSubTypes.Text.Json; + +[JsonSubTypesAotConverter(""type"")] +[KnownSubType(typeof(Cat), ""cat"")] +[KnownSubType(typeof(Dog), ""dog"")] +public class Animal +{ + public int Age { get; set; } +} + +public class Cat : Animal { public int Lives { get; set; } } +public class Dog : Animal { public bool CanHunt { get; set; } } +"; + + private static (Type animalType, Type catType, object converter) CompileAndLoad() + { + GeneratorRun run = GeneratorDriverRunner.GetRun(Hierarchy); + Assert.That(run.DriverResults.Diagnostics, Is.Empty); + + using MemoryStream assemblyStream = new MemoryStream(); + using MemoryStream pdbStream = new MemoryStream(); + var emitResult = run.OutputCompilation.Emit(assemblyStream, pdbStream); + Assert.That(emitResult.Success, Is.True, + string.Join("\n", emitResult.Diagnostics.Select(d => d.ToString()))); + + Assembly assembly = Assembly.Load(assemblyStream.ToArray()); + Type[] allTypes = assembly.GetTypes(); + Type? animalType = allTypes.FirstOrDefault(t => t.Name == "Animal"); + Type? catType = allTypes.FirstOrDefault(t => t.Name == "Cat"); + Type? converterType = allTypes.FirstOrDefault(t => t.Name == "AnimalJsonSubTypesConverter"); + Assert.That(animalType, Is.Not.Null, "types: " + string.Join(", ", allTypes.Select(t => t.FullName))); + Assert.That(catType, Is.Not.Null); + Assert.That(converterType, Is.Not.Null); + object converter = Activator.CreateInstance(converterType!)!; + return (animalType!, catType!, converter); + } + + [Test] + public void Serialize_Cat_WritesDiscriminator() + { + (Type animalType, Type catType, object converter) = CompileAndLoad(); + object cat = Activator.CreateInstance(catType)!; + catType.GetProperty("Age")!.SetValue(cat, 3); + catType.GetProperty("Lives")!.SetValue(cat, 9); + + var options = new JsonSerializerOptions { Converters = { (JsonConverter)converter } }; + string json = JsonSerializer.Serialize(cat, animalType, options); + + Assert.That(json, Does.Contain("\"type\":\"cat\"")); + } + + [Test] + public void Deserialize_CatDiscriminator_ReturnsCat() + { + (Type animalType, _, object converter) = CompileAndLoad(); + + var options = new JsonSerializerOptions { Converters = { (JsonConverter)converter } }; + object? result = JsonSerializer.Deserialize("{\"type\":\"cat\",\"Lives\":9,\"Age\":3}", animalType, options); + + Assert.That(result, Is.Not.Null); + Assert.That(result!.GetType().Name, Is.EqualTo("Cat")); + } + + [Test] + public void Deserialize_UnknownDiscriminator_FallsBackToBase() + { + (Type animalType, _, object converter) = CompileAndLoad(); + + var options = new JsonSerializerOptions { Converters = { (JsonConverter)converter } }; + object? result = JsonSerializer.Deserialize("{\"type\":\"fish\",\"Age\":3}", animalType, options); + + Assert.That(result, Is.Not.Null); + Assert.That(result!.GetType().Name, Is.EqualTo("Animal")); + } + } +} diff --git a/JsonSubTypes.Aot.Generator.Tests/GeneratorDriverRunner.cs b/JsonSubTypes.Aot.Generator.Tests/GeneratorDriverRunner.cs new file mode 100644 index 0000000..493a877 --- /dev/null +++ b/JsonSubTypes.Aot.Generator.Tests/GeneratorDriverRunner.cs @@ -0,0 +1,78 @@ +#nullable enable +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; +using System.Text.Json.Serialization; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using JsonSubTypes.Aot; +using JsonSubTypes.Text.Json; + +namespace JsonSubTypes.Aot.Generator.Tests +{ + internal static class GeneratorDriverRunner + { + // Reference the assemblies the generated code needs: System.Text.Json for + // the JsonConverter base, and JsonSubTypes.Text.Json for the attributes. + // Loading the runtime assemblies from their loaded location keeps the + // reference set aligned with the runtime the tests execute on. + private static readonly List References = new List + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(JsonConverterAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(JsonSubTypesAotConverterAttribute).Assembly.Location), + MetadataReference.CreateFromFile(Assembly.Load("System.Runtime").Location), + MetadataReference.CreateFromFile(Assembly.Load("System.Private.CoreLib").Location), + MetadataReference.CreateFromFile(Assembly.Load("netstandard").Location), + MetadataReference.CreateFromFile(typeof(System.Collections.Generic.List<>).Assembly.Location), + MetadataReference.CreateFromFile(Assembly.Load("System.Memory").Location), + MetadataReference.CreateFromFile(Assembly.Load("System.Collections.Concurrent").Location), + MetadataReference.CreateFromFile(Assembly.Load("System.Text.Json").Location), + MetadataReference.CreateFromFile(Assembly.Load("System.Runtime.CompilerServices.Unsafe").Location), + }; + + public static GeneratorRun GetRun(string source) + { + CSharpParseOptions parseOptions = new CSharpParseOptions(LanguageVersion.Latest); + SyntaxTree tree = CSharpSyntaxTree.ParseText(source, parseOptions); + CSharpCompilation compilation = CSharpCompilation.Create( + "GeneratorTest", + new[] { tree }, + References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(new JsonSubTypesGenerator()); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation outputCompilation, out _); + + return new GeneratorRun(driver.GetRunResult(), outputCompilation); + } + + public static string? GetGeneratedSource(GeneratorRun run, string hintName) + { + foreach (GeneratorRunResult result in run.DriverResults.Results) + { + foreach (GeneratedSourceResult source in result.GeneratedSources) + { + if (source.HintName == hintName) + { + return source.SourceText.ToString(); + } + } + } + return null; + } + } + + internal sealed class GeneratorRun + { + public GeneratorDriverRunResult DriverResults { get; } + public Compilation OutputCompilation { get; } + + public GeneratorRun(GeneratorDriverRunResult driverResults, Compilation outputCompilation) + { + DriverResults = driverResults; + OutputCompilation = outputCompilation; + } + } +} diff --git a/JsonSubTypes.Aot.Generator.Tests/GeneratorDriverTests.cs b/JsonSubTypes.Aot.Generator.Tests/GeneratorDriverTests.cs new file mode 100644 index 0000000..9589887 --- /dev/null +++ b/JsonSubTypes.Aot.Generator.Tests/GeneratorDriverTests.cs @@ -0,0 +1,129 @@ +#nullable enable +using System.Linq; +using NUnit.Framework; + +namespace JsonSubTypes.Aot.Generator.Tests +{ + [TestFixture] + public class GeneratorDriverTests + { + private const string SimpleHierarchy = @" +using System.Text.Json.Serialization; +using JsonSubTypes.Text.Json; + +[JsonSubTypesAotConverter(""type"")] +[KnownSubType(typeof(Cat), ""cat"")] +[KnownSubType(typeof(Dog), ""dog"")] +public class Animal +{ + public int Age { get; set; } +} + +public class Cat : Animal { public int Lives { get; set; } } +public class Dog : Animal { public bool CanHunt { get; set; } } +"; + + [Test] + public void Generate_SingleBase_EmitsConverterAndRegistry() + { + GeneratorRun run = GeneratorDriverRunner.GetRun(SimpleHierarchy); + + string[] hints = run.DriverResults.Results + .SelectMany(r => r.GeneratedSources) + .Select(s => s.HintName).OrderBy(h => h).ToArray(); + CollectionAssert.AreEqual( + new[] { "AnimalJsonSubTypesConverter.g.cs", "JsonSubTypesAotConverters.g.cs" }, + hints); + } + + [Test] + public void Generate_SingleBase_RegistryExposesConverterInstance() + { + GeneratorRun run = GeneratorDriverRunner.GetRun(SimpleHierarchy); + + string? text = GeneratorDriverRunner.GetGeneratedSource(run, "JsonSubTypesAotConverters.g.cs"); + Assert.That(text, Is.Not.Null); + StringAssert.Contains("public static readonly AnimalJsonSubTypesConverter Animal", text!); + } + + [Test] + public void Generate_SingleBase_ConverterRoutesSubtypes() + { + GeneratorRun run = GeneratorDriverRunner.GetRun(SimpleHierarchy); + + string? text = GeneratorDriverRunner.GetGeneratedSource(run, "AnimalJsonSubTypesConverter.g.cs"); + Assert.That(text, Is.Not.Null); + StringAssert.Contains("class AnimalJsonSubTypesConverter : JsonConverter<", text!); + StringAssert.Contains("\"cat\"", text!); + StringAssert.Contains("\"dog\"", text!); + } + + [Test] + public void Generate_NoAttribute_EmitsNothing() + { + GeneratorRun run = GeneratorDriverRunner.GetRun("public class Plain { public int X { get; set; } }"); + + Assert.That(run.DriverResults.Results.SelectMany(r => r.GeneratedSources), Is.Empty); + } + + [Test] + public void Generate_PropertyPresence_EmitsPresenceRouting() + { + const string presenceHierarchy = @" +using System.Text.Json.Serialization; +using JsonSubTypes.Text.Json; + +[JsonSubTypesAotConverter] +[KnownSubTypeWithProperty(typeof(Employee), ""JobTitle"")] +[KnownSubTypeWithProperty(typeof(Artist), ""Skill"")] +public class Person { public string? FirstName { get; set; } } + +public class Employee : Person { public string? JobTitle { get; set; } } +public class Artist : Person { public string? Skill { get; set; } } +"; + GeneratorRun run = GeneratorDriverRunner.GetRun(presenceHierarchy); + + string? text = GeneratorDriverRunner.GetGeneratedSource(run, "PersonJsonSubTypesConverter.g.cs"); + Assert.That(text, Is.Not.Null); + StringAssert.Contains("TryGetProperty(\"JobTitle\"", text!); + StringAssert.Contains("TryGetProperty(\"Skill\"", text!); + } + + [Test] + public void Generate_TwoBasesWithSameNameInDifferentNamespaces_ProducesDistinctConverters() + { + const string twoAnimals = @" +using System.Text.Json.Serialization; +using JsonSubTypes.Text.Json; + +namespace A +{ + [JsonSubTypesAotConverter(""type"")] + [KnownSubType(typeof(Cat), ""cat"")] + public class Animal { public int Age { get; set; } } + public class Cat : Animal { } +} + +namespace B +{ + [JsonSubTypesAotConverter(""type"")] + [KnownSubType(typeof(Dog), ""dog"")] + public class Animal { public int Age { get; set; } } + public class Dog : Animal { } +} +"; + GeneratorRun run = GeneratorDriverRunner.GetRun(twoAnimals); + + string[] hints = run.DriverResults.Results + .SelectMany(r => r.GeneratedSources) + .Select(s => s.HintName).OrderBy(h => h).ToArray(); + + // Both converters are emitted under distinct qualified names, not one + // overwriting the other. + Assert.That(hints, Does.Contain("global__A_AnimalJsonSubTypesConverter.g.cs")); + Assert.That(hints, Does.Contain("global__B_AnimalJsonSubTypesConverter.g.cs")); + Assert.That(hints.Count(h => h.StartsWith("Animal")), Is.EqualTo(0), + "unqualified Animal converter should not exist when names collide"); + } + } +} diff --git a/JsonSubTypes.Aot.Generator.Tests/GoldenMasterTests.cs b/JsonSubTypes.Aot.Generator.Tests/GoldenMasterTests.cs new file mode 100644 index 0000000..a9d7e02 --- /dev/null +++ b/JsonSubTypes.Aot.Generator.Tests/GoldenMasterTests.cs @@ -0,0 +1,75 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using NUnit.Framework; + +namespace JsonSubTypes.Aot.Generator.Tests +{ + // Golden-master test: runs the generator over the committed test domain and + // compares every produced file against the committed .g.cs files. Any change + // in the generator's output breaks this test, forcing a conscious regeneration. + [TestFixture] + public class GoldenMasterTests + { + private const string DomainPath = "JsonSubTypes.Aot.Generated/TestDomain.cs"; + private const string GeneratedDir = "JsonSubTypes.Aot.Generated/Generated"; + + private static string FindRepoRoot() + { + DirectoryInfo? dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "JsonSubTypes.sln"))) + { + dir = dir.Parent; + } + return dir?.FullName ?? throw new DirectoryNotFoundException("Repo root not found"); + } + + [Test] + public void GeneratedFiles_MatchCurrentGeneratorOutput() + { + string root = FindRepoRoot(); + string domain = File.ReadAllText(Path.Combine(root, DomainPath)); + string generatedDir = Path.Combine(root, GeneratedDir); + + GeneratorRun run = GeneratorDriverRunner.GetRun(domain); + + var produced = run.DriverResults.Results + .SelectMany(r => r.GeneratedSources) + .ToDictionary(s => s.HintName, s => s.SourceText.ToString()); + + // Every committed .cs file must exist in the current output and match + // exactly. The generator names its output *.g.cs; the committed golden + // master drops that suffix so Sonar analyzes them as regular sources. + string[] committed = Directory.GetFiles(generatedDir, "*.cs", SearchOption.AllDirectories); + + TestContext.WriteLine("PRODUCED: " + string.Join(", ", produced.Keys.OrderBy(k => k))); + TestContext.WriteLine("COMMITTED: " + string.Join(", ", committed.Select(f => Path.GetFileName(f)).OrderBy(k => k))); + + Assert.That(produced.Count, Is.EqualTo(committed.Length), + "Produced " + produced.Count + " files but " + committed.Length + " are committed"); + + foreach (string committedFile in committed) + { + string committedName = Path.GetFileName(committedFile); + string hintName = committedName.Replace(".cs", "") + ".g.cs"; + string committedText = File.ReadAllText(committedFile).Trim(); + Assert.That(produced.ContainsKey(hintName), Is.True, + "Generator no longer produces committed file " + committedName); + + string producedText = produced[hintName].Trim(); + Assert.That(producedText, Is.EqualTo(committedText), + "Generator output differs from committed " + committedName + + ".\nRegenerate with: dotnet build JsonSubTypes.Aot.Generated -p:EmitCompilerGeneratedFiles=true"); + } + + // No extra files produced that are not committed. + foreach (string hintName in produced.Keys) + { + string committedName = hintName.EndsWith(".g.cs") ? hintName.Replace(".g.cs", ".cs") : hintName; + Assert.That(committed.Any(f => Path.GetFileName(f) == committedName), Is.True, + "Generator produces " + hintName + " but it is not committed"); + } + } + } +} diff --git a/JsonSubTypes.Aot.Generator.Tests/JsonSubTypes.Aot.Generator.Tests.csproj b/JsonSubTypes.Aot.Generator.Tests/JsonSubTypes.Aot.Generator.Tests.csproj new file mode 100644 index 0000000..1e75a0c --- /dev/null +++ b/JsonSubTypes.Aot.Generator.Tests/JsonSubTypes.Aot.Generator.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0;net10.0 + false + enable + latest + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/JsonSubTypes.Aot/JsonSubTypesGenerator.cs b/JsonSubTypes.Aot/JsonSubTypesGenerator.cs index e2bc371..01a8e71 100644 --- a/JsonSubTypes.Aot/JsonSubTypesGenerator.cs +++ b/JsonSubTypes.Aot/JsonSubTypesGenerator.cs @@ -70,11 +70,12 @@ .. bases } BuildGlobalModel(generated); + AssignUniqueConverterNames(generated); spc.AddSource("JsonSubTypesAotConverters.g.cs", SourceText.From(EmitRegistry(generated), System.Text.Encoding.UTF8)); foreach (BaseTypeInfo baseInfo in generated) { - spc.AddSource($"{baseInfo.TypeName}JsonSubTypesConverter.g.cs", SourceText.From(EmitConverter(baseInfo), System.Text.Encoding.UTF8)); + spc.AddSource($"{baseInfo.ConverterName}.g.cs", SourceText.From(EmitConverter(baseInfo), System.Text.Encoding.UTF8)); } }); } @@ -83,6 +84,8 @@ private sealed class BaseTypeInfo { public string FullyQualifiedName { get; set; } = ""; public string TypeName { get; set; } = ""; + public string ConverterName { get; set; } = ""; + public string RegistryMemberName { get; set; } = ""; public string? DiscriminatorPropertyName { get; set; } public bool AddDiscriminatorFirst { get; set; } = true; public bool HasPropertyPresence { get; set; } @@ -387,6 +390,39 @@ private static bool TryGetDiscriminator(TypedConstant value, SubtypeRegistration // ---------------------------------------------------------- global model + private static void AssignUniqueConverterNames(List bases) + { + // Default: the short type name. If two base types share the same short name + // (different namespaces), qualify them with the sanitized namespace so the + // generated classes and file names do not collide. + foreach (IGrouping group in bases.GroupBy(b => b.TypeName)) + { + foreach (BaseTypeInfo b in group) + { + if (group.Count() == 1) + { + b.ConverterName = b.TypeName + "JsonSubTypesConverter"; + b.RegistryMemberName = b.TypeName; + } + else + { + b.ConverterName = Sanitize(b.FullyQualifiedName) + "JsonSubTypesConverter"; + b.RegistryMemberName = Sanitize(b.FullyQualifiedName); + } + } + } + } + + private static string Sanitize(string fullyQualifiedName) + { + var builder = new System.Text.StringBuilder(fullyQualifiedName.Length); + foreach (char c in fullyQualifiedName) + { + builder.Append(char.IsLetterOrDigit(c) || c == '_' ? c : '_'); + } + return builder.ToString(); + } + private static void BuildGlobalModel(List bases) { Dictionary baseByType = bases @@ -552,7 +588,7 @@ .. ancestors private static string EmitRegistry(List bases) { string converters = string.Join("\n", - bases.Select(b => $" public static readonly {b.TypeName}JsonSubTypesConverter {b.TypeName} = new {b.TypeName}JsonSubTypesConverter();")); + bases.Select(b => $" public static readonly {b.ConverterName} {b.RegistryMemberName} = new {b.ConverterName}();")); return $$""" // #nullable enable @@ -588,7 +624,7 @@ namespace JsonSubTypes.Aot.Generated { /// Generated by JsonSubTypes.Aot. Compiled converter for {{info.FullyQualifiedName}}. [global::System.CodeDom.Compiler.GeneratedCode("JsonSubTypes.Aot", "1.0.0")] - public sealed class {{info.TypeName}}JsonSubTypesConverter : JsonConverter<{{info.FullyQualifiedName}}> + public sealed class {{info.ConverterName}} : JsonConverter<{{info.FullyQualifiedName}}> { public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof({{info.FullyQualifiedName}}); diff --git a/JsonSubTypes.sln b/JsonSubTypes.sln index e412a42..127fbde 100644 --- a/JsonSubTypes.sln +++ b/JsonSubTypes.sln @@ -34,6 +34,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JsonSubTypes.Aot.Sample", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JsonSubTypes.Benchmarks", "JsonSubTypes.Benchmarks\JsonSubTypes.Benchmarks.csproj", "{43447A7A-0C1D-4613-A7FD-0D4A8C7A11AD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JsonSubTypes.Aot.Generator.Tests", "JsonSubTypes.Aot.Generator.Tests\JsonSubTypes.Aot.Generator.Tests.csproj", "{50112231-C47C-4FAD-B2E2-7A61A9486C04}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JsonSubTypes.Aot.Generated", "JsonSubTypes.Aot.Generated\JsonSubTypes.Aot.Generated.csproj", "{5A0027A7-3617-4CD7-9E87-5F84F38B58A4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -176,6 +180,30 @@ Global {43447A7A-0C1D-4613-A7FD-0D4A8C7A11AD}.Release|x64.Build.0 = Release|Any CPU {43447A7A-0C1D-4613-A7FD-0D4A8C7A11AD}.Release|x86.ActiveCfg = Release|Any CPU {43447A7A-0C1D-4613-A7FD-0D4A8C7A11AD}.Release|x86.Build.0 = Release|Any CPU + {50112231-C47C-4FAD-B2E2-7A61A9486C04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {50112231-C47C-4FAD-B2E2-7A61A9486C04}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50112231-C47C-4FAD-B2E2-7A61A9486C04}.Debug|x64.ActiveCfg = Debug|Any CPU + {50112231-C47C-4FAD-B2E2-7A61A9486C04}.Debug|x64.Build.0 = Debug|Any CPU + {50112231-C47C-4FAD-B2E2-7A61A9486C04}.Debug|x86.ActiveCfg = Debug|Any CPU + {50112231-C47C-4FAD-B2E2-7A61A9486C04}.Debug|x86.Build.0 = Debug|Any CPU + {50112231-C47C-4FAD-B2E2-7A61A9486C04}.Release|Any CPU.ActiveCfg = Release|Any CPU + {50112231-C47C-4FAD-B2E2-7A61A9486C04}.Release|Any CPU.Build.0 = Release|Any CPU + {50112231-C47C-4FAD-B2E2-7A61A9486C04}.Release|x64.ActiveCfg = Release|Any CPU + {50112231-C47C-4FAD-B2E2-7A61A9486C04}.Release|x64.Build.0 = Release|Any CPU + {50112231-C47C-4FAD-B2E2-7A61A9486C04}.Release|x86.ActiveCfg = Release|Any CPU + {50112231-C47C-4FAD-B2E2-7A61A9486C04}.Release|x86.Build.0 = Release|Any CPU + {5A0027A7-3617-4CD7-9E87-5F84F38B58A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5A0027A7-3617-4CD7-9E87-5F84F38B58A4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5A0027A7-3617-4CD7-9E87-5F84F38B58A4}.Debug|x64.ActiveCfg = Debug|Any CPU + {5A0027A7-3617-4CD7-9E87-5F84F38B58A4}.Debug|x64.Build.0 = Debug|Any CPU + {5A0027A7-3617-4CD7-9E87-5F84F38B58A4}.Debug|x86.ActiveCfg = Debug|Any CPU + {5A0027A7-3617-4CD7-9E87-5F84F38B58A4}.Debug|x86.Build.0 = Debug|Any CPU + {5A0027A7-3617-4CD7-9E87-5F84F38B58A4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5A0027A7-3617-4CD7-9E87-5F84F38B58A4}.Release|Any CPU.Build.0 = Release|Any CPU + {5A0027A7-3617-4CD7-9E87-5F84F38B58A4}.Release|x64.ActiveCfg = Release|Any CPU + {5A0027A7-3617-4CD7-9E87-5F84F38B58A4}.Release|x64.Build.0 = Release|Any CPU + {5A0027A7-3617-4CD7-9E87-5F84F38B58A4}.Release|x86.ActiveCfg = Release|Any CPU + {5A0027A7-3617-4CD7-9E87-5F84F38B58A4}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..491a8f7 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,22 @@ +# Codecov configuration. +# +# The JsonSubTypes.Aot.Generated project is the golden master: committed output of the +# source generator, used to test the generator and to let Sonar analyze generated code. +# Its coverage is not a meaningful project-level metric (it is exercised only partially +# by the generator tests), so it is excluded from the Codecov project and patch checks. + +ignore: + - "JsonSubTypes.Aot.Generated/**" + - "JsonSubTypes.Aot.Generator.Tests/**" + - "**/*Tests*/**" + - "**/*.g.cs" + +coverage: + status: + project: + default: + target: auto + threshold: 5% + patch: + default: + target: 80%