diff --git a/.github/workflows/egil-systemtextjson-migration-ci.yml b/.github/workflows/egil-systemtextjson-migration-ci.yml index 47fb5887..25021a95 100644 --- a/.github/workflows/egil-systemtextjson-migration-ci.yml +++ b/.github/workflows/egil-systemtextjson-migration-ci.yml @@ -25,9 +25,9 @@ env: permissions: contents: read -# SDK band policy: every setup-dotnet step below installs the 10.x band plus +# SDK band policy: every setup-dotnet step below installs the 8.x, 9.x and 10.x bands plus # the .NET 11 SDK because the package and all of its projects (src, test, perf, -# samples) multi-target net10.0 and net11.0. The .NET 11 SDK is pinned to the +# samples) multi-target net8.0, net9.0, net10.0 and net11.0. The .NET 11 SDK is pinned to the # RC1 build because setup-dotnet applies dotnet-quality to every listed band and # an "11.0.x" wildcard would not resolve a prerelease. Replace the pin with # "11.0.x" at .NET 11 GA (2026-11-10). Keep these bands in sync with the retained @@ -48,6 +48,8 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: | + 8.x + 9.x 10.x 11.0.100-rc.1.26425.128 @@ -58,7 +60,10 @@ jobs: dotnet build ./src/Egil.SystemTextJson.Migration.Analyzers/Egil.SystemTextJson.Migration.Analyzers.csproj -c Release dotnet build ./src/Egil.SystemTextJson.Migration/Egil.SystemTextJson.Migration.csproj -c Release dotnet pack ./src/Egil.SystemTextJson.Migration/Egil.SystemTextJson.Migration.csproj -c Release --no-build --output ${{ env.NuGetDirectory }} - pwsh -NoProfile -File ./scripts/verify-analyzer-package.ps1 -PackagePath "$(find ${{ env.NuGetDirectory }} -maxdepth 1 -name '*.nupkg' -print -quit)" + # setup-dotnet installs SDKs in this job; this script verifies one target per call. + for framework in net8.0 net9.0 net10.0 net11.0; do + pwsh -NoProfile -File ./scripts/verify-analyzer-package.ps1 -TargetFramework "$framework" -PackagePath "$(find ${{ env.NuGetDirectory }} -maxdepth 1 -name '*.nupkg' -print -quit)" + done - name: Install NativeAOT prerequisites run: | @@ -102,6 +107,8 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: | + 8.x + 9.x 10.x 11.0.100-rc.1.26425.128 @@ -135,6 +142,8 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: | + 8.x + 9.x 10.x 11.0.100-rc.1.26425.128 @@ -168,6 +177,8 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: | + 8.x + 9.x 10.x 11.0.100-rc.1.26425.128 diff --git a/Egil.SystemTextJson.Migration/AGENTS.md b/Egil.SystemTextJson.Migration/AGENTS.md index 9d2b3241..98d448b7 100644 --- a/Egil.SystemTextJson.Migration/AGENTS.md +++ b/Egil.SystemTextJson.Migration/AGENTS.md @@ -24,7 +24,7 @@ Use the solution file from repository root: - `dotnet outdated`: check for dependency updates. ## Coding Style & Naming Conventions -- Language/runtime: C# on `net10.0` and `net11.0` (multi-targeted), nullable enabled. .NET 11 specific code lives under `#if NET11_0_OR_GREATER`. +- Language/runtime: C# on `net8.0`, `net9.0`, `net10.0` and `net11.0` (multi-targeted), nullable enabled. .NET 11 specific code lives under `#if NET11_0_OR_GREATER`. - Indentation: 4 spaces for C#; follow `.editorconfig` for other file types. - Prefer file-scoped namespaces and explicit braces. - Use `var` when the type is obvious; keep naming in PascalCase for types/methods. diff --git a/Egil.SystemTextJson.Migration/Directory.Build.props b/Egil.SystemTextJson.Migration/Directory.Build.props new file mode 100644 index 00000000..5741c718 --- /dev/null +++ b/Egil.SystemTextJson.Migration/Directory.Build.props @@ -0,0 +1,10 @@ + + + + + + false + + diff --git a/Egil.SystemTextJson.Migration/README.md b/Egil.SystemTextJson.Migration/README.md index 7aa356fa..3c60b488 100644 --- a/Egil.SystemTextJson.Migration/README.md +++ b/Egil.SystemTextJson.Migration/README.md @@ -4,6 +4,8 @@ Version-tolerant JSON migration for `System.Text.Json`. +Supports .NET 8, .NET 9, .NET 10, and .NET 11 using each target's framework-provided `System.Text.Json`; the package does not require a newer JSON package in .NET 8 or .NET 9 hosts. C# union support is available only in the .NET 11 asset. + When data models evolve, old JSON payloads still exist — in databases, caches, queues, and on disk. This library migrates those payloads to the current type **automatically during deserialization**, so application code never deals with obsolete shapes. **Key characteristics:** @@ -484,7 +486,7 @@ builder.Services.AddOpenTelemetry() Every benchmark compares the library against hand-written migration code on top of plain `System.Text.Json`. The small profile is a minimal `{ "name": "...", "age": ... }` object that highlights worst-case fixed overhead; the medium profile is a best-guess average object with about 12 object members; and the large profile has about 96 object members spread across nested objects, arrays, and dictionary entries. See [benchmark payload examples](https://github.com/egil/framework/blob/main/Egil.SystemTextJson.Migration/docs/perf/payload-examples.md) for representative JSON from each profile. -The generated table below is refreshed by `.\scripts\update-perf-docs.ps1` from the latest source-generated BenchmarkDotNet report, produced with the `net11.0` build of the benchmarks (the union dispatch scenario only exists there; every other scenario also runs on `net10.0`). It keeps BenchmarkDotNet's `Ratio`, `RatioSD`, and `Alloc Ratio` columns so README numbers stay tied to the raw benchmark output. +The generated table below is refreshed by `.\scripts\update-perf-docs.ps1` from the latest source-generated BenchmarkDotNet report, produced with the `net11.0` build of the benchmarks (the union dispatch scenario only exists there; every other scenario also runs on `net8.0`, `net9.0` and `net10.0`). It keeps BenchmarkDotNet's `Ratio`, `RatioSD`, and `Alloc Ratio` columns so README numbers stay tied to the raw benchmark output. diff --git a/Egil.SystemTextJson.Migration/docs/recipes/aot-source-gen.md b/Egil.SystemTextJson.Migration/docs/recipes/aot-source-gen.md index 4f369ccf..ba82b764 100644 --- a/Egil.SystemTextJson.Migration/docs/recipes/aot-source-gen.md +++ b/Egil.SystemTextJson.Migration/docs/recipes/aot-source-gen.md @@ -61,6 +61,15 @@ Two consequences of living in the resolver chain: Options that have no resolver at all still serialize through reflection, as they would without the library. +On .NET 8 and .NET 9, assigning a decorated `options.TypeInfoResolver` back to the same options can create a self-referencing chain because System.Text.Json mutates the options-bound chain during assignment. Snapshot the entries before decorating them: + +```csharp +var resolver = JsonTypeInfoResolver.Combine(options.TypeInfoResolverChain.ToArray()); +options.TypeInfoResolver = resolver.WithAddedModifier(ModifyContract); +``` + +Alternatively, configure the base resolver and its modifiers before calling `AddJsonMigrationSupport()`. + ## What the library does at runtime Migration itself is driven by `static abstract` interface methods and the type metadata your `JsonSerializerContext` provides; once a type's converter has been created, the library's own read and write paths do not use reflection (System.Text.Json's converter and metadata resolution behaves as it does without the library). @@ -71,4 +80,4 @@ Discovery does. When a converter is created for a `[JsonMigratable]` type — on The [deployment requirements above](#aot--source-generation) follow from migration's interface discovery, assembly scanning, external migrator activation, and runtime generic construction. JSON source generation supplies serialization metadata but does not replace those operations. Even `RegisterMigrator()` currently enters the reflection-based invoker factory. Trimming can remove required contracts or constructors, and NativeAOT may lack code for runtime generic instantiations. Failures can include missing migrators when reading old payloads even when current payloads appear to work. -The library enables trim and AOT analyzers on both supported target frameworks without declaring `IsTrimmable` or `IsAotCompatible`. Member-preservation annotations and warning-free library analysis do not establish compatibility. A supported NativeAOT path requires separate implementation and published runtime evidence. +The library enables trim and AOT analyzers on all supported target frameworks without declaring `IsTrimmable` or `IsAotCompatible`. Member-preservation annotations and warning-free library analysis do not establish compatibility. A supported NativeAOT path requires separate implementation and published runtime evidence. diff --git a/Egil.SystemTextJson.Migration/docs/recipes/upgrading-to-v2.md b/Egil.SystemTextJson.Migration/docs/recipes/upgrading-to-v2.md index a593fd99..44b037a1 100644 --- a/Egil.SystemTextJson.Migration/docs/recipes/upgrading-to-v2.md +++ b/Egil.SystemTextJson.Migration/docs/recipes/upgrading-to-v2.md @@ -51,7 +51,7 @@ Applying the attribute to a type whose contract is not a JSON object now throws ## .NET 11 -The package multi-targets `net10.0` and `net11.0`. On .NET 11, `AddJsonMigrationSupport()` also registers `JsonMigratableUnionTypeClassifier`, so a C# `union` whose cases are `[JsonMigratable]` types is classified by migration discriminator. Source-generated contexts must name the classifier on the union (`[JsonUnion(TypeClassifier = typeof(JsonMigratableUnionTypeClassifier))]`); see [polymorphism.md](polymorphism.md). The `[JsonPolymorphic]` limitation is unchanged. +The package multi-targets `net8.0`, `net9.0`, `net10.0` and `net11.0`. On .NET 11, `AddJsonMigrationSupport()` also registers `JsonMigratableUnionTypeClassifier`, so a C# `union` whose cases are `[JsonMigratable]` types is classified by migration discriminator. Source-generated contexts must name the classifier on the union (`[JsonUnion(TypeClassifier = typeof(JsonMigratableUnionTypeClassifier))]`); see [polymorphism.md](polymorphism.md). The `[JsonPolymorphic]` limitation is unchanged. ## Checklist diff --git a/Egil.SystemTextJson.Migration/perf/Egil.SystemTextJson.Migration.PerfTests/Egil.SystemTextJson.Migration.PerfTests.csproj b/Egil.SystemTextJson.Migration/perf/Egil.SystemTextJson.Migration.PerfTests/Egil.SystemTextJson.Migration.PerfTests.csproj index 24fa7d77..234210d2 100644 --- a/Egil.SystemTextJson.Migration/perf/Egil.SystemTextJson.Migration.PerfTests/Egil.SystemTextJson.Migration.PerfTests.csproj +++ b/Egil.SystemTextJson.Migration/perf/Egil.SystemTextJson.Migration.PerfTests/Egil.SystemTextJson.Migration.PerfTests.csproj @@ -1,7 +1,7 @@ - net10.0;net11.0 + net8.0;net9.0;net10.0;net11.0 Exe enable enable diff --git a/Egil.SystemTextJson.Migration/samples/Egil.SystemTextJson.Migration.Samples/Egil.SystemTextJson.Migration.Samples.csproj b/Egil.SystemTextJson.Migration/samples/Egil.SystemTextJson.Migration.Samples/Egil.SystemTextJson.Migration.Samples.csproj index 16f6528e..981d840f 100644 --- a/Egil.SystemTextJson.Migration/samples/Egil.SystemTextJson.Migration.Samples/Egil.SystemTextJson.Migration.Samples.csproj +++ b/Egil.SystemTextJson.Migration/samples/Egil.SystemTextJson.Migration.Samples/Egil.SystemTextJson.Migration.Samples.csproj @@ -5,7 +5,7 @@ enable Exe Egil.SystemTextJson.Migration.Samples - net10.0;net11.0 + net8.0;net9.0;net10.0;net11.0 true false $(NoWarn);CA1707 diff --git a/Egil.SystemTextJson.Migration/scripts/verify-analyzer-package.ps1 b/Egil.SystemTextJson.Migration/scripts/verify-analyzer-package.ps1 index de1210a7..868dbab9 100644 --- a/Egil.SystemTextJson.Migration/scripts/verify-analyzer-package.ps1 +++ b/Egil.SystemTextJson.Migration/scripts/verify-analyzer-package.ps1 @@ -1,7 +1,9 @@ [CmdletBinding()] param( [Parameter(Mandatory)] - [string]$PackagePath + [string]$PackagePath, + [ValidateSet('net8.0', 'net9.0', 'net10.0', 'net11.0')] + [string]$TargetFramework = 'net10.0' ) Set-StrictMode -Version Latest @@ -59,7 +61,8 @@ try { Exe - net10.0 + $TargetFramework + false enable $($package.Directory.FullName) @@ -92,7 +95,8 @@ try { Library - net10.0 + $TargetFramework + false enable $($package.Directory.FullName) @@ -137,7 +141,8 @@ public sealed class Source Library - net10.0 + $TargetFramework + false enable $($package.Directory.FullName) diff --git a/Egil.SystemTextJson.Migration/scripts/verify-trimming-package.ps1 b/Egil.SystemTextJson.Migration/scripts/verify-trimming-package.ps1 index e75eede0..10c16237 100644 --- a/Egil.SystemTextJson.Migration/scripts/verify-trimming-package.ps1 +++ b/Egil.SystemTextJson.Migration/scripts/verify-trimming-package.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS -Verifies trimming/AOT diagnostics from a packed migration library on both supported frameworks. +Verifies trimming/AOT diagnostics from a packed migration library on all supported frameworks. .DESCRIPTION Uses isolated consumers and package caches, retains logs and a package-hash results manifest, and executes only the supported untrimmed consumers. -Publish adds rooted trim and NativeAOT @@ -18,7 +18,7 @@ param( [string]$RuntimeIdentifier = [System.Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier, [switch]$Publish, [switch]$NativeCompileOnly, - [ValidateSet('net10.0', 'net11.0')] [string[]]$TargetFrameworks = @('net10.0', 'net11.0') + [ValidateSet('net8.0', 'net9.0', 'net10.0', 'net11.0')] [string[]]$TargetFrameworks = @('net8.0', 'net9.0', 'net10.0', 'net11.0') ) Set-StrictMode -Version Latest @@ -140,6 +140,7 @@ foreach ($framework in $TargetFrameworks) { Exe $framework + false enable $trim $aot @@ -168,7 +169,7 @@ foreach ($framework in $TargetFrameworks) { if ($mode -eq 'aot' -and $NativeCompileOnly) { # Run the actual NativeAOT compiler and its analysis, but do not invoke a linker. # This is useful on Windows without the C++ workload; CI uses the full publish. - # The .NET 11 compiler consumes ResolvedFileToPublish; .NET 10 consumes copy-local assets. + # The .NET 11 compiler consumes ResolvedFileToPublish; earlier targets consume copy-local assets. $targets = if ($framework -eq 'net11.0') { 'Build;ComputeResolvedFilesToPublishList;IlcCompile' } else { 'Build;_ComputeResolvedCopyLocalPublishAssets;_ComputeAssembliesToCompileToNative;IlcCompile' } $output = Invoke-DotNet @('msbuild', $project, "-t:$targets", '-p:Configuration=Release', '-p:SelfContained=true', "-p:RuntimeIdentifier=$RuntimeIdentifier", '-p:IlcUseEnvironmentalTools=true') (Join-Path $directory 'native-compile.log') } diff --git a/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Egil.SystemTextJson.Migration.csproj b/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Egil.SystemTextJson.Migration.csproj index 067d6b1c..0b30a8fc 100644 --- a/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Egil.SystemTextJson.Migration.csproj +++ b/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Egil.SystemTextJson.Migration.csproj @@ -1,7 +1,7 @@  - net10.0;net11.0 + net8.0;net9.0;net10.0;net11.0 enable enable true diff --git a/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/SourceValueShape.cs b/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/SourceValueShape.cs index 93c85268..d7018851 100644 --- a/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/SourceValueShape.cs +++ b/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/SourceValueShape.cs @@ -169,5 +169,9 @@ public static bool IsTokenCompatible(JsonTokenType tokenType, SourceValueShape s /// report their real element type. /// public static Type GetValueType(JsonTypeInfo collectionTypeInfo) +#if NET8_0 + => StjInternals.GetElementType(collectionTypeInfo) ?? typeof(object); +#else => collectionTypeInfo.ElementType ?? typeof(object); +#endif } diff --git a/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/StjInternals.cs b/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/StjInternals.cs index dde3bc62..18f1a0e2 100644 --- a/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/StjInternals.cs +++ b/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/StjInternals.cs @@ -1,13 +1,16 @@ using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; +#if NET8_0 +using System.Text.Json.Serialization.Metadata; +#endif namespace Egil.SystemTextJson.Migration.Migrations; /// -/// Provides access to internal System.Text.Json members that are necessary -/// to bypass the GetReaderScopedToNextValue overhead in -/// JsonSerializer.Deserialize. +/// Provides access to internal System.Text.Json collection metadata and converter +/// entry points, including a path that bypasses the GetReaderScopedToNextValue +/// overhead in JsonSerializer.Deserialize. /// /// When JsonSerializer.Deserialize(ref reader, typeInfo) is called, it /// internally copies the reader, skips the entire JSON value to measure its span, @@ -19,15 +22,30 @@ namespace Egil.SystemTextJson.Migration.Migrations; /// JsonResumableConverter<T>.Read path which creates a ReadStack /// and calls TryRead directly — no scoped reader, no double-parse. /// -/// Targeted internal APIs (System.Text.Json, .NET 10 and .NET 11; the signature was verified -/// unchanged against the v11.0.0-rc.1 source): +/// Targeted internal APIs: /// - JsonConverter.ReadAsObject(ref Utf8JsonReader, Type, JsonSerializerOptions) +/// on .NET 8, .NET 9, .NET 10 and .NET 11; the signature was verified unchanged +/// through the v11.0.0-rc.1 source. +/// - JsonTypeInfo.get_ElementType() on .NET 8 only; this property is internal +/// in System.Text.Json 8 and public from System.Text.Json 9 onward. /// /// A signature change in a future runtime surfaces as a -/// on first use; the test suite exercises this call on every target framework. +/// on first use; the test suite exercises each accessor on its applicable target frameworks. /// internal static class StjInternals { +#if NET8_0 + // ElementType is internal in STJ 8 and public from STJ 9 onward. Read the actual + // resolved contract rather than inferring its element from CLR interfaces: custom + // metadata can select a different collection contract than the default resolver. + // Keeping this accessor in the net8.0 asset also avoids upgrading JSON inside hosts + // that already loaded STJ 8 (for example, AutoCAD/Civil 3D). + // TODO: Remove this accessor when the net8.0 target is retired. + // https://github.com/dotnet/runtime/blob/v8.0.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_ElementType")] + internal static extern Type? GetElementType(JsonTypeInfo @this); +#endif + /// /// Calls the internal ReadAsObject method on a . /// This dispatches to JsonConverter<T>.ReadAsObject which calls diff --git a/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Analyzers.Tests/Egil.SystemTextJson.Migration.Analyzers.Tests.csproj b/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Analyzers.Tests/Egil.SystemTextJson.Migration.Analyzers.Tests.csproj index d1349b7a..31b5b2fd 100644 --- a/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Analyzers.Tests/Egil.SystemTextJson.Migration.Analyzers.Tests.csproj +++ b/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Analyzers.Tests/Egil.SystemTextJson.Migration.Analyzers.Tests.csproj @@ -1,7 +1,7 @@ - net10.0;net11.0 + net8.0;net9.0;net10.0;net11.0 Exe true true diff --git a/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/CollectionContractCompatibilityTests.cs b/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/CollectionContractCompatibilityTests.cs new file mode 100644 index 00000000..dbf4343e --- /dev/null +++ b/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/CollectionContractCompatibilityTests.cs @@ -0,0 +1,186 @@ +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Collections.ObjectModel; +using System.Text.Json; + +namespace Egil.SystemTextJson.Migration.Tests; + +public class CollectionContractCompatibilityTests +{ + public static TheoryData EnumerableSources => new() + { + typeof(int[]), + typeof(List), + typeof(IList), + typeof(ICollection), + typeof(IEnumerable), + typeof(IReadOnlyList), + typeof(IReadOnlyCollection), + typeof(ISet), + typeof(HashSet), + typeof(Queue), + typeof(Stack), + typeof(ConcurrentQueue), + typeof(ConcurrentStack), + typeof(Collection), + typeof(ImmutableArray), + typeof(ImmutableList), + typeof(ImmutableHashSet), + typeof(ImmutableQueue), + typeof(ImmutableStack), + typeof(Memory), + typeof(ReadOnlyMemory), + typeof(AlternateEnumerableList), + typeof(AlternateEnumerableQueue), + }; + + public static TheoryData DictionarySources => new() + { + typeof(Dictionary), + typeof(IDictionary), + typeof(IReadOnlyDictionary), + typeof(SortedDictionary), + typeof(SortedList), + typeof(ConcurrentDictionary), + typeof(ImmutableDictionary), + typeof(ImmutableSortedDictionary), + typeof(AlternateEnumerableDictionary), + }; + + [Theory] + [MemberData(nameof(EnumerableSources))] + public void Numeric_enumerable_source_wins_over_boolean_source(Type sourceType) + { + var factory = (IOptionsFactory)Activator.CreateInstance(typeof(OptionsFactory<>).MakeGenericType(sourceType))!; + var options = factory.CreateEnumerableOptions(); + var target = typeof(EnumerableTarget<>).MakeGenericType(sourceType); + + var result = (ISelectedSource)JsonSerializer.Deserialize("[42]", target, options)!; + + Assert.Equal("[42]", result.Payload); + } + + [Theory] + [MemberData(nameof(DictionarySources))] + public void Numeric_dictionary_values_win_over_boolean_values(Type sourceType) + { + var factory = (IOptionsFactory)Activator.CreateInstance(typeof(OptionsFactory<>).MakeGenericType(sourceType))!; + var options = factory.CreateDictionaryOptions(); + var target = typeof(DictionaryTarget<>).MakeGenericType(sourceType); + + var result = (ISelectedSource)JsonSerializer.Deserialize("""{"value":42}""", target, options)!; + + Assert.Equal("""{"value":42}""", result.Payload); + } + + [Theory] + [InlineData(typeof(ArrayList), "[42]")] + [InlineData(typeof(Hashtable), "{\"value\":42}")] + public void Non_generic_collection_sources_remain_readable(Type sourceType, string json) + { + var options = new JsonSerializerOptions().AddJsonMigrationSupport(); + var target = typeof(SingleSourceTarget<>).MakeGenericType(sourceType); + + var result = (ISelectedSource)JsonSerializer.Deserialize(json, target, options)!; + + Assert.Equal(json, result.Payload); + } + + [Fact] + public void Read_only_collection_source_keeps_the_serializer_deserialization_error() + { + var options = new OptionsFactory>().CreateEnumerableOptions(); + + Assert.Throws(() => JsonSerializer.Deserialize>>("[42]", options)); + } + + [Fact] + public void Read_only_dictionary_source_keeps_the_serializer_deserialization_error() + { + var options = new OptionsFactory>().CreateDictionaryOptions(); + + Assert.Throws(() => JsonSerializer.Deserialize>>("""{"value":42}""", options)); + } + + public interface ISelectedSource + { + string Payload { get; } + } + + [JsonMigratable] + public sealed record EnumerableTarget(string Payload) + : ISelectedSource, IMigrateFrom> + { + public static bool TryMigrateFrom(TSource source, out EnumerableTarget result) + { + result = new(JsonSerializer.Serialize(source)); + return true; + } + + } + + [JsonMigratable] + public sealed record DictionaryTarget(string Payload) + : ISelectedSource, IMigrateFrom> + { + public static bool TryMigrateFrom(TSource source, out DictionaryTarget result) + { + result = new(JsonSerializer.Serialize(source)); + return true; + } + + } + + [JsonMigratable] + public sealed record SingleSourceTarget(string Payload) : ISelectedSource, IMigrateFrom> + { + public static bool TryMigrateFrom(TSource source, out SingleSourceTarget result) + { + result = new(JsonSerializer.Serialize(source)); + return true; + } + } + + public sealed class AlternateEnumerableList : List, IEnumerable + { + IEnumerator IEnumerable.GetEnumerator() => Enumerable.Empty().GetEnumerator(); + } + + public sealed class AlternateEnumerableQueue : Queue, IEnumerable + { + IEnumerator IEnumerable.GetEnumerator() => Enumerable.Empty().GetEnumerator(); + } + + public sealed class AlternateEnumerableDictionary : Dictionary, IEnumerable + { + IEnumerator IEnumerable.GetEnumerator() => Enumerable.Empty().GetEnumerator(); + } + + public interface IOptionsFactory + { + JsonSerializerOptions CreateEnumerableOptions(); + JsonSerializerOptions CreateDictionaryOptions(); + } + + public sealed class OptionsFactory : IOptionsFactory + { + public JsonSerializerOptions CreateEnumerableOptions() => new JsonSerializerOptions() + .AddJsonMigrationSupport(builder => builder.RegisterMigrator>()); + + public JsonSerializerOptions CreateDictionaryOptions() => new JsonSerializerOptions() + .AddJsonMigrationSupport(builder => builder.RegisterMigrator>()); + } + + public sealed class BooleanEnumerableMigrator : IMigrate, EnumerableTarget> + { + public bool TryMigrateFrom(List source, out EnumerableTarget result) + => throw new InvalidOperationException("Numeric input must not select Boolean migration."); + } + + public sealed class BooleanDictionaryMigrator : IMigrate, DictionaryTarget> + { + public bool TryMigrateFrom(Dictionary source, out DictionaryTarget result) + => throw new InvalidOperationException("Numeric values must not select Boolean migration."); + } +} diff --git a/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/Egil.SystemTextJson.Migration.Tests.csproj b/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/Egil.SystemTextJson.Migration.Tests.csproj index 11f8d323..1edc581e 100644 --- a/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/Egil.SystemTextJson.Migration.Tests.csproj +++ b/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/Egil.SystemTextJson.Migration.Tests.csproj @@ -5,7 +5,7 @@ enable Exe Egil.SystemTextJson.Migration.Tests - net10.0;net11.0 + net8.0;net9.0;net10.0;net11.0 true [Egil.SystemTextJson.Migration]* [*.Tests]* diff --git a/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/ResolverChainTests.cs b/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/ResolverChainTests.cs index 985bfdff..1969207e 100644 --- a/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/ResolverChainTests.cs +++ b/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/ResolverChainTests.cs @@ -489,7 +489,20 @@ public void Replacement_migration_resolver_retains_its_external_registration() } [Fact] - public void Cyclic_decorated_chain_registration_terminates_without_resolving_contracts() + public void Snapshot_decorated_chain_retains_registration_without_resolving_contracts() + { + var options = new JsonSerializerOptions().AddJsonMigrationSupport(); + options.TypeInfoResolver = JsonTypeInfoResolver.Combine(options.TypeInfoResolverChain.ToArray()) + .WithAddedModifier(_ => Assert.Fail("Registration must not resolve contracts.")); + + options.AddJsonMigrationSupport(_ => Assert.Fail("Existing registration must be retained.")); + + Assert.Single(options.TypeInfoResolverChain); + } + +#if NET10_0_OR_GREATER + [Fact] + public void Decorated_options_bound_chain_retains_registration_without_resolving_contracts() { var options = new JsonSerializerOptions().AddJsonMigrationSupport(); options.TypeInfoResolver = options.TypeInfoResolver!.WithAddedModifier(_ => Assert.Fail("Registration must not resolve contracts.")); @@ -498,6 +511,30 @@ public void Cyclic_decorated_chain_registration_terminates_without_resolving_con Assert.Single(options.TypeInfoResolverChain); } +#else + [Fact] + public void Cyclic_options_bound_chain_allows_fresh_registration_without_resolving_contracts() + { + var options = new JsonSerializerOptions().AddJsonMigrationSupport(); + var chain = options.TypeInfoResolverChain; + Assert.Same(chain, options.TypeInfoResolver); + var decorated = options.TypeInfoResolver!.WithAddedModifier(_ => Assert.Fail("Registration must not resolve contracts.")); + + // STJ 8/9 clears this same chain before adding its wrapper, losing the original + // registration and creating a cycle through the wrapper's source. Keep the + // actual cycle here to exercise registration discovery's termination guard. + // https://github.com/dotnet/runtime/blob/v9.0.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.cs + options.TypeInfoResolver = decorated; + Assert.Same(decorated, Assert.Single(chain)); + bool configured = false; + + options.AddJsonMigrationSupport(_ => configured = true); + + Assert.True(configured); + Assert.Equal(2, chain.Count); + Assert.Same(decorated, chain[1]); + } +#endif [Fact] public void Repeated_resolver_entries_keep_the_existing_migration_registration()