feat: add string segment api to PropertyPath

This resolves the api constraints defined in #5
This commit is contained in:
Louis Seubert 2026-05-22 23:22:43 +02:00
commit b1d2b749a4
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
4 changed files with 308 additions and 25 deletions

View file

@ -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}"""); 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<string>)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<string>)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<string>)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<string>)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<string>)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<string>)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<ArgumentOutOfRangeException>();
}
[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] [Test]
public async Task I_can_combine_property_paths_with_the_plus_operator() public async Task I_can_combine_property_paths_with_the_plus_operator()
{ {
var propertyPath = "Address" + (PropertyPath)"Street"; var propertyPath = "Address" + (PropertyPath)"Street";
await Assert.That(propertyPath).IsEqualTo("Address.Street"); await Assert.That(propertyPath.ToString()).IsEqualTo("Address.Street");
} }
[Test] [Test]
@ -54,8 +186,8 @@ internal sealed class PropertyPathTests
var leftEmpty = "" + (PropertyPath)"Street"; var leftEmpty = "" + (PropertyPath)"Street";
var rightEmpty = (PropertyPath)"Address" + ""; var rightEmpty = (PropertyPath)"Address" + "";
await Assert.That(leftEmpty).IsEqualTo("Street"); await Assert.That(leftEmpty.ToString()).IsEqualTo("Street");
await Assert.That(rightEmpty).IsEqualTo("Address"); await Assert.That(rightEmpty.ToString()).IsEqualTo("Address");
} }
[Test] [Test]
@ -63,7 +195,7 @@ internal sealed class PropertyPathTests
{ {
var propertyPath = (PropertyPath)"Members" + 1; var propertyPath = (PropertyPath)"Members" + 1;
await Assert.That(propertyPath).IsEqualTo("Members[1]"); await Assert.That(propertyPath.ToString()).IsEqualTo("Members[1]");
} }
[Test] [Test]
@ -71,6 +203,6 @@ internal sealed class PropertyPathTests
{ {
var propertyPath = (PropertyPath)"" + 1; var propertyPath = (PropertyPath)"" + 1;
await Assert.That(propertyPath).IsEqualTo("[1]"); await Assert.That(propertyPath.ToString()).IsEqualTo("[1]");
} }
} }

View file

@ -330,7 +330,7 @@ internal sealed class RuleBuilderExtensionsTests
using (Assert.Multiple()) 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); await Assert.That(problem.Message).IsEqualTo(message);
} }
} }

View file

@ -21,7 +21,7 @@ internal sealed class ValidatorTests
using (Assert.Multiple()) 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.Message).IsEqualTo("Name is required.");
await Assert.That(problem.Code).IsEqualTo("NAME_REQUIRED"); await Assert.That(problem.Code).IsEqualTo("NAME_REQUIRED");
await Assert.That(problem.Severity).IsEqualTo(Severity.Warning); 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).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] [Test]
@ -74,7 +74,7 @@ internal sealed class ValidatorTests
}); });
await Assert.That(result.Problems).Count().IsEqualTo(1); 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] [Test]
@ -103,7 +103,7 @@ internal sealed class ValidatorTests
}); });
await Assert.That(result.Problems).Count().IsEqualTo(1); 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] [Test]
@ -119,7 +119,7 @@ internal sealed class ValidatorTests
}); });
await Assert.That(result.Problems).Count().IsEqualTo(1); 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] [Test]
@ -137,7 +137,7 @@ internal sealed class ValidatorTests
using (Assert.Multiple()) 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."); await Assert.That(problem.Message).IsEqualTo("Value is required.");
} }
} }
@ -156,7 +156,7 @@ internal sealed class ValidatorTests
using (Assert.Multiple()) 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."); await Assert.That(problem.Message).IsEqualTo("Value is required.");
} }
} }
@ -171,7 +171,7 @@ internal sealed class ValidatorTests
var result = validator.Validate(new Person { Name = "" }); var result = validator.Validate(new Person { Name = "" });
await Assert.That(result.Problems).Count().IsEqualTo(1); 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] [Test]
@ -188,7 +188,7 @@ internal sealed class ValidatorTests
}); });
await Assert.That(result.Problems).Count().IsEqualTo(1); 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] [Test]
@ -270,8 +270,8 @@ internal sealed class ValidatorTests
}); });
await Assert.That(result.Problems).Count().IsEqualTo(2); await Assert.That(result.Problems).Count().IsEqualTo(2);
await Assert.That(result.Problems[0].PropertyPath).IsEqualTo("Members[0].Name"); await Assert.That(result.Problems[0].PropertyPath.ToString()).IsEqualTo("Members[0].Name");
await Assert.That(result.Problems[1].PropertyPath).IsEqualTo("Members[2].Name"); await Assert.That(result.Problems[1].PropertyPath.ToString()).IsEqualTo("Members[2].Name");
} }
[Test] [Test]
@ -292,7 +292,7 @@ internal sealed class ValidatorTests
var result = validator.Validate(context); var result = validator.Validate(context);
await Assert.That(result.Problems).Count().IsEqualTo(1); 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] [Test]

View file

@ -1,6 +1,7 @@
// Copyright (c) The Geekeey Authors // Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2 // SPDX-License-Identifier: EUPL-1.2
using System.Collections;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
@ -11,7 +12,7 @@ namespace Geekeey.Request.Validation;
/// Represents a property path used by validation problems. /// Represents a property path used by validation problems.
/// </summary> /// </summary>
[JsonConverter(typeof(PropertyPathJsonConverter))] [JsonConverter(typeof(PropertyPathJsonConverter))]
public readonly struct PropertyPath : IEquatable<PropertyPath> public readonly struct PropertyPath : IEquatable<PropertyPath>, IEquatable<string>, IReadOnlyList<string>
{ {
/// <summary> /// <summary>
/// Creates a new property path. /// Creates a new property path.
@ -27,6 +28,54 @@ public readonly struct PropertyPath : IEquatable<PropertyPath>
/// </summary> /// </summary>
public string Value { get; } = string.Empty; public string Value { get; } = string.Empty;
/// <summary>
/// Gets the number of path segments.
/// </summary>
public int Count
{
get
{
var count = 0;
var enumerator = new Enumerator(Value);
while (enumerator.MoveNext())
{
count++;
}
return count;
}
}
/// <summary>
/// Gets whether the property path contains no segments.
/// </summary>
public bool IsEmpty => Value.Length is 0;
/// <summary>
/// Gets the segment at the specified index.
/// </summary>
public ReadOnlySpan<char> 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.");
}
}
/// <inheritdoc /> /// <inheritdoc />
public override string ToString() public override string ToString()
{ {
@ -55,10 +104,16 @@ public readonly struct PropertyPath : IEquatable<PropertyPath>
return string.Equals(Value, other.Value, StringComparison.Ordinal); return string.Equals(Value, other.Value, StringComparison.Ordinal);
} }
/// <inheritdoc />
public bool Equals(string? other)
{
return string.Equals(Value, other, StringComparison.Ordinal);
}
/// <inheritdoc /> /// <inheritdoc />
public override bool Equals(object? obj) 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));
} }
/// <inheritdoc /> /// <inheritdoc />
@ -109,9 +164,48 @@ public readonly struct PropertyPath : IEquatable<PropertyPath>
return propertyPath.Value is { Length: > 0 } ? $"{propertyPath}[{index}]" : $"[{index}]"; return propertyPath.Value is { Length: > 0 } ? $"{propertyPath}[{index}]" : $"[{index}]";
} }
/// <summary>
/// Returns an allocation-free enumerator over the path segments.
/// </summary>
public Enumerator GetEnumerator()
{
return new Enumerator(Value);
}
/// <inheritdoc />
string IReadOnlyList<string>.this[int index] => this[index].ToString();
/// <inheritdoc />
IEnumerator<string> IEnumerable<string>.GetEnumerator()
{
var enumerator = new Enumerator(Value);
List<string> segments = [];
while (enumerator.MoveNext())
{
segments.Add(enumerator.Current.ToString());
}
return segments.GetEnumerator();
}
/// <inheritdoc />
IEnumerator IEnumerable.GetEnumerator()
{
var enumerator = new Enumerator(Value);
List<string> segments = [];
while (enumerator.MoveNext())
{
segments.Add(enumerator.Current.ToString());
}
return segments.GetEnumerator();
}
internal string ToJsonName(JsonNamingPolicy? namingPolicy) internal string ToJsonName(JsonNamingPolicy? namingPolicy)
{ {
var value = Value ?? string.Empty; var value = Value;
if (namingPolicy is null || value.Length is 0) if (namingPolicy is null || value.Length is 0)
{ {
return value; return value;
@ -119,16 +213,15 @@ public readonly struct PropertyPath : IEquatable<PropertyPath>
var builder = new StringBuilder(value.Length); var builder = new StringBuilder(value.Length);
var first = true; var first = true;
var enumerator = new Enumerator(value);
foreach (var range in value.AsSpan().Split('.')) while (enumerator.MoveNext())
{ {
if (!first) if (!first)
{ {
builder.Append('.'); builder.Append('.');
} }
var segment = value.AsSpan()[range]; if (enumerator.Current is { IsEmpty: false } segment)
if (segment.Length is not 0)
{ {
if (segment.IndexOf('[') is >= 0 and var x) if (segment.IndexOf('[') is >= 0 and var x)
{ {
@ -146,6 +239,64 @@ public readonly struct PropertyPath : IEquatable<PropertyPath>
return builder.ToString(); return builder.ToString();
} }
/// <summary>
/// Enumerates path segments without allocations.
/// </summary>
public ref struct Enumerator
{
private readonly ReadOnlySpan<char> _value;
private int _nextStart;
internal Enumerator(ReadOnlySpan<char> value)
{
_value = value;
_nextStart = value.Length is 0 ? -1 : 0;
Current = default;
}
/// <summary>
/// Gets the current path segment.
/// </summary>
public ReadOnlySpan<char> Current { get; private set; }
/// <summary>
/// Advances to the next path segment.
/// </summary>
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<PropertyPath> internal sealed class PropertyPathJsonConverter : JsonConverter<PropertyPath>