Option 3: Null-contract proxy design for ModelReaderWriter#59318
Draft
m-redding wants to merge 37 commits into
Draft
Option 3: Null-contract proxy design for ModelReaderWriter#59318m-redding wants to merge 37 commits into
m-redding wants to merge 37 commits into
Conversation
- Fix invalid ??= on method return values in ModelReaderWriter.cs - Remove IJsonModel.Create path in ReadInternal that broke JsonPatch seeding - Update AvailabilitySetDataProxy to match new DeserializeComputeSku and AvailabilitySetData constructor signatures - Fix CompareAvailabilitySetData merge conflict (restore nameAlwaysExists logic) - Regenerate net9.0 API listing with proxy additions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Change proxy storage from Dictionary<Type, object> to Dictionary<Type, List<object>> to support multiple proxies per type. Last registered proxy has highest priority (consulted first). Resolution iterates the chain last-to-first. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add 9 new tests for chain behavior (multi-proxy registration, resolution order, backward compat, shared chain in copied options, read/write with multiple proxies) - Add proxy chain documentation section to ModelReaderWriter.md - Add sample snippet and OutputModelProxyOverride class Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix 'persitable' -> 'persistable' in ModelReaderWriter.md - Move json variable inside snippet region for Readme_Proxy_Chain Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Run Update-Snippets.ps1 to match expected output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Read path: proxies are iterated last-to-first; returning null from Create means 'decline' and the next proxy is tried. If all decline, the model itself handles the read as terminal fallback. Write path: unchanged (last-registered-wins). - ReadWithChain<T> (internal) iterates proxy chain for read resolution - ReadInternal now delegates to options.ReadWithChain instead of ResolveProxy - Updated docs to describe read vs write chain behavior - Added chain tests: SimpleModel, ChainProxy, DiscriminatorProxy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…d collection reader Implements the Utf8JsonReader snapshot technique (same pattern used by STJ for polymorphic deserialization) to support chain-of-responsibility on the reader-based paths: - JsonModelConverter.Read: now uses ReadWithChain per element instead of single TryGetProxy resolution. Always activates the model as terminal fallback. - JsonCollectionReader: now uses ReadWithChain per collection element instead of resolving the proxy once for all elements. - Added ReadWithChain(IJsonModel<T>, ref Utf8JsonReader) overload with reader-snapshot semantics (copy reader before attempt, restore on null). - Added ReadWithChain(Type, IJsonModel<object>, ref Utf8JsonReader) for non-generic call sites. Reference: https://github.com/dotnet/runtime - Utf8JsonReader copy pattern used in System.Text.Json polymorphic converters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tests verify the implicit contracts around ProxiedModel: - ProxiedModel is set to the original model during proxy.Write - ProxiedModel is set during proxy.Create(BinaryData) - ProxiedModel is set during proxy.Create(ref Utf8JsonReader) - ProxiedModel is null when no proxy exists (write path) - ProxiedModel remains set after successful proxy read - ProxiedModel is cleared when all proxies decline and model handles read - ProxiedModel is correctly set for each proxy in chain (not leaked) - ProxiedModel is correct for each proxy in reader-based chain - ProxiedModel is set per-element in collection reads Each test has descriptive error messages explaining the contract violation if it fails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Update all test assertions to expect first-registered proxy to be resolved first, matching the FIFO ordering convention (consistent with STJ JsonSerializerOptions.Converters). Rename test methods from 'LastRegistered' to 'FirstRegistered' and fix discriminator pattern test to register specific proxies before catch-all proxies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add ModelProxy/ModelProxy<T> to public API surface files for all TFMs. Also include JsonModelConverter simplification that was missed in prior commit — uses TryGetProxy directly instead of ReadWithChain since the CanHandle-based chain is now resolved at the options level. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Non-generic ModelProxy replaced with internal IModelProxy interface - ModelProxy<T> explicitly implements IPersistableModel<T> (hidden from API) - Protected abstract PersistableModel*Core methods for subclass overrides - Public API surface: only ModelProxy<T> with CanHandle methods visible - All 1044 MRW tests pass Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ethods Remove the protected *Core pattern and explicit interface implementation. ModelProxy<T> now just has public abstract Create/Write/GetFormatFromOptions that implicitly satisfy IPersistableModel<T>. Much cleaner API surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ModelProxy<T> now only defines CanHandle methods. Subclasses implement IPersistableModel<T> (or IJsonModel<T>) themselves directly. This keeps the public API surface minimal - only the CanHandle contract is added. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…thods Follows the standard .NET pattern (Stream, TextWriter, JsonConverter<T>) where an abstract class implements an interface and surfaces the methods as public abstract for subclasses to override. This provides compile-time enforcement that all proxies are valid IPersistableModel<T> implementations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…responsibility The converter was calling TryGetProxy which returns the first proxy in the chain, then directly calling Create on it - bypassing the CanHandle checks. This caused all reads through STJ to always use the first proxy regardless of the data content. Now it always creates the template model and passes it through ReadWithChain, which properly walks the chain calling CanHandle(BinaryData) on each proxy until one accepts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…eneric path Covers testing gaps found from the JsonModelConverter bug fix: - JsonModelConverter.Read with chain (second proxy handles after first declines) - JsonModelConverter.Read discriminator pattern via STJ - JsonModelConverter.Write with chain CanHandle - ResolveProxy write path where first proxy declines via CanHandle(model) - Non-generic ReadWithChain(Type, IJsonModel<object>, ref reader) overload - Collection read with per-element chain discrimination Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Benchmarks comparing no-proxy, 1 proxy, and 10 proxies (last wins) for both read and write paths. Results show: - Chain walk overhead (1 vs 10 proxies): ~28ns write, ~8ns read - Proxy presence overhead (0 vs 1): ~62ns write, ~34ns read - All in nanosecond range — negligible for real-world use Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix chain-of-responsibility section: was 'last registered wins' / 'null return', now correctly describes FIFO ordering with CanHandle gating - Swap snippet registration order to demonstrate first-registered = highest priority - Update write path description: CanHandle(model) instead of unconditional last-wins - Update read path description: CanHandle(BinaryData, options) instead of null-return Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Both CanHandle overloads now default to true for consistency: - CanHandle(T model) => true (write path) - CanHandle(BinaryData, options) => true (read path) The simple case (single proxy for a type) no longer requires implementing CanHandle at all. Override to selectively decline in chain scenarios. Removed redundant 'override => true' from all test proxies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add 4 STJ read benchmarks: NoProxy, OneProxy, TenProxiesLastWins, AllDecline (exercises the Utf8JsonReader snapshot + BinaryData CanHandle path) - Fix outdated comment in JsonCollectionReader: was 'restore on null (decline)', now correctly describes CanHandle-based chain Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
TryGetProxy returned a proxy instance without CanHandle checks, which was incompatible with the chain-of-responsibility model. Replace with HasProxy<T>() which is a simple boolean check for whether any proxies are registered. Generated code pattern for nested model properties: - HasProxy<T>() → true: route through ModelReaderWriter.Read<T>(BinaryData) which performs full chain resolution (CanHandle walk + fallback) - HasProxy<T>() → false: use normal fast-path deserialization (no allocation) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Both CanHandle overloads now take ModelReaderWriterOptions: - CanHandle(T model, ModelReaderWriterOptions options) - CanHandle(BinaryData data, ModelReaderWriterOptions options) This provides consistent access to options (e.g., format) on both the write and read gating paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… docs - Convert all chain-walk for loops to foreach for readability - Revert variable rename back to iJsonModel in JsonModelConverter - Update IModelProxy doc comment to explain T-erasure requirement (ModelReaderWriter.ReadInternal and STJ both call with T=object) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove ModelProxy<T> and IModelProxy entirely - Add DiscriminatorProxy<T> with virtual CanHandle(BinaryData) and CanHandle(ref Utf8JsonReader) - Write path: register raw IPersistableModel<T> via AddProxy, first registered wins - Read path: discriminator proxies via AddDiscriminatorProxy with CanHandle selection - Update all tests to reflect new design - Export updated API listings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ctor) - DiscriminatorRouter<T> takes IJsonModel<T> or IPersistableModel<T> via ctor - Router only has CanHandle logic; model does deserialization - _proxies is now single-slot (last AddProxy wins), used for both read fallback and write - _routers is FIFO chain for discriminator read path - Read resolution: routers -> proxy fallback -> model itself - Updated test helpers to use composition pattern Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…semantics - Remove public HasProxy<T>() and HasProxy(Type) methods - Add AddProxy<T>(IJsonModel<T>) overload matching PR 50024 API - Make IsJsonModel internal on DiscriminatorRouter - AddProxy is single-slot (last registered wins) - Update tests to reflect last-wins semantics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove generic type parameter from DiscriminatorRouter class - Add virtual Create(ref Utf8JsonReader, options) and Create(BinaryData, options) returning object? - Remove IDiscriminatorRouter.IsJsonModel property - T is now only on AddDiscriminatorRouter<T> for lookup key purposes - Simplify ReadWithChain to not branch on IsJsonModel - Fix test helpers to implement Create directly instead of base(model) ctor Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Simplifies the proxy system to a single concept: - AddProxy<T> adds proxies to a list per type - Write: last proxy in list wins - Read: iterate first-to-last, first Create that returns non-null wins - Returning null from Create = 'I cannot handle this, try next' - Discriminator routing is convention-based (return null if discriminator doesn't match) - No separate DiscriminatorRouter class needed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
m-redding
commented
May 20, 2026
| @@ -78,7 +78,9 @@ private static void ReadJsonCollection( | |||
| } | |||
| else if (jsonModel is not null) | |||
| { | |||
| collectionWrapper.AddItem(jsonModel!.Create(ref reader, options), propertyName); | |||
| // Chain-of-responsibility per element: snapshot reader, try proxies via CanHandle, | |||
Member
Author
There was a problem hiding this comment.
TODO comment is outdated
m-redding
commented
May 20, 2026
| return model; | ||
| } | ||
|
|
||
| // Last in list wins for write |
Member
Author
There was a problem hiding this comment.
TODO - honestly not 100% sure exactly what we want for this, if someone wants to just add some proxies for reading only, I guess they would need to manually delegate to the base model in the write path?
…ntee Changed write resolution from last-wins to FIFO (first registered wins), matching read resolution order. Both paths now use consistent first-wins semantics. The reader path already snapshots before calling each proxy's Create; updated xmldoc to make this guarantee explicit. Co-authored-by: Copilot <223556219+Copilot@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.
Option 3: Null-contract proxy design (no DiscriminatorRouter)
This PR explores the simplest alternative design for the ModelReaderWriter proxy/routing feature.
Comparison of all three approaches
API Surface (Option 3)
The null contract
The discriminator case is handled purely by convention:
Tradeoffs
Pros:
Cons: