From 53866afb8c95ba4a2e3fab957acd38aa962772fb Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Tue, 3 Mar 2026 01:39:37 -0800 Subject: [PATCH 1/3] Add atomic path-based modification API and JSON patch support - Introduced `TryModifyAt` method for direct modification of fields, array elements, and dictionary entries without constructing full objects. - Implemented path parsing logic to navigate to specific members using a string-based path format. - Added `TryPatch` method to apply JSON Merge Patch documents, allowing multiple fields to be modified in a single call. - Created new partial files: `Reflector.ModifyAt.cs` for atomic modifications and `Reflector.Patch.cs` for JSON patching. - Enhanced error handling with detailed messages for navigation failures. - Added tests to verify the functionality of the new modification methods and JSON patching capabilities. --- README.md | 110 ++++- .../src/ReflectorTests/AtomicModifyTests.cs | 377 +++++++++++++++ .../ArrayReflectionConverter.Modify.cs | 145 ++++++ .../Base/BaseReflectionConverter.Modify.cs | 6 +- .../src/Reflector/Reflector.ModifyAt.cs | 351 ++++++++++++++ ReflectorNet/src/Reflector/Reflector.Patch.cs | 436 ++++++++++++++++++ docs/plan/atomic-modify.md | 427 +++++++++++++++++ 7 files changed, 1850 insertions(+), 2 deletions(-) create mode 100644 ReflectorNet.Tests/src/ReflectorTests/AtomicModifyTests.cs create mode 100644 ReflectorNet/src/Converter/Reflection/ArrayReflectionConverter.Modify.cs create mode 100644 ReflectorNet/src/Reflector/Reflector.ModifyAt.cs create mode 100644 ReflectorNet/src/Reflector/Reflector.Patch.cs create mode 100644 docs/plan/atomic-modify.md diff --git a/README.md b/README.md index dd7ae63c..d4d1633d 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Traditional reflection is brittle and requires exact matches. ReflectorNet is bu * **🔍 Fuzzy Matching**: Discover methods and types even with incomplete names or parameters (configurable match levels 0-6). * **📦 Type-Safe Serialization**: Preserves full type information, supporting complex nested objects, collections, and custom types. * **🔄 In-Place Modification**: Update existing object instances from serialized data without breaking references. +* **🎯 Atomic Path-Based Modification**: Navigate directly to any field, array element, or dictionary entry by path and modify only that — without touching anything else. +* **🩹 JSON Patch**: Apply a JSON document to modify multiple fields at different depths in a single call, following JSON Merge Patch (RFC 7396) semantics. * **📄 JSON Schema Generation**: Automatically generate schemas for your types and methods to feed into LLM context windows. ## 📦 Installation @@ -79,7 +81,113 @@ var existingInstance = new MyComplexClass(); bool success = reflector.TryModify(ref existingInstance, serialized); ``` -### 5. Dynamic Method Invocation +### 5. Atomic Path-Based Modification + +Navigate directly to a specific field, array element, or dictionary entry by path and modify **only that target** — no surrounding data is affected. + +**Path format:** + +| Segment | Meaning | +| --- | --- | +| `fieldName` | Field or property by name | +| `[i]` | Array / list element at index `i` | +| `[key]` | Dictionary entry with key `key` (any key type) | + +A leading `#/` is stripped automatically for compatibility with `SerializationContext` paths. + +```csharp +var reflector = new Reflector(); +object? system = new SolarSystem +{ + globalOrbitSpeedMultiplier = 1f, + celestialBodies = new[] + { + new CelestialBody { orbitRadius = 10f, orbitSpeed = 1f }, + new CelestialBody { orbitRadius = 20f, orbitSpeed = 2f }, + } +}; + +// Modify a root field +reflector.TryModifyAt(ref system, "globalOrbitSpeedMultiplier", 5f); + +// Modify a nested field — only that one field changes +reflector.TryModifyAt(ref system, "celestialBodies/[0]/orbitRadius", 999f); + +// Dictionary entry — string or integer keys both work +object? container = new Config { settings = new Dictionary { ["timeout"] = 10 } }; +reflector.TryModifyAt(ref container, "settings/[timeout]", 60); +``` + +For partial updates of a complex object at a path, supply a `SerializedMember` that lists only the fields to change: + +```csharp +var patch = new SerializedMember { typeName = typeof(CelestialBody).GetTypeId() }; +patch.SetFieldValue(reflector, "orbitRadius", 777f); // only orbitRadius changes + +var logs = new Logs(); +reflector.TryModifyAt(ref system, "celestialBodies/[1]", patch, logs: logs); +// celestialBodies[1].orbitSpeed is untouched +``` + +Errors are collected in the `Logs` object — nothing is thrown: + +```csharp +var logs = new Logs(); +bool ok = reflector.TryModifyAt(ref system, "doesNotExist", 5f, logs: logs); +// ok == false; logs contains: +// "Segment 'doesNotExist' not found on type 'SolarSystem'. +// Available fields: globalOrbitSpeedMultiplier, celestialBodies, ..." +``` + +### 6. JSON Patch + +Apply a JSON document to modify multiple fields at different depths in a single call. +Follows **JSON Merge Patch** (RFC 7396) semantics, extended with bracket-notation keys for arrays and dictionaries. + +```csharp +var reflector = new Reflector(); +object? system = new SolarSystem { /* ... */ }; + +// Modify several fields at once — untouched fields are preserved +var logs = new Logs(); +bool ok = reflector.TryPatch(ref system, """ +{ + "globalOrbitSpeedMultiplier": 5.0, + "globalSizeMultiplier": 2.0, + "celestialBodies": { + "[0]": { "orbitRadius": 42.0 } + } +} +""", logs: logs); +``` + +**Patch document rules:** + +* A JSON **object** key navigates into that field (`"fieldName"`) or element (`"[i]"` / `"[key]"`) +* A JSON **non-object** value sets the field directly +* `null` sets the field to `null` +* `"$type"` key inside a JSON object specifies a desired subtype — the existing instance is replaced with a fresh instance of the new type before applying the remaining keys + +```csharp +// Replace a base-type field with a derived type and set its fields +reflector.TryPatch(ref system, """ +{ + "star": { + "$type": "MyNamespace.NeutronStar", + "mass": 2.5 + } +} +"""); +``` + +A `JsonElement` overload is also available when you already have a parsed document: + +```csharp +using var doc = JsonDocument.Parse(@"{ ""globalOrbitSpeedMultiplier"": 9.0 }"); +reflector.TryPatch(ref system, doc.RootElement, logs: logs); +``` + +### 7. Dynamic Method Invocation Allow AI to find and call methods without knowing the exact signature. diff --git a/ReflectorNet.Tests/src/ReflectorTests/AtomicModifyTests.cs b/ReflectorNet.Tests/src/ReflectorTests/AtomicModifyTests.cs new file mode 100644 index 00000000..821894c7 --- /dev/null +++ b/ReflectorNet.Tests/src/ReflectorTests/AtomicModifyTests.cs @@ -0,0 +1,377 @@ +using System.Collections.Generic; +using System.Text.Json; +using com.IvanMurzak.ReflectorNet.Model; +using com.IvanMurzak.ReflectorNet.Tests.Model; +using com.IvanMurzak.ReflectorNet.Utils; +using Xunit; +using Xunit.Abstractions; + +namespace com.IvanMurzak.ReflectorNet.Tests.ReflectorTests +{ + public class AtomicModifyTests : BaseTest + { + public AtomicModifyTests(ITestOutputHelper output) : base(output) { } + + // ─── TryModifyAt — root field ───────────────────────────────────────────── + + [Fact] + public void TryModifyAt_RootField() + { + var reflector = new Reflector(); + var system = new SolarSystem { globalOrbitSpeedMultiplier = 1f, globalSizeMultiplier = 2f }; + object? obj = system; + + var success = reflector.TryModifyAt(ref obj, "globalOrbitSpeedMultiplier", 5f); + + Assert.True(success); + var result = (SolarSystem)obj!; + Assert.Equal(5f, result.globalOrbitSpeedMultiplier); + Assert.Equal(2f, result.globalSizeMultiplier); // untouched + } + + // ─── TryModifyAt — two-level nested field ───────────────────────────────── + + [Fact] + public void TryModifyAt_TwoLevelField_Property() + { + var reflector = new Reflector(); + var system = new SolarSystem { sun = new GameObjectRef { instanceID = 1 } }; + object? obj = system; + + var success = reflector.TryModifyAt(ref obj, "sun/instanceID", 99); + + Assert.True(success); + Assert.Equal(99, ((SolarSystem)obj!).sun!.instanceID); + } + + // ─── TryModifyAt — array element field ──────────────────────────────────── + + [Fact] + public void TryModifyAt_ArrayElementField() + { + var reflector = new Reflector(); + var system = new SolarSystem + { + celestialBodies = new[] + { + new SolarSystem.CelestialBody { orbitRadius = 10f, orbitSpeed = 1f }, + new SolarSystem.CelestialBody { orbitRadius = 20f, orbitSpeed = 2f }, + } + }; + object? obj = system; + + // Modify only [0].orbitRadius — nothing else should change + var success = reflector.TryModifyAt(ref obj, "celestialBodies/[0]/orbitRadius", 999f); + + Assert.True(success); + var result = (SolarSystem)obj!; + Assert.Equal(999f, result.celestialBodies![0].orbitRadius); + Assert.Equal(1f, result.celestialBodies![0].orbitSpeed); // untouched + Assert.Equal(20f, result.celestialBodies![1].orbitRadius); // untouched + Assert.Equal(2f, result.celestialBodies![1].orbitSpeed); // untouched + + _output.WriteLine($"celestialBodies[0].orbitRadius = {result.celestialBodies[0].orbitRadius}"); + } + + // ─── TryModifyAt — partial update of array element (SerializedMember) ───── + + [Fact] + public void TryModifyAt_PartialPatch_ArrayElement() + { + var reflector = new Reflector(); + var system = new SolarSystem + { + celestialBodies = new[] + { + new SolarSystem.CelestialBody { orbitRadius = 10f, orbitSpeed = 3f }, + new SolarSystem.CelestialBody { orbitRadius = 20f, orbitSpeed = 4f }, + } + }; + object? obj = system; + + // Navigate to [1] and apply a partial patch — only orbitRadius changes + var patch = new SerializedMember { typeName = typeof(SolarSystem.CelestialBody).GetTypeId() ?? string.Empty }; + patch.SetFieldValue(reflector, "orbitRadius", 777f); + + var logs = new Logs(); + var success = reflector.TryModifyAt(ref obj, "celestialBodies/[1]", patch, logs: logs); + + _output.WriteLine(logs.ToString()); + Assert.True(success); + var result = (SolarSystem)obj!; + Assert.Equal(777f, result.celestialBodies![1].orbitRadius); + Assert.Equal(4f, result.celestialBodies![1].orbitSpeed); // untouched + Assert.Equal(10f, result.celestialBodies![0].orbitRadius); // untouched + } + + // ─── TryModifyAt — invalid member — detailed error ──────────────────────── + + [Fact] + public void TryModifyAt_InvalidMember_DetailedError() + { + var reflector = new Reflector(); + var system = new SolarSystem { globalOrbitSpeedMultiplier = 1f }; + object? obj = system; + var logs = new Logs(); + + var success = reflector.TryModifyAt(ref obj, "doesNotExist", 5f, logs: logs); + + Assert.False(success); + var logsText = logs.ToString(); + _output.WriteLine(logsText); + Assert.Contains("doesNotExist", logsText); + Assert.Contains("not found", logsText); + } + + [Fact] + public void TryModifyAt_NestedInvalidSegment_DetailedError() + { + var reflector = new Reflector(); + var system = new SolarSystem { sun = new GameObjectRef { instanceID = 1 } }; + object? obj = system; + var logs = new Logs(); + + var success = reflector.TryModifyAt(ref obj, "sun/badSegment", 0, logs: logs); + + Assert.False(success); + var logsText = logs.ToString(); + _output.WriteLine(logsText); + Assert.Contains("badSegment", logsText); + } + + // ─── TryModifyAt — out-of-bounds index — detailed error ─────────────────── + + [Fact] + public void TryModifyAt_OutOfBoundsIndex_DetailedError() + { + var reflector = new Reflector(); + var system = new SolarSystem + { + celestialBodies = new[] + { + new SolarSystem.CelestialBody { orbitRadius = 1f }, + new SolarSystem.CelestialBody { orbitRadius = 2f }, + } + }; + object? obj = system; + var logs = new Logs(); + + var success = reflector.TryModifyAt(ref obj, "celestialBodies/[99]/orbitRadius", 0f, logs: logs); + + Assert.False(success); + var logsText = logs.ToString(); + _output.WriteLine(logsText); + Assert.Contains("[99]", logsText); + Assert.Contains("out of range", logsText); + // Array should be unchanged + Assert.Equal(1f, ((SolarSystem)obj!).celestialBodies![0].orbitRadius); + } + + // ─── TryModifyAt — Dictionary (string key) ──────────────────────────────── + + [Fact] + public void TryModifyAt_DictionaryStringKey() + { + var reflector = new Reflector(); + + var container = new DictionaryContainer + { + config = new Dictionary { ["timeout"] = 10, ["retries"] = 3 } + }; + object? obj = container; + + var success = reflector.TryModifyAt(ref obj, "config/[timeout]", 60); + + Assert.True(success); + var result = (DictionaryContainer)obj!; + Assert.Equal(60, result.config["timeout"]); + Assert.Equal(3, result.config["retries"]); // untouched + } + + // ─── TryModifyAt — Dictionary (integer key) ─────────────────────────────── + + [Fact] + public void TryModifyAt_DictionaryIntKey() + { + var reflector = new Reflector(); + + var container = new IntDictionaryContainer + { + lookup = new Dictionary { [1] = "one", [2] = "two", [3] = "three" } + }; + object? obj = container; + + var success = reflector.TryModifyAt(ref obj, "lookup/[2]", "TWO"); + + Assert.True(success); + var result = (IntDictionaryContainer)obj!; + Assert.Equal("TWO", result.lookup[2]); + Assert.Equal("one", result.lookup[1]); // untouched + Assert.Equal("three", result.lookup[3]); // untouched + } + + // ─── TryModifyAt — generic overload (leaf value) ────────────────────────── + + [Fact] + public void TryModifyAt_GenericOverload_LeafValue() + { + var reflector = new Reflector(); + var system = new SolarSystem { globalSizeMultiplier = 1f }; + object? obj = system; + + var success = reflector.TryModifyAt(ref obj, "globalSizeMultiplier", 3.14f); + + Assert.True(success); + Assert.Equal(3.14f, ((SolarSystem)obj!).globalSizeMultiplier, precision: 5); + } + + // ─── TryModifyAt — hash-prefixed path ──────────────────────────────────── + + [Fact] + public void TryModifyAt_HashPrefixedPath_IsStripped() + { + var reflector = new Reflector(); + var system = new SolarSystem { globalOrbitSpeedMultiplier = 1f }; + object? obj = system; + + var success = reflector.TryModifyAt(ref obj, "#/globalOrbitSpeedMultiplier", 7f); + + Assert.True(success); + Assert.Equal(7f, ((SolarSystem)obj!).globalOrbitSpeedMultiplier); + } + + // ─── ArrayReflectionConverter.TryModify — partial element via SerializedMember ── + + [Fact] + public void ArrayConverter_PartialElementModify_ViaSerializedMember() + { + var reflector = new Reflector(); + object? celestialBodies = new SolarSystem.CelestialBody[] + { + new SolarSystem.CelestialBody { orbitRadius = 10f, orbitSpeed = 1f }, + new SolarSystem.CelestialBody { orbitRadius = 20f, orbitSpeed = 2f }, + }; + + // Build a SerializedMember that only specifies element [1].orbitRadius + var elem1 = new SerializedMember + { + name = "[1]", + typeName = typeof(SolarSystem.CelestialBody).GetTypeId() ?? string.Empty + }; + elem1.SetFieldValue(reflector, "orbitRadius", 88f); + + var data = new SerializedMember + { + typeName = typeof(SolarSystem.CelestialBody[]).GetTypeId() ?? string.Empty + }; + data.AddField(elem1); + + var logs = new Logs(); + var success = reflector.TryModify(ref celestialBodies, data, logs: logs); + + _output.WriteLine(logs.ToString()); + Assert.True(success); + var arr = (SolarSystem.CelestialBody[])celestialBodies!; + Assert.Equal(88f, arr[1].orbitRadius); + Assert.Equal(2f, arr[1].orbitSpeed); // untouched + Assert.Equal(10f, arr[0].orbitRadius); // untouched + Assert.Equal(1f, arr[0].orbitSpeed); // untouched + } + + // ─── TryPatch — multiple fields at different depths ──────────────────────── + + [Fact] + public void TryPatch_MultipleFieldsAtOnce() + { + var reflector = new Reflector(); + var system = new SolarSystem + { + globalOrbitSpeedMultiplier = 1f, + globalSizeMultiplier = 1f, + celestialBodies = new[] + { + new SolarSystem.CelestialBody { orbitRadius = 10f, orbitSpeed = 1f }, + new SolarSystem.CelestialBody { orbitRadius = 20f, orbitSpeed = 2f }, + } + }; + object? obj = system; + + var json = @"{ + ""globalOrbitSpeedMultiplier"": 5.0, + ""globalSizeMultiplier"": 2.0 + }"; + + var logs = new Logs(); + var success = reflector.TryPatch(ref obj, json, logs: logs); + + _output.WriteLine(logs.ToString()); + Assert.True(success); + var result = (SolarSystem)obj!; + Assert.Equal(5f, result.globalOrbitSpeedMultiplier); + Assert.Equal(2f, result.globalSizeMultiplier); + Assert.Equal(10f, result.celestialBodies![0].orbitRadius); // untouched + } + + [Fact] + public void TryPatch_ArrayElementAndField() + { + var reflector = new Reflector(); + var system = new SolarSystem + { + celestialBodies = new[] + { + new SolarSystem.CelestialBody { orbitRadius = 10f, orbitSpeed = 1f }, + new SolarSystem.CelestialBody { orbitRadius = 20f, orbitSpeed = 2f }, + } + }; + object? obj = system; + + // Modify celestialBodies[0].orbitRadius via JSON patch + var json = @"{ + ""celestialBodies"": { + ""[0]"": { + ""orbitRadius"": 42.0 + } + } + }"; + + var logs = new Logs(); + var success = reflector.TryPatch(ref obj, json, logs: logs); + + _output.WriteLine(logs.ToString()); + Assert.True(success); + var result = (SolarSystem)obj!; + Assert.Equal(42f, result.celestialBodies![0].orbitRadius); + Assert.Equal(1f, result.celestialBodies![0].orbitSpeed); // untouched + Assert.Equal(20f, result.celestialBodies![1].orbitRadius); // untouched + } + + [Fact] + public void TryPatch_JsonElement_Overload() + { + var reflector = new Reflector(); + var system = new SolarSystem { globalOrbitSpeedMultiplier = 1f }; + object? obj = system; + + using var doc = JsonDocument.Parse(@"{ ""globalOrbitSpeedMultiplier"": 9.0 }"); + var logs = new Logs(); + var success = reflector.TryPatch(ref obj, doc.RootElement, logs: logs); + + _output.WriteLine(logs.ToString()); + Assert.True(success); + Assert.Equal(9f, ((SolarSystem)obj!).globalOrbitSpeedMultiplier); + } + + // ─── Test-local helper types ─────────────────────────────────────────────── + + private class DictionaryContainer + { + public Dictionary config = new Dictionary(); + } + + private class IntDictionaryContainer + { + public Dictionary lookup = new Dictionary(); + } + } +} diff --git a/ReflectorNet/src/Converter/Reflection/ArrayReflectionConverter.Modify.cs b/ReflectorNet/src/Converter/Reflection/ArrayReflectionConverter.Modify.cs new file mode 100644 index 00000000..d757284b --- /dev/null +++ b/ReflectorNet/src/Converter/Reflection/ArrayReflectionConverter.Modify.cs @@ -0,0 +1,145 @@ +/* + * ReflectorNet + * Author: Ivan Murzak (https://github.com/IvanMurzak) + * Copyright (c) 2025 Ivan Murzak + * Licensed under the Apache License, Version 2.0. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.Extensions.Logging; +using com.IvanMurzak.ReflectorNet.Model; +using com.IvanMurzak.ReflectorNet.Utils; + +namespace com.IvanMurzak.ReflectorNet.Converter +{ + public partial class ArrayReflectionConverter + { + /// + /// Overrides TryModify to support partial in-place modification of individual array/list elements + /// using [i]-indexed field names in data.fields (e.g. name="[2]"). When data.valueJsonElement is + /// present, falls back to full replacement (existing behaviour). When data.fields contains only + /// non-indexed names, also falls back to base (unchanged behaviour). + /// + public override bool TryModify( + Reflector reflector, + ref object? obj, + SerializedMember data, + Type type, + int depth = 0, + Logs? logs = null, + BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + ILogger? logger = null) + { + // Full replacement requested — delegate to base (calls SetValue → TryDeserializeValueListInternal) + if (data.valueJsonElement.HasValue) + return base.TryModify(reflector, ref obj, data, type, depth, logs, flags, logger); + + // Partition data.fields into indexed ([i]-named) and non-indexed + var indexedFields = data.fields?.Where(f => IsArrayIndexName(f?.name)).ToList(); + + // No indexed fields — let base handle (unchanged behaviour) + if (indexedFields == null || indexedFields.Count == 0) + return base.TryModify(reflector, ref obj, data, type, depth, logs, flags, logger); + + if (obj == null) + { + logs?.Error($"Cannot modify array elements: array is null.", depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{StringUtils.GetPadding(depth)}Cannot modify array elements: array is null."); + return false; + } + + var elementType = TypeUtils.GetEnumerableItemType(type); + var overallSuccess = true; + + if (obj is Array array) + { + foreach (var indexedField in indexedFields) + { + var idx = ParseArrayIndex(indexedField.name!); + if (idx < 0 || idx >= array.Length) + { + var msg = $"Bracket segment '[{idx}]' index out of range on type '{type.GetTypeShortName()}'. Array length is {array.Length}."; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{StringUtils.GetPadding(depth)}{msg}"); + overallSuccess = false; + continue; + } + + var currentElement = array.GetValue(idx); + var success = reflector.TryModify( + ref currentElement, + data: indexedField, + fallbackObjType: elementType, + depth: depth + 1, + logs: logs, + flags: flags, + logger: logger); + + if (success) + array.SetValue(currentElement, idx); + + overallSuccess &= success; + } + } + else if (obj is IList list) + { + foreach (var indexedField in indexedFields) + { + var idx = ParseArrayIndex(indexedField.name!); + if (idx < 0 || idx >= list.Count) + { + var msg = $"Bracket segment '[{idx}]' index out of range on type '{type.GetTypeShortName()}'. List count is {list.Count}."; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{StringUtils.GetPadding(depth)}{msg}"); + overallSuccess = false; + continue; + } + + var currentElement = list[idx]; + var success = reflector.TryModify( + ref currentElement, + data: indexedField, + fallbackObjType: elementType, + depth: depth + 1, + logs: logs, + flags: flags, + logger: logger); + + if (success) + list[idx] = currentElement; + + overallSuccess &= success; + } + } + else + { + var msg = $"Cannot modify array elements: type '{type.GetTypeShortName()}' is not an array or list."; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{StringUtils.GetPadding(depth)}{msg}"); + return false; + } + + return overallSuccess; + } + + private static bool IsArrayIndexName(string? name) + { + if (string.IsNullOrEmpty(name) || name.Length < 3) + return false; + if (name[0] != '[' || name[name.Length - 1] != ']') + return false; + return int.TryParse(name.Substring(1, name.Length - 2), out _); + } + + private static int ParseArrayIndex(string name) + => int.Parse(name.Substring(1, name.Length - 2)); + } +} diff --git a/ReflectorNet/src/Converter/Reflection/Base/BaseReflectionConverter.Modify.cs b/ReflectorNet/src/Converter/Reflection/Base/BaseReflectionConverter.Modify.cs index 57aa41bd..d9c76e4f 100644 --- a/ReflectorNet/src/Converter/Reflection/Base/BaseReflectionConverter.Modify.cs +++ b/ReflectorNet/src/Converter/Reflection/Base/BaseReflectionConverter.Modify.cs @@ -101,7 +101,11 @@ public virtual bool TryModify( } var overallSuccess = true; - if (AllowSetValue) + // Skip SetValue only when this is a partial-patch call: fields/props are present but no + // explicit value was supplied. When there are no fields/props and no valueJsonElement the + // intent is to SET the target to null (or its default), so SetValue must still run. + var hasFieldsOrProps = (data.fields?.Count > 0) || (data.props?.Count > 0); + if (AllowSetValue && (data.valueJsonElement.HasValue || !hasFieldsOrProps)) { try { diff --git a/ReflectorNet/src/Reflector/Reflector.ModifyAt.cs b/ReflectorNet/src/Reflector/Reflector.ModifyAt.cs new file mode 100644 index 00000000..6d1a7862 --- /dev/null +++ b/ReflectorNet/src/Reflector/Reflector.ModifyAt.cs @@ -0,0 +1,351 @@ +/* + * ReflectorNet + * Author: Ivan Murzak (https://github.com/IvanMurzak) + * Copyright (c) 2025 Ivan Murzak + * Licensed under the Apache License, Version 2.0. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using com.IvanMurzak.ReflectorNet.Model; +using com.IvanMurzak.ReflectorNet.Utils; +using Microsoft.Extensions.Logging; + +namespace com.IvanMurzak.ReflectorNet +{ + public partial class Reflector + { + /// + /// Navigates to a specific field, array element, or dictionary entry by path and modifies only that target. + /// This is a truly atomic modification — no other parts of the object graph are touched. + /// + /// Path format: + /// - Plain segment: field or property name (e.g. "admin" or "admin/name") + /// - [i] where obj is Array/IList: array index (e.g. "users/[2]/name") + /// - [key] where obj is IDictionary: dictionary key (e.g. "config/[timeout]") + /// - Leading "#/" stripped automatically (compatible with SerializationContext paths) + /// + /// Errors are accumulated in the optional Logs object; nothing is thrown. + /// + public bool TryModifyAt( + ref object? obj, + string path, + SerializedMember value, + Type? fallbackObjType = null, + int depth = 0, + Logs? logs = null, + BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + ILogger? logger = null) + { + var segments = ParsePath(path); + + // No segments left — this is the terminal: apply the modification + if (segments.Length == 0) + return TryModify(ref obj, value, fallbackObjType, depth, logs, flags, logger); + + if (obj == null) + { + var msg = $"Cannot navigate path '{path}': object is null."; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{StringUtils.GetPadding(depth)}{msg}"); + return false; + } + + var segment = segments[0]; + var remainingPath = segments.Length > 1 + ? string.Join("/", segments, 1, segments.Length - 1) + : string.Empty; + + var objType = obj.GetType(); + + if (TryParseBracketSegment(segment, out var innerKey)) + return TryModifyAtBracketed(ref obj, segment, innerKey, remainingPath, value, objType, depth, logs, flags, logger); + + return TryModifyAtMember(ref obj, segment, remainingPath, value, objType, depth, logs, flags, logger); + } + + /// + /// Convenience generic overload — ideal for modifying leaf/primitive values by path. + /// Internally creates a SerializedMember.FromValue<T> and calls the primary overload. + /// + public bool TryModifyAt( + ref object? obj, + string path, + T value, + Type? fallbackObjType = null, + int depth = 0, + Logs? logs = null, + BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + ILogger? logger = null) + { + var serializedValue = SerializedMember.FromValue(this, value); + return TryModifyAt(ref obj, path, serializedValue, fallbackObjType, depth, logs, flags, logger); + } + + // ─── Path parsing ──────────────────────────────────────────────────────────── + + private static string[] ParsePath(string? path) + { + if (string.IsNullOrEmpty(path)) + return Array.Empty(); + + // Strip leading "#/" or "#" (SerializationContext convention) + if (path.StartsWith("#/")) + path = path.Substring(2); + else if (path.StartsWith("#")) + path = path.Substring(1); + + return path.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + } + + internal static bool TryParseBracketSegment(string segment, out string innerKey) + { + if (segment.Length > 2 && segment[0] == '[' && segment[segment.Length - 1] == ']') + { + innerKey = segment.Substring(1, segment.Length - 2); + return true; + } + innerKey = string.Empty; + return false; + } + + // ─── Type-replacement check ─────────────────────────────────────────────────── + + /// + /// At the terminal navigation step, if the SerializedMember requests a different (but compatible subtype) + /// than the current value's type, resets currentValue to null so TryModify will create a fresh instance. + /// + private void ApplyTypeReplacementCheck(ref object? currentValue, SerializedMember value, Type declaredType, string remainingPath) + { + if (!string.IsNullOrEmpty(remainingPath)) + return; + + var desiredType = TypeUtils.GetTypeWithNamePriority(value, declaredType, out _); + if (desiredType != null + && currentValue != null + && desiredType != currentValue.GetType() + && declaredType.IsAssignableFrom(desiredType)) + { + currentValue = null; // force fresh instance creation in TryModify's null branch + } + } + + // ─── Bracket navigation (array index / dict key) ───────────────────────────── + + private bool TryModifyAtBracketed( + ref object? obj, + string segment, + string innerKey, + string remainingPath, + SerializedMember value, + Type objType, + int depth, + Logs? logs, + BindingFlags flags, + ILogger? logger) + { + var padding = StringUtils.GetPadding(depth); + + if (obj is IList) + { + if (!int.TryParse(innerKey, out int idx)) + { + var msg = $"Bracket segment '{segment}' cannot be used as index on type '{objType.GetTypeShortName()}'. Expected integer index."; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{msg}"); + return false; + } + return TryModifyAtArrayIndex(ref obj, segment, idx, remainingPath, value, objType, depth, logs, flags, logger); + } + + if (TypeUtils.IsDictionary(objType)) + { + var args = TypeUtils.GetDictionaryGenericArguments(objType); + var keyType = args?[0] ?? typeof(object); + var valType = args?[1] ?? typeof(object); + + object dictKey; + try + { + dictKey = Convert.ChangeType(innerKey, keyType); + } + catch (Exception ex) + { + var msg = $"Bracket segment '{segment}' cannot be converted to key type '{keyType.GetTypeShortName()}' on type '{objType.GetTypeShortName()}': {ex.Message}"; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{msg}"); + return false; + } + return TryModifyAtDictKey(ref obj, segment, dictKey, remainingPath, value, valType, depth, logs, flags, logger); + } + + { + var msg = $"Bracket segment '{segment}' cannot be used on type '{objType.GetTypeShortName()}'. Type is not an array, list, or dictionary."; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{msg}"); + return false; + } + } + + private bool TryModifyAtArrayIndex( + ref object? obj, + string segment, + int idx, + string remainingPath, + SerializedMember value, + Type objType, + int depth, + Logs? logs, + BindingFlags flags, + ILogger? logger) + { + var elementType = TypeUtils.GetEnumerableItemType(objType); + var padding = StringUtils.GetPadding(depth); + + if (obj is Array array) + { + if (idx < 0 || idx >= array.Length) + { + var msg = $"Bracket segment '{segment}' index out of range on type '{objType.GetTypeShortName()}'. Array length is {array.Length}."; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{msg}"); + return false; + } + + var currentElement = array.GetValue(idx); + ApplyTypeReplacementCheck(ref currentElement, value, elementType ?? typeof(object), remainingPath); + + var success = TryModifyAt(ref currentElement, remainingPath, value, elementType, depth + 1, logs, flags, logger); + if (success) + array.SetValue(currentElement, idx); + return success; + } + + if (obj is IList list) + { + if (idx < 0 || idx >= list.Count) + { + var msg = $"Bracket segment '{segment}' index out of range on type '{objType.GetTypeShortName()}'. List count is {list.Count}."; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{msg}"); + return false; + } + + var currentElement = list[idx]; + ApplyTypeReplacementCheck(ref currentElement, value, elementType ?? typeof(object), remainingPath); + + var success = TryModifyAt(ref currentElement, remainingPath, value, elementType, depth + 1, logs, flags, logger); + if (success) + list[idx] = currentElement; + return success; + } + + { + var msg = $"Bracket segment '{segment}' cannot be applied: type '{objType.GetTypeShortName()}' is not an array or list."; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{msg}"); + return false; + } + } + + private bool TryModifyAtDictKey( + ref object? obj, + string segment, + object dictKey, + string remainingPath, + SerializedMember value, + Type valueType, + int depth, + Logs? logs, + BindingFlags flags, + ILogger? logger) + { + var dict = (IDictionary)obj!; + var currentElement = dict.Contains(dictKey) ? dict[dictKey] : null; + + ApplyTypeReplacementCheck(ref currentElement, value, valueType, remainingPath); + + var success = TryModifyAt(ref currentElement, remainingPath, value, valueType, depth + 1, logs, flags, logger); + if (success) + dict[dictKey] = currentElement; + return success; + } + + // ─── Member navigation (field / property) ──────────────────────────────────── + + private bool TryModifyAtMember( + ref object? obj, + string memberName, + string remainingPath, + SerializedMember value, + Type objType, + int depth, + Logs? logs, + BindingFlags flags, + ILogger? logger) + { + var padding = StringUtils.GetPadding(depth); + + // Try field + var fieldInfo = TypeMemberUtils.GetField(objType, flags, memberName); + if (fieldInfo != null) + { + var currentValue = fieldInfo.GetValue(obj); + ApplyTypeReplacementCheck(ref currentValue, value, fieldInfo.FieldType, remainingPath); + + var success = TryModifyAt(ref currentValue, remainingPath, value, fieldInfo.FieldType, depth + 1, logs, flags, logger); + if (success) + fieldInfo.SetValue(obj, currentValue); // struct-safe write-back + return success; + } + + // Try property + var propInfo = TypeMemberUtils.GetProperty(objType, flags, memberName); + if (propInfo != null) + { + if (!propInfo.CanWrite) + { + var readOnlyMsg = $"Property '{memberName}' on type '{objType.GetTypeShortName()}' is read-only."; + logs?.Error(readOnlyMsg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{readOnlyMsg}"); + return false; + } + + var currentValue = propInfo.GetValue(obj); + ApplyTypeReplacementCheck(ref currentValue, value, propInfo.PropertyType, remainingPath); + + var success = TryModifyAt(ref currentValue, remainingPath, value, propInfo.PropertyType, depth + 1, logs, flags, logger); + if (success) + propInfo.SetValue(obj, currentValue); + return success; + } + + // Neither field nor property found — detailed error with available members + var fieldNames = objType.GetFields(flags).Select(f => f.Name).ToList(); + var propNames = objType.GetProperties(flags).Select(p => p.Name).ToList(); + var fieldsStr = fieldNames.Count > 0 ? string.Join(", ", fieldNames) : "none"; + var propsStr = propNames.Count > 0 ? string.Join(", ", propNames) : "none"; + + var msg = $"Segment '{memberName}' not found on type '{objType.GetTypeShortName()}'." + + $"\nAvailable fields: {fieldsStr}" + + $"\nAvailable properties: {propsStr}"; + + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{msg}"); + + return false; + } + } +} diff --git a/ReflectorNet/src/Reflector/Reflector.Patch.cs b/ReflectorNet/src/Reflector/Reflector.Patch.cs new file mode 100644 index 00000000..4e28ed95 --- /dev/null +++ b/ReflectorNet/src/Reflector/Reflector.Patch.cs @@ -0,0 +1,436 @@ +/* + * ReflectorNet + * Author: Ivan Murzak (https://github.com/IvanMurzak) + * Copyright (c) 2025 Ivan Murzak + * Licensed under the Apache License, Version 2.0. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using com.IvanMurzak.ReflectorNet.Model; +using com.IvanMurzak.ReflectorNet.Utils; +using Microsoft.Extensions.Logging; + +namespace com.IvanMurzak.ReflectorNet +{ + public partial class Reflector + { + /// + /// Applies a JSON Merge Patch document to an object, modifying multiple fields at different depths in + /// a single call. Follows RFC 7396 JSON Merge Patch semantics, extended with bracket-notation keys + /// for array/dictionary access. + /// + /// Patch document rules: + /// - JSON object key → navigate into that field/property (plain) or array/dict element (bracket) + /// - JSON non-object → set as the value at current node + /// - JSON null → set the field to null + /// - "$type" key → optional type hint: replace current instance with new type (must be a subtype + /// of the declared type). Other keys in the same object are then applied to the + /// fresh instance. + /// + /// Errors are accumulated in the optional Logs object; nothing is thrown. + /// + public bool TryPatch( + ref object? obj, + string json, + Type? fallbackObjType = null, + int depth = 0, + Logs? logs = null, + BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + ILogger? logger = null) + { + JsonDocument doc; + try + { + doc = JsonDocument.Parse(json); + } + catch (Exception ex) + { + var msg = $"Failed to parse JSON patch: {ex.Message}"; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{StringUtils.GetPadding(depth)}{msg}"); + return false; + } + + using (doc) + { + return TryPatch(ref obj, doc.RootElement, fallbackObjType, depth, logs, flags, logger); + } + } + + /// + /// Applies a JSON Merge Patch document to an object. See the string overload for full documentation. + /// + public bool TryPatch( + ref object? obj, + JsonElement patch, + Type? fallbackObjType = null, + int depth = 0, + Logs? logs = null, + BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + ILogger? logger = null) + { + var objType = obj?.GetType() ?? fallbackObjType; + return TryPatchInternal(ref obj, patch, objType, depth, logs, flags, logger); + } + + // ─── Internal recursive patch engine ───────────────────────────────────────── + + private bool TryPatchInternal( + ref object? obj, + JsonElement patch, + Type? objType, + int depth, + Logs? logs, + BindingFlags flags, + ILogger? logger) + { + var padding = StringUtils.GetPadding(depth); + + // null patch → set the current node to null + if (patch.ValueKind == JsonValueKind.Null) + { + obj = null; + return true; + } + + // Leaf value (non-object JSON) → set directly via existing TryModify + if (patch.ValueKind != JsonValueKind.Object) + { + var member = SerializedMember.FromJson(objType ?? obj?.GetType() ?? typeof(object), patch); + return TryModify(ref obj, member, objType, depth, logs, flags, logger); + } + + // JSON object → process optional "$type" hint first, then navigate keys + + // Extract "$type" if present + string? typeHint = null; + if (patch.TryGetProperty("$type", out var typeElement)) + typeHint = typeElement.GetString(); + + // Apply type replacement if $type specifies a compatible subtype + if (typeHint != null) + { + var desiredType = TypeUtils.GetType(typeHint); + var declaredType = objType ?? obj?.GetType(); + if (desiredType != null + && obj != null + && desiredType != obj.GetType() + && (declaredType == null || declaredType.IsAssignableFrom(desiredType))) + { + obj = null; + objType = desiredType; + } + else if (desiredType != null && objType == null) + { + objType = desiredType; + } + } + + // If obj is null but we have a type, create a default instance so we can navigate into it + if (obj == null && objType != null) + { + obj = CreateInstance(objType); + if (obj == null) + { + var msg = $"Cannot create instance of type '{objType.GetTypeShortName()}' for patch application."; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{msg}"); + return false; + } + } + + if (obj == null) + { + var msg = $"Cannot apply JSON patch: target object is null and no type is known."; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{msg}"); + return false; + } + + objType = obj.GetType(); + var overallSuccess = true; + + foreach (var property in patch.EnumerateObject()) + { + if (property.Name == "$type") + continue; // already consumed above + + bool success; + if (TryParseBracketSegment(property.Name, out var innerKey)) + success = TryPatchBracketed(ref obj, property.Name, innerKey, property.Value, objType, depth, logs, flags, logger); + else + success = TryPatchMember(ref obj, property.Name, property.Value, objType, depth, logs, flags, logger); + + overallSuccess &= success; + } + + return overallSuccess; + } + + // ─── Patch member navigation ────────────────────────────────────────────────── + + private bool TryPatchMember( + ref object? obj, + string memberName, + JsonElement patchValue, + Type? objType, + int depth, + Logs? logs, + BindingFlags flags, + ILogger? logger) + { + if (objType == null || obj == null) + { + var msg = $"Cannot navigate to member '{memberName}': object is null."; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{StringUtils.GetPadding(depth)}{msg}"); + return false; + } + + var padding = StringUtils.GetPadding(depth); + + // Try field + var fieldInfo = TypeMemberUtils.GetField(objType, flags, memberName); + if (fieldInfo != null) + { + var currentValue = fieldInfo.GetValue(obj); + ApplyPatchTypeReplacement(ref currentValue, patchValue, fieldInfo.FieldType); + + var childType = currentValue?.GetType() ?? fieldInfo.FieldType; + var success = TryPatchInternal(ref currentValue, patchValue, childType, depth + 1, logs, flags, logger); + if (success) + fieldInfo.SetValue(obj, currentValue); + return success; + } + + // Try property + var propInfo = TypeMemberUtils.GetProperty(objType, flags, memberName); + if (propInfo != null) + { + if (!propInfo.CanWrite) + { + var readOnlyMsg = $"Property '{memberName}' on type '{objType.GetTypeShortName()}' is read-only."; + logs?.Error(readOnlyMsg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{readOnlyMsg}"); + return false; + } + + var currentValue = propInfo.GetValue(obj); + ApplyPatchTypeReplacement(ref currentValue, patchValue, propInfo.PropertyType); + + var childType = currentValue?.GetType() ?? propInfo.PropertyType; + var success = TryPatchInternal(ref currentValue, patchValue, childType, depth + 1, logs, flags, logger); + if (success) + propInfo.SetValue(obj, currentValue); + return success; + } + + // Neither found — detailed error + var fieldNames = objType.GetFields(flags).Select(f => f.Name).ToList(); + var propNames = objType.GetProperties(flags).Select(p => p.Name).ToList(); + var fieldsStr = fieldNames.Count > 0 ? string.Join(", ", fieldNames) : "none"; + var propsStr = propNames.Count > 0 ? string.Join(", ", propNames) : "none"; + + var notFoundMsg = $"Segment '{memberName}' not found on type '{objType.GetTypeShortName()}'." + + $"\nAvailable fields: {fieldsStr}" + + $"\nAvailable properties: {propsStr}"; + + logs?.Error(notFoundMsg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{notFoundMsg}"); + + return false; + } + + private bool TryPatchBracketed( + ref object? obj, + string segment, + string innerKey, + JsonElement patchValue, + Type? objType, + int depth, + Logs? logs, + BindingFlags flags, + ILogger? logger) + { + if (obj == null || objType == null) + { + var msg = $"Cannot navigate bracket segment '{segment}': object is null."; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{StringUtils.GetPadding(depth)}{msg}"); + return false; + } + + var padding = StringUtils.GetPadding(depth); + + if (obj is IList) + { + if (!int.TryParse(innerKey, out int idx)) + { + var msg = $"Bracket segment '{segment}' cannot be used as index on type '{objType.GetTypeShortName()}'. Expected integer index."; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{msg}"); + return false; + } + return TryPatchArrayIndex(ref obj, segment, idx, patchValue, objType, depth, logs, flags, logger); + } + + if (TypeUtils.IsDictionary(objType)) + { + var args = TypeUtils.GetDictionaryGenericArguments(objType); + var keyType = args?[0] ?? typeof(object); + var valType = args?[1] ?? typeof(object); + + object dictKey; + try + { + dictKey = Convert.ChangeType(innerKey, keyType); + } + catch (Exception ex) + { + var msg = $"Bracket segment '{segment}' cannot be converted to key type '{keyType.GetTypeShortName()}' on type '{objType.GetTypeShortName()}': {ex.Message}"; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{msg}"); + return false; + } + return TryPatchDictKey(ref obj, segment, dictKey, patchValue, valType, depth, logs, flags, logger); + } + + { + var msg = $"Bracket segment '{segment}' cannot be used on type '{objType.GetTypeShortName()}'. Type is not an array, list, or dictionary."; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{msg}"); + return false; + } + } + + private bool TryPatchArrayIndex( + ref object? obj, + string segment, + int idx, + JsonElement patchValue, + Type objType, + int depth, + Logs? logs, + BindingFlags flags, + ILogger? logger) + { + var elementType = TypeUtils.GetEnumerableItemType(objType); + var padding = StringUtils.GetPadding(depth); + + if (obj is Array array) + { + if (idx < 0 || idx >= array.Length) + { + var msg = $"Bracket segment '{segment}' index out of range on type '{objType.GetTypeShortName()}'. Array length is {array.Length}."; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{msg}"); + return false; + } + + var currentElement = array.GetValue(idx); + ApplyPatchTypeReplacement(ref currentElement, patchValue, elementType ?? typeof(object)); + + var childType = currentElement?.GetType() ?? elementType; + var success = TryPatchInternal(ref currentElement, patchValue, childType, depth + 1, logs, flags, logger); + if (success) + array.SetValue(currentElement, idx); + return success; + } + + if (obj is IList list) + { + if (idx < 0 || idx >= list.Count) + { + var msg = $"Bracket segment '{segment}' index out of range on type '{objType.GetTypeShortName()}'. List count is {list.Count}."; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{msg}"); + return false; + } + + var currentElement = list[idx]; + ApplyPatchTypeReplacement(ref currentElement, patchValue, elementType ?? typeof(object)); + + var childType = currentElement?.GetType() ?? elementType; + var success = TryPatchInternal(ref currentElement, patchValue, childType, depth + 1, logs, flags, logger); + if (success) + list[idx] = currentElement; + return success; + } + + { + var msg = $"Bracket segment '{segment}' cannot be applied: type '{objType.GetTypeShortName()}' is not an array or list."; + logs?.Error(msg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{msg}"); + return false; + } + } + + private bool TryPatchDictKey( + ref object? obj, + string segment, + object dictKey, + JsonElement patchValue, + Type valueType, + int depth, + Logs? logs, + BindingFlags flags, + ILogger? logger) + { + var dict = (IDictionary)obj!; + var currentElement = dict.Contains(dictKey) ? dict[dictKey] : null; + + ApplyPatchTypeReplacement(ref currentElement, patchValue, valueType); + + var childType = currentElement?.GetType() ?? valueType; + var success = TryPatchInternal(ref currentElement, patchValue, childType, depth + 1, logs, flags, logger); + if (success) + dict[dictKey] = currentElement; + return success; + } + + // ─── Type replacement for patch (uses "$type" from JsonElement) ────────────── + + /// + /// Checks if the JSON patch value contains a "$type" key specifying a compatible subtype. + /// If so, resets currentValue to null so TryPatchInternal will create a fresh instance. + /// + private static void ApplyPatchTypeReplacement(ref object? currentValue, JsonElement patchValue, Type declaredType) + { + if (patchValue.ValueKind != JsonValueKind.Object) + return; + + if (!patchValue.TryGetProperty("$type", out var typeElement)) + return; + + var typeName = typeElement.GetString(); + if (string.IsNullOrEmpty(typeName)) + return; + + var desiredType = TypeUtils.GetType(typeName); + if (desiredType != null + && currentValue != null + && desiredType != currentValue.GetType() + && declaredType.IsAssignableFrom(desiredType)) + { + currentValue = null; // force fresh instance creation + } + } + } +} diff --git a/docs/plan/atomic-modify.md b/docs/plan/atomic-modify.md new file mode 100644 index 00000000..e2c09e3f --- /dev/null +++ b/docs/plan/atomic-modify.md @@ -0,0 +1,427 @@ +# Plan: Atomic Path-Based Modification API + +## Context + +ReflectorNet's `TryModify` requires supplying the full nested `SerializedMember` graph down to the target. For arrays there is an additional hard limitation: `TryModifyField` calls `TypeMemberUtils.GetField(arrayType, "[2]")` which always returns null. Targeting a single element in an array currently requires replacing the entire array. + +**Goal**: add an ergonomic, truly atomic modification API that navigates directly to a specific field, array element, or dictionary entry and modifies only that — without constructing full intermediate objects. + +```csharp +// Modify only the name field on the existing User at index 2 (no new User created) +reflector.TryModifyAt(ref database, "users/[2]/name", "Bob"); + +// Partial update of a complex object — only listed fields are touched +var patch = new SerializedMember { typeName = typeof(User).GetTypeId() }; +patch.SetFieldValue(reflector, "name", "Bob"); +reflector.TryModifyAt(ref database, "users/[2]", patch); + +// JSON patch — modify multiple fields at different depths at once +reflector.TryPatch(ref database, """ +{ + "admin": { "name": "Carol" }, + "users": { "[2]": { "name": "Bob", "email": "bob@example.com" } }, + "config": { "[maxRetries]": 5 } +} +"""); + +// Type replacement — replace StringData field with derived StringDataAdvanced +var advanced = SerializedMember.FromValue(reflector, typeof(StringDataAdvanced), instance); +reflector.TryModifyAt(ref database, "admin/displayName", advanced); +``` + +--- + +## Path Format + +| Segment form | Meaning | +|---|---| +| `fieldName` or `PropertyName` | Field or property by name (plain, no brackets) | +| `[i]` where inner value is integer AND obj is Array/IList | Array index | +| `[key]` where obj is `IDictionary` | Dictionary string key | +| `[key]` where obj is `IDictionary` | Dictionary integer key | + +**Disambiguation**: if the segment starts with `[…]`, check runtime type of `obj`: + +- `Array` / `IList` → integer index (inner must parse as int) +- `IDictionary` → key (parse inner to K via `Convert.ChangeType`) +- Neither → log detailed error and return false + +Leading `#/` stripped automatically (compatible with `SerializationContext` path format). + +--- + +## Error Messages + +Every navigation failure must include the segment name explicitly: + +``` +Segment 'admin' not found on type 'Database'. +Available fields: admin, users, config +Available properties: Id, Name +``` + +``` +Bracket segment '[99]' index out of range on type 'User[]'. Array length is 3. +``` + +``` +Bracket segment '[badKey]' cannot be used as index on type 'User[]'. Expected integer index. +``` + +``` +Bracket segment '[myKey]' cannot be used as key on type 'Database'. +Type is not an array, list, or dictionary. +``` + +Errors are accumulated in the `Logs` object (same pattern as `TryModify`) — not thrown as exceptions. + +--- + +## Implementation + +### 1. `Reflector.ModifyAt.cs` — new partial file + +**Location**: `ReflectorNet/src/Reflector/Reflector.ModifyAt.cs` + +#### Public API + +```csharp +// Primary overload — full control via SerializedMember +public bool TryModifyAt( + ref object? obj, string path, SerializedMember value, + Type? fallbackObjType = null, int depth = 0, Logs? logs = null, + BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + ILogger? logger = null) + +// Convenience generic overload — ideal for leaf/primitive targets +public bool TryModifyAt( + ref object? obj, string path, T value, + Type? fallbackObjType = null, int depth = 0, Logs? logs = null, + BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + ILogger? logger = null) +// body: SerializedMember.FromValue(this, value) → call primary overload +``` + +#### Path parsing helpers (private static) + +```csharp +private static string[] ParsePath(string path) +// Strip "#/" prefix. Split on '/'. Use string.Split(new char[]{'/'}, RemoveEmptyEntries) +// for netstandard2.1 compatibility. + +private static bool TryParseBracketSegment(string segment, out string innerKey) +// Returns true if segment is "[anything]"; innerKey = content between brackets. +``` + +#### Core algorithm — `TryModifyAt` (primary) + +``` +1. segments = ParsePath(path) +2. If empty → terminal: return TryModify(ref obj, value, fallbackObjType, ...) +3. segment = segments[0]; remainingPath = join(segments[1..], "/") +4. If TryParseBracketSegment(segment, out innerKey): + → TryModifyAtBracketed(ref obj, segment, innerKey, remainingPath, value, ...) + Else: + → TryModifyAtMember(ref obj, segment, remainingPath, value, ...) +``` + +#### `TryModifyAtBracketed` (private) + +Dispatches on runtime type of `obj`: + +``` +if obj is null: + logs.Error("Cannot navigate segment '...' on null object.", depth) + return false + +if obj is Array or IList: + if !int.TryParse(innerKey, out int idx): + logs.Error("Bracket segment '[innerKey]' cannot be used as index on type '...'. + Expected integer index.", depth) + return false + → TryModifyAtArrayIndex(ref obj, segment, idx, remainingPath, value, ...) + +elif TypeUtils.IsDictionary(objType): + args = TypeUtils.GetDictionaryGenericArguments(objType) + keyType = args[0] + try: dictKey = Convert.ChangeType(innerKey, keyType) + catch: logs.Error("Bracket segment '[innerKey]' cannot be converted to + key type '...' on type '...'.", depth); return false + → TryModifyAtDictKey(ref obj, segment, dictKey, remainingPath, value, ...) + +else: + logs.Error("Bracket segment '[innerKey]' cannot be used on type '...'. + Type is not an array, list, or dictionary.", depth) + return false +``` + +#### `TryModifyAtArrayIndex` (private) + +``` +1. if idx < 0 or idx >= length: + logs.Error("Bracket segment '[i]' index out of range on type '...'. + Array length is N.", depth) + return false +2. elementType = TypeUtils.GetEnumerableItemType(objType) +3. currentElement = array.GetValue(idx) or list[idx] +4. Type-replacement check (see below, only if remainingPath is empty) +5. success = TryModifyAt(ref currentElement, remainingPath, value, elementType, depth+1, ...) +6. if success: array.SetValue(currentElement, idx) or list[idx] = currentElement +7. return success +``` + +#### `TryModifyAtDictKey` (private) + +``` +1. dict = (IDictionary)obj +2. currentElement = dict.Contains(dictKey) ? dict[dictKey] : null +3. valueType = GetDictionaryGenericArguments(objType)[1] +4. Type-replacement check (see below, only if remainingPath is empty) +5. success = TryModifyAt(ref currentElement, remainingPath, value, valueType, depth+1, ...) +6. if success: dict[dictKey] = currentElement +7. return success +``` + +#### `TryModifyAtMember` (private) + +``` +1. Try TypeMemberUtils.GetField(objType, flags, memberName): + if not found → go to step 2 + currentValue = fieldInfo.GetValue(obj) + Type-replacement check (see below, only if remainingPath is empty) + success = TryModifyAt(ref currentValue, remainingPath, value, fieldInfo.FieldType, depth+1, ...) + if success: fieldInfo.SetValue(obj, currentValue) // struct-safe (mirrors BaseReflectionConverter.Modify.cs:342) + return success + +2. Try TypeMemberUtils.GetProperty(objType, flags, memberName): + if not found → go to step 3 + check CanWrite; if not: logs.Error("Property '...' is read-only.", depth); return false + currentValue = propInfo.GetValue(obj) + Type-replacement check (see below, only if remainingPath is empty) + success = TryModifyAt(ref currentValue, remainingPath, value, propInfo.PropertyType, depth+1, ...) + if success: propInfo.SetValue(obj, currentValue) + return success + +3. // Neither field nor property found + (fieldNames, propNames) = GetCachedSerializableMemberNames(reflector, objType, flags, logger) + logs.Error( + "Segment 'memberName' not found on type 'objType'.\n" + + "Available fields: field1, field2, ...\n" + + "Available properties: prop1, prop2, ...", depth) + return false +``` + +#### Type-replacement check (applied in array/dict/member helpers) + +Only at **terminal step** (`string.IsNullOrEmpty(remainingPath)`): + +```csharp +if (string.IsNullOrEmpty(remainingPath)) +{ + var desiredType = TypeUtils.GetTypeWithNamePriority(value, declaredType, out _); + if (desiredType != null + && currentValue != null + && desiredType != currentValue.GetType() + && declaredType.IsAssignableFrom(desiredType)) + { + currentValue = null; // force fresh instance creation via TryModify's null branch + } +} +``` + +--- + +### 2. `Reflector.Patch.cs` — new partial file (JSON patch) + +**Location**: `ReflectorNet/src/Reflector/Reflector.Patch.cs` + +Modifies multiple fields at different depths in a single call using a JSON document as the patch descriptor. Follows **JSON Merge Patch** semantics (RFC 7396) extended with bracket-notation keys for array/dictionary access. + +#### Public API + +```csharp +// JsonElement overload (primary) +public bool TryPatch( + ref object? obj, JsonElement patch, + Type? fallbackObjType = null, int depth = 0, Logs? logs = null, + BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + ILogger? logger = null) + +// String convenience overload (parses JSON internally) +public bool TryPatch( + ref object? obj, string json, + Type? fallbackObjType = null, int depth = 0, Logs? logs = null, + BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + ILogger? logger = null) +// body: parse json → JsonDocument → call JsonElement overload +``` + +#### Patch document format + +- **JSON object** → navigate: each key is a path segment (field name, `[i]`, `[key]`). +- **JSON primitive / array** → set value: deserialize and assign to current node. +- **`"$type"` key** inside a JSON object → optional type hint for type replacement. Value is the full type name string (same as `SerializedMember.typeName`). + +Example: + +```json +{ + "admin": { + "$type": "com.MyApp.AdminUser", + "name": "Carol", + "level": 3 + }, + "users": { + "[2]": { + "email": "bob@example.com" + } + }, + "config": { + "[maxRetries]": 5, + "[timeout]": 30 + } +} +``` + +#### Core algorithm — `TryPatchInternal` (recursive, private) + +``` +TryPatchInternal(ref obj, patch JsonElement, objType, depth, logs, flags, logger): + + 1. If patch.ValueKind == Null: + obj = null; return true + + 2. If patch.ValueKind != Object (primitive, bool, array): + // Leaf value — set directly using existing TryModify + var member = SerializedMember.FromJson(objType ?? obj?.GetType(), patch) + return TryModify(ref obj, member, objType, depth, logs, flags, logger) + + 3. // patch is a JSON object — navigate its keys + Extract optional "$type" from patch properties + Apply type-replacement check using $type (same logic as TryModifyAt) + + var overallSuccess = true + foreach property in patch.EnumerateObject(): + if property.Name == "$type": continue // already consumed above + + if TryParseBracketSegment(property.Name, out innerKey): + success = TryPatchBracketed(ref obj, property.Name, innerKey, property.Value, ...) + else: + success = TryPatchMember(ref obj, property.Name, property.Value, ...) + + overallSuccess &= success + + return overallSuccess +``` + +**`TryPatchMember`** and **`TryPatchBracketed`**: same navigation logic as `TryModifyAtMember` / `TryModifyAtBracketed`, but the "value" is a `JsonElement` subtree rather than a `SerializedMember`. At each level, call `TryPatchInternal` recursively. + +Error messages follow the same pattern as `TryModifyAt` (segment name always explicit). + +--- + +### 3. `ArrayReflectionConverter.Modify.cs` — new partial file + +**Location**: `ReflectorNet/src/Converter/Reflection/ArrayReflectionConverter.Modify.cs` + +Overrides `TryModify` so the existing API (without a path string) also supports partial array element modification when `data.fields` contains `[i]`-named entries. + +**Algorithm**: + +``` +override TryModify(...): + 1. If data.valueJsonElement is present → base.TryModify (full replacement, unchanged) + 2. Partition data.fields into: indexedFields (IsArrayIndexName) and non-indexed + 3. If no indexedFields → base.TryModify (unchanged) + 4. Validate obj is non-null; elementType = TypeUtils.GetEnumerableItemType(type) + 5. For each indexedField: + idx = ParseArrayIndex(indexedField.name); bounds check with explicit error + currentElement = array[idx] + reflector.TryModify(ref currentElement, indexedField, elementType, depth+1, ...) + if success: array[idx] = currentElement + 6. Return AND of all results +``` + +Private helpers: + +```csharp +private static bool IsArrayIndexName(string? name) // "[digits]" only +private static int ParseArrayIndex(string name) +``` + +--- + +### 4. Tests — `AtomicModifyTests.cs` + +**Location**: `ReflectorNet.Tests/src/ReflectorTests/AtomicModifyTests.cs` + +Uses `SolarSystem` / `SolarSystem.CelestialBody` / `GameObjectRef`. + +| Test | Method | Path / JSON | +|---|---|---| +| `TryModifyAt_RootField` | `TryModifyAt` | `"globalOrbitSpeedMultiplier"` | +| `TryModifyAt_TwoLevelField` | `TryModifyAt` | `"sun/instanceID"` | +| `TryModifyAt_ArrayElementField` | `TryModifyAt` | `"celestialBodies/[0]/orbitRadius"` → only `[0]` changes | +| `TryModifyAt_InvalidMember_DetailedError` | `TryModifyAt` | `"doesNotExist"` → false, Logs has "Segment 'doesNotExist' not found" | +| `TryModifyAt_OutOfBoundsIndex_DetailedError` | `TryModifyAt` | `"celestialBodies/[99]/orbitRadius"` → false, Logs has "'[99]' index out of range" | +| `TryModifyAt_DictionaryStringKey` | `TryModifyAt` | `"config/[timeout]"` on `Dictionary` | +| `TryModifyAt_DictionaryIntKey` | `TryModifyAt` | `"lookup/[3]"` on `Dictionary` | +| `TryModifyAt_TypeReplacement` | `TryModifyAt(SerializedMember)` | replace base type field with derived | +| `TryModifyAt_PartialPatch` | `TryModifyAt(SerializedMember)` | navigate to `[0]` and apply partial fields | +| `TryPatch_MultipleFieldsAtOnce` | `TryPatch(string json)` | JSON with two nested modifications at once | +| `TryPatch_ArrayElementAndField` | `TryPatch(JsonElement)` | `"[2]"` key navigates into array element | +| `TryPatch_WithTypeHint` | `TryPatch(string json)` | `"$type"` key triggers type replacement | +| `ArrayConverter_PartialElementModify` | `TryModify(SerializedMember)` | `[1]`-named field entry → only that element changes | + +--- + +## Files Created (no existing files modified) + +| File | Purpose | +|---|---| +| `ReflectorNet/src/Reflector/Reflector.ModifyAt.cs` | Path-based single-target modification | +| `ReflectorNet/src/Reflector/Reflector.Patch.cs` | JSON patch document — multi-path modification | +| `ReflectorNet/src/Converter/Reflection/ArrayReflectionConverter.Modify.cs` | Partial array element modification via `TryModify` | +| `ReflectorNet.Tests/src/ReflectorTests/AtomicModifyTests.cs` | Tests for all new functionality | + +--- + +## Key Reused Utilities + +| Utility | File | Used for | +|---|---|---| +| `TypeMemberUtils.GetField / GetProperty` | `src/Utils/TypeMemberUtils.cs` | member lookup | +| `TypeUtils.GetEnumerableItemType(type)` | `src/Utils/TypeUtils.Collections.cs` | array element type | +| `TypeUtils.IsDictionary(type)` | `src/Utils/TypeUtils.Collections.cs` | dictionary detection | +| `TypeUtils.GetDictionaryGenericArguments(type)` | `src/Utils/TypeUtils.Collections.cs` | key/value types | +| `TypeUtils.GetTypeWithNamePriority(data, fallback, out _)` | `src/Utils/TypeUtils.GetType.cs` | type replacement / `$type` resolution | +| `declaredType.IsAssignableFrom(desiredType)` | `src/Utils/TypeUtils.Helpers.cs` | assignability check | +| `StringUtils.GetPadding(depth)` | `src/Utils/StringUtils.cs` | log indentation | +| `SerializedMember.FromValue(reflector, value)` | `src/Model/SerializedMember.Static.cs` | generic overload factory | +| `SerializedMember.FromJson(type, jsonElement)` | `src/Model/SerializedMember.Static.cs` | JSON-to-member for `TryPatch` leaves | +| `GetCachedSerializableMemberNames(...)` | `BaseReflectionConverter.Modify.cs` | "Available fields/props" in error messages | +| `IsGenericList(type, out elementType)` | `ArrayReflectionConverter.cs` | reused in override | + +--- + +## Design Notes + +**"Truly atomic"**: `TryModifyAt(ref db, "users/[2]/name", "Bob")` navigates directly to the `name` field on the existing `User` at index 2. No new User is created, no other field on the User changes, and no other element in the array is touched. + +**`TryPatch` for multi-field**: when multiple fields at different depths need to change in a single operation, use `TryPatch` with a JSON document. The JSON keys drive navigation, leaf values are set directly. + +**Type info in `TryPatch`**: the `"$type"` key inside any JSON object node specifies the desired type (full type name). When the desired type is a subtype of the declared type, the existing instance is discarded and a new one is created — enabling polymorphic replacement in JSON form. + +**`Logs` throughout**: every public method takes `Logs? logs` and accumulates errors using the same depth-aware pattern as `TryModify`. Errors include explicit segment names. Nothing is thrown. + +**No changes to existing files**: all new functionality is additive through new partial-class files and a new test file. + +--- + +## Verification + +```bash +dotnet build --configuration Release +dotnet test --configuration Release --filter "FullyQualifiedName~AtomicModifyTests" +dotnet test --configuration Release --verbosity normal +``` From 2f6c7dd97970746fdcd662e8f89dd9c4d93d79f0 Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Tue, 3 Mar 2026 01:54:15 -0800 Subject: [PATCH 2/3] refactor: remove unused System.Collections.Generic namespace import --- .../src/Converter/Reflection/ArrayReflectionConverter.Modify.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/ReflectorNet/src/Converter/Reflection/ArrayReflectionConverter.Modify.cs b/ReflectorNet/src/Converter/Reflection/ArrayReflectionConverter.Modify.cs index d757284b..97fca620 100644 --- a/ReflectorNet/src/Converter/Reflection/ArrayReflectionConverter.Modify.cs +++ b/ReflectorNet/src/Converter/Reflection/ArrayReflectionConverter.Modify.cs @@ -7,7 +7,6 @@ using System; using System.Collections; -using System.Collections.Generic; using System.Linq; using System.Reflection; using Microsoft.Extensions.Logging; From 25b26172bcd40551d4c6a3fdae13e545b227623f Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Tue, 3 Mar 2026 02:15:49 -0800 Subject: [PATCH 3/3] test: add unit tests for TryPatch method and enhance error logging in Reflector --- .../src/ReflectorTests/AtomicModifyTests.cs | 191 ++++++++++++++++++ ReflectorNet/src/Reflector/Reflector.Patch.cs | 27 ++- 2 files changed, 212 insertions(+), 6 deletions(-) diff --git a/ReflectorNet.Tests/src/ReflectorTests/AtomicModifyTests.cs b/ReflectorNet.Tests/src/ReflectorTests/AtomicModifyTests.cs index 821894c7..94e85804 100644 --- a/ReflectorNet.Tests/src/ReflectorTests/AtomicModifyTests.cs +++ b/ReflectorNet.Tests/src/ReflectorTests/AtomicModifyTests.cs @@ -362,6 +362,177 @@ public void TryPatch_JsonElement_Overload() Assert.Equal(9f, ((SolarSystem)obj!).globalOrbitSpeedMultiplier); } + // ─── TryPatch — null value sets field to null (RFC 7396) ────────────────── + + [Fact] + public void TryPatch_NullValue_SetsFieldToNull() + { + var reflector = new Reflector(); + var system = new SolarSystem { sun = new GameObjectRef { instanceID = 1 } }; + object? obj = system; + + var logs = new Logs(); + var success = reflector.TryPatch(ref obj, @"{""sun"": null}", logs: logs); + + _output.WriteLine(logs.ToString()); + Assert.True(success); + Assert.Null(((SolarSystem)obj!).sun); + } + + // ─── TryPatch — $type polymorphic replacement ───────────────────────────── + + [Fact] + public void TryPatch_TypeHint_PolymorphicReplacement() + { + var reflector = new Reflector(); + var container = new AnimalContainer { animal = new Animal { name = "Cat" } }; + object? obj = container; + + var dogTypeId = typeof(Dog).GetTypeId(); + var json = $@"{{""animal"": {{""$type"": ""{dogTypeId}"", ""name"": ""Rex"", ""breed"": ""Husky""}}}}"; + + var logs = new Logs(); + var success = reflector.TryPatch(ref obj, json, logs: logs); + + _output.WriteLine(logs.ToString()); + Assert.True(success); + var result = (AnimalContainer)obj!; + Assert.IsType(result.animal); + Assert.Equal("Rex", result.animal!.name); + Assert.Equal("Husky", ((Dog)result.animal).breed); + } + + // ─── TryPatch — $type incompatible type logs error ──────────────────────── + + [Fact] + public void TryPatch_TypeHint_IncompatibleType_LogsError() + { + var reflector = new Reflector(); + var container = new AnimalContainer { animal = new Animal { name = "Cat" } }; + object? obj = container; + + var incompatibleTypeId = typeof(SolarSystem).GetTypeId(); + var json = $@"{{""animal"": {{""$type"": ""{incompatibleTypeId}""}}}}"; + + var logs = new Logs(); + var success = reflector.TryPatch(ref obj, json, logs: logs); + + var logsText = logs.ToString(); + _output.WriteLine(logsText); + Assert.False(success); + Assert.Contains("not assignable", logsText); + Assert.IsType(((AnimalContainer)obj!).animal); // field untouched + } + + // ─── TryPatch — $type unknown type string logs error ────────────────────── + + [Fact] + public void TryPatch_TypeHint_UnknownType_LogsError() + { + var reflector = new Reflector(); + var container = new AnimalContainer { animal = new Animal { name = "Cat" } }; + object? obj = container; + + var json = @"{""animal"": {""$type"": ""NoSuchType.Anywhere""}}"; + + var logs = new Logs(); + var success = reflector.TryPatch(ref obj, json, logs: logs); + + var logsText = logs.ToString(); + _output.WriteLine(logsText); + Assert.False(success); + Assert.Contains("could not be resolved", logsText); + } + + // ─── TryPatch — unknown key returns false and logs error ────────────────── + + [Fact] + public void TryPatch_UnknownKey_ReturnsFalseAndLogsError() + { + var reflector = new Reflector(); + var system = new SolarSystem { globalOrbitSpeedMultiplier = 1f }; + object? obj = system; + var logs = new Logs(); + + var success = reflector.TryPatch(ref obj, @"{""doesNotExist"": 99.0}", logs: logs); + + var logsText = logs.ToString(); + _output.WriteLine(logsText); + Assert.False(success); + Assert.Contains("doesNotExist", logsText); + Assert.Equal(1f, ((SolarSystem)obj!).globalOrbitSpeedMultiplier); // unchanged + } + + // ─── TryModifyAt — List element (IList branch) ──────────────────────── + + [Fact] + public void TryModifyAt_ListElement() + { + var reflector = new Reflector(); + var container = new ListContainer + { + bodies = new List + { + new SolarSystem.CelestialBody { orbitRadius = 10f, orbitSpeed = 1f }, + new SolarSystem.CelestialBody { orbitRadius = 20f, orbitSpeed = 2f }, + } + }; + object? obj = container; + + var success = reflector.TryModifyAt(ref obj, "bodies/[0]/orbitRadius", 55f); + + Assert.True(success); + var result = (ListContainer)obj!; + Assert.Equal(55f, result.bodies[0].orbitRadius); + Assert.Equal(1f, result.bodies[0].orbitSpeed); // untouched + Assert.Equal(20f, result.bodies[1].orbitRadius); // untouched + } + + // ─── TryModifyAt — three-level path (field/index/property) ─────────────── + + [Fact] + public void TryModifyAt_ThreeLevelPath() + { + var reflector = new Reflector(); + var system = new SolarSystem + { + celestialBodies = new[] + { + new SolarSystem.CelestialBody { orbitTilt = new Vector3(1f, 2f, 3f) }, + } + }; + object? obj = system; + + var success = reflector.TryModifyAt(ref obj, "celestialBodies/[0]/orbitTilt/x", 99f); + + Assert.True(success); + var result = (SolarSystem)obj!; + Assert.Equal(99f, result.celestialBodies![0].orbitTilt.x); + Assert.Equal(2f, result.celestialBodies![0].orbitTilt.y); // untouched + Assert.Equal(3f, result.celestialBodies![0].orbitTilt.z); // untouched + } + + // ─── TryModifyAt — empty path delegates to TryModify on root ───────────── + + [Fact] + public void TryModifyAt_EmptyPath_AppliesModifyToRoot() + { + var reflector = new Reflector(); + var system = new SolarSystem { globalOrbitSpeedMultiplier = 1f, globalSizeMultiplier = 1f }; + object? obj = system; + + var patch = new SerializedMember { typeName = typeof(SolarSystem).GetTypeId() ?? string.Empty }; + patch.SetFieldValue(reflector, "globalOrbitSpeedMultiplier", 42f); + + var logs = new Logs(); + var success = reflector.TryModifyAt(ref obj, "", patch, logs: logs); + + _output.WriteLine(logs.ToString()); + Assert.True(success); + Assert.Equal(42f, ((SolarSystem)obj!).globalOrbitSpeedMultiplier); + Assert.Equal(1f, ((SolarSystem)obj!).globalSizeMultiplier); // untouched + } + // ─── Test-local helper types ─────────────────────────────────────────────── private class DictionaryContainer @@ -373,5 +544,25 @@ private class IntDictionaryContainer { public Dictionary lookup = new Dictionary(); } + + private class ListContainer + { + public List bodies = new List(); + } + + private class Animal + { + public string name = string.Empty; + } + + private class Dog : Animal + { + public string breed = string.Empty; + } + + private class AnimalContainer + { + public Animal? animal; + } } } diff --git a/ReflectorNet/src/Reflector/Reflector.Patch.cs b/ReflectorNet/src/Reflector/Reflector.Patch.cs index 4e28ed95..746052dd 100644 --- a/ReflectorNet/src/Reflector/Reflector.Patch.cs +++ b/ReflectorNet/src/Reflector/Reflector.Patch.cs @@ -113,20 +113,36 @@ private bool TryPatchInternal( typeHint = typeElement.GetString(); // Apply type replacement if $type specifies a compatible subtype + var overallSuccess = true; if (typeHint != null) { var desiredType = TypeUtils.GetType(typeHint); var declaredType = objType ?? obj?.GetType(); - if (desiredType != null - && obj != null - && desiredType != obj.GetType() - && (declaredType == null || declaredType.IsAssignableFrom(desiredType))) + if (desiredType == null) + { + var typeMsg = $"$type hint '{typeHint}' could not be resolved to a known type."; + logs?.Error(typeMsg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{typeMsg}"); + overallSuccess = false; + } + else if (declaredType != null && !declaredType.IsAssignableFrom(desiredType)) + { + var typeMsg = $"$type hint '{typeHint}' ('{desiredType.GetTypeShortName()}') is not assignable to declared type '{declaredType.GetTypeShortName()}'."; + logs?.Error(typeMsg, depth); + if (logger?.IsEnabled(LogLevel.Error) == true) + logger.LogError($"{padding}{typeMsg}"); + overallSuccess = false; + } + else if (obj != null && desiredType != obj.GetType()) { obj = null; objType = desiredType; } - else if (desiredType != null && objType == null) + else if (objType == null || (obj == null && desiredType != objType)) { + // obj was reset to null (by ApplyPatchTypeReplacement) and we have a declared type; + // upgrade to the desired subtype so the fresh instance is created as the correct type. objType = desiredType; } } @@ -155,7 +171,6 @@ private bool TryPatchInternal( } objType = obj.GetType(); - var overallSuccess = true; foreach (var property in patch.EnumerateObject()) {