Skip to content

Commit 0fdeaf3

Browse files
shimatclaude
andcommitted
Add FileNode/FileStorage.GetPath and a JsonNode write bridge
GetPath(params object[] path) navigates a chain of mapping keys (string) and/or sequence indices (int), disposing every intermediate FileNode along the way - the chained-indexer pattern (node["a"]["b"]) otherwise leaves each intermediate node referenced by nothing, relying on SafeHandle's finalizer for eventual (delayed) cleanup instead of prompt disposal. FileStorage.GetPath starts from the top-level mapping and delegates the rest to FileNode.GetPath. FileStorage.Write(string, JsonNode?) is the write-side counterpart to FileNode.ToJsonNode(): it recursively writes a JsonObject/JsonArray/ JsonValue tree via the existing Write/WriteStruct calls. Native FileStorage remains the actual XML/YAML/JSON engine (no format-parsing logic is reimplemented) - this only lets callers build the data with System.Text.Json types instead of one call per value/struct scope. A JSON null throws, since FileStorage has no native representation for an explicit null scalar. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent bb8d8fc commit 0fdeaf3

3 files changed

Lines changed: 251 additions & 1 deletion

File tree

src/OpenCvSharp/Modules/core/FileNode.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,50 @@ public FileNode? this[int i]
315315
}
316316
}
317317

318+
/// <summary>
319+
/// Navigates a chain of mapping keys (<see cref="string"/>) and/or sequence indices
320+
/// (<see cref="int"/>), disposing every intermediate <see cref="FileNode"/> along the way.
321+
/// Equivalent to repeated indexer chaining (e.g. <c>node["a"][2]["b"]</c>), except that the
322+
/// indexer chain leaves every intermediate node unreferenced - each one is still a real
323+
/// native allocation that would otherwise sit around until the GC finalizes it.
324+
/// </summary>
325+
/// <param name="path">One or more mapping keys / sequence indices to follow, in order.</param>
326+
/// <returns>The node at the end of the path, or null if any segment along the way is missing.</returns>
327+
public FileNode? GetPath(params object[] path)
328+
{
329+
ArgumentNullException.ThrowIfNull(path);
330+
if (path.Length == 0)
331+
throw new ArgumentException("Path must contain at least one key or index.", nameof(path));
332+
333+
ThrowIfDisposed();
334+
335+
var current = this;
336+
var ownsCurrent = false;
337+
338+
foreach (var segment in path)
339+
{
340+
var next = segment switch
341+
{
342+
string key => current[key],
343+
int index => current[index],
344+
_ => throw new ArgumentException(
345+
$"Path segments must be string (mapping key) or int (sequence index), got '{segment?.GetType()}'.",
346+
nameof(path)),
347+
};
348+
349+
if (ownsCurrent)
350+
current.Dispose();
351+
352+
if (next is null)
353+
return null;
354+
355+
current = next;
356+
ownsCurrent = true;
357+
}
358+
359+
return current;
360+
}
361+
318362
/// <summary>
319363
/// Returns true if the node is empty
320364
/// </summary>

src/OpenCvSharp/Modules/core/FileStorage.cs

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Diagnostics.CodeAnalysis;
2+
using System.Text.Json.Nodes;
23
using OpenCvSharp.Internal;
34
using OpenCvSharp.Internal.Vectors;
45

@@ -79,6 +80,41 @@ public FileNode? this[string nodeName]
7980
}
8081
}
8182

83+
/// <summary>
84+
/// Navigates a chain of mapping keys (<see cref="string"/>) and/or sequence indices
85+
/// (<see cref="int"/>) starting from the top-level mapping, disposing every intermediate
86+
/// <see cref="FileNode"/> along the way. Equivalent to repeated indexer chaining (e.g.
87+
/// <c>fs["a"][2]["b"]</c>), except that the indexer chain leaves every intermediate node
88+
/// unreferenced - each one is still a real native allocation that would otherwise sit
89+
/// around until the GC finalizes it.
90+
/// </summary>
91+
/// <param name="path">One or more mapping keys / sequence indices to follow, in order.
92+
/// The first segment must be a string (a key of the top-level mapping).</param>
93+
/// <returns>The node at the end of the path, or null if any segment along the way is missing.</returns>
94+
public FileNode? GetPath(params object[] path)
95+
{
96+
ArgumentNullException.ThrowIfNull(path);
97+
if (path.Length == 0)
98+
throw new ArgumentException("Path must contain at least one key or index.", nameof(path));
99+
if (path[0] is not string firstKey)
100+
throw new ArgumentException("The first path segment must be a string (top-level mapping key).", nameof(path));
101+
102+
ThrowIfDisposed();
103+
104+
var first = this[firstKey];
105+
if (first is null || path.Length == 1)
106+
return first;
107+
108+
try
109+
{
110+
return first.GetPath(path[1..]);
111+
}
112+
finally
113+
{
114+
first.Dispose();
115+
}
116+
}
117+
82118
/// <summary>
83119
/// the currently written element
84120
/// </summary>
@@ -521,7 +557,63 @@ public void Write(string name, IEnumerable<string> value)
521557
}
522558

