feat: add string segment api to PropertyPath
This resolves the api constraints defined in #5
This commit is contained in:
parent
a7ecf08efb
commit
b1d2b749a4
4 changed files with 308 additions and 25 deletions
|
|
@ -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<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]
|
||||
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]");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue