From b1d2b749a4693e10d9723189e55ec78bdbff78cf Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Fri, 22 May 2026 23:22:43 +0200 Subject: [PATCH] feat: add string segment api to PropertyPath This resolves the api constraints defined in #5 --- .../PropertyPathTests.cs | 142 ++++++++++++++- .../RuleBuilderExtensionsTests.cs | 2 +- .../ValidatorTests.cs | 24 +-- src/request.validation/PropertyPath.cs | 165 +++++++++++++++++- 4 files changed, 308 insertions(+), 25 deletions(-) diff --git a/src/request.validation.tests/PropertyPathTests.cs b/src/request.validation.tests/PropertyPathTests.cs index b74450c..1d495ae 100644 --- a/src/request.validation.tests/PropertyPathTests.cs +++ b/src/request.validation.tests/PropertyPathTests.cs @@ -40,12 +40,144 @@ internal sealed class PropertyPathTests await Assert.That(json).IsEqualTo(/*lang=json,strict*/ """{"propertyPath":"members[1].name","severity":0,"message":"Member name is required.","code":null,"attemptedValue":null}"""); } + [Test] + public async Task I_serialize_paths_with_multiple_indexers_using_the_json_naming_policy() + { + var problem = new Problem + { + PropertyPath = "Matrix[1][2].Value", + Message = "Value is required.", + }; + + var json = JsonSerializer.Serialize(problem, CamelCaseJsonOptions); + + await Assert.That(json).IsEqualTo(/*lang=json,strict*/ """{"propertyPath":"matrix[1][2].value","severity":0,"message":"Value is required.","code":null,"attemptedValue":null}"""); + } + + [Test] + public async Task I_can_iterate_and_index_segments() + { + var propertyPath = (PropertyPath)"Members[1].Name"; + var segments = (IReadOnlyList)propertyPath; + + await Assert.That(propertyPath.Count).IsEqualTo(2); + await Assert.That(propertyPath.IsEmpty).IsFalse(); + await Assert.That(segments.Count).IsEqualTo(2); + await Assert.That(segments[0]).IsEqualTo("Members[1]"); + await Assert.That(segments[1]).IsEqualTo("Name"); + await Assert.That(propertyPath[0].ToString()).IsEqualTo("Members[1]"); + await Assert.That(propertyPath[1].ToString()).IsEqualTo("Name"); + } + + [Test] + public async Task I_treat_multiple_indexers_as_a_single_segment() + { + var propertyPath = (PropertyPath)"Matrix[1][2].Value"; + var segments = (IReadOnlyList)propertyPath; + + await Assert.That(propertyPath.Count).IsEqualTo(2); + await Assert.That(segments.Count).IsEqualTo(2); + await Assert.That(segments[0]).IsEqualTo("Matrix[1][2]"); + await Assert.That(segments[1]).IsEqualTo("Value"); + } + + [Test] + public async Task I_do_not_split_dots_inside_brackets() + { + var propertyPath = (PropertyPath)"Items[foo.bar].Name"; + var segments = (IReadOnlyList)propertyPath; + + await Assert.That(propertyPath.Count).IsEqualTo(2); + await Assert.That(segments.Count).IsEqualTo(2); + await Assert.That(segments[0]).IsEqualTo("Items[foo.bar]"); + await Assert.That(segments[1]).IsEqualTo("Name"); + await Assert.That(propertyPath[0].ToString()).IsEqualTo("Items[foo.bar]"); + await Assert.That(propertyPath[1].ToString()).IsEqualTo("Name"); + } + + [Test] + public async Task I_expose_empty_segments_for_malformed_paths() + { + var propertyPath = (PropertyPath)"Address..Street"; + var segments = (IReadOnlyList)propertyPath; + + await Assert.That(propertyPath.Count).IsEqualTo(3); + await Assert.That(propertyPath.IsEmpty).IsFalse(); + await Assert.That(segments.Count).IsEqualTo(3); + await Assert.That(segments[0]).IsEqualTo("Address"); + await Assert.That(segments[1]).IsEqualTo(string.Empty); + await Assert.That(segments[2]).IsEqualTo("Street"); + await Assert.That(propertyPath[1].ToString()).IsEqualTo(string.Empty); + } + + [Test] + public async Task I_treat_an_empty_path_as_having_no_segments() + { + var propertyPath = (PropertyPath)""; + + await Assert.That(propertyPath.Count).IsEqualTo(0); + await Assert.That(propertyPath.IsEmpty).IsTrue(); + // ReSharper disable once RedundantCast + await Assert.That(((IReadOnlyList)propertyPath).Count).IsEqualTo(0); + } + + [Test] + public async Task I_can_access_segments_through_the_explicit_read_only_list_interface() + { + var propertyPath = (PropertyPath)"Members[1].Name"; + var segments = (IReadOnlyList)propertyPath; + + await Assert.That(segments.Count).IsEqualTo(2); + await Assert.That(segments[0]).IsEqualTo("Members[1]"); + await Assert.That(segments[1]).IsEqualTo("Name"); + await Assert.That(string.Join("|", segments)).IsEqualTo("Members[1]|Name"); + } + + [Test] + public async Task I_see_it_throw_for_out_of_range_segment_access() + { + var propertyPath = (PropertyPath)"Name"; + + await Assert.That(() => propertyPath[1].ToString()) + .Throws(); + } + + [Test] + public async Task I_can_compare_a_property_path_to_a_string() + { + var propertyPath = (PropertyPath)"Address.Street"; + + using (Assert.Multiple()) + { + await Assert.That(propertyPath.Equals("Address.Street")).IsTrue(); + await Assert.That(propertyPath.Equals("Address.Street")).IsTrue(); + await Assert.That(propertyPath.Equals("address.street")).IsFalse(); + await Assert.That(propertyPath.Equals((string?)null)).IsFalse(); + } + } + + [Test] + public async Task I_can_compare_a_property_path_to_a_string_through_object_equality() + { + var propertyPath = (PropertyPath)"Address.Street"; + + using (Assert.Multiple()) + { + // ReSharper disable once SuspiciousTypeConversion.Global + await Assert.That(propertyPath.Equals((object?)"Address.Street")).IsTrue(); + // ReSharper disable once SuspiciousTypeConversion.Global + await Assert.That(propertyPath.Equals((object?)"address.street")).IsFalse(); + // ReSharper disable once SuspiciousTypeConversion.Global + await Assert.That(propertyPath.Equals((object?)null)).IsFalse(); + } + } + [Test] public async Task I_can_combine_property_paths_with_the_plus_operator() { var propertyPath = "Address" + (PropertyPath)"Street"; - await Assert.That(propertyPath).IsEqualTo("Address.Street"); + await Assert.That(propertyPath.ToString()).IsEqualTo("Address.Street"); } [Test] @@ -54,8 +186,8 @@ internal sealed class PropertyPathTests var leftEmpty = "" + (PropertyPath)"Street"; var rightEmpty = (PropertyPath)"Address" + ""; - await Assert.That(leftEmpty).IsEqualTo("Street"); - await Assert.That(rightEmpty).IsEqualTo("Address"); + await Assert.That(leftEmpty.ToString()).IsEqualTo("Street"); + await Assert.That(rightEmpty.ToString()).IsEqualTo("Address"); } [Test] @@ -63,7 +195,7 @@ internal sealed class PropertyPathTests { var propertyPath = (PropertyPath)"Members" + 1; - await Assert.That(propertyPath).IsEqualTo("Members[1]"); + await Assert.That(propertyPath.ToString()).IsEqualTo("Members[1]"); } [Test] @@ -71,6 +203,6 @@ internal sealed class PropertyPathTests { var propertyPath = (PropertyPath)"" + 1; - await Assert.That(propertyPath).IsEqualTo("[1]"); + await Assert.That(propertyPath.ToString()).IsEqualTo("[1]"); } } diff --git a/src/request.validation.tests/RuleBuilderExtensionsTests.cs b/src/request.validation.tests/RuleBuilderExtensionsTests.cs index 23395bb..00da15e 100644 --- a/src/request.validation.tests/RuleBuilderExtensionsTests.cs +++ b/src/request.validation.tests/RuleBuilderExtensionsTests.cs @@ -330,7 +330,7 @@ internal sealed class RuleBuilderExtensionsTests using (Assert.Multiple()) { - await Assert.That(problem.PropertyPath).IsEqualTo(propertyPath); + await Assert.That(problem.PropertyPath.ToString()).IsEqualTo(propertyPath); await Assert.That(problem.Message).IsEqualTo(message); } } diff --git a/src/request.validation.tests/ValidatorTests.cs b/src/request.validation.tests/ValidatorTests.cs index a40151d..5358367 100644 --- a/src/request.validation.tests/ValidatorTests.cs +++ b/src/request.validation.tests/ValidatorTests.cs @@ -21,7 +21,7 @@ internal sealed class ValidatorTests using (Assert.Multiple()) { - await Assert.That(problem.PropertyPath).IsEqualTo(nameof(Person.Name)); + await Assert.That(problem.PropertyPath.ToString()).IsEqualTo(nameof(Person.Name)); await Assert.That(problem.Message).IsEqualTo("Name is required."); await Assert.That(problem.Code).IsEqualTo("NAME_REQUIRED"); await Assert.That(problem.Severity).IsEqualTo(Severity.Warning); @@ -58,7 +58,7 @@ internal sealed class ValidatorTests }); await Assert.That(result.Problems).Count().IsEqualTo(1); - await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Address.Street"); + await Assert.That(result.Problems.Single().PropertyPath.ToString()).IsEqualTo("Address.Street"); } [Test] @@ -74,7 +74,7 @@ internal sealed class ValidatorTests }); await Assert.That(result.Problems).Count().IsEqualTo(1); - await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Street"); + await Assert.That(result.Problems.Single().PropertyPath.ToString()).IsEqualTo("Street"); } [Test] @@ -103,7 +103,7 @@ internal sealed class ValidatorTests }); await Assert.That(result.Problems).Count().IsEqualTo(1); - await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Members[1].Name"); + await Assert.That(result.Problems.Single().PropertyPath.ToString()).IsEqualTo("Members[1].Name"); } [Test] @@ -119,7 +119,7 @@ internal sealed class ValidatorTests }); await Assert.That(result.Problems).Count().IsEqualTo(1); - await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("People[1].Name"); + await Assert.That(result.Problems.Single().PropertyPath.ToString()).IsEqualTo("People[1].Name"); } [Test] @@ -137,7 +137,7 @@ internal sealed class ValidatorTests using (Assert.Multiple()) { - await Assert.That(problem.PropertyPath).IsEqualTo("Address.Street"); + await Assert.That(problem.PropertyPath.ToString()).IsEqualTo("Address.Street"); await Assert.That(problem.Message).IsEqualTo("Value is required."); } } @@ -156,7 +156,7 @@ internal sealed class ValidatorTests using (Assert.Multiple()) { - await Assert.That(problem.PropertyPath).IsEqualTo(nameof(Person.Name)); + await Assert.That(problem.PropertyPath.ToString()).IsEqualTo(nameof(Person.Name)); await Assert.That(problem.Message).IsEqualTo("Value is required."); } } @@ -171,7 +171,7 @@ internal sealed class ValidatorTests var result = validator.Validate(new Person { Name = "" }); await Assert.That(result.Problems).Count().IsEqualTo(1); - await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Username"); + await Assert.That(result.Problems.Single().PropertyPath.ToString()).IsEqualTo("Username"); } [Test] @@ -188,7 +188,7 @@ internal sealed class ValidatorTests }); await Assert.That(result.Problems).Count().IsEqualTo(1); - await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Location.Road"); + await Assert.That(result.Problems.Single().PropertyPath.ToString()).IsEqualTo("Location.Road"); } [Test] @@ -270,8 +270,8 @@ internal sealed class ValidatorTests }); await Assert.That(result.Problems).Count().IsEqualTo(2); - await Assert.That(result.Problems[0].PropertyPath).IsEqualTo("Members[0].Name"); - await Assert.That(result.Problems[1].PropertyPath).IsEqualTo("Members[2].Name"); + await Assert.That(result.Problems[0].PropertyPath.ToString()).IsEqualTo("Members[0].Name"); + await Assert.That(result.Problems[1].PropertyPath.ToString()).IsEqualTo("Members[2].Name"); } [Test] @@ -292,7 +292,7 @@ internal sealed class ValidatorTests var result = validator.Validate(context); await Assert.That(result.Problems).Count().IsEqualTo(1); - await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Address.Street"); + await Assert.That(result.Problems.Single().PropertyPath.ToString()).IsEqualTo("Address.Street"); } [Test] diff --git a/src/request.validation/PropertyPath.cs b/src/request.validation/PropertyPath.cs index 35782b1..32f5dc4 100644 --- a/src/request.validation/PropertyPath.cs +++ b/src/request.validation/PropertyPath.cs @@ -1,6 +1,7 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 +using System.Collections; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -11,7 +12,7 @@ namespace Geekeey.Request.Validation; /// Represents a property path used by validation problems. /// [JsonConverter(typeof(PropertyPathJsonConverter))] -public readonly struct PropertyPath : IEquatable +public readonly struct PropertyPath : IEquatable, IEquatable, IReadOnlyList { /// /// Creates a new property path. @@ -27,6 +28,54 @@ public readonly struct PropertyPath : IEquatable /// public string Value { get; } = string.Empty; + /// + /// Gets the number of path segments. + /// + public int Count + { + get + { + var count = 0; + var enumerator = new Enumerator(Value); + while (enumerator.MoveNext()) + { + count++; + } + + return count; + } + } + + /// + /// Gets whether the property path contains no segments. + /// + public bool IsEmpty => Value.Length is 0; + + /// + /// Gets the segment at the specified index. + /// + public ReadOnlySpan this[int index] + { + get + { + ArgumentOutOfRangeException.ThrowIfNegative(index); + + var currentIndex = 0; + var enumerator = new Enumerator(Value); + while (enumerator.MoveNext()) + { + if (currentIndex == index) + { + return enumerator.Current; + } + + currentIndex++; + } + + throw new ArgumentOutOfRangeException(nameof(index), index, "The segment index must refer to an existing property path segment."); + } + } + /// public override string ToString() { @@ -55,10 +104,16 @@ public readonly struct PropertyPath : IEquatable return string.Equals(Value, other.Value, StringComparison.Ordinal); } + /// + public bool Equals(string? other) + { + return string.Equals(Value, other, StringComparison.Ordinal); + } + /// public override bool Equals(object? obj) { - return obj is PropertyPath other && Equals(other); + return (obj is PropertyPath other && Equals(other)) || (obj is string s && Equals(s)); } /// @@ -109,9 +164,48 @@ public readonly struct PropertyPath : IEquatable return propertyPath.Value is { Length: > 0 } ? $"{propertyPath}[{index}]" : $"[{index}]"; } + /// + /// Returns an allocation-free enumerator over the path segments. + /// + public Enumerator GetEnumerator() + { + return new Enumerator(Value); + } + + /// + string IReadOnlyList.this[int index] => this[index].ToString(); + + /// + IEnumerator IEnumerable.GetEnumerator() + { + var enumerator = new Enumerator(Value); + + List segments = []; + while (enumerator.MoveNext()) + { + segments.Add(enumerator.Current.ToString()); + } + + return segments.GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + var enumerator = new Enumerator(Value); + + List segments = []; + while (enumerator.MoveNext()) + { + segments.Add(enumerator.Current.ToString()); + } + + return segments.GetEnumerator(); + } + internal string ToJsonName(JsonNamingPolicy? namingPolicy) { - var value = Value ?? string.Empty; + var value = Value; if (namingPolicy is null || value.Length is 0) { return value; @@ -119,16 +213,15 @@ public readonly struct PropertyPath : IEquatable var builder = new StringBuilder(value.Length); var first = true; - - foreach (var range in value.AsSpan().Split('.')) + var enumerator = new Enumerator(value); + while (enumerator.MoveNext()) { if (!first) { builder.Append('.'); } - var segment = value.AsSpan()[range]; - if (segment.Length is not 0) + if (enumerator.Current is { IsEmpty: false } segment) { if (segment.IndexOf('[') is >= 0 and var x) { @@ -146,6 +239,64 @@ public readonly struct PropertyPath : IEquatable return builder.ToString(); } + + /// + /// Enumerates path segments without allocations. + /// + public ref struct Enumerator + { + private readonly ReadOnlySpan _value; + private int _nextStart; + + internal Enumerator(ReadOnlySpan value) + { + _value = value; + _nextStart = value.Length is 0 ? -1 : 0; + Current = default; + } + + /// + /// Gets the current path segment. + /// + public ReadOnlySpan Current { get; private set; } + + /// + /// Advances to the next path segment. + /// + public bool MoveNext() + { + if (_nextStart < 0) + { + return false; + } + + var start = _nextStart; + var bracketDepth = 0; + + for (var i = start; i < _value.Length; i++) + { + switch (_value[i]) + { + case '[': + bracketDepth++; + break; + case ']': + bracketDepth = int.Max(bracketDepth - 1, 0); + break; + case '.' when bracketDepth is 0: + Current = _value[start..i]; + _nextStart = i + 1; + return true; + default: + break; + } + } + + Current = _value[start..]; + _nextStart = -1; + return true; + } + } } internal sealed class PropertyPathJsonConverter : JsonConverter