Skip to content

Commit 63c1ea0

Browse files
committed
feat: add bracket string literal syntax support and enhance path parsing
1 parent ba72b84 commit 63c1ea0

4 files changed

Lines changed: 300 additions & 5 deletions

File tree

docs/CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Bracket string literal syntax**: Access keys containing special characters using `["key.name"]` or `['key.name']`
13+
- Supports keys with dots: `["my.config"]`
14+
- Supports keys with brackets: `["items[0]"]`
15+
- Supports escaped quotes: `["key\"quote"]`
16+
- Works with JSON, dictionaries, and mixed paths
1217
- **Enhanced type conversion**: Support for `Enum`, `Guid`, and `Nullable<T>` types in `GetValue<T>()`
1318
- **Smarter JSON number handling**: JSON integers return `int`/`long`, floats return `double` (previously all returned `decimal`)
1419
- **XML documentation**: Added comprehensive XML docs to `ToExpando()` method
15-
- **New tests**: 13 additional test cases for bug fixes and improvements (108 total)
20+
- **New tests**: 24 additional test cases for new features (119 total)
1621
- **Cache management**: `ClearCaches()` public method for testing and memory management
1722
- **Cache size limits**: Automatic cache trimming when exceeding 1000 entries to prevent memory leaks
1823
- **Dictionary reflection caching**: Cached `IDictionary<string, T>` interface info for improved performance
1924

25+
### Changed
26+
27+
- **Path parser**: Replaced simple `Split()` with stateful tokenizer for bracket literal support
28+
2029
### Fixed
2130

2231
- **TryGetValue return logic**: Now correctly returns `true` for valid paths even when the value is `null`

src/Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
88

99
<!-- Package Metadata -->
10-
<Version>1.3.0</Version>
10+
<Version>1.4.0</Version>
1111
<Authors>iyulab</Authors>
1212
<Company>Iyulab Corporation</Company>
1313
<Owners>iyulab</Owners>

src/ObjectPath.Tests/ObjectPathTests.cs

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1931,4 +1931,189 @@ public void InvalidArrayIndex_ExceptionIncludesFullPath_ForJsonElement()
19311931
}
19321932

