Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MemberNameValidator to work with indexes in path #2056

Merged
merged 4 commits into from
Jan 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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