diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b606752 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,93 @@ +# Repository Guidelines + +## Audience & Scope +This guide is for AI agents and human contributors working anywhere in this mono-repo. Per-project `AGENTS.md` files (for example, `Egil.SystemTextJson.Migration/AGENTS.md`, `Egil.Orleans.Testing/AGENTS.md`) extend these rules with project-specific build, test, and style conventions. When a per-project file conflicts with this one, the per-project file wins for changes in that project. + +## Projects in this Mono-Repo +Each top-level project ships as its own NuGet package and has its own CI workflow under `.github/workflows/`. Use the project's short scope code in every Conventional Commit subject: + +| Project directory | Conventional Commit scope | +|----------------------------------|---------------------------| +| `Egil.SystemTextJson.Migration/` | `stjm` | +| `Egil.Orleans.EventSourcing/` | `oes` | +| `Egil.Orleans.Storage/` | `os` | +| `Egil.Orleans.Testing/` | `ot` | +| `Egil.StronglyTypedPrimitives/` | `stp` | + +Cross-cutting changes that don't belong to a single package may use: +- `ci` for CI/workflow changes under `.github/` +- `build` for repo-wide build/tooling changes (for example, root `Directory.Build.props`) +- `docs` for repo-level documentation outside any one project (root `README.md`, etc.) + +Do not invent new scopes. Do not omit the scope. Every commit that touches files inside one of the project directories above must use that project's scope. + +## Commit & Pull Request Guidelines +Commits and PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +(): + + + + +``` + +- **Type**: `feat`, `fix`, `chore`, `docs`, `test`, `refactor`, `perf`, `build`, `ci`, `style`. +- **Scope**: required, from the table above. +- **Body**: required for `feat`/`fix` and any change a package consumer should know about. Write user-facing prose explaining *what* changed and *why*. Avoid internal-only context (chat logs, agent session IDs, implementation minutiae). +- **Breaking changes**: add a `BREAKING CHANGE: ` footer and/or use `()!:` in the subject. + +Do not mix unrelated commit types in a single commit. Tests that validate a `feat` or `fix` belong in the same commit as that `feat`/`fix`. Use `test()` only for test-only additions to existing behavior. Refactors, docs, and fixes each go in their own commits. + +PR titles must use the same Conventional Commit format as the primary commit they ship. + +### Release Notes and `[skip notes]` +Each project generates its release notes from its own commit history (see `/scripts/generate-release-notes.ps1`). The commit subject becomes the changelog entry heading and the commit body becomes the entry detail. + +To exclude a commit from release notes — for example, build/CI/tooling/refactor work that uses `feat` or `fix` types but isn't relevant to package consumers — add `[skip notes]` anywhere in the subject or body. + +Examples of when to use `[skip notes]`: +- Internal refactors that don't change observable behavior. +- CI workflow tweaks committed with `fix(ci): ...`. +- Dependency bumps that don't affect the public API. +- Docs updates already covered by another commit in the same release. + +Examples of when **not** to use `[skip notes]`: +- Any user-visible feature, fix, or behavior change. +- Performance improvements consumers can observe. +- Breaking changes (these must always appear in notes). + +Trailers (such as `Co-authored-by:`) are stripped from the body before inclusion in release notes, so they are safe to add freely. + +### Sign-off Trailer +Always append the following trailer to commit messages: + +``` +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +``` + +## Development Process +- Work one project at a time. Don't bundle unrelated changes across projects in one commit. +- Commit logical units of work; keep commits small and focused. +- `Release` builds fail on warnings. Make sure each commit is warning-free. +- Never change both production code (under `/src`) and test code (under `/test`) in a single step without running tests in between. Change one or the other, then run tests. + +For new features or bug fixes in production code: +1. Write a failing test covering the change. +2. Run the test; confirm it fails for the expected reason. +3. Implement the change in `/src`. +4. Run the test; confirm it now passes. +5. Run the full project test suite to confirm nothing else broke. + +For new tests against existing behavior: +1. Write the test with the assertion inverted. +2. Run it; confirm it fails for the expected reason. +3. Correct the assertion. +4. Run it; confirm it now passes. + +For refactors: +1. Refactor production code. +2. Run all tests in the affected project; confirm all still pass. + +## Build, Test, and Tooling +Each project has its own solution file (`/.slnx`). Run `dotnet restore`, `dotnet build`, `dotnet test`, and `dotnet pack` against the relevant project's solution. Per-project `AGENTS.md` files document the exact commands and any project-specific tooling (BenchmarkDotNet, mdsnippets, etc.). diff --git a/Egil.SystemTextJson.Migration/AGENTS.md b/Egil.SystemTextJson.Migration/AGENTS.md index e57b0bc..b114944 100644 --- a/Egil.SystemTextJson.Migration/AGENTS.md +++ b/Egil.SystemTextJson.Migration/AGENTS.md @@ -20,6 +20,7 @@ Use the solution file from repository root: - `dotnet test Egil.SystemTextJson.Migration.slnx -c Release`: run test suite (xUnit + Microsoft Testing Platform). - `dotnet pack src/Egil.SystemTextJson.Migration/Egil.SystemTextJson.Migration.csproj -c Release`: produce NuGet package. - `dotnet run --project perf/Egil.SystemTextJson.Migration.PerfTests -c Release`: run benchmarks. +- `.\scripts\update-perf-docs.ps1`: after running benchmarks, copy BenchmarkDotNet reports into `docs/perf/` and refresh the README performance summary. - `dotnet outdated`: check for dependency updates. ## Coding Style & Naming Conventions diff --git a/Egil.SystemTextJson.Migration/README.md b/Egil.SystemTextJson.Migration/README.md index 020cefd..bc3046e 100644 --- a/Egil.SystemTextJson.Migration/README.md +++ b/Egil.SystemTextJson.Migration/README.md @@ -77,6 +77,45 @@ UserV2 user = JsonSerializer.Deserialize(json, options)!; snippet source | anchor +### Discriminator-less object payloads + +When stored JSON was written before migration support existed and represents an older source shape, configure the target with `UndiscriminatedSourceType`: + + + +```cs +[JsonMigratable( + TypeDiscriminator = "customer-name-v1", + UndiscriminatedSourceType = typeof(CustomerNameV0))] +public record class CustomerNameV1(string Name) + : IMigrateFrom +{ + public static bool TryMigrateFrom(CustomerNameV0 source, out CustomerNameV1 result) + { + result = new CustomerNameV1($"{source.FirstName} {source.LastName}"); + return true; + } +} +``` +snippet source | anchor + + + + +```cs +var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); +options.AddJsonMigrationSupport(); + +// Existing stored JSON was written before migration support existed, +// so it has no $type discriminator. CustomerNameV1 opts in to treating +// discriminator-less objects as CustomerNameV0 and runs its migrator. +var json = """{"firstName":"Jane","lastName":"Doe"}"""; + +CustomerNameV1 customer = JsonSerializer.Deserialize(json, options)!; +// customer is CustomerNameV1 { Name = "Jane Doe" } +``` +snippet source | anchor + ### External migration @@ -431,8 +470,9 @@ Every benchmark compares the library against hand-written migration code on top **Key takeaways:** - **Happy path (no migration needed):** deserialization is ~1.0–1.3× plain STJ with **zero extra allocations**. The overhead comes from the O(1) first-property discriminator check and is constant regardless of payload size. -- **Migration path:** 1.4–1.5× plain STJ for small payloads, converging toward ~1.0× as payload size grows — the fixed migration overhead is amortized over more data. -- **Legacy payloads (no discriminator):** 1.0–1.2× plain STJ with **zero extra allocations** — the same as current-version payloads. +- **Migration path:** static/external discriminator-based migrations are ~1.4× plain STJ for small payloads, converging toward ~1.0× as payload size grows — the fixed migration overhead is amortized over more data. +- **Undiscriminated source migration:** ~1.0–1.1× plain STJ with **zero extra allocations** compared to a manual source deserialize plus conversion. +- **Target-shaped legacy payloads (no discriminator):** 1.0–1.2× plain STJ with **zero extra allocations** — the same as current-version payloads. - **Serialization:** near 1:1 at larger payloads (ratio ≈ 1.0). Small payloads show ~2× due to the fixed cost of writing the discriminator property. Detailed results with source-generated `JsonSerializerContext`: @@ -442,26 +482,30 @@ Detailed results with source-generated `JsonSerializerContext`: | Scenario | TagCount | Ratio vs plain STJ | Alloc Ratio | |----------|:--------:|:-------------------:|:-----------:| -| **No migration (happy path)** | 2 | 1.25× | 1.00 | -| | 32 | 0.76× | 1.00 | +| **No migration (happy path)** | 2 | 1.24× | 1.00 | +| | 32 | 1.08× | 1.00 | +| | 256 | 1.02× | 1.00 | +| **Static migration** | 2 | 1.42× | 1.00 | +| | 32 | 1.14× | 1.00 | +| | 256 | 1.02× | 1.00 | +| **External migration** | 2 | 1.37× | 1.00 | +| | 32 | 1.13× | 1.00 | +| | 256 | 1.01× | 1.00 | +| **Undiscriminated source migration** | 2 | 0.99× | 1.00 | +| | 32 | 1.07× | 1.00 | | | 256 | 1.02× | 1.00 | -| **Static migration** | 2 | 1.43× | 1.13 | -| | 32 | 1.22× | 1.04 | -| | 256 | 1.06× | 1.01 | -| **External migration** | 2 | 1.50× | 1.13 | -| | 32 | 1.18× | 1.05 | -| | 256 | 1.04× | 1.01 | -| **Legacy payload** | 2 | 1.16× | 1.00 | -| | 32 | 1.05× | 1.00 | -| | 256 | 0.82× | 1.00 | -| **Serialization** | 2 | 2.10× | 5.45 | -| | 32 | 1.17× | 2.02 | -| | 256 | 0.93× | 1.15 | +| **Legacy payload** | 2 | 1.13× | 1.00 | +| | 32 | 1.07× | 1.00 | +| | 256 | 1.01× | 1.00 | +| **Serialization** | 2 | 1.81× | 5.45 | +| | 32 | 1.08× | 2.02 | +| | 256 | 0.89× | 1.15 | > Full benchmark reports: [source-gen](https://github.com/egil/framework/blob/main/Egil.SystemTextJson.Migration/docs/perf/source-gen-benchmarks.md) · [reflection](https://github.com/egil/framework/blob/main/Egil.SystemTextJson.Migration/docs/perf/reflection-benchmarks.md) > > Run benchmarks locally with `dotnet run --project perf/Egil.SystemTextJson.Migration.PerfTests -c Release`. +> Refresh these docs from the latest BenchmarkDotNet output with `.\scripts\update-perf-docs.ps1`. ## Design notes diff --git a/Egil.SystemTextJson.Migration/docs/perf/reflection-benchmarks.md b/Egil.SystemTextJson.Migration/docs/perf/reflection-benchmarks.md index 2b1b5c0..f87e888 100644 --- a/Egil.SystemTextJson.Migration/docs/perf/reflection-benchmarks.md +++ b/Egil.SystemTextJson.Migration/docs/perf/reflection-benchmarks.md @@ -4,64 +4,73 @@ > Do not edit manually. Re-run benchmarks and this script to update. ``` -BenchmarkDotNet v0.15.6, Windows 11 (10.0.26200.8117) -13th Gen Intel Core i7-13800H 2.90GHz, 1 CPU, 20 logical and 14 physical cores -.NET SDK 10.0.201 - [Host] : .NET 10.0.5 (10.0.5, 10.0.526.15411), X64 RyuJIT x86-64-v3 +BenchmarkDotNet v0.15.6, Windows 11 (10.0.26200.8328) +AMD Ryzen 9 5950X 3.40GHz, 1 CPU, 32 logical and 16 physical cores +.NET SDK 10.0.203 + [Host] : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3 Toolchain=InProcessNoEmitToolchain IterationCount=5 LaunchCount=1 WarmupCount=1 ``` -| Method | Categories | TagCount | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Allocated | Alloc Ratio | -|---------------------------------------- |------------------------------ |--------- |-----------:|----------:|---------:|------:|--------:|-------:|-------:|----------:|------------:| -| **JsonMigratableExternalMigration** | **Deserialize,ExternalMigration** | **2** | **500.3 ns** | **17.70 ns** | **4.60 ns** | **1.51** | **0.01** | **0.0925** | **-** | **1168 B** | **1.13** | -| PlainStjExternalMigrationManual | Deserialize,ExternalMigration | 2 | 330.4 ns | 6.64 ns | 1.03 ns | 1.00 | 0.00 | 0.0820 | - | 1032 B | 1.00 | -| | | | | | | | | | | | | -| **JsonMigratableExternalMigration** | **Deserialize,ExternalMigration** | **32** | **1,153.3 ns** | **38.87 ns** | **10.09 ns** | **1.20** | **0.01** | **0.2480** | **0.0038** | **3128 B** | **1.05** | -| PlainStjExternalMigrationManual | Deserialize,ExternalMigration | 32 | 959.8 ns | 9.42 ns | 1.46 ns | 1.00 | 0.00 | 0.2384 | 0.0019 | 2992 B | 1.00 | -| | | | | | | | | | | | | -| **JsonMigratableExternalMigration** | **Deserialize,ExternalMigration** | **256** | **5,541.6 ns** | **278.67 ns** | **43.12 ns** | **1.04** | **0.01** | **1.3962** | **0.1373** | **17536 B** | **1.01** | -| PlainStjExternalMigrationManual | Deserialize,ExternalMigration | 256 | 5,347.9 ns | 188.47 ns | 48.95 ns | 1.00 | 0.01 | 1.3809 | 0.1373 | 17400 B | 1.00 | -| | | | | | | | | | | | | -| **JsonMigratableLegacyPayload** | **Deserialize,LegacyPayload** | **2** | **391.6 ns** | **22.74 ns** | **3.52 ns** | **1.17** | **0.02** | **0.0720** | **-** | **904 B** | **1.00** | -| PlainStjLegacyPayloadManual | Deserialize,LegacyPayload | 2 | 334.8 ns | 14.50 ns | 3.77 ns | 1.00 | 0.01 | 0.0720 | - | 904 B | 1.00 | -| | | | | | | | | | | | | -| **JsonMigratableLegacyPayload** | **Deserialize,LegacyPayload** | **32** | **1,057.5 ns** | **71.07 ns** | **11.00 ns** | **1.05** | **0.01** | **0.2270** | **0.0019** | **2864 B** | **1.00** | -| PlainStjLegacyPayloadManual | Deserialize,LegacyPayload | 32 | 1,005.3 ns | 37.09 ns | 9.63 ns | 1.00 | 0.01 | 0.2270 | 0.0019 | 2864 B | 1.00 | -| | | | | | | | | | | | | -| **JsonMigratableLegacyPayload** | **Deserialize,LegacyPayload** | **256** | **5,512.5 ns** | **78.17 ns** | **20.30 ns** | **1.02** | **0.01** | **1.3733** | **0.1297** | **17272 B** | **1.00** | -| PlainStjLegacyPayloadManual | Deserialize,LegacyPayload | 256 | 5,424.0 ns | 159.87 ns | 41.52 ns | 1.00 | 0.01 | 1.3733 | 0.1297 | 17272 B | 1.00 | -| | | | | | | | | | | | | -| **PlainStjNoMigration** | **Deserialize,NoMigration** | **2** | **290.5 ns** | **7.10 ns** | **1.84 ns** | **1.00** | **0.01** | **0.0691** | **-** | **872 B** | **1.00** | -| PolymorphicPlainStjNoMigration | Deserialize,NoMigration | 2 | 323.3 ns | 10.98 ns | 1.70 ns | 1.11 | 0.01 | 0.0691 | - | 872 B | 1.00 | -| JsonMigratableNoMigration | Deserialize,NoMigration | 2 | 376.3 ns | 12.70 ns | 1.96 ns | 1.30 | 0.01 | 0.0691 | - | 872 B | 1.00 | -| | | | | | | | | | | | | -| **PlainStjNoMigration** | **Deserialize,NoMigration** | **32** | **954.2 ns** | **57.83 ns** | **15.02 ns** | **1.00** | **0.02** | **0.2251** | **0.0029** | **2832 B** | **1.00** | -| PolymorphicPlainStjNoMigration | Deserialize,NoMigration | 32 | 986.3 ns | 35.78 ns | 9.29 ns | 1.03 | 0.02 | 0.2251 | 0.0019 | 2832 B | 1.00 | -| JsonMigratableNoMigration | Deserialize,NoMigration | 32 | 1,028.9 ns | 48.44 ns | 7.50 ns | 1.08 | 0.02 | 0.2251 | 0.0019 | 2832 B | 1.00 | -| | | | | | | | | | | | | -| **PlainStjNoMigration** | **Deserialize,NoMigration** | **256** | **5,288.3 ns** | **256.32 ns** | **66.57 ns** | **1.00** | **0.02** | **1.3733** | **0.1297** | **17240 B** | **1.00** | -| PolymorphicPlainStjNoMigration | Deserialize,NoMigration | 256 | 5,456.1 ns | 106.67 ns | 27.70 ns | 1.03 | 0.01 | 1.3733 | 0.1297 | 17240 B | 1.00 | -| JsonMigratableNoMigration | Deserialize,NoMigration | 256 | 5,469.0 ns | 291.00 ns | 75.57 ns | 1.03 | 0.02 | 1.3733 | 0.1297 | 17240 B | 1.00 | -| | | | | | | | | | | | | -| **JsonMigratableStaticMigration** | **Deserialize,StaticMigration** | **2** | **519.2 ns** | **13.71 ns** | **3.56 ns** | **1.56** | **0.01** | **0.0916** | **-** | **1160 B** | **1.12** | -| PlainStjStaticMigrationManual | Deserialize,StaticMigration | 2 | 333.4 ns | 8.02 ns | 1.24 ns | 1.00 | 0.00 | 0.0820 | - | 1032 B | 1.00 | -| | | | | | | | | | | | | -| **JsonMigratableStaticMigration** | **Deserialize,StaticMigration** | **32** | **1,170.8 ns** | **62.26 ns** | **16.17 ns** | **1.13** | **0.02** | **0.2480** | **0.0038** | **3120 B** | **1.04** | -| PlainStjStaticMigrationManual | Deserialize,StaticMigration | 32 | 1,039.7 ns | 63.15 ns | 16.40 ns | 1.00 | 0.02 | 0.2384 | 0.0019 | 2992 B | 1.00 | -| | | | | | | | | | | | | -| **JsonMigratableStaticMigration** | **Deserialize,StaticMigration** | **256** | **5,653.1 ns** | **206.52 ns** | **53.63 ns** | **1.03** | **0.02** | **1.3962** | **0.1373** | **17528 B** | **1.01** | -| PlainStjStaticMigrationManual | Deserialize,StaticMigration | 256 | 5,499.5 ns | 308.17 ns | 80.03 ns | 1.00 | 0.02 | 1.3809 | 0.1373 | 17400 B | 1.00 | -| | | | | | | | | | | | | -| **PlainStjSerializeNoMigration** | **Serialize,NoMigration** | **2** | **146.7 ns** | **2.15 ns** | **0.33 ns** | **1.00** | **0.00** | **0.0317** | **-** | **400 B** | **1.00** | -| PolymorphicPlainStjSerializeNoMigration | Serialize,NoMigration | 2 | 224.0 ns | 1.30 ns | 0.34 ns | 1.53 | 0.00 | 0.0350 | - | 440 B | 1.10 | -| JsonMigratableSerializeNoMigration | Serialize,NoMigration | 2 | 188.3 ns | 2.64 ns | 0.69 ns | 1.28 | 0.01 | 0.0381 | - | 480 B | 1.20 | -| | | | | | | | | | | | | -| **PlainStjSerializeNoMigration** | **Serialize,NoMigration** | **32** | **499.0 ns** | **2.84 ns** | **0.74 ns** | **1.00** | **0.00** | **0.0553** | **-** | **696 B** | **1.00** | -| PolymorphicPlainStjSerializeNoMigration | Serialize,NoMigration | 32 | 574.5 ns | 13.62 ns | 3.54 ns | 1.15 | 0.01 | 0.0591 | - | 744 B | 1.07 | -| JsonMigratableSerializeNoMigration | Serialize,NoMigration | 32 | 551.2 ns | 5.10 ns | 1.32 ns | 1.10 | 0.00 | 0.0610 | - | 776 B | 1.11 | -| | | | | | | | | | | | | -| **PlainStjSerializeNoMigration** | **Serialize,NoMigration** | **256** | **2,962.1 ns** | **118.41 ns** | **30.75 ns** | **1.00** | **0.01** | **0.2327** | **-** | **2936 B** | **1.00** | -| PolymorphicPlainStjSerializeNoMigration | Serialize,NoMigration | 256 | 3,044.5 ns | 135.97 ns | 35.31 ns | 1.03 | 0.01 | 0.2365 | - | 2984 B | 1.02 | -| JsonMigratableSerializeNoMigration | Serialize,NoMigration | 256 | 3,034.6 ns | 56.42 ns | 14.65 ns | 1.02 | 0.01 | 0.2403 | - | 3016 B | 1.03 | +| Method | Categories | TagCount | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Allocated | Alloc Ratio | +|--------------------------------------------- |------------------------------------------- |--------- |-----------:|------------:|----------:|------:|--------:|-------:|-------:|----------:|------------:| +| **JsonMigratableExternalMigration** | **Deserialize,ExternalMigration** | **2** | **641.7 ns** | **26.62 ns** | **6.91 ns** | **1.38** | **0.02** | **0.0610** | **-** | **1032 B** | **1.00** | +| PlainStjExternalMigrationManual | Deserialize,ExternalMigration | 2 | 466.2 ns | 11.40 ns | 2.96 ns | 1.00 | 0.01 | 0.0615 | - | 1032 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableExternalMigration** | **Deserialize,ExternalMigration** | **32** | **1,653.2 ns** | **20.74 ns** | **5.39 ns** | **1.13** | **0.01** | **0.1774** | **0.0019** | **2992 B** | **1.00** | +| PlainStjExternalMigrationManual | Deserialize,ExternalMigration | 32 | 1,461.3 ns | 39.66 ns | 10.30 ns | 1.00 | 0.01 | 0.1774 | 0.0019 | 2992 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableExternalMigration** | **Deserialize,ExternalMigration** | **256** | **8,622.3 ns** | **215.99 ns** | **56.09 ns** | **1.02** | **0.01** | **1.0376** | **0.0916** | **17400 B** | **1.00** | +| PlainStjExternalMigrationManual | Deserialize,ExternalMigration | 256 | 8,456.8 ns | 336.65 ns | 87.43 ns | 1.00 | 0.01 | 1.0376 | 0.0916 | 17400 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableLegacyPayload** | **Deserialize,LegacyPayload** | **2** | **549.8 ns** | **26.55 ns** | **6.89 ns** | **1.18** | **0.01** | **0.0534** | **-** | **904 B** | **1.00** | +| PlainStjLegacyPayloadManual | Deserialize,LegacyPayload | 2 | 467.2 ns | 9.61 ns | 2.50 ns | 1.00 | 0.01 | 0.0539 | - | 904 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableLegacyPayload** | **Deserialize,LegacyPayload** | **32** | **1,570.0 ns** | **40.64 ns** | **6.29 ns** | **1.06** | **0.01** | **0.1698** | **0.0019** | **2864 B** | **1.00** | +| PlainStjLegacyPayloadManual | Deserialize,LegacyPayload | 32 | 1,484.4 ns | 34.00 ns | 8.83 ns | 1.00 | 0.01 | 0.1698 | 0.0019 | 2864 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableLegacyPayload** | **Deserialize,LegacyPayload** | **256** | **8,480.5 ns** | **198.67 ns** | **51.60 ns** | **1.02** | **0.01** | **1.0223** | **0.0916** | **17272 B** | **1.00** | +| PlainStjLegacyPayloadManual | Deserialize,LegacyPayload | 256 | 8,296.1 ns | 265.61 ns | 68.98 ns | 1.00 | 0.01 | 1.0223 | 0.0916 | 17272 B | 1.00 | +| | | | | | | | | | | | | +| **PlainStjNoMigration** | **Deserialize,NoMigration** | **2** | **414.7 ns** | **6.40 ns** | **1.66 ns** | **1.00** | **0.01** | **0.0520** | **-** | **872 B** | **1.00** | +| PolymorphicPlainStjNoMigration | Deserialize,NoMigration | 2 | 453.9 ns | 6.39 ns | 0.99 ns | 1.09 | 0.00 | 0.0520 | - | 872 B | 1.00 | +| JsonMigratableNoMigration | Deserialize,NoMigration | 2 | 510.9 ns | 12.45 ns | 1.93 ns | 1.23 | 0.01 | 0.0515 | - | 872 B | 1.00 | +| | | | | | | | | | | | | +| **PlainStjNoMigration** | **Deserialize,NoMigration** | **32** | **1,392.1 ns** | **24.82 ns** | **6.45 ns** | **1.00** | **0.01** | **0.1678** | **0.0019** | **2832 B** | **1.00** | +| PolymorphicPlainStjNoMigration | Deserialize,NoMigration | 32 | 1,436.1 ns | 28.64 ns | 7.44 ns | 1.03 | 0.01 | 0.1678 | 0.0019 | 2832 B | 1.00 | +| JsonMigratableNoMigration | Deserialize,NoMigration | 32 | 1,522.4 ns | 37.28 ns | 9.68 ns | 1.09 | 0.01 | 0.1678 | 0.0019 | 2832 B | 1.00 | +| | | | | | | | | | | | | +| **PlainStjNoMigration** | **Deserialize,NoMigration** | **256** | **8,212.0 ns** | **176.11 ns** | **27.25 ns** | **1.00** | **0.00** | **1.0223** | **0.0916** | **17240 B** | **1.00** | +| PolymorphicPlainStjNoMigration | Deserialize,NoMigration | 256 | 8,254.3 ns | 113.97 ns | 29.60 ns | 1.01 | 0.00 | 1.0223 | 0.0916 | 17240 B | 1.00 | +| JsonMigratableNoMigration | Deserialize,NoMigration | 256 | 8,307.1 ns | 124.08 ns | 19.20 ns | 1.01 | 0.00 | 1.0223 | 0.0916 | 17240 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableStaticMigration** | **Deserialize,StaticMigration** | **2** | **642.4 ns** | **8.30 ns** | **2.16 ns** | **1.42** | **0.01** | **0.0610** | **-** | **1032 B** | **1.00** | +| PlainStjStaticMigrationManual | Deserialize,StaticMigration | 2 | 452.3 ns | 8.31 ns | 2.16 ns | 1.00 | 0.01 | 0.0615 | - | 1032 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableStaticMigration** | **Deserialize,StaticMigration** | **32** | **1,646.7 ns** | **25.86 ns** | **4.00 ns** | **1.14** | **0.00** | **0.1774** | **0.0019** | **2992 B** | **1.00** | +| PlainStjStaticMigrationManual | Deserialize,StaticMigration | 32 | 1,441.5 ns | 14.68 ns | 3.81 ns | 1.00 | 0.00 | 0.1774 | 0.0019 | 2992 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableStaticMigration** | **Deserialize,StaticMigration** | **256** | **8,581.1 ns** | **406.20 ns** | **105.49 ns** | **1.03** | **0.01** | **1.0376** | **0.0916** | **17400 B** | **1.00** | +| PlainStjStaticMigrationManual | Deserialize,StaticMigration | 256 | 8,345.4 ns | 141.01 ns | 36.62 ns | 1.00 | 0.01 | 1.0376 | 0.0916 | 17400 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableUndiscriminatedSourceMigration** | **Deserialize,UndiscriminatedSourceMigration** | **2** | **555.0 ns** | **16.97 ns** | **2.63 ns** | **1.14** | **0.01** | **0.0610** | **-** | **1032 B** | **1.00** | +| PlainStjUndiscriminatedSourceMigrationManual | Deserialize,UndiscriminatedSourceMigration | 2 | 484.9 ns | 11.86 ns | 3.08 ns | 1.00 | 0.01 | 0.0610 | - | 1032 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableUndiscriminatedSourceMigration** | **Deserialize,UndiscriminatedSourceMigration** | **32** | **1,554.2 ns** | **37.16 ns** | **9.65 ns** | **1.07** | **0.01** | **0.1774** | **0.0019** | **2992 B** | **1.00** | +| PlainStjUndiscriminatedSourceMigrationManual | Deserialize,UndiscriminatedSourceMigration | 32 | 1,458.8 ns | 52.00 ns | 13.50 ns | 1.00 | 0.01 | 0.1774 | 0.0019 | 2992 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableUndiscriminatedSourceMigration** | **Deserialize,UndiscriminatedSourceMigration** | **256** | **9,609.7 ns** | **1,446.95 ns** | **375.77 ns** | **1.00** | **0.05** | **1.0376** | **0.0916** | **17400 B** | **1.00** | +| PlainStjUndiscriminatedSourceMigrationManual | Deserialize,UndiscriminatedSourceMigration | 256 | 9,617.4 ns | 1,562.82 ns | 405.86 ns | 1.00 | 0.05 | 1.0376 | 0.0916 | 17400 B | 1.00 | +| | | | | | | | | | | | | +| **PlainStjSerializeNoMigration** | **Serialize,NoMigration** | **2** | **235.9 ns** | **33.40 ns** | **5.17 ns** | **1.00** | **0.03** | **0.0238** | **-** | **400 B** | **1.00** | +| PolymorphicPlainStjSerializeNoMigration | Serialize,NoMigration | 2 | 338.0 ns | 75.35 ns | 19.57 ns | 1.43 | 0.08 | 0.0262 | - | 440 B | 1.10 | +| JsonMigratableSerializeNoMigration | Serialize,NoMigration | 2 | 294.9 ns | 42.36 ns | 6.56 ns | 1.25 | 0.03 | 0.0286 | - | 480 B | 1.20 | +| | | | | | | | | | | | | +| **PlainStjSerializeNoMigration** | **Serialize,NoMigration** | **32** | **771.0 ns** | **104.49 ns** | **27.14 ns** | **1.00** | **0.05** | **0.0410** | **-** | **696 B** | **1.00** | +| PolymorphicPlainStjSerializeNoMigration | Serialize,NoMigration | 32 | 847.5 ns | 130.34 ns | 33.85 ns | 1.10 | 0.05 | 0.0439 | - | 744 B | 1.07 | +| JsonMigratableSerializeNoMigration | Serialize,NoMigration | 32 | 714.0 ns | 9.64 ns | 1.49 ns | 0.93 | 0.03 | 0.0458 | - | 776 B | 1.11 | +| | | | | | | | | | | | | +| **PlainStjSerializeNoMigration** | **Serialize,NoMigration** | **256** | **3,810.3 ns** | **34.32 ns** | **8.91 ns** | **1.00** | **0.00** | **0.1755** | **-** | **2936 B** | **1.00** | +| PolymorphicPlainStjSerializeNoMigration | Serialize,NoMigration | 256 | 3,919.9 ns | 110.11 ns | 17.04 ns | 1.03 | 0.00 | 0.1755 | - | 2984 B | 1.02 | +| JsonMigratableSerializeNoMigration | Serialize,NoMigration | 256 | 3,875.3 ns | 18.89 ns | 2.92 ns | 1.02 | 0.00 | 0.1755 | - | 3016 B | 1.03 | diff --git a/Egil.SystemTextJson.Migration/docs/perf/source-gen-benchmarks.md b/Egil.SystemTextJson.Migration/docs/perf/source-gen-benchmarks.md index f201aa8..67aa805 100644 --- a/Egil.SystemTextJson.Migration/docs/perf/source-gen-benchmarks.md +++ b/Egil.SystemTextJson.Migration/docs/perf/source-gen-benchmarks.md @@ -4,64 +4,73 @@ > Do not edit manually. Re-run benchmarks and this script to update. ``` -BenchmarkDotNet v0.15.6, Windows 11 (10.0.26200.8117) -13th Gen Intel Core i7-13800H 2.90GHz, 1 CPU, 20 logical and 14 physical cores -.NET SDK 10.0.201 - [Host] : .NET 10.0.5 (10.0.5, 10.0.526.15411), X64 RyuJIT x86-64-v3 +BenchmarkDotNet v0.15.6, Windows 11 (10.0.26200.8328) +AMD Ryzen 9 5950X 3.40GHz, 1 CPU, 32 logical and 16 physical cores +.NET SDK 10.0.203 + [Host] : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3 Toolchain=InProcessNoEmitToolchain IterationCount=5 LaunchCount=1 WarmupCount=1 ``` -| Method | Categories | TagCount | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Allocated | Alloc Ratio | -|---------------------------------------- |------------------------------ |--------- |------------:|--------------:|-------------:|------:|--------:|-------:|-------:|----------:|------------:| -| **JsonMigratableExternalMigration** | **Deserialize,ExternalMigration** | **2** | **532.19 ns** | **6.297 ns** | **1.635 ns** | **1.50** | **0.01** | **0.0906** | **-** | **1144 B** | **1.13** | -| PlainStjExternalMigrationManual | Deserialize,ExternalMigration | 2 | 354.75 ns | 7.036 ns | 1.827 ns | 1.00 | 0.01 | 0.0801 | - | 1008 B | 1.00 | -| | | | | | | | | | | | | -| **JsonMigratableExternalMigration** | **Deserialize,ExternalMigration** | **32** | **1,218.09 ns** | **13.330 ns** | **3.462 ns** | **1.18** | **0.01** | **0.2460** | **0.0019** | **3104 B** | **1.05** | -| PlainStjExternalMigrationManual | Deserialize,ExternalMigration | 32 | 1,032.50 ns | 26.009 ns | 6.754 ns | 1.00 | 0.01 | 0.2365 | 0.0019 | 2968 B | 1.00 | -| | | | | | | | | | | | | -| **JsonMigratableExternalMigration** | **Deserialize,ExternalMigration** | **256** | **5,753.82 ns** | **114.869 ns** | **29.831 ns** | **1.04** | **0.02** | **1.3885** | **0.1068** | **17512 B** | **1.01** | -| PlainStjExternalMigrationManual | Deserialize,ExternalMigration | 256 | 5,544.82 ns | 346.269 ns | 89.925 ns | 1.00 | 0.02 | 1.3809 | 0.1144 | 17376 B | 1.00 | -| | | | | | | | | | | | | -| **JsonMigratableLegacyPayload** | **Deserialize,LegacyPayload** | **2** | **411.91 ns** | **23.743 ns** | **3.674 ns** | **1.16** | **0.01** | **0.0701** | **-** | **880 B** | **1.00** | -| PlainStjLegacyPayloadManual | Deserialize,LegacyPayload | 2 | 356.45 ns | 11.833 ns | 3.073 ns | 1.00 | 0.01 | 0.0701 | - | 880 B | 1.00 | -| | | | | | | | | | | | | -| **JsonMigratableLegacyPayload** | **Deserialize,LegacyPayload** | **32** | **1,069.53 ns** | **29.367 ns** | **4.545 ns** | **1.05** | **0.01** | **0.2251** | **0.0019** | **2840 B** | **1.00** | -| PlainStjLegacyPayloadManual | Deserialize,LegacyPayload | 32 | 1,017.64 ns | 23.083 ns | 5.995 ns | 1.00 | 0.01 | 0.2251 | 0.0019 | 2840 B | 1.00 | -| | | | | | | | | | | | | -| **JsonMigratableLegacyPayload** | **Deserialize,LegacyPayload** | **256** | **5,716.62 ns** | **365.545 ns** | **94.931 ns** | **0.82** | **0.20** | **1.3733** | **0.1144** | **17248 B** | **1.00** | -| PlainStjLegacyPayloadManual | Deserialize,LegacyPayload | 256 | 7,394.41 ns | 13,156.649 ns | 2,036.006 ns | 1.06 | 0.37 | 1.3733 | 0.1144 | 17248 B | 1.00 | -| | | | | | | | | | | | | -| **PlainStjNoMigration** | **Deserialize,NoMigration** | **2** | **469.33 ns** | **39.333 ns** | **6.087 ns** | **1.00** | **0.02** | **0.0668** | **-** | **848 B** | **1.00** | -| PolymorphicPlainStjNoMigration | Deserialize,NoMigration | 2 | 516.91 ns | 11.987 ns | 3.113 ns | 1.10 | 0.01 | 0.0668 | - | 848 B | 1.00 | -| JsonMigratableNoMigration | Deserialize,NoMigration | 2 | 586.99 ns | 25.298 ns | 6.570 ns | 1.25 | 0.02 | 0.0668 | - | 848 B | 1.00 | -| | | | | | | | | | | | | -| **PlainStjNoMigration** | **Deserialize,NoMigration** | **32** | **1,391.81 ns** | **293.564 ns** | **76.238 ns** | **1.00** | **0.07** | **0.2232** | **-** | **2808 B** | **1.00** | -| PolymorphicPlainStjNoMigration | Deserialize,NoMigration | 32 | 990.08 ns | 16.216 ns | 2.509 ns | 0.71 | 0.04 | 0.2232 | - | 2808 B | 1.00 | -| JsonMigratableNoMigration | Deserialize,NoMigration | 32 | 1,049.61 ns | 42.667 ns | 11.081 ns | 0.76 | 0.04 | 0.2232 | 0.0019 | 2808 B | 1.00 | -| | | | | | | | | | | | | -| **PlainStjNoMigration** | **Deserialize,NoMigration** | **256** | **5,371.34 ns** | **290.755 ns** | **75.508 ns** | **1.00** | **0.02** | **1.3657** | **0.1068** | **17216 B** | **1.00** | -| PolymorphicPlainStjNoMigration | Deserialize,NoMigration | 256 | 5,403.22 ns | 474.154 ns | 73.376 ns | 1.01 | 0.02 | 1.3657 | 0.1068 | 17216 B | 1.00 | -| JsonMigratableNoMigration | Deserialize,NoMigration | 256 | 5,453.46 ns | 56.438 ns | 8.734 ns | 1.02 | 0.01 | 1.3657 | 0.1068 | 17216 B | 1.00 | -| | | | | | | | | | | | | -| **JsonMigratableStaticMigration** | **Deserialize,StaticMigration** | **2** | **544.65 ns** | **24.360 ns** | **3.770 ns** | **1.43** | **0.01** | **0.0896** | **-** | **1136 B** | **1.13** | -| PlainStjStaticMigrationManual | Deserialize,StaticMigration | 2 | 379.62 ns | 16.715 ns | 2.587 ns | 1.00 | 0.01 | 0.0801 | - | 1008 B | 1.00 | -| | | | | | | | | | | | | -| **JsonMigratableStaticMigration** | **Deserialize,StaticMigration** | **32** | **1,271.72 ns** | **57.484 ns** | **14.928 ns** | **1.22** | **0.02** | **0.2460** | **0.0019** | **3096 B** | **1.04** | -| PlainStjStaticMigrationManual | Deserialize,StaticMigration | 32 | 1,045.61 ns | 36.755 ns | 9.545 ns | 1.00 | 0.01 | 0.2365 | 0.0019 | 2968 B | 1.00 | -| | | | | | | | | | | | | -| **JsonMigratableStaticMigration** | **Deserialize,StaticMigration** | **256** | **5,845.01 ns** | **449.893 ns** | **116.836 ns** | **1.06** | **0.02** | **1.3885** | **0.1144** | **17504 B** | **1.01** | -| PlainStjStaticMigrationManual | Deserialize,StaticMigration | 256 | 5,535.72 ns | 244.447 ns | 63.482 ns | 1.00 | 0.01 | 1.3809 | 0.1144 | 17376 B | 1.00 | -| | | | | | | | | | | | | -| **PlainStjSerializeNoMigration** | **Serialize,NoMigration** | **2** | **93.45 ns** | **5.564 ns** | **1.445 ns** | **1.00** | **0.02** | **0.0069** | **-** | **88 B** | **1.00** | -| PolymorphicPlainStjSerializeNoMigration | Serialize,NoMigration | 2 | 224.60 ns | 17.018 ns | 4.419 ns | 2.40 | 0.06 | 0.0350 | - | 440 B | 5.00 | -| JsonMigratableSerializeNoMigration | Serialize,NoMigration | 2 | 196.40 ns | 7.253 ns | 1.884 ns | 2.10 | 0.04 | 0.0381 | - | 480 B | 5.45 | -| | | | | | | | | | | | | -| **PlainStjSerializeNoMigration** | **Serialize,NoMigration** | **32** | **469.83 ns** | **10.670 ns** | **1.651 ns** | **1.00** | **0.00** | **0.0305** | **-** | **384 B** | **1.00** | -| PolymorphicPlainStjSerializeNoMigration | Serialize,NoMigration | 32 | 551.10 ns | 7.936 ns | 2.061 ns | 1.17 | 0.01 | 0.0591 | - | 744 B | 1.94 | -| JsonMigratableSerializeNoMigration | Serialize,NoMigration | 32 | 551.24 ns | 23.090 ns | 5.996 ns | 1.17 | 0.01 | 0.0610 | - | 776 B | 2.02 | -| | | | | | | | | | | | | -| **PlainStjSerializeNoMigration** | **Serialize,NoMigration** | **256** | **3,231.88 ns** | **67.147 ns** | **17.438 ns** | **1.00** | **0.01** | **0.2060** | **-** | **2624 B** | **1.00** | -| PolymorphicPlainStjSerializeNoMigration | Serialize,NoMigration | 256 | 2,864.80 ns | 106.002 ns | 27.528 ns | 0.89 | 0.01 | 0.2365 | - | 2984 B | 1.14 | -| JsonMigratableSerializeNoMigration | Serialize,NoMigration | 256 | 3,016.84 ns | 99.137 ns | 25.746 ns | 0.93 | 0.01 | 0.2403 | - | 3016 B | 1.15 | +| Method | Categories | TagCount | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Allocated | Alloc Ratio | +|--------------------------------------------- |------------------------------------------- |--------- |-----------:|----------:|----------:|------:|--------:|-------:|-------:|----------:|------------:| +| **JsonMigratableExternalMigration** | **Deserialize,ExternalMigration** | **2** | **663.6 ns** | **19.49 ns** | **3.02 ns** | **1.37** | **0.01** | **0.0601** | **-** | **1008 B** | **1.00** | +| PlainStjExternalMigrationManual | Deserialize,ExternalMigration | 2 | 485.3 ns | 13.54 ns | 3.52 ns | 1.00 | 0.01 | 0.0601 | - | 1008 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableExternalMigration** | **Deserialize,ExternalMigration** | **32** | **1,661.4 ns** | **25.40 ns** | **3.93 ns** | **1.13** | **0.01** | **0.1774** | **0.0019** | **2968 B** | **1.00** | +| PlainStjExternalMigrationManual | Deserialize,ExternalMigration | 32 | 1,472.9 ns | 38.22 ns | 9.92 ns | 1.00 | 0.01 | 0.1774 | 0.0019 | 2968 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableExternalMigration** | **Deserialize,ExternalMigration** | **256** | **8,513.6 ns** | **139.64 ns** | **36.26 ns** | **1.01** | **0.01** | **1.0376** | **0.0916** | **17376 B** | **1.00** | +| PlainStjExternalMigrationManual | Deserialize,ExternalMigration | 256 | 8,406.2 ns | 234.40 ns | 60.87 ns | 1.00 | 0.01 | 1.0376 | 0.0916 | 17376 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableLegacyPayload** | **Deserialize,LegacyPayload** | **2** | **566.9 ns** | **7.70 ns** | **2.00 ns** | **1.13** | **0.01** | **0.0525** | **-** | **880 B** | **1.00** | +| PlainStjLegacyPayloadManual | Deserialize,LegacyPayload | 2 | 499.6 ns | 13.66 ns | 3.55 ns | 1.00 | 0.01 | 0.0525 | - | 880 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableLegacyPayload** | **Deserialize,LegacyPayload** | **32** | **1,586.7 ns** | **21.27 ns** | **5.52 ns** | **1.07** | **0.00** | **0.1698** | **0.0019** | **2840 B** | **1.00** | +| PlainStjLegacyPayloadManual | Deserialize,LegacyPayload | 32 | 1,486.1 ns | 20.81 ns | 5.40 ns | 1.00 | 0.00 | 0.1698 | 0.0019 | 2840 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableLegacyPayload** | **Deserialize,LegacyPayload** | **256** | **8,510.0 ns** | **260.11 ns** | **67.55 ns** | **1.01** | **0.01** | **1.0223** | **0.0916** | **17248 B** | **1.00** | +| PlainStjLegacyPayloadManual | Deserialize,LegacyPayload | 256 | 8,423.3 ns | 223.72 ns | 58.10 ns | 1.00 | 0.01 | 1.0223 | 0.0916 | 17248 B | 1.00 | +| | | | | | | | | | | | | +| **PlainStjNoMigration** | **Deserialize,NoMigration** | **2** | **425.2 ns** | **10.17 ns** | **2.64 ns** | **1.00** | **0.01** | **0.0505** | **-** | **848 B** | **1.00** | +| PolymorphicPlainStjNoMigration | Deserialize,NoMigration | 2 | 464.1 ns | 15.34 ns | 3.98 ns | 1.09 | 0.01 | 0.0505 | - | 848 B | 1.00 | +| JsonMigratableNoMigration | Deserialize,NoMigration | 2 | 525.3 ns | 8.77 ns | 2.28 ns | 1.24 | 0.01 | 0.0505 | - | 848 B | 1.00 | +| | | | | | | | | | | | | +| **PlainStjNoMigration** | **Deserialize,NoMigration** | **32** | **1,405.9 ns** | **36.50 ns** | **9.48 ns** | **1.00** | **0.01** | **0.1678** | **0.0019** | **2808 B** | **1.00** | +| PolymorphicPlainStjNoMigration | Deserialize,NoMigration | 32 | 1,456.6 ns | 39.65 ns | 10.30 ns | 1.04 | 0.01 | 0.1678 | 0.0019 | 2808 B | 1.00 | +| JsonMigratableNoMigration | Deserialize,NoMigration | 32 | 1,517.7 ns | 27.40 ns | 7.12 ns | 1.08 | 0.01 | 0.1678 | 0.0019 | 2808 B | 1.00 | +| | | | | | | | | | | | | +| **PlainStjNoMigration** | **Deserialize,NoMigration** | **256** | **8,399.5 ns** | **162.26 ns** | **42.14 ns** | **1.00** | **0.01** | **1.0223** | **0.0916** | **17216 B** | **1.00** | +| PolymorphicPlainStjNoMigration | Deserialize,NoMigration | 256 | 8,468.0 ns | 416.86 ns | 108.26 ns | 1.01 | 0.01 | 1.0223 | 0.0916 | 17216 B | 1.00 | +| JsonMigratableNoMigration | Deserialize,NoMigration | 256 | 8,529.3 ns | 159.40 ns | 41.39 ns | 1.02 | 0.01 | 1.0223 | 0.0763 | 17216 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableStaticMigration** | **Deserialize,StaticMigration** | **2** | **699.2 ns** | **21.49 ns** | **5.58 ns** | **1.42** | **0.01** | **0.0601** | **-** | **1008 B** | **1.00** | +| PlainStjStaticMigrationManual | Deserialize,StaticMigration | 2 | 492.0 ns | 13.32 ns | 3.46 ns | 1.00 | 0.01 | 0.0601 | - | 1008 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableStaticMigration** | **Deserialize,StaticMigration** | **32** | **1,701.2 ns** | **85.06 ns** | **13.16 ns** | **1.14** | **0.01** | **0.1774** | **0.0019** | **2968 B** | **1.00** | +| PlainStjStaticMigrationManual | Deserialize,StaticMigration | 32 | 1,491.8 ns | 28.94 ns | 7.52 ns | 1.00 | 0.01 | 0.1774 | 0.0019 | 2968 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableStaticMigration** | **Deserialize,StaticMigration** | **256** | **8,565.8 ns** | **231.65 ns** | **60.16 ns** | **1.02** | **0.01** | **1.0376** | **0.0763** | **17376 B** | **1.00** | +| PlainStjStaticMigrationManual | Deserialize,StaticMigration | 256 | 8,437.9 ns | 186.70 ns | 48.48 ns | 1.00 | 0.01 | 1.0376 | 0.0763 | 17376 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableUndiscriminatedSourceMigration** | **Deserialize,UndiscriminatedSourceMigration** | **2** | **587.8 ns** | **47.36 ns** | **7.33 ns** | **0.99** | **0.08** | **0.0601** | **-** | **1008 B** | **1.00** | +| PlainStjUndiscriminatedSourceMigrationManual | Deserialize,UndiscriminatedSourceMigration | 2 | 599.2 ns | 189.12 ns | 49.11 ns | 1.01 | 0.11 | 0.0601 | - | 1008 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableUndiscriminatedSourceMigration** | **Deserialize,UndiscriminatedSourceMigration** | **32** | **1,847.2 ns** | **233.05 ns** | **60.52 ns** | **1.07** | **0.10** | **0.1774** | **0.0019** | **2968 B** | **1.00** | +| PlainStjUndiscriminatedSourceMigrationManual | Deserialize,UndiscriminatedSourceMigration | 32 | 1,737.2 ns | 655.45 ns | 170.22 ns | 1.01 | 0.13 | 0.1774 | 0.0019 | 2968 B | 1.00 | +| | | | | | | | | | | | | +| **JsonMigratableUndiscriminatedSourceMigration** | **Deserialize,UndiscriminatedSourceMigration** | **256** | **8,540.4 ns** | **241.11 ns** | **37.31 ns** | **1.02** | **0.01** | **1.0376** | **0.0763** | **17376 B** | **1.00** | +| PlainStjUndiscriminatedSourceMigrationManual | Deserialize,UndiscriminatedSourceMigration | 256 | 8,364.0 ns | 192.45 ns | 49.98 ns | 1.00 | 0.01 | 1.0376 | 0.0763 | 17376 B | 1.00 | +| | | | | | | | | | | | | +| **PlainStjSerializeNoMigration** | **Serialize,NoMigration** | **2** | **144.2 ns** | **1.42 ns** | **0.37 ns** | **1.00** | **0.00** | **0.0052** | **-** | **88 B** | **1.00** | +| PolymorphicPlainStjSerializeNoMigration | Serialize,NoMigration | 2 | 290.8 ns | 7.82 ns | 2.03 ns | 2.02 | 0.01 | 0.0262 | - | 440 B | 5.00 | +| JsonMigratableSerializeNoMigration | Serialize,NoMigration | 2 | 260.9 ns | 4.99 ns | 0.77 ns | 1.81 | 0.01 | 0.0286 | - | 480 B | 5.45 | +| | | | | | | | | | | | | +| **PlainStjSerializeNoMigration** | **Serialize,NoMigration** | **32** | **658.6 ns** | **8.84 ns** | **2.30 ns** | **1.00** | **0.00** | **0.0229** | **-** | **384 B** | **1.00** | +| PolymorphicPlainStjSerializeNoMigration | Serialize,NoMigration | 32 | 710.9 ns | 13.79 ns | 3.58 ns | 1.08 | 0.01 | 0.0439 | - | 744 B | 1.94 | +| JsonMigratableSerializeNoMigration | Serialize,NoMigration | 32 | 713.2 ns | 9.24 ns | 2.40 ns | 1.08 | 0.00 | 0.0458 | - | 776 B | 2.02 | +| | | | | | | | | | | | | +| **PlainStjSerializeNoMigration** | **Serialize,NoMigration** | **256** | **4,343.2 ns** | **33.28 ns** | **8.64 ns** | **1.00** | **0.00** | **0.1526** | **-** | **2624 B** | **1.00** | +| PolymorphicPlainStjSerializeNoMigration | Serialize,NoMigration | 256 | 3,713.0 ns | 75.63 ns | 11.70 ns | 0.85 | 0.00 | 0.1755 | - | 2984 B | 1.14 | +| JsonMigratableSerializeNoMigration | Serialize,NoMigration | 256 | 3,880.7 ns | 18.08 ns | 2.80 ns | 0.89 | 0.00 | 0.1755 | - | 3016 B | 1.15 | diff --git a/Egil.SystemTextJson.Migration/docs/recipes/README.md b/Egil.SystemTextJson.Migration/docs/recipes/README.md index 20bd351..3969b1b 100644 --- a/Egil.SystemTextJson.Migration/docs/recipes/README.md +++ b/Egil.SystemTextJson.Migration/docs/recipes/README.md @@ -44,6 +44,7 @@ All code samples are extracted from the [samples project](../../samples/Egil.Sys ## Legacy & incremental adoption - [Adopting the library with existing stored JSON](legacy-adoption.md#adopting-the-library-with-existing-stored-json) +- [Migrating discriminator-less object payloads from a source type](legacy-adoption.md#migrating-discriminator-less-object-payloads-from-a-source-type) - [Discriminator not in first position](legacy-adoption.md#discriminator-not-in-first-position) ## AOT & source generation diff --git a/Egil.SystemTextJson.Migration/docs/recipes/legacy-adoption.md b/Egil.SystemTextJson.Migration/docs/recipes/legacy-adoption.md index bcef222..fde8329 100644 --- a/Egil.SystemTextJson.Migration/docs/recipes/legacy-adoption.md +++ b/Egil.SystemTextJson.Migration/docs/recipes/legacy-adoption.md @@ -17,14 +17,56 @@ var json = """{"itemName":"Widget","quantity":5}"""; var order = JsonSerializer.Deserialize(json, options); // Works perfectly — existing data keeps working with zero changes. ``` -snippet source | anchor +snippet source | anchor > **Note:** This means you can adopt the library incrementally. Existing stored JSON keeps working with zero data migration. New writes will include the `$type` discriminator, and over time, old payloads can be upgraded via the [read-migrate-write-back pattern](migration-tracking.md#read-migrate-write-back-pattern). +## Migrating discriminator-less object payloads from a source type + +If the stored JSON represents an older object shape, but the new target type has a different shape, configure the target with `UndiscriminatedSourceType`. This tells the library which single source type to assume when an object payload has no recognized first-property discriminator: + + + +```cs +[JsonMigratable( + TypeDiscriminator = "customer-name-v1", + UndiscriminatedSourceType = typeof(CustomerNameV0))] +public record class CustomerNameV1(string Name) + : IMigrateFrom +{ + public static bool TryMigrateFrom(CustomerNameV0 source, out CustomerNameV1 result) + { + result = new CustomerNameV1($"{source.FirstName} {source.LastName}"); + return true; + } +} +``` +snippet source | anchor + + + + +```cs +var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); +options.AddJsonMigrationSupport(); + +// Existing stored JSON was written before migration support existed, +// so it has no $type discriminator. CustomerNameV1 opts in to treating +// discriminator-less objects as CustomerNameV0 and runs its migrator. +var json = """{"firstName":"Jane","lastName":"Doe"}"""; + +CustomerNameV1 customer = JsonSerializer.Deserialize(json, options)!; +// customer is CustomerNameV1 { Name = "Jane Doe" } +``` +snippet source | anchor + + +> **Note:** This opt-in supports one source type per target. If a target can migrate from multiple object source types, choose the one that represents discriminator-less stored payloads. Payloads with a recognized discriminator still use discriminator-based matching. + ## Discriminator not in first position -The library only inspects the **first JSON property** for the type discriminator. If `$type` appears anywhere else in the payload, it is ignored and the payload is treated as a legacy payload. +The library only inspects the **first JSON property** for the type discriminator. If `$type` appears anywhere else in the payload, it is ignored and the payload follows the same path as any other object without a recognized discriminator: target deserialization by default, or the configured `UndiscriminatedSourceType` migrator when that opt-in is set. @@ -39,7 +81,7 @@ var json = """{"itemName":"Widget","quantity":5,"$type":"order-v2"}"""; var order = JsonSerializer.Deserialize(json, options); ``` -snippet source | anchor +snippet source | anchor > **Note:** This design is intentional — checking only the first property allows for O(1) discriminator detection without buffering the entire JSON payload. When serializing, the library always writes `$type` as the first property. diff --git a/Egil.SystemTextJson.Migration/docs/recipes/migration-authoring.md b/Egil.SystemTextJson.Migration/docs/recipes/migration-authoring.md index bd8277c..b3430ec 100644 --- a/Egil.SystemTextJson.Migration/docs/recipes/migration-authoring.md +++ b/Egil.SystemTextJson.Migration/docs/recipes/migration-authoring.md @@ -104,6 +104,14 @@ options.AddJsonMigrationSupport(builder => > **Note:** External migrators must be registered explicitly via `RegisterMigrator()` or discovered via `RegisterMigratorsFromAssembly()`. They support DI via `UseServiceProvider()`. +## Migrating from object payloads without discriminators + +When adopting migration support for a type that already has stored JSON, older object payloads may not contain a `$type` discriminator. If those payloads should be read as a previous source shape instead of the current target shape, set `UndiscriminatedSourceType` on the target's `[JsonMigratable]` attribute. + +The configured source type does not need `[JsonMigratable]`; it only needs JSON metadata and a matching static or registered external migrator to the target. The opt-in names exactly one source type, so targets with several object migrators remain deterministic. + +See [Migrating discriminator-less object payloads from a source type](legacy-adoption.md#migrating-discriminator-less-object-payloads-from-a-source-type) for a complete sample. + ## Migrating from non-object JSON payloads When the stored JSON is not an object — for example, a plain array or a primitive value — the library can still migrate it to a structured target type. This is useful when the original data model stored a simple value (like a `List` or a raw `string`) and you later upgraded to a richer type. diff --git a/Egil.SystemTextJson.Migration/perf/Egil.SystemTextJson.Migration.PerfTests/MigrationScenarioBenchmarks.cs b/Egil.SystemTextJson.Migration/perf/Egil.SystemTextJson.Migration.PerfTests/MigrationScenarioBenchmarks.cs index e67a9f6..f55148e 100644 --- a/Egil.SystemTextJson.Migration/perf/Egil.SystemTextJson.Migration.PerfTests/MigrationScenarioBenchmarks.cs +++ b/Egil.SystemTextJson.Migration/perf/Egil.SystemTextJson.Migration.PerfTests/MigrationScenarioBenchmarks.cs @@ -73,6 +73,8 @@ public abstract class MigrationScenarioBenchmarksBase private byte[] migratableStaticMigrationPayload = null!; private byte[] plainExternalMigrationPayload = null!; private byte[] migratableExternalMigrationPayload = null!; + private byte[] plainUndiscriminatedSourceMigrationPayload = null!; + private byte[] migratableUndiscriminatedSourceMigrationPayload = null!; private byte[] plainLegacyPayload = null!; private byte[] migratableLegacyPayload = null!; private PerfCurrentStatePlain plainCurrentState = null!; @@ -107,6 +109,9 @@ public void Setup() plainExternalMigrationPayload = JsonSerializer.SerializeToUtf8Bytes(new PerfExternalPlainV1("Egil Hansen", 42, tags), plainOptions); migratableExternalMigrationPayload = JsonSerializer.SerializeToUtf8Bytes(new PerfExternalV1("Egil Hansen", 42, tags), migratableExternalOptions); + plainUndiscriminatedSourceMigrationPayload = JsonSerializer.SerializeToUtf8Bytes(new PerfUndiscriminatedPlainV1("Egil Hansen", 42, tags), plainOptions); + migratableUndiscriminatedSourceMigrationPayload = JsonSerializer.SerializeToUtf8Bytes(new PerfUndiscriminatedV1("Egil Hansen", 42, tags), plainOptions); + plainLegacyPayload = JsonSerializer.SerializeToUtf8Bytes(new PerfExternalPlainV2("Egil", "Hansen", 42, tags), plainOptions); migratableLegacyPayload = JsonSerializer.SerializeToUtf8Bytes(new PerfExternalV2("Egil", "Hansen", 42, tags), plainOptions); } @@ -152,6 +157,19 @@ public PerfExternalPlainV2 PlainStjExternalMigrationManual() return PerfExternalPlainV2.ManualFrom(source); } + [Benchmark] + [BenchmarkCategory("Deserialize", "UndiscriminatedSourceMigration")] + public PerfUndiscriminatedV2 JsonMigratableUndiscriminatedSourceMigration() + => JsonSerializer.Deserialize(migratableUndiscriminatedSourceMigrationPayload, migratableStaticOptions)!; + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Deserialize", "UndiscriminatedSourceMigration")] + public PerfUndiscriminatedPlainV2 PlainStjUndiscriminatedSourceMigrationManual() + { + PerfUndiscriminatedPlainV1 source = JsonSerializer.Deserialize(plainUndiscriminatedSourceMigrationPayload, plainOptions)!; + return PerfUndiscriminatedPlainV2.ManualFrom(source); + } + [Benchmark] [BenchmarkCategory("Deserialize", "LegacyPayload")] public PerfExternalV2 JsonMigratableLegacyPayload() @@ -302,6 +320,43 @@ public bool TryMigrateFrom(PerfExternalV1 source, out PerfExternalV2 result) } } +public record class PerfUndiscriminatedPlainV1(string Name, int Age, string[] Tags); + +public record class PerfUndiscriminatedPlainV2(string FirstName, string LastName, int Age, string[] Tags) +{ + public static PerfUndiscriminatedPlainV2 ManualFrom(PerfUndiscriminatedPlainV1 source) + { + var names = source.Name.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return new PerfUndiscriminatedPlainV2( + names.Length > 0 ? names[0] : string.Empty, + names.Length > 1 ? names[1] : string.Empty, + source.Age, + source.Tags); + } +} + +public record class PerfUndiscriminatedV1(string Name, int Age, string[] Tags); + +[JsonMigratable(UndiscriminatedSourceType = typeof(PerfUndiscriminatedV1))] +public record class PerfUndiscriminatedV2(string FirstName, string LastName, int Age, string[] Tags) : + IJsonMigrationTracked, + IMigrateFrom +{ + [JsonIgnore] + public bool MigratedDuringDeserialization { get; set; } + + public static bool TryMigrateFrom(PerfUndiscriminatedV1 source, out PerfUndiscriminatedV2 result) + { + var names = source.Name.Split(' ', StringSplitOptions.RemoveEmptyEntries); + result = new PerfUndiscriminatedV2( + names.Length > 0 ? names[0] : string.Empty, + names.Length > 1 ? names[1] : string.Empty, + source.Age, + source.Tags); + return true; + } +} + [JsonSourceGenerationOptions] [JsonSerializable(typeof(PerfCurrentStatePlain))] [JsonSerializable(typeof(PerfCurrentStateMigratable))] @@ -314,4 +369,8 @@ public bool TryMigrateFrom(PerfExternalV1 source, out PerfExternalV2 result) [JsonSerializable(typeof(PerfExternalV1))] [JsonSerializable(typeof(PerfExternalV2))] [JsonSerializable(typeof(PerfExternalPolymorphicPlainV1))] +[JsonSerializable(typeof(PerfUndiscriminatedPlainV1))] +[JsonSerializable(typeof(PerfUndiscriminatedPlainV2))] +[JsonSerializable(typeof(PerfUndiscriminatedV1))] +[JsonSerializable(typeof(PerfUndiscriminatedV2))] public partial class PerfJsonContext : JsonSerializerContext; diff --git a/Egil.SystemTextJson.Migration/samples/Egil.SystemTextJson.Migration.Samples/LegacyPayloadSample.cs b/Egil.SystemTextJson.Migration/samples/Egil.SystemTextJson.Migration.Samples/LegacyPayloadSample.cs index 044f857..c9639ad 100644 --- a/Egil.SystemTextJson.Migration/samples/Egil.SystemTextJson.Migration.Samples/LegacyPayloadSample.cs +++ b/Egil.SystemTextJson.Migration/samples/Egil.SystemTextJson.Migration.Samples/LegacyPayloadSample.cs @@ -14,6 +14,23 @@ public static bool TryMigrateFrom(OrderV1 source, out OrderV2 result) } } +public record class CustomerNameV0(string FirstName, string LastName); + +#region legacy_undiscriminated_source_type +[JsonMigratable( + TypeDiscriminator = "customer-name-v1", + UndiscriminatedSourceType = typeof(CustomerNameV0))] +public record class CustomerNameV1(string Name) + : IMigrateFrom +{ + public static bool TryMigrateFrom(CustomerNameV0 source, out CustomerNameV1 result) + { + result = new CustomerNameV1($"{source.FirstName} {source.LastName}"); + return true; + } +} +#endregion + public sealed class LegacyPayloadSampleTests { [Fact] @@ -55,4 +72,23 @@ public void Discriminator_not_first_treated_as_legacy() Assert.Equal("Widget", order.ItemName); Assert.Equal(5, order.Quantity); } + + [Fact] + public void Undiscriminated_source_payload_is_migrated() + { + #region legacy_undiscriminated_source_usage + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.AddJsonMigrationSupport(); + + // Existing stored JSON was written before migration support existed, + // so it has no $type discriminator. CustomerNameV1 opts in to treating + // discriminator-less objects as CustomerNameV0 and runs its migrator. + var json = """{"firstName":"Jane","lastName":"Doe"}"""; + + CustomerNameV1 customer = JsonSerializer.Deserialize(json, options)!; + // customer is CustomerNameV1 { Name = "Jane Doe" } + #endregion + + Assert.Equal("Jane Doe", customer.Name); + } } diff --git a/Egil.SystemTextJson.Migration/scripts/update-perf-docs.ps1 b/Egil.SystemTextJson.Migration/scripts/update-perf-docs.ps1 index 1b781e2..7823656 100644 --- a/Egil.SystemTextJson.Migration/scripts/update-perf-docs.ps1 +++ b/Egil.SystemTextJson.Migration/scripts/update-perf-docs.ps1 @@ -3,11 +3,12 @@ Updates performance documentation from BenchmarkDotNet output. .DESCRIPTION - Reads BDN -report-github.md files from the perf project artifacts, + Reads BDN -report-github.md files from BenchmarkDotNet artifacts, copies full reports to docs/perf/, and updates the curated summary table in README.md. .EXAMPLE + dotnet run --project perf/Egil.SystemTextJson.Migration.PerfTests -c Release ./scripts/update-perf-docs.ps1 #> [CmdletBinding()] @@ -16,7 +17,15 @@ param() $ErrorActionPreference = 'Stop' $root = Split-Path $PSScriptRoot -Parent -$artifactsDir = Join-Path $root 'perf\Egil.SystemTextJson.Migration.PerfTests\BenchmarkDotNet.Artifacts\results' +$artifactDirCandidates = @( + (Join-Path $root 'perf\Egil.SystemTextJson.Migration.PerfTests\BenchmarkDotNet.Artifacts\results'), + (Join-Path $root 'BenchmarkDotNet.Artifacts\results') +) +$artifactsDir = $artifactDirCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $artifactsDir) { + Write-Error "Benchmark reports not found. Run benchmarks first: dotnet run --project perf\Egil.SystemTextJson.Migration.PerfTests -c Release" +} + $docsDir = Join-Path $root 'docs\perf' $readme = Join-Path $root 'README.md' @@ -87,6 +96,7 @@ $scenarios = @( @{ Label = '**No migration (happy path)**'; Method = 'JsonMigratableNoMigration' } @{ Label = '**Static migration**'; Method = 'JsonMigratableStaticMigration' } @{ Label = '**External migration**'; Method = 'JsonMigratableExternalMigration' } + @{ Label = '**Undiscriminated source migration**'; Method = 'JsonMigratableUndiscriminatedSourceMigration' } @{ Label = '**Legacy payload**'; Method = 'JsonMigratableLegacyPayload' } @{ Label = '**Serialization**'; Method = 'JsonMigratableSerializeNoMigration' } ) diff --git a/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/JsonMigratableAttribute.cs b/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/JsonMigratableAttribute.cs index 0a37e57..b88d826 100644 --- a/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/JsonMigratableAttribute.cs +++ b/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/JsonMigratableAttribute.cs @@ -22,4 +22,10 @@ public sealed class JsonMigratableAttribute : Attribute /// When explicitly set on the attribute usage, this overrides the builder-level handling. /// public JsonMigrationFailureHandling MigrationFailureHandling { get; set; } = JsonMigrationFailureHandling.ThrowJsonException; + + /// + /// Gets or sets the source type to assume when deserializing an object payload without a recognized discriminator. + /// When not set, discriminator-less object payloads are deserialized directly as the target type. + /// + public Type? UndiscriminatedSourceType { get; set; } } diff --git a/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/JsonMigratableConverter.cs b/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/JsonMigratableConverter.cs index 5fd39a9..2841a0b 100644 --- a/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/JsonMigratableConverter.cs +++ b/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/JsonMigratableConverter.cs @@ -132,7 +132,7 @@ private InspectionResult Inspect(ref Utf8JsonReader reader, out MigratorReferenc if (probe.TokenType is JsonTokenType.EndObject) { - return InspectionResult.LegacyPayload; + return InspectUndiscriminatedObject(out migrator); } if (probe.TokenType is not JsonTokenType.PropertyName) @@ -189,6 +189,12 @@ private InspectionResult Inspect(ref Utf8JsonReader reader, out MigratorReferenc ThrowUnknownDiscriminator(ref probe); } + InspectionResult undiscriminatedObjectResult = InspectUndiscriminatedObject(out migrator); + if (undiscriminatedObjectResult is InspectionResult.MigrationRequired) + { + return undiscriminatedObjectResult; + } + // The first property didn't match any discriminator. Before treating // as a legacy payload, check for dictionary-kind migrators — dictionaries // serialize as JSON objects but have no discriminator. @@ -201,6 +207,14 @@ private InspectionResult Inspect(ref Utf8JsonReader reader, out MigratorReferenc return InspectionResult.LegacyPayload; } + private InspectionResult InspectUndiscriminatedObject(out MigratorReference? migrator) + { + migrator = context.UndiscriminatedSourceMigrator; + return migrator is not null + ? InspectionResult.MigrationRequired + : InspectionResult.LegacyPayload; + } + private MigratorReference? FindMigratorByDiscriminator(ref Utf8JsonReader reader) { foreach (MigratorReference migrator in context.Migrators) diff --git a/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/JsonMigratableConverterFactory.cs b/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/JsonMigratableConverterFactory.cs index ac7780b..04e7f38 100644 --- a/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/JsonMigratableConverterFactory.cs +++ b/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/JsonMigratableConverterFactory.cs @@ -61,6 +61,11 @@ private JsonConverter CreateConverterCore(Type typeToConvert, JsonSerializerOpti .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); + MigratorReference? undiscriminatedSourceMigrator = ResolveUndiscriminatedSourceMigrator( + typeToConvert, + targetMetadata, + migrators); + // Freeze the metadata options so that internal STJ methods like // GetTypeInfoInternal (called by JsonResumableConverter.Read) use the // read-only cache path instead of the mutable path which silently returns null @@ -72,6 +77,7 @@ private JsonConverter CreateConverterCore(Type typeToConvert, JsonSerializerOpti targetMetadata, migrators, sourcePropertyNames, + undiscriminatedSourceMigrator, registry.GetMigrationFailureHandling(typeToConvert)); Type converterType = typeof(JsonMigratableConverter<>).MakeGenericType(typeToConvert); @@ -118,6 +124,27 @@ private MigratorReference[] BuildMigratorMap(Type targetType, JsonSerializerOpti return [.. migrators.Values.Select(static candidate => candidate.Migrator)]; } + private static MigratorReference? ResolveUndiscriminatedSourceMigrator( + Type targetType, + TypeMetadata targetMetadata, + MigratorReference[] migrators) + { + Type? sourceType = targetMetadata.UndiscriminatedSourceType; + if (sourceType is null) + { + return null; + } + + MigratorReference? match = migrators.FirstOrDefault(migrator => migrator.SourceType == sourceType); + if (match is not null) + { + return match; + } + + throw new InvalidOperationException( + $"Target type '{targetType.FullName}' configures '{nameof(JsonMigratableAttribute.UndiscriminatedSourceType)}' as '{sourceType.FullName}', but no migrator was found for '{sourceType.FullName}' -> '{targetType.FullName}'."); + } + private static void AddMigratorCandidate( Dictionary migrators, Type targetType, diff --git a/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/MigratorContext.cs b/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/MigratorContext.cs index b61764e..5737e46 100644 --- a/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/MigratorContext.cs +++ b/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/MigratorContext.cs @@ -7,6 +7,7 @@ internal sealed record MigratorContext( TypeMetadata TargetMetadata, MigratorReference[] Migrators, string[] SourceDiscriminatorPropertyNames, + MigratorReference? UndiscriminatedSourceMigrator, JsonMigrationFailureHandling MigrationFailureHandling) { public byte[] TargetDiscriminatorPropertyNameUtf8 { get; } = System.Text.Encoding.UTF8.GetBytes(TargetMetadata.DiscriminatorPropertyName); diff --git a/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/TypeMetadata.cs b/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/TypeMetadata.cs index 068cb2f..02c853c 100644 --- a/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/TypeMetadata.cs +++ b/Egil.SystemTextJson.Migration/src/Egil.SystemTextJson.Migration/Migrations/TypeMetadata.cs @@ -5,7 +5,8 @@ namespace Egil.SystemTextJson.Migration.Migrations; internal sealed record TypeMetadata( Type Type, string Discriminator, - string DiscriminatorPropertyName) + string DiscriminatorPropertyName, + Type? UndiscriminatedSourceType) { public static TypeMetadata FromType( Type type, @@ -16,6 +17,6 @@ public static TypeMetadata FromType( string? customDiscriminator = typeDiscriminatorResolver?.Invoke(type); string discriminator = customDiscriminator ?? attribute?.TypeDiscriminator ?? type.FullName ?? type.Name; string propertyName = attribute?.TypeDiscriminatorPropertyName ?? defaultDiscriminatorPropertyName ?? "$type"; - return new TypeMetadata(type, discriminator, propertyName); + return new TypeMetadata(type, discriminator, propertyName, attribute?.UndiscriminatedSourceType); } } diff --git a/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/MutationCoverageTests.cs b/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/MutationCoverageTests.cs index 221cf3f..2712fc8 100644 --- a/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/MutationCoverageTests.cs +++ b/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/MutationCoverageTests.cs @@ -455,6 +455,7 @@ private static JsonConverter CreateTrackingConverterWithUntypedTarge object? targetMetadata = contextType.GetProperty("TargetMetadata")?.GetValue(originalContext); object? migrators = contextType.GetProperty("Migrators")?.GetValue(originalContext); object? sourceDiscriminatorPropertyNames = contextType.GetProperty("SourceDiscriminatorPropertyNames")?.GetValue(originalContext); + object? undiscriminatedSourceMigrator = contextType.GetProperty("UndiscriminatedSourceMigrator")?.GetValue(originalContext); object? migrationFailureHandling = contextType.GetProperty("MigrationFailureHandling")?.GetValue(originalContext); Assert.NotNull(targetMetadata); @@ -467,7 +468,7 @@ private static JsonConverter CreateTrackingConverterWithUntypedTarge contextType, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, binder: null, - args: [untypedTargetTypeInfo, targetMetadata, migrators, sourceDiscriminatorPropertyNames, migrationFailureHandling], + args: [untypedTargetTypeInfo, targetMetadata, migrators, sourceDiscriminatorPropertyNames, undiscriminatedSourceMigrator, migrationFailureHandling], culture: null)!; object converterInstance = Activator.CreateInstance( diff --git a/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/UndiscriminatedSourceMigrationTests.cs b/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/UndiscriminatedSourceMigrationTests.cs new file mode 100644 index 0000000..8173c66 --- /dev/null +++ b/Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/UndiscriminatedSourceMigrationTests.cs @@ -0,0 +1,126 @@ +using System.Text.Json; + +namespace Egil.SystemTextJson.Migration.Tests; + +public sealed class UndiscriminatedSourceMigrationTests +{ + [Fact] + public void Deserialize_object_without_discriminator_uses_configured_static_source_migrator() + { + var options = CreateOptions(); + const string json = """{"firstName":"Egil","lastName":"Hansen"}"""; + + UndiscriminatedStaticTarget? result = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(result); + Assert.Equal("Egil Hansen", result.Name); + } + + [Fact] + public void Deserialize_object_without_discriminator_uses_configured_external_source_migrator() + { + var options = CreateOptions(static builder => builder.RegisterMigrator()); + const string json = """{"firstName":"Egil","lastName":"Hansen"}"""; + + UndiscriminatedExternalTarget? result = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(result); + Assert.Equal("Egil Hansen", result.Name); + } + + [Fact] + public void Deserialize_object_without_discriminator_preserves_target_fallback_without_opt_in() + { + var options = CreateOptions(); + const string json = """{"value":"target","migrationPath":"target"}"""; + + UndiscriminatedDefaultTarget? result = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(result); + Assert.Equal("target", result.Value); + Assert.Equal("target", result.MigrationPath); + } + + [Fact] + public void Deserialize_with_configured_source_without_matching_migrator_throws_clear_error() + { + var options = CreateOptions(); + const string json = """{"firstName":"Egil","lastName":"Hansen"}"""; + + var exception = Assert.Throws( + () => JsonSerializer.Deserialize(json, options)); + + Assert.Contains(nameof(JsonMigratableAttribute.UndiscriminatedSourceType), exception.Message, StringComparison.Ordinal); + Assert.Contains(nameof(UndiscriminatedInvalidSource), exception.Message, StringComparison.Ordinal); + Assert.Contains(nameof(UndiscriminatedInvalidTarget), exception.Message, StringComparison.Ordinal); + } + + private static JsonSerializerOptions CreateOptions(Action? configure = null) + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.AddJsonMigrationSupport(configure); + return options; + } +} + +public record class UndiscriminatedStaticSource(string FirstName, string LastName); + +public record class UndiscriminatedStaticOtherSource(string FirstName, string LastName); + +[JsonMigratable( + TypeDiscriminator = "undiscriminated-static-target", + UndiscriminatedSourceType = typeof(UndiscriminatedStaticSource))] +public record class UndiscriminatedStaticTarget(string Name) : + IMigrateFrom, + IMigrateFrom +{ + public static bool TryMigrateFrom(UndiscriminatedStaticSource source, out UndiscriminatedStaticTarget result) + { + result = new UndiscriminatedStaticTarget($"{source.FirstName} {source.LastName}"); + return true; + } + + public static bool TryMigrateFrom(UndiscriminatedStaticOtherSource source, out UndiscriminatedStaticTarget result) + { + result = new UndiscriminatedStaticTarget($"other:{source.FirstName} {source.LastName}"); + return true; + } +} + +public record class UndiscriminatedExternalSource(string FirstName, string LastName); + +[JsonMigratable( + TypeDiscriminator = "undiscriminated-external-target", + UndiscriminatedSourceType = typeof(UndiscriminatedExternalSource))] +public record class UndiscriminatedExternalTarget(string Name); + +public sealed class UndiscriminatedExternalMigrator : + IMigrate +{ + public bool TryMigrateFrom(UndiscriminatedExternalSource source, out UndiscriminatedExternalTarget result) + { + result = new UndiscriminatedExternalTarget($"{source.FirstName} {source.LastName}"); + return true; + } +} + +public record class UndiscriminatedDefaultSource(string Name); + +[JsonMigratable(TypeDiscriminator = "undiscriminated-default-target")] +public record class UndiscriminatedDefaultTarget(string Value, string MigrationPath) : + IMigrateFrom +{ + public static bool TryMigrateFrom(UndiscriminatedDefaultSource source, out UndiscriminatedDefaultTarget result) + { + result = new UndiscriminatedDefaultTarget(source.Name, "migrated"); + return true; + } +} + +public record class UndiscriminatedInvalidSource(string FirstName, string LastName); + +[JsonMigratable( + TypeDiscriminator = "undiscriminated-invalid-target", + UndiscriminatedSourceType = typeof(UndiscriminatedInvalidSource))] +public record class UndiscriminatedInvalidTarget(string Name); +