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

@ -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.
/// </summary>
[JsonConverter(typeof(PropertyPathJsonConverter))]
public readonly struct PropertyPath : IEquatable<PropertyPath>
public readonly struct PropertyPath : IEquatable<PropertyPath>, IEquatable<string>, IReadOnlyList<string>
{
/// <summary>
/// Creates a new property path.
@ -27,6 +28,54 @@ public readonly struct PropertyPath : IEquatable<PropertyPath>
/// </summary>
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 />
public override string ToString()
{
@ -55,10 +104,16 @@ public readonly struct PropertyPath : IEquatable<PropertyPath>
return string.Equals(Value, other.Value, StringComparison.Ordinal);
}
/// <inheritdoc />
public bool Equals(string? other)
{
return string.Equals(Value, other, StringComparison.Ordinal);
}
/// <inheritdoc />
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 />
@ -109,9 +164,48 @@ public readonly struct PropertyPath : IEquatable<PropertyPath>
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)
{
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<PropertyPath>
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<PropertyPath>
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>