523559
/// <summary>
524-
///
560+
/// Writes a <see cref="JsonNode"/> tree (scalars, arrays, objects) under the given key,
561+
/// recursively, via the ordinary <see cref="Write(string,int)"/>/<see cref="WriteStruct"/>
562+
/// calls - the counterpart to <see cref="FileNode.ToJsonNode"/>. Native FileStorage remains
563+
/// the actual XML/YAML/JSON engine; this only lets callers build the data to write using
564+
/// <see cref="System.Text.Json"/> types instead of one-call-per-value/struct-scope calls.
565+
/// </summary>
566+
/// <param name="name">Key to write the value under (top level or inside an open mapping).
567+
/// Pass an empty string for an anonymous element inside an open sequence.</param>
568+
/// <param name="value">The value to write. A JSON null throws, since FileStorage has no
569+
/// native representation for an explicit null scalar.</param>
570+
public void Write(string name, JsonNode? value)
571+
{
572+
ThrowIfDisposed();
573+
ArgumentNullException.ThrowIfNull(name);
574+
575+
switch (value)
576+
{
577+
case null:
578+
throw new NotSupportedException(
579+
$"Cannot write a JSON null for key '{name}': FileStorage has no representation for an explicit null value.");
580+
581+
case JsonObject obj:
582+
using (WriteStruct(name, FileNode.Types.Map))
583+
{
584+
foreach (var (key, child) in obj)
585+
Write(key, child);
586+
}
587+
break;
588+
589+
case JsonArray array:
590+
using (WriteStruct(name, FileNode.Types.Seq))
591+
{
592+
foreach (var item in array)
593+
Write(string.Empty, item);
594+
}
595+
break;
596+
597+
case JsonValue scalar:
598+
WriteJsonScalar(name, scalar);
599+
break;
600+
601+
default:
602+
throw new NotSupportedException($"Unsupported JsonNode type '{value.GetType()}' for key '{name}'.");
603+
}
604+
}
605+
606+
private void WriteJsonScalar(string name, JsonValue value)
607+
{
608+
if (value.TryGetValue(out bool b)) { Write(name, b); return; }
609+
if (value.TryGetValue(out long l)) { Write(name, l); return; }
610+
if (value.TryGetValue(out double d)) { Write(name, d); return; }
611+
if (value.TryGetValue(out string? s)) { Write(name, s!); return; }
612+
throw new NotSupportedException($"Unsupported JsonValue underlying type for key '{name}'.");
613+
}
614+
615+
/// <summary>
616+
///
525617
/// </summary>
526618
/// <param name="value"></param>
527619
public void WriteScalar(int value)

