Repository navigation
(MINOR): Add IPatchClassBuilder, with PatchClassBuilder delegating to an internal RoslynPatchClassBuilder - #133
Merged
Merged
Conversation
PatchClassBuilder implements the new public IPatchClassBuilder interface. EmitPatchClassBuilder changes from a static class to a sealed singleton that implements it too, so the shared test fixture takes the builder itself rather than a Func<Type, Type>. No behaviour changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PatchClassBuilder becomes a thin public facade over a new internal RoslynPatchClassBuilder, which holds the CodeDom/Roslyn implementation and its static cache. PatchClassBuilder keeps its Instance, its obsolete constructor and its GetPatchClassFor signature, so nothing breaks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
✅ PR Title Formatted CorrectlyThe title of this PR has been updated to match the correct format. Thank you! |
This was referenced Oct 4, 2026
PaulTrampert
added a commit
that referenced
this pull request
Oct 4, 2026
## Why `IPatchObject<T>` throws `NotSupportedException` for an `internal` `T`, because the Roslyn builder compiles the patch class into a separate assembly that can only name public types (#94). #128 added an internal Reflection.Emit builder that doesn't have this limit, and #133 put both builders behind `IPatchClassBuilder`, with `PatchClassBuilder` as a facade. This PR lets applications opt in to the Emit builder. ## What changed - New `public static bool PatchClassBuilder.UseExperimentalDynamicClassBuilder { get; set; }`, off by default. `PatchClassBuilder.GetPatchClassFor` reads it on every call and delegates to `EmitPatchClassBuilder.Instance` when it's on, or `RoslynPatchClassBuilder.Instance` when it's off. `Instance` keeps its type, so this is binary compatible. - The flag is process-wide, so `PatchJsonConverterFactory`, the Swashbuckle filter and the OpenAPI transformer all follow it with no extra wiring. Its XML docs say to set it at startup, before first use: each builder keeps its own cache, but System.Text.Json and the OpenAPI generators cache the types they've already resolved. - Both builder classes stay internal. The flag is the only new public API, so this is MINOR. - The Roslyn builder's `NotSupportedException` for non-public types now tells you to set the flag. - Docs: the flag's XML docs, the `GetPatchClassFor` `<exception>` docs, a new "Non-public write models (experimental)" section in `README.md`, a short section in `docs/getting-started.md`, and `AGENTS.md` ("How it works"). ## Alternatives rejected - **An `AddSimplePatchConverters(options, bool)` overload (#95's original design).** It hides a process-wide setting behind an API that looks per-options. It also needed a "can only be turned on" rule so that two option sets couldn't disagree. - **Retyping `Instance` to `IPatchClassBuilder`.** This breaks binary compatibility, so it's deferred to #135. - **A one-way `Enable…()` method, or a flag that locks after first use.** Both are harder to test and add API surface. Documenting "set at startup" is enough for an experimental opt-in. ## Tests - Core (`UseExperimentalDynamicClassBuilderTest`, `[NonParallelizable]`, resets the flag in `TearDown`): - The flag defaults to off. - With the flag on, `Instance` returns the Emit builder's type. - After the flag is turned back off, it returns the Roslyn builder's type again (separate caches). - With the flag off, an internal type throws an error that names the flag. - With the flag on, an internal type works end to end through `AddSimplePatchConverters`: patching, explicit null, an omitted property, and `[Range]` validation. - Swashbuckle and OpenAPI: new tests generate a patch schema for an internal model with the flag on. Each test project grants `InternalsVisibleTo("PTrampert.SimplePatch.Emitted")`. I checked that both tests fail with `NotSupportedException` when the flag isn't set. - `dotnet build`: no new warnings, and none come from changed files. `dotnet docfx`: only the existing warnings. - `dotnet test`: core 129/129, Swashbuckle 18/18, OpenApi 12/12. The net8.0 projects were run with `DOTNET_ROLL_FORWARD=Major` because only the .NET 10 runtime is installed locally. Closes #95 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
PaulTrampert
added a commit
that referenced
this pull request
Oct 4, 2026
…make PatchClassBuilder static (#141) ## Cause #133 made `PatchClassBuilder` a facade over the internal `RoslynPatchClassBuilder`, and #95 added `UseExperimentalDynamicClassBuilder`, which the facade's `GetPatchClassFor` read on every call. Both kept `PatchClassBuilder.Instance` typed as `PatchClassBuilder`, because changing a property's type breaks binary compatibility. This is the 2.0 change that #135 deferred. Once `Instance` hands out the selected internal builder, nothing hands out a `PatchClassBuilder` any more. Its instance surface (the obsolete constructor, instance `GetPatchClassFor`, and its `IPatchClassBuilder` implementation) only forwards to `Instance` and is dead weight (#143). The two changes are one PR because a static class can't be a property type, so #143 can't compile without #135. ## Fix **Retype `Instance` (#135)** - `public static IPatchClassBuilder Instance` returns the selected builder itself: `EmitPatchClassBuilder.Instance` when the flag is on, otherwise `RoslynPatchClassBuilder.Instance`. Both internal builders already had static `Instance` singletons. - `Instance` is a computed property (`=>`), not an initialised one, so it honours the flag at read time. The flag is a settable static, and the tests flip it back and forth. - Call sites (`PatchJsonConverterFactory`, the Swashbuckle filter, the OpenAPI transformer) already call `PatchClassBuilder.Instance.GetPatchClassFor(...)` on each use, so they compile and behave unchanged against the new type. None of them caches the builder. **Make `PatchClassBuilder` static (#143)** - `public static class PatchClassBuilder` now holds only `Instance` and `UseExperimentalDynamicClassBuilder`. - The constructor, instance `GetPatchClassFor` and the `IPatchClassBuilder` implementation are removed. - The XML docs worth keeping moved. The description of the generated class (its properties, its `Patch` method, sealed and public, the `.Optionals` namespace) is now in the class remarks. The `NotSupportedException` conditions are now in `Instance`'s remarks. `IPatchClassBuilder.GetPatchClassFor` keeps its builder-neutral contract. `RoslynPatchClassBuilder.GetPatchClassFor` had `<inheritdoc cref="PatchClassBuilder.GetPatchClassFor"/>` and now has its own summary and exception docs. - `RoslynPatchClassBuilderTest.GetPatchClassFor_SharesGeneratedTypesAcrossBuilders` constructed `PatchClassBuilder` twice. It is now `GetPatchClassFor_CachesTheGeneratedType`, which calls `RoslynPatchClassBuilder.Instance` twice and still checks that `PatchClassBuilder.Instance` agrees with it while the flag is off. The internal builders have private constructors, so a second instance can't be constructed. **Docs:** `README.md` and `docs/getting-started.md` note that `Instance` returns the selected builder, so you should read it where you use it and not keep it. `AGENTS.md` describes the static class. **Breaking:** - Binaries compiled against `PatchClassBuilder PatchClassBuilder.Instance { get; }` fail with `MissingMethodException`. - `new PatchClassBuilder()` (obsolete since 1.x) and instance `GetPatchClassFor` are gone. - `PatchClassBuilder` no longer implements `IPatchClassBuilder`, and can't be used as a variable, parameter or generic argument type. ## Coordination with sibling PRs into `release/2.0` - **#139 (#75) makes the constructor internal.** This PR deletes the constructor, which supersedes that change. When the two meet, resolve the conflict by deleting the constructor. - **#142 (#126)** makes Emit the only runtime builder and removes the flag. Once it lands, `Instance` collapses to `EmitPatchClassBuilder.Instance`. - **#140 (#76)** changes TFMs. They are unchanged here. ## Alternatives rejected - **Initialise `Instance` once (`{ get; } = ...`).** This would freeze whatever builder the flag selected at type initialisation and ignore later changes to the flag. - **Keep returning the facade, typed as the interface.** This would avoid the read-time caveat, but it keeps the per-call forwarding that #135 asks to remove. It also leaves `Instance` as the one thing that hands out a `PatchClassBuilder`. - **Keep `PatchClassBuilder` non-static, with the constructor internal (#75 alone).** Nothing would construct it, so its instance members would be unreachable dead code. - **Ship #143 as a separate PR stacked on this one.** The maintainer chose to fold it in, because #143 can't compile without the retype. ## Tests New tests in `UseExperimentalDynamicClassBuilderTest`: - `Instance_IsTheRoslynBuilderWhenOff` - `Instance_IsTheEmitBuilderWhenOn` - `Instance_FollowsTheFlagWhenItIsTurnedBackOff` Against the old `PatchClassBuilder.cs`, the NUnit analyzer rejects all three with NUnit2020 (a `SameAs` that always fails because the types are mutually exclusive), so they fail before the fix. CI's build pipeline doesn't run on PRs that target a branch other than `main`, so these results are local only: - `dotnet build`: 0 errors, 21 warnings, the same count as the base commit. - `dotnet test`: all passed. Core 132/132, Swashbuckle 18/18, OpenApi 12/12. The net8.0 test hosts ran with `DOTNET_ROLL_FORWARD=Major`, because only the .NET 10 runtime is installed locally. Closes #135 Closes #143 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
PaulTrampert
pushed a commit
to PaulTrampert/PacmanManager
that referenced
this pull request
Oct 6, 2026
Updated [PTrampert.SimplePatch](https://github.com/PaulTrampert/PTrampert.SimplePatch) from 1.3.6 to 2.0.3. <details> <summary>Release notes</summary> _Sourced from [PTrampert.SimplePatch's releases](https://github.com/PaulTrampert/PTrampert.SimplePatch/releases)._ ## 2.0.3 ## Changes - (PATCH): Drop the Roslyn patch class builder's own cache ([#154](PaulTrampert/PTrampert.SimplePatch#154)) ## 2.0.2 ## Changes - (PATCH): Route PatchClassBuilder.Instance through CachingPatchClassBuilder ([#155](PaulTrampert/PTrampert.SimplePatch#155)) ## 2.0.1 ## Changes - (PATCH): Add an internal CachingPatchClassBuilder decorator ([#153](PaulTrampert/PTrampert.SimplePatch#153)) - (PATCH): Document the release PR format for staged release branches ([#152](PaulTrampert/PTrampert.SimplePatch#152)) ## 2.0.0 ## Changes - (MAJOR): Release 2.0 ([#147](PaulTrampert/PTrampert.SimplePatch#147)) - (PATCH): Build PRs into release/** branches and document the release flow ([#146](PaulTrampert/PTrampert.SimplePatch#146)) ## 1.5.0 ## What's Changed * (MINOR): Add PatchClassBuilder.UseExperimentalDynamicClassBuilder by @PaulTrampert in PaulTrampert/PTrampert.SimplePatch#136 **Full Changelog**: PaulTrampert/PTrampert.SimplePatch@v1.4.0...v1.5.0 ## 1.4.0 ## What's Changed * (MINOR): Add IPatchClassBuilder, with PatchClassBuilder delegating to an internal RoslynPatchClassBuilder by @PaulTrampert in PaulTrampert/PTrampert.SimplePatch#133 **Full Changelog**: PaulTrampert/PTrampert.SimplePatch@v1.3.8...v1.4.0 ## 1.3.8 ## What's Changed * (PATCH): Add an internal Reflection.Emit patch class builder by @PaulTrampert in PaulTrampert/PTrampert.SimplePatch#130 **Full Changelog**: PaulTrampert/PTrampert.SimplePatch@v1.3.7...v1.3.8 ## 1.3.7 ## What's Changed * (PATCH): Bump the csharp-deps group with 6 updates by @dependabot[bot] in PaulTrampert/PTrampert.SimplePatch#81 * (PATCH): Add AGENTS.md, CLAUDE.md symlink and /implement-unblocked by @PaulTrampert in PaulTrampert/PTrampert.SimplePatch#125 * (PATCH): Extract the patch class model from PatchClassBuilder by @PaulTrampert in PaulTrampert/PTrampert.SimplePatch#129 **Full Changelog**: PaulTrampert/PTrampert.SimplePatch@v1.3.6...v1.3.7 Commits viewable in [compare view](PaulTrampert/PTrampert.SimplePatch@v1.3.6...v2.0.3). </details> Updated [PTrampert.SimplePatch.Swashbuckle](https://github.com/PaulTrampert/PTrampert.SimplePatch) from 1.3.6 to 2.0.3. <details> <summary>Release notes</summary> _Sourced from [PTrampert.SimplePatch.Swashbuckle's releases](https://github.com/PaulTrampert/PTrampert.SimplePatch/releases)._ ## 2.0.3 ## Changes - (PATCH): Drop the Roslyn patch class builder's own cache ([#154](PaulTrampert/PTrampert.SimplePatch#154)) ## 2.0.2 ## Changes - (PATCH): Route PatchClassBuilder.Instance through CachingPatchClassBuilder ([#155](PaulTrampert/PTrampert.SimplePatch#155)) ## 2.0.1 ## Changes - (PATCH): Add an internal CachingPatchClassBuilder decorator ([#153](PaulTrampert/PTrampert.SimplePatch#153)) - (PATCH): Document the release PR format for staged release branches ([#152](PaulTrampert/PTrampert.SimplePatch#152)) ## 2.0.0 ## Changes - (MAJOR): Release 2.0 ([#147](PaulTrampert/PTrampert.SimplePatch#147)) - (PATCH): Build PRs into release/** branches and document the release flow ([#146](PaulTrampert/PTrampert.SimplePatch#146)) ## 1.5.0 ## What's Changed * (MINOR): Add PatchClassBuilder.UseExperimentalDynamicClassBuilder by @PaulTrampert in PaulTrampert/PTrampert.SimplePatch#136 **Full Changelog**: PaulTrampert/PTrampert.SimplePatch@v1.4.0...v1.5.0 ## 1.4.0 ## What's Changed * (MINOR): Add IPatchClassBuilder, with PatchClassBuilder delegating to an internal RoslynPatchClassBuilder by @PaulTrampert in PaulTrampert/PTrampert.SimplePatch#133 **Full Changelog**: PaulTrampert/PTrampert.SimplePatch@v1.3.8...v1.4.0 ## 1.3.8 ## What's Changed * (PATCH): Add an internal Reflection.Emit patch class builder by @PaulTrampert in PaulTrampert/PTrampert.SimplePatch#130 **Full Changelog**: PaulTrampert/PTrampert.SimplePatch@v1.3.7...v1.3.8 ## 1.3.7 ## What's Changed * (PATCH): Bump the csharp-deps group with 6 updates by @dependabot[bot] in PaulTrampert/PTrampert.SimplePatch#81 * (PATCH): Add AGENTS.md, CLAUDE.md symlink and /implement-unblocked by @PaulTrampert in PaulTrampert/PTrampert.SimplePatch#125 * (PATCH): Extract the patch class model from PatchClassBuilder by @PaulTrampert in PaulTrampert/PTrampert.SimplePatch#129 **Full Changelog**: PaulTrampert/PTrampert.SimplePatch@v1.3.6...v1.3.7 Commits viewable in [compare view](PaulTrampert/PTrampert.SimplePatch@v1.3.6...v2.0.3). </details> Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore <dependency name> major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore <dependency name> minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore <dependency name>` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore <dependency name>` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore <dependency name> <ignore condition>` will remove the ignore condition of the specified dependency and ignore conditions </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The library has two ways to build a patch class: the Roslyn builder, which lives inside the public
PatchClassBuilder, and the internalEmitPatchClassBuilder. They share no abstraction, so callers that choose between them, such as the shared test fixture, pass method groups around asFunc<Type, Type>.PatchClassBuilderis also both the public entry point and the Roslyn implementation. #95 (opt-in Emit builder) and #126 (remove the Roslyn builder) both need the entry point to stay put while the implementation behind it changes.What changed
IPatchClassBuilderwithType GetPatchClassFor(Type type).internal sealed class RoslynPatchClassBuilder : IPatchClassBuilderholds the CodeDom/Roslyn generation and its static cache, moved verbatim fromPatchClassBuilder. It has a private constructor and a staticInstance.PatchClassBuilderimplementsIPatchClassBuilderand is now a thin facade. It keeps its type,Instance, the obsolete public constructor, and theGetPatchClassForsignature and docs.GetPatchClassFordelegates toRoslynPatchClassBuilder.Instance. Because the cache is static, instances made with the obsolete constructor still share it.EmitPatchClassBuilderchanges from aninternal static classto aninternal sealed classthat implements the interface, with a private constructor and a staticInstance. Its cache,AssemblyNameand the generation helpers are unchanged.PatchClassBuilderTestfixture takes anIPatchClassBuilderand runs againstRoslynPatchClassBuilder.InstanceandEmitPatchClassBuilder.Instance.RoslynPatchClassBuilderdirectly.PatchClassBuilderreturns the Roslyn builder's types.No behaviour changes. The only public API change is the new interface, which is additive, so this is MINOR.
Alternatives rejected
PatchClassBuilder.Instanceto returnIPatchClassBuilder: this is a binary break, because the property signature changes.RoslynPatchClassBuilderpublic: this would add public API that Remove the Roslyn-based patch class builder in favour of Reflection.Emit #126 would later have to remove.SourceGeneratingPatchClassBuilder: the name would be confused with .NET source generators, which run at compile time, while this builder compiles at runtime.EmitPatchClassBuilderpublic now: that belongs with the opt-in flag in Add an opt-in flag to use the Reflection.Emit patch class builder #95.Tests
dotnet build: the warning set is identical tomain's.dotnet test: core 124/124, Swashbuckle 17/17 and OpenApi 11/11 pass. The net8.0 projects were run withDOTNET_ROLL_FORWARD=Majorbecause only the .NET 10 runtime is installed locally.Supersedes #134, which was stacked on this PR and is now folded into it.
🤖 Generated with Claude Code