Skip to content

Commit 67f777c

Browse files
authored
Add atomic modification API and JSON patch support with enhancements (#71)
* 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. * refactor: remove unused System.Collections.Generic namespace import * test: add unit tests for TryPatch method and enhance error logging in Reflector * feat: add View and TryReadAt methods for enhanced object serialization and navigation * refactor: replace HashSet<int> with HashSet<object> for circular reference detection * feat: add View method with TypeFilter and enhance ViewQuery descriptions * feat: add View and Grep methods for enhanced read-side navigation and filtering * feat: implement TryLookupMember and TryLookupBracketedElement methods for enhanced member resolution * feat: enhance View and Grep methods with additional test cases for null inputs and invalid patterns * Add extensive ReflectorNet unit tests Add numerous unit tests to AtomicModifyTests.cs and ViewTests.cs covering edge cases for TryModifyAt, TryPatch, TryReadAt, Grep and View behaviors. New tests exercise null-intermediate paths, negative indices, dictionary key add/type-conversion, bracket notation misuse, read-only properties, mixed dictionary/array nesting, invalid JSON patches, deep array-based patches, null-at-root patches, hash-prefixed paths, circular-reference handling, property discovery, max-depth pruning, empty-path handling, List<T> element matching, and maxDepth=0 behavior. Also add small test helper types (ReadOnlyPropertyContainer, DictOfArraysContainer, ListContainer, CircularNode) and include logging assertions to verify diagnostic messages.
1 parent 50337a6 commit 67f777c

12 files changed

Lines changed: 3949 additions & 3 deletions

File tree

README.md

Lines changed: 238 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ Traditional reflection is brittle and requires exact matches. ReflectorNet is bu
2121
* **🔍 Fuzzy Matching**: Discover methods and types even with incomplete names or parameters (configurable match levels 0-6).
2222
* **📦 Type-Safe Serialization**: Preserves full type information, supporting complex nested objects, collections, and custom types.
2323
* **🔄 In-Place Modification**: Update existing object instances from serialized data without breaking references.
24+
* **🎯 Atomic Path-Based Modification**: Navigate directly to any field, array element, or dictionary entry by path and modify only that — without touching anything else.
25+
* **🩹 JSON Patch**: Apply a JSON document to modify multiple fields at different depths in a single call, following JSON Merge Patch (RFC 7396) semantics.
26+
* **🔎 View & Grep**: Read exactly what you need — navigate to a subtree, filter by name pattern or type, or grep the entire live object graph for all fields matching a regex. The read-side counterpart to path-based modification.
2427
* **📄 JSON Schema Generation**: Automatically generate schemas for your types and methods to feed into LLM context windows.
2528

2629
## 📦 Installation
@@ -79,7 +82,240 @@ var existingInstance = new MyComplexClass();
7982
bool success = reflector.TryModify(ref existingInstance, serialized);
8083
```
8184

82-
### 5. Dynamic Method Invocation
85+
### 5. Atomic Path-Based Modification
86+
87+
Navigate directly to a specific field, array element, or dictionary entry by path and modify **only that target** — no surrounding data is affected.
88+
89+
**Path format:**
90+
91+
| Segment | Meaning |
92+
| --- | --- |
93+
| `fieldName` | Field or property by name |
94+
| `[i]` | Array / list element at index `i` |
95+
| `[key]` | Dictionary entry with key `key` (any key type) |
96+
97+
A leading `#/` is stripped automatically for compatibility with `SerializationContext` paths.
98+
99+
```csharp
100+
var reflector = new Reflector();
101+
object? system = new SolarSystem
102+
{
103+
globalOrbitSpeedMultiplier = 1f,
104+
celestialBodies = new[]
105+
{
106+
new CelestialBody { orbitRadius = 10f, orbitSpeed = 1f },
107+
new CelestialBody { orbitRadius = 20f, orbitSpeed = 2f },
108+
}
109+
};
110+
111+
// Modify a root field
112+
reflector.TryModifyAt<float>(ref system, "globalOrbitSpeedMultiplier", 5f);
113+
114+
// Modify a nested field — only that one field changes
115+
reflector.TryModifyAt<float>(ref system, "celestialBodies/[0]/orbitRadius", 999f);
116+
117+
// Dictionary entry — string or integer keys both work
118+
object? container = new Config { settings = new Dictionary<string, int> { ["timeout"] = 10 } };
119+
reflector.TryModifyAt<int>(ref container, "settings/[timeout]", 60);
120+
```
121+
122+
For partial updates of a complex object at a path, supply a `SerializedMember` that lists only the fields to change:
123+
124+
```csharp
125+
var patch = new SerializedMember { typeName = typeof(CelestialBody).GetTypeId() };
126+
patch.SetFieldValue(reflector, "orbitRadius", 777f); // only orbitRadius changes
127+
128+
var logs = new Logs();
129+
reflector.TryModifyAt(ref system, "celestialBodies/[1]", patch, logs: logs);
130+
// celestialBodies[1].orbitSpeed is untouched
131+
```
132+
133+
Errors are collected in the `Logs` object — nothing is thrown:
134+
135+
```csharp
136+
var logs = new Logs();
137+
bool ok = reflector.TryModifyAt<float>(ref system, "doesNotExist", 5f, logs: logs);
138+
// ok == false; logs contains:
139+
// "Segment 'doesNotExist' not found on type 'SolarSystem'.
140+
// Available fields: globalOrbitSpeedMultiplier, celestialBodies, ..."
141+
```
142+
143+
### 6. JSON Patch
144+
145+
Apply a JSON document to modify multiple fields at different depths in a single call.
146+
Follows **JSON Merge Patch** (RFC 7396) semantics, extended with bracket-notation keys for arrays and dictionaries.
147+
148+
```csharp
149+
var reflector = new Reflector();
150+
object? system = new SolarSystem { /* ... */ };
151+
152+
// Modify several fields at once — untouched fields are preserved
153+
var logs = new Logs();
154+
bool ok = reflector.TryPatch(ref system, """
155+
{
156+
"globalOrbitSpeedMultiplier": 5.0,
157+
"globalSizeMultiplier": 2.0,
158+
"celestialBodies": {
159+
"[0]": { "orbitRadius": 42.0 }
160+
}
161+
}
162+
""", logs: logs);
163+
```
164+
165+
**Patch document rules:**
166+
167+
* A JSON **object** key navigates into that field (`"fieldName"`) or element (`"[i]"` / `"[key]"`)
168+
* A JSON **non-object** value sets the field directly
169+
* `null` sets the field to `null`
170+
* `"$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
171+
172+
```csharp
173+
// Replace a base-type field with a derived type and set its fields
174+
reflector.TryPatch(ref system, """
175+
{
176+
"star": {
177+
"$type": "MyNamespace.NeutronStar",
178+
"mass": 2.5
179+
}
180+
}
181+
""");
182+
```
183+
184+
A `JsonElement` overload is also available when you already have a parsed document:
185+
186+
```csharp
187+
using var doc = JsonDocument.Parse(@"{ ""globalOrbitSpeedMultiplier"": 9.0 }");
188+
reflector.TryPatch(ref system, doc.RootElement, logs: logs);
189+
```
190+
191+
### 7. View & Grep — Read-Side Navigation
192+
193+
Read exactly the data you need from a live object — navigate to a specific subtree, filter by name or type, or grep the entire graph for every field matching a pattern.
194+
This is the read-side counterpart to [Atomic Path-Based Modification](#5-atomic-path-based-modification).
195+
196+
---
197+
198+
#### `reflector.View` — filtered serialization
199+
200+
Returns a `SerializedMember` tree with optional path navigation and post-filters applied.
201+
202+
```csharp
203+
var reflector = new Reflector();
204+
object? system = new SolarSystem
205+
{
206+
globalOrbitSpeedMultiplier = 1f,
207+
globalSizeMultiplier = 2f,
208+
celestialBodies = new[]
209+
{
210+
new CelestialBody { orbitRadius = 10f, orbitSpeed = 1f },
211+
new CelestialBody { orbitRadius = 20f, orbitSpeed = 2f },
212+
}
213+
};
214+
215+
// Full view — equivalent to Serialize()
216+
SerializedMember? full = reflector.View(system);
217+
218+
// Navigate to a subtree (same path format as TryModifyAt)
219+
SerializedMember? firstBody = reflector.View(system,
220+
new ViewQuery { Path = "celestialBodies/[0]" });
221+
// firstBody.typeName contains "CelestialBody"
222+
223+
// Depth-limited — MaxDepth=0 returns root node only (no children)
224+
SerializedMember? shallow = reflector.View(system,
225+
new ViewQuery { MaxDepth = 1 });
226+
227+
// Pattern filter — keep only branches containing a matching field name
228+
// Accepts any .NET regex; matching is case-insensitive
229+
SerializedMember? orbitFields = reflector.View(system,
230+
new ViewQuery { NamePattern = "^orbit" });
231+
232+
// Type filter — keep only branches whose resolved type is assignable to float
233+
SerializedMember? floatFields = reflector.View(system,
234+
new ViewQuery { TypeFilter = typeof(float) });
235+
236+
// Combined — navigate first, then filter
237+
SerializedMember? result = reflector.View(system, new ViewQuery
238+
{
239+
Path = "celestialBodies/[0]",
240+
NamePattern = "^orbit",
241+
MaxDepth = 2,
242+
});
243+
```
244+
245+
When a filter produces no matches the **root envelope is still returned** with an empty fields collection so that `result.typeName` always identifies the navigated node's type.
246+
247+
**`ViewQuery` options:**
248+
249+
| Option | Type | Default | Description |
250+
| --- | --- | --- | --- |
251+
| `Path` | `string?` | `null` | Navigate to this path before serializing (same format as `TryModifyAt`) |
252+
| `MaxDepth` | `int?` | `null` | Maximum depth of returned tree (`0` = root node only, no children) |
253+
| `NamePattern` | `string?` | `null` | .NET regex matched against field / property names (case-insensitive) |
254+
| `TypeFilter` | `Type?` | `null` | Keep only branches whose resolved runtime type is assignable to this type |
255+
256+
---
257+
258+
#### `reflector.TryReadAt` — single-value path read
259+
260+
Navigate to exactly one value by path and serialize it. Mirrors `TryModifyAt` but reads instead of writes.
261+
262+
```csharp
263+
// Scalar leaf
264+
bool ok = reflector.TryReadAt(system, "celestialBodies/[0]/orbitRadius", out SerializedMember? r);
265+
if (ok)
266+
Console.WriteLine(r!.GetValue<float>(reflector)); // 10f
267+
268+
// Composite node — result has full fields tree for the navigated object
269+
reflector.TryReadAt(system, "celestialBodies/[0]", out var body);
270+
float radius = body!.fields!.First(f => f.name == "orbitRadius").GetValue<float>(reflector);
271+
272+
// Dictionary access — string or any key type
273+
reflector.TryReadAt(container, "config/[timeout]", out var timeout);
274+
int ms = timeout!.GetValue<int>(reflector); // 30
275+
276+
// Invalid paths return false; errors are collected in Logs — nothing is thrown
277+
var logs = new Logs();
278+
bool ok2 = reflector.TryReadAt(system, "doesNotExist", out _, logs: logs);
279+
// ok2 == false
280+
// logs: "Segment 'doesNotExist' not found on type 'SolarSystem'.
281+
// Available fields: globalOrbitSpeedMultiplier, celestialBodies, ..."
282+
```
283+
284+
---
285+
286+
#### `reflector.Grep` — grep the live object graph
287+
288+
Walks the entire live object graph and returns a flat list of every field / property whose name matches the given regex pattern — like the `grep` command, but for in-RAM objects.
289+
290+
```csharp
291+
// Find every field whose name starts with "orbit"
292+
IReadOnlyList<ViewMatch> hits = reflector.Grep(system, "^orbit");
293+
294+
foreach (var hit in hits)
295+
Console.WriteLine($"{hit.Path} = {hit.Value.GetValue<float>(reflector)}");
296+
// celestialBodies/[0]/orbitRadius = 10
297+
// celestialBodies/[0]/orbitSpeed = 1
298+
// celestialBodies/[1]/orbitRadius = 20
299+
// celestialBodies/[1]/orbitSpeed = 2
300+
301+
// Limit search depth (0 = top-level fields only, no recursion)
302+
IReadOnlyList<ViewMatch> topLevel = reflector.Grep(system, ".*", maxDepth: 0);
303+
304+
// Exact name — anchored regex
305+
IReadOnlyList<ViewMatch> exact = reflector.Grep(system, "^globalOrbitSpeedMultiplier$");
306+
Console.WriteLine(exact[0].Path); // "globalOrbitSpeedMultiplier"
307+
```
308+
309+
Each `ViewMatch` exposes:
310+
311+
* `Path` — full slash-delimited path, e.g. `"celestialBodies/[0]/orbitRadius"`
312+
* `Value``SerializedMember` of the matched field, ready for `GetValue<T>(reflector)`
313+
314+
> **Grep vs. View + NamePattern**: `Grep` walks the **live object graph** and can find fields inside array elements. `View` + `NamePattern` filters the `SerializedMember` tree after serialization; array element contents are stored as JSON and are not individually filterable by name. Use `Grep` when you need to search inside arrays.
315+
316+
---
317+
318+
### 8. Dynamic Method Invocation
83319

84320
Allow AI to find and call methods without knowing the exact signature.
85321

@@ -110,7 +346,7 @@ string result = reflector.MethodCall(
110346
Console.WriteLine(result); // Output: [Success] 30
111347
```
112348

113-
### 6. Method Inspection & Schema Generation
349+
### 9. Method Inspection & Schema Generation
114350

115351
Generate JSON schemas for types and methods to help LLMs understand your code structure.
116352

0 commit comments

Comments
 (0)