test/OpenCvSharp.Tests/core/FileStorageTest.cs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,4 +593,118 @@ public void ToJsonNodeConvertsScalarsSequencesAndMappings()
593593
Assert.Contains("widget", roundTripped, StringComparison.Ordinal);
594594
}
595595
}
596+
597+
[Fact]
598+
public void WriteJsonNodeRoundTripsThroughToJsonNode()
599+
{
600+
const string fileName = "fs_write_json_node.yml";
601+
602+
var source = JsonNode.Parse("""
603+
{
604+
"name": "widget",
605+
"count": 3,
606+
"ratio": 1.5,
607+
"enabled": true,
608+
"tags": ["red", "green", "blue"],
609+
"nested": { "x": 1, "y": 2 },
610+
"matrix": [[1, 2], [3, 4]]
611+
}
612+
""")!;
613+
614+
using (var fs = new FileStorage(fileName, FileStorage.Modes.Write))
615+
{
616+
foreach (var (key, value) in source.AsObject())
617+
fs.Write(key, value);
618+
}
619+
620+
using (var fs = new FileStorage(fileName, FileStorage.Modes.Read))
621+
{
622+
using var root = fs.Root();
623+
Assert.NotNull(root);
624+
var json = root.ToJsonNode();
625+
Assert.NotNull(json);
626+
627+
Assert.Equal("widget", json!["name"]!.GetValue<string>());
628+
Assert.Equal(3L, json["count"]!.GetValue<long>());
629+
Assert.Equal(1.5, json["ratio"]!.GetValue<double>());
630+
Assert.Equal(1L, json["enabled"]!.GetValue<long>()); // bool round-trips as int (0/1) in FileStorage
631+
632+
var tags = Assert.IsType<JsonArray>(json["tags"]);
633+
Assert.Equal(["red", "green", "blue"], tags.Select(n => n!.GetValue<string>()));
634+
635+
var nested = Assert.IsType<JsonObject>(json["nested"]);
636+
Assert.Equal(1L, nested["x"]!.GetValue<long>());
637+
Assert.Equal(2L, nested["y"]!.GetValue<long>());
638+
639+
var matrix = Assert.IsType<JsonArray>(json["matrix"]);
640+
Assert.Equal(2, matrix.Count);
641+
var row0 = Assert.IsType<JsonArray>(matrix[0]);
642+
Assert.Equal([1L, 2L], row0.Select(n => n!.GetValue<long>()));
643+
var row1 = Assert.IsType<JsonArray>(matrix[1]);
644+
Assert.Equal([3L, 4L], row1.Select(n => n!.GetValue<long>()));
645+
}
646+
}
647+
648+
[Fact]
649+
public void WriteJsonNodeNullThrows()
650+
{
651+
const string fileName = "fs_write_json_node_null.yml";
652+
using var fs = new FileStorage(fileName, FileStorage.Modes.Write);
653+
654+
Assert.Throws<NotSupportedException>(() => fs.Write("x", (JsonNode?)null));
655+
}
656+
657+
[Fact]
658+
public void GetPathNavigatesNestedStructure()
659+
{
660+
const string fileName = "fs_get_path.yml";
661+
662+
using (var fs = new FileStorage(fileName, FileStorage.Modes.Write))
663+
{
664+
using (fs.WriteStruct("a", FileNode.Types.Map))
665+
{
666+
using (fs.WriteStruct("b", FileNode.Types.Seq))
667+
{
668+
fs.Add(10).Add(20).Add(30);
669+
}
670+
}
671+
}
672+
673+
using (var fs = new FileStorage(fileName, FileStorage.Modes.Read))
674+
{
675+
using var value = fs.GetPath("a", "b", 1);
676+
Assert.NotNull(value);
677+
Assert.Equal(20, value.ReadInt());
678+
679+
Assert.Null(fs.GetPath("a", "missing"));
680+
Assert.Null(fs.GetPath("missing"));
681+
682+
using var root = fs.Root();
683+
Assert.NotNull(root);
684+
using var viaFileNode = root.GetPath("a", "b", 2);
685+
Assert.NotNull(viaFileNode);
686+
Assert.Equal(30, viaFileNode.ReadInt());
687+
}
688+
}
689+
690+
[Fact]
691+
public void GetPathRejectsEmptyOrInvalidSegments()
692+
{
693+
const string fileName = "fs_get_path_invalid.yml";
694+
using (var fs = new FileStorage(fileName, FileStorage.Modes.Write))
695+
{
696+
fs.Write("a", 1);
697+
}
698+
699+
using (var fs = new FileStorage(fileName, FileStorage.Modes.Read))
700+
{
701+
Assert.Throws<ArgumentException>(() => fs.GetPath());
702+
Assert.Throws<ArgumentException>(() => fs.GetPath(42)); // first segment must be a string
703+
704+
using var root = fs.Root();
705+
Assert.NotNull(root);
706+
Assert.Throws<ArgumentException>(() => root.GetPath());
707+
Assert.Throws<ArgumentException>(() => root.GetPath(3.14)); // unsupported segment type
708+
}
709+
}
596710
}

0 commit comments

Comments
 (0)