19331933
#endregion
1934+
1935+
#region Path Escape Syntax Tests
1936+
1937+
public class BracketStringLiteralTests
1938+
{
1939+
[Fact]
1940+
public void GetValue_DoubleQuoteBracket_AccessesKeyWithDot()
1941+
{
1942+
// Arrange
1943+
var dict = new Dictionary<string, object>
1944+
{
1945+
["my.key"] = "value with dot"
1946+
};
1947+
1948+
// Act
1949+
var result = ObjectPath.GetValue(dict, "[\"my.key\"]");
1950+
1951+
// Assert
1952+
Assert.Equal("value with dot", result);
1953+
}
1954+
1955+
[Fact]
1956+
public void GetValue_SingleQuoteBracket_AccessesKeyWithDot()
1957+
{
1958+
// Arrange
1959+
var dict = new Dictionary<string, object>
1960+
{
1961+
["my.key"] = "value with dot"
1962+
};
1963+
1964+
// Act
1965+
var result = ObjectPath.GetValue(dict, "['my.key']");
1966+
1967+
// Assert
1968+
Assert.Equal("value with dot", result);
1969+
}
1970+
1971+
[Fact]
1972+
public void GetValue_BracketKey_AccessesKeyWithBrackets()
1973+
{
1974+
// Arrange
1975+
var dict = new Dictionary<string, object>
1976+
{
1977+
["key[0]"] = "value with brackets"
1978+
};
1979+
1980+
// Act
1981+
var result = ObjectPath.GetValue(dict, "[\"key[0]\"]");
1982+
1983+
// Assert
1984+
Assert.Equal("value with brackets", result);
1985+
}
1986+
1987+
[Fact]
1988+
public void GetValue_MixedSyntax_WorksCorrectly()
1989+
{
1990+
// Arrange
1991+
var data = new Dictionary<string, object>
1992+
{
1993+
["config.settings"] = new Dictionary<string, object>
1994+
{
1995+
["value"] = 42
1996+
}
1997+
};
1998+
1999+
// Act - Access "config.settings" key, then "value" property
2000+
var result = ObjectPath.GetValue(data, "[\"config.settings\"].value");
2001+
2002+
// Assert
2003+
Assert.Equal(42, result);
2004+
}
2005+
2006+
[Fact]
2007+
public void GetValue_NestedBracketKeys_WorksCorrectly()
2008+
{
2009+
// Arrange
2010+
var data = new Dictionary<string, object>
2011+
{
2012+
["level.one"] = new Dictionary<string, object>
2013+
{
2014+
["level.two"] = "nested value"
2015+
}
2016+
};
2017+
2018+
// Act
2019+
var result = ObjectPath.GetValue(data, "[\"level.one\"][\"level.two\"]");
2020+
2021+
// Assert
2022+
Assert.Equal("nested value", result);
2023+
}
2024+
2025+
[Fact]
2026+
public void GetValue_EscapedQuotes_WorksCorrectly()
2027+
{
2028+
// Arrange
2029+
var dict = new Dictionary<string, object>
2030+
{
2031+
["key\"quote"] = "escaped quote value"
2032+
};
2033+
2034+
// Act - Use backslash to escape the quote
2035+
var result = ObjectPath.GetValue(dict, "[\"key\\\"quote\"]");
2036+
2037+
// Assert
2038+
Assert.Equal("escaped quote value", result);
2039+
}
2040+
2041+
[Fact]
2042+
public void GetValue_CombinedWithArrayIndex_WorksCorrectly()
2043+
{
2044+
// Arrange
2045+
var data = new Dictionary<string, object>
2046+
{
2047+
["items.list"] = new List<object> { "first", "second", "third" }
2048+
};
2049+
2050+
// Act
2051+
var result = ObjectPath.GetValue(data, "[\"items.list\"][1]");
2052+
2053+
// Assert
2054+
Assert.Equal("second", result);
2055+
}
2056+
2057+
[Fact]
2058+
public void GetValue_JsonElement_WithBracketSyntax()
2059+
{
2060+
// Arrange
2061+
var json = """{"data.key": {"nested.prop": "json value"}}""";
2062+
var doc = JsonDocument.Parse(json);
2063+
2064+
// Act
2065+
var result = ObjectPath.GetValue(doc.RootElement, "[\"data.key\"][\"nested.prop\"]");
2066+
2067+
// Assert
2068+
Assert.Equal("json value", result);
2069+
}
2070+
2071+
[Fact]
2072+
public void GetValue_RegularObjectAfterBracketKey_WorksCorrectly()
2073+
{
2074+
// Arrange
2075+
var dict = new Dictionary<string, object>
2076+
{
2077+
["my.config"] = new { Name = "Test", Value = 123 }
2078+
};
2079+
2080+
// Act
2081+
var name = ObjectPath.GetValue(dict, "[\"my.config\"].Name");
2082+
var value = ObjectPath.GetValue(dict, "[\"my.config\"].Value");
2083+
2084+
// Assert
2085+
Assert.Equal("Test", name);
2086+
Assert.Equal(123, value);
2087+
}
2088+
2089+
[Fact]
2090+
public void TryGetValue_WithBracketSyntax_ReturnsTrue()
2091+
{
2092+
// Arrange
2093+
var dict = new Dictionary<string, object> { ["a.b"] = "value" };
2094+
2095+
// Act
2096+
var result = ObjectPath.TryGetValue(dict, "[\"a.b\"]", out var value);
2097+
2098+
// Assert
2099+
Assert.True(result);
2100+
Assert.Equal("value", value);
2101+
}
2102+
2103+
[Fact]
2104+
public void TryGetValue_WithInvalidBracketKey_ReturnsFalse()
2105+
{
2106+
// Arrange
2107+
var dict = new Dictionary<string, object> { ["key"] = "value" };
2108+
2109+
// Act
2110+
var result = ObjectPath.TryGetValue(dict, "[\"nonexistent.key\"]", out var value);
2111+
2112+
// Assert
2113+
Assert.False(result);
2114+
Assert.Null(value);
2115+
}
2116+
}
2117+
2118+
#endregion
19342119
}

src/ObjectPath/ObjectPath.cs

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ namespace ObjectPathLibrary
88
{
99
public static class ObjectPath
1010
{
11-
private static readonly char[] Separator = new[] { '.', '[', ']' };
12-
1311
/// <summary>
1412
/// Maximum number of entries in each cache. When exceeded, oldest entries are removed.
1513
/// </summary>
@@ -24,7 +22,7 @@ public static class ObjectPath
2422
if (obj == null) return null;
2523
if (string.IsNullOrEmpty(path)) return obj;
2624

27-
var segments = path.Split(Separator, StringSplitOptions.RemoveEmptyEntries);
25+
var segments = ParsePath(path);
2826
int index = 0;
2927

3028
while (obj != null && index < segments.Length)
@@ -343,6 +341,109 @@ public static bool TryGetValue<T>(object? obj, string path, out T? value, bool i
343341
return fieldInfo;
344342
}
345343

344+
/// <summary>
345+
/// Parses a path expression into segments, supporting:
346+
/// - Dot notation: "User.Name" → ["User", "Name"]
347+
/// - Bracket index: "Items[0]" → ["Items", "0"]
348+
/// - Bracket string literals: "Data[\"my.key\"]" or "Data['my.key']" → ["Data", "my.key"]
349+
/// </summary>
350+
private static string[] ParsePath(string path)
351+
{
352+
var segments = new List<string>();
353+
var current = new System.Text.StringBuilder();
354+
int i = 0;
355+
356+
while (i < path.Length)
357+
{
358+
char c = path[i];
359+
360+
if (c == '.')
361+
{
362+
// Dot separator - finish current segment
363+
if (current.Length > 0)
364+
{
365+
segments.Add(current.ToString());
366+
current.Clear();
367+
}
368+
i++;
369+
}
370+
else if (c == '[')
371+
{
372+
// Start of bracket expression
373+
if (current.Length > 0)
374+
{
375+
segments.Add(current.ToString());
376+
current.Clear();
377+
}
378+
i++;
379+
380+
if (i < path.Length && (path[i] == '"' || path[i] == '\''))
381+
{
382+
// String literal: ["key"] or ['key']
383+
char quote = path[i];
384+
i++;
385+
while (i < path.Length && path[i] != quote)
386+
{
387+
if (path[i] == '\\' && i + 1 < path.Length)
388+
{
389+
// Handle escape sequences
390+
i++;
391+
current.Append(path[i]);
392+
}
393+
else
394+
{
395+
current.Append(path[i]);
396+
}
397+
i++;
398+
}
399+
if (i < path.Length) i++; // Skip closing quote
400+
if (i < path.Length && path[i] == ']') i++; // Skip closing bracket
401+
402+
if (current.Length > 0)
403+
{
404+
segments.Add(current.ToString());
405+
current.Clear();
406+
}
407+
}
408+
else
409+
{
410+
// Numeric index: [0]
411+
while (i < path.Length && path[i] != ']')
412+
{
413+
current.Append(path[i]);
414+
i++;
415+
}
416+
if (i < path.Length) i++; // Skip closing bracket
417+
418+
if (current.Length > 0)
419+
{
420+
segments.Add(current.ToString());
421+
current.Clear();
422+
}
423+
}
424+
}
425+
else if (c == ']')
426+
{
427+
// Unexpected closing bracket - skip
428+
i++;
429+
}
430+
else
431+
{
432+
// Regular character
433+
current.Append(c);
434+
i++;
435+
}
436+
}
437+
438+
// Add final segment
439+
if (current.Length > 0)
440+
{
441+
segments.Add(current.ToString());
442+
}
443+
444+
return segments.ToArray();
445+
}
446+
346447
private static DictionaryTypeInfo? GetCachedDictionaryTypeInfo(Type type)
347448
{
348449
if (!DictionaryTypeCache.TryGetValue(type, out var typeInfo))

0 commit comments

Comments
 (0)