Skip to content

Commit

Permalink
MemberNameValidator to work with indexes in path (#2056)
Browse files Browse the repository at this point in the history
Co-authored-by: Jeremy Skinner <90130+JeremySkinner@users.noreply.github.com>
  • Loading branch information
abramchev and JeremySkinner committed Jan 30, 2023
1 parent b3eb55e commit 2fed490
Show file tree
Hide file tree
Showing 3 changed files with 167 additions and 0 deletions.
6 changes: 6 additions & 0 deletions src/FluentValidation.Tests/Person.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ public interface IOrder {
public class Order : IOrder {
public string ProductName { get; set; }
public decimal Amount { get; set; }

public List<Payment> Payments { get; set; }
}

public class Payment {
public decimal Amount { get; set; }
}

public enum EnumGender
Expand Down
120 changes: 120 additions & 0 deletions src/FluentValidation.Tests/ValidatorSelectorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@

namespace FluentValidation.Tests;

using System.Collections.Generic;
using System.Text.RegularExpressions;
using Xunit;
using System.Threading.Tasks;
using Internal;

public class ValidatorSelectorTests {

Expand Down Expand Up @@ -163,6 +166,123 @@ public class ValidatorSelectorTests {
result.Errors.Count.ShouldEqual(0);
}

[Fact]
public void Only_validates_doubly_nested_property() {
var person = new Person {
Address = new Address {
Country = new Country()
},
Orders = new List<Order> {
new() {Amount = 5},
new() {ProductName = "Foo"}
}
};

var validator = new InlineValidator<Person>();
validator.RuleFor(x => x.Address.Country.Name).NotEmpty();

// ChildRules should not be included. Bug prior to 11.1.1 meant that ChildRules were
// incorrectly included for execution.
validator.RuleForEach(x => x.Orders).ChildRules(x => {
x.RuleFor(y => y.Amount).GreaterThan(6);
x.RuleFor(y => y.ProductName).MinimumLength(5);
});

var result = validator.Validate(person, options => options.IncludeProperties("Address.Country.Name"));
result.Errors.Count.ShouldEqual(1);
result.Errors[0].PropertyName.ShouldEqual("Address.Country.Name");
result.Errors[0].ErrorMessage.ShouldEqual("'Address Country Name' must not be empty.");
}

[Fact]
public void Only_validates_child_property_for_single_item_in_collection() {
var person = new Person {
Address = new Address {
Country = new Country()
},
Orders = new List<Order> {
new() {Amount = 5},
new() {ProductName = "Foo"}
}
};

var validator = new InlineValidator<Person>();
validator.RuleFor(x => x.Address.Country.Name).NotEmpty();
validator.RuleForEach(x => x.Orders).ChildRules(x => {
x.RuleFor(y => y.Amount).GreaterThan(6);
x.RuleFor(y => y.ProductName).MinimumLength(5);
});

var result = validator.Validate(person, opt => opt.IncludeProperties("Orders[1].Amount"));
result.Errors.Count.ShouldEqual(1);
result.Errors[0].PropertyName.ShouldEqual("Orders[1].Amount");
result.Errors[0].ErrorMessage.ShouldEqual("'Amount' must be greater than '6'.");
}

[Fact]
public void Only_validates_single_child_property_of_all_elements_in_collection() {
var person = new Person {
Address = new Address {
Country = new Country()
},
Orders = new List<Order> {
new() {Amount = 5},
new() {ProductName = "Foo"},
new() {Amount = 10}
}
};

var validator = new InlineValidator<Person>();
validator.RuleFor(x => x.Address.Country.Name).NotEmpty();
validator.RuleForEach(x => x.Orders).ChildRules(x => {
x.RuleFor(y => y.Amount).GreaterThan(6);
x.RuleFor(y => y.ProductName).MinimumLength(5);
});

var result = validator.Validate(person, opt => opt.IncludeProperties("Orders[].Amount"));
result.Errors.Count.ShouldEqual(2);
result.Errors[0].PropertyName.ShouldEqual("Orders[0].Amount");
result.Errors[0].ErrorMessage.ShouldEqual("'Amount' must be greater than '6'.");
result.Errors[1].PropertyName.ShouldEqual("Orders[1].Amount");
result.Errors[1].ErrorMessage.ShouldEqual("'Amount' must be greater than '6'.");
}

[Fact]
public void Only_validates_single_child_property_of_all_elements_in_nested_collection() {
var person = new Person {
Orders = new List<Order> {
new() {
Amount = 5,
Payments = new List<Payment> {
new Payment() { Amount = 0 },
}
},
new() {
ProductName = "Foo",
Payments = new List<Payment> {
new Payment() { Amount = 1 },
new Payment() { Amount = 0 }
}
},
}
};

var validator = new InlineValidator<Person>();
validator.RuleForEach(x => x.Orders).ChildRules(x => {
x.RuleFor(y => y.Amount).GreaterThan(6);
x.RuleForEach(y => y.Payments).ChildRules(a => {
a.RuleFor(b => b.Amount).GreaterThan(0);
});
});

var result = validator.Validate(person, opt => opt.IncludeProperties("Orders[].Payments[].Amount"));
result.Errors.Count.ShouldEqual(2);
result.Errors[0].PropertyName.ShouldEqual("Orders[0].Payments[0].Amount");
result.Errors[0].ErrorMessage.ShouldEqual("'Amount' must be greater than '0'.");
result.Errors[1].PropertyName.ShouldEqual("Orders[1].Payments[1].Amount");
result.Errors[1].ErrorMessage.ShouldEqual("'Amount' must be greater than '0'.");
}

private class TestObject {
public object SomeProperty { get; set; }
public object SomeOtherProperty { get; set; }
Expand Down
41 changes: 41 additions & 0 deletions src/FluentValidation/Internal/MemberNameValidatorSelector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ namespace FluentValidation.Internal;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Text.RegularExpressions;

/// <summary>
/// Selects validators that are associated with a particular property.
Expand All @@ -30,6 +31,9 @@ public class MemberNameValidatorSelector : IValidatorSelector {
internal const string DisableCascadeKey = "_FV_DisableSelectorCascadeForChildRules";
readonly IEnumerable<string> _memberNames;

// Regex for normalizing collection indicies from Orders[0].Name to Orders[].Name
private static Regex _collectionIndexNormalizer = new Regex(@"\[.*?\]", RegexOptions.Compiled);

/// <summary>
/// Creates a new instance of MemberNameValidatorSelector.
/// </summary>
Expand Down Expand Up @@ -69,6 +73,10 @@ public class MemberNameValidatorSelector : IValidatorSelector {
return true;
}

// Stores the normalized property name if we're working with collection properties
// eg Orders[0].Name -> Orders[].Name. This is only initialized if needed (see below).
string normalizedPropertyPath = null;

// If the current property path is equal to any of the member names for inclusion
// or it's a child property path (indicated by a period) where we have a partial match.
foreach (var memberName in _memberNames) {
Expand All @@ -90,6 +98,39 @@ public class MemberNameValidatorSelector : IValidatorSelector {
if (memberName.StartsWith(propertyPath + ".")) {
return true;
}

// If the property path is for a collection property
// and a child property for this collection has been passed in for inclusion.
// For example, memberName is "Orders[0].Amount"
// and propertyPath is "Orders" then it should be allowed to execute.
if (memberName.StartsWith(propertyPath + "[")) {
return true;
}

// If property path is for child property within collection,
// and member path contains wildcard [] then this means that we want to match
// with all items in the collection, but we need to normalize the property path
// in order to match. For example, if the propertyPath is "Orders[0].Name"
// and the memberName for inclusion is "Orders[].Name" then this should
// be allowed to match.
if (memberName.Contains("[]")) {
if (normalizedPropertyPath == null) {
// Normalize the property path using a regex. Orders[0].Name -> Orders[].Name.
normalizedPropertyPath = _collectionIndexNormalizer.Replace(propertyPath, "[]");
}

if (memberName == normalizedPropertyPath) {
return true;
}

if (memberName.StartsWith(normalizedPropertyPath + ".")) {
return true;
}

if (memberName.StartsWith(normalizedPropertyPath + "[")) {
return true;
}
}
}

return false;
Expand Down

0 comments on commit 2fed490

Please sign in to comment.