From 994a161c172bdfb27c03ba17c3a8c3c4837f3313 Mon Sep 17 00:00:00 2001 From: Jeremy Skinner <90130+JeremySkinner@users.noreply.github.com> Date: Tue, 8 Aug 2023 11:37:02 +0100 Subject: [PATCH] Add PropertyPath placeholder (#2134) --- Changelog.txt | 4 ++++ docs/built-in-validators.md | 23 ++++++++++++++++++- .../CustomMessageFormatTester.cs | 14 +++++++++++ .../ExactLengthValidatorTester.cs | 4 +++- .../PredicateValidatorTester.cs | 4 +++- src/FluentValidation/IValidationContext.cs | 15 +++++++----- .../Internal/CollectionPropertyRule.cs | 6 ++--- .../Internal/MessageBuilderContext.cs | 2 +- .../Internal/PropertyChain.cs | 6 ++++- src/FluentValidation/Internal/PropertyRule.cs | 6 ++--- src/FluentValidation/Internal/RuleBase.cs | 3 ++- 11 files changed, 69 insertions(+), 18 deletions(-) diff --git a/Changelog.txt b/Changelog.txt index d67683ab4..ba4cb1176 100644 --- a/Changelog.txt +++ b/Changelog.txt @@ -1,3 +1,7 @@ +11.7.0 - +Add additional constructor for combining multiple ValidationResult instances (#2125) +Add PropertyPath placeholder (#2134) + 11.6.0 - 4 Jul 2023 Add OnFailurecCreated callback in ValidatorOptions.Global (#2120) Fix typo in Russian localization (#2102) diff --git a/docs/built-in-validators.md b/docs/built-in-validators.md index ee5aaa99e..bb33d88db 100644 --- a/docs/built-in-validators.md +++ b/docs/built-in-validators.md @@ -14,6 +14,7 @@ Example error: *'Surname' must not be empty.* String format args: * `{PropertyName}` – Name of the property being validated * `{PropertyValue}` – Current value of the property +* `{PropertyPath}` - The full path of the property ## NotEmpty Validator Ensures that the specified property is not null, an empty string or whitespace (or the default value for value types, e.g., 0 for `int`). @@ -27,6 +28,7 @@ Example error: *'Surname' should not be empty.* String format args: * `{PropertyName}` – Name of the property being validated * `{PropertyValue}` – Current value of the property +* `{PropertyPath}` - The full path of the property ## NotEqual Validator @@ -47,6 +49,7 @@ String format args: * `{ComparisonValue}` – Value that the property should not equal * `{ComparisonProperty}` – Name of the property being compared against (if any) * `{PropertyValue}` – Current value of the property +* `{PropertyPath}` - The full path of the property Optionally, a comparer can be provided to ensure a specific type of comparison is performed: @@ -83,6 +86,7 @@ String format args: * `{ComparisonValue}` – Value that the property should equal * `{ComparisonProperty}` – Name of the property being compared against (if any) * `{PropertyValue}` – Current value of the property +* `{PropertyPath}` - The full path of the property ```csharp RuleFor(customer => customer.Surname).Equal("Foo", StringComparer.OrdinalIgnoreCase); @@ -118,6 +122,7 @@ String format args: * `{MaxLength}` – Maximum length * `{TotalLength}` – Number of characters entered * `{PropertyValue}` – Current value of the property +* `{PropertyPath}` - The full path of the property ## MaxLength Validator Ensures that the length of a particular string property is no longer than the specified value. @@ -135,6 +140,7 @@ String format args: * `{MaxLength}` – Maximum length * `{TotalLength}` – Number of characters entered * `{PropertyValue}` – Current value of the property +* `{PropertyPath}` - The full path of the property ## MinLength Validator Ensures that the length of a particular string property is longer than the specified value. @@ -152,7 +158,7 @@ String format args: * `{MinLength}` – Minimum length * `{TotalLength}` – Number of characters entered * `{PropertyValue}` – Current value of the property - +* `{PropertyPath}` - The full path of the property ## Less Than Validator Ensures that the value of the specified property is less than a particular value (or less than the value of another property). @@ -174,6 +180,7 @@ String format args: * `{ComparisonValue}` – Value to which the property was compared * `{ComparisonProperty}` – Name of the property being compared against (if any) * `{PropertyValue}` – Current value of the property +* `{PropertyPath}` - The full path of the property ## Less Than Or Equal Validator Ensures that the value of the specified property is less than or equal to a particular value (or less than or equal to the value of another property). @@ -192,6 +199,7 @@ Notes: Only valid on types that implement `IComparable` * `{ComparisonValue}` – Value to which the property was compared * `{ComparisonProperty}` – Name of the property being compared against (if any) * `{PropertyValue}` – Current value of the property +* `{PropertyPath}` - The full path of the property ## Greater Than Validator Ensures that the value of the specified property is greater than a particular value (or greater than the value of another property). @@ -210,6 +218,7 @@ Notes: Only valid on types that implement `IComparable` * `{ComparisonValue}` – Value to which the property was compared * `{ComparisonProperty}` – Name of the property being compared against (if any) * `{PropertyValue}` – Current value of the property +* `{PropertyPath}` - The full path of the property ## Greater Than Or Equal Validator Ensures that the value of the specified property is greater than or equal to a particular value (or greater than or equal to the value of another property). @@ -228,6 +237,7 @@ Notes: Only valid on types that implement `IComparable` * `{ComparisonValue}` – Value to which the property was compared * `{ComparisonProperty}` – Name of the property being compared against (if any) * `{PropertyValue}` – Current value of the property +* `{PropertyPath}` - The full path of the property ## Predicate Validator (Also known as `Must`) @@ -244,6 +254,7 @@ Example error: *The specified condition was not met for 'Surname'* String format args: * `{PropertyName}` – Name of the property being validated * `{PropertyValue}` – Current value of the property +* `{PropertyPath}` - The full path of the property Note that there is an additional overload for `Must` that also accepts an instance of the parent object being validated. This can be useful if you want to compare the current property with another property from inside the predicate: @@ -265,6 +276,7 @@ String format args: * `{PropertyName}` – Name of the property being validated * `{PropertyValue}` – Current value of the property * `{RegularExpression}` – Regular expression that was not matched +* `{PropertyPath}` - The full path of the property ## Email Validator Ensures that the value of the specified property is a valid email address format. @@ -278,6 +290,7 @@ Example error: *'Email' is not a valid email address.* String format args: * `{PropertyName}` – Name of the property being validated * `{PropertyValue}` – Current value of the property +* `{PropertyPath}` - The full path of the property The email address validator can work in 2 modes. The default mode just performs a simple check that the string contains an "@" sign which is not at the beginning or the end of the string. This is an intentionally naive check to match the behaviour of ASP.NET Core's `EmailAddressAttribute`, which performs the same check. For the reasoning behind this, see [this post](https://github.com/dotnet/corefx/issues/32740): @@ -304,6 +317,7 @@ Example error: *'Credit Card' is not a valid credit card number.* String format args: * `{PropertyName}` – Name of the property being validated * `{PropertyValue}` – Current value of the property +* `{PropertyPath}` - The full path of the property ## Enum Validator Checks whether a numeric value is valid to be in that enum. This is used to prevent numeric values from being cast to an enum type when the resulting value would be invalid. For example, the following is possible: @@ -335,6 +349,7 @@ Example error: *'Error Level' has a range of values which does not include '4'.* String format args: * `{PropertyName}` – Name of the property being validated * `{PropertyValue}` – Current value of the property +* `{PropertyPath}` - The full path of the property ## Enum Name Validator Checks whether a string is a valid enum name. @@ -352,6 +367,7 @@ Example error: *'Error Level' has a range of values which does not include 'Foo' String format args: * `{PropertyName}` – Name of the property being validated * `{PropertyValue}` – Current value of the property +* `{PropertyPath}` - The full path of the property ## Empty Validator Opposite of the `NotEmpty` validator. Checks if a property value is null, or is the default value for the type. @@ -366,6 +382,7 @@ Example error: *'Surname' must be empty.* String format args: * `{PropertyName}` – Name of the property being validated * `{PropertyValue}` – Current value of the property +* `{PropertyPath}` - The full path of the property ## Null Validator Opposite of the `NotNull` validator. Checks if a property value is null. @@ -379,6 +396,7 @@ Example error: *'Surname' must be empty.* String format args: * `{PropertyName}` – Name of the property being validated * `{PropertyValue}` – Current value of the property +* `{PropertyPath}` - The full path of the property ## ExclusiveBetween Validator Checks whether the property value is in a range between the two specified numbers (exclusive). @@ -394,6 +412,7 @@ String format args: * `{PropertyValue}` – Current value of the property * `{From}` – Lower bound of the range * `{To}` – Upper bound of the range +* `{PropertyPath}` - The full path of the property ## InclusiveBetween Validator Checks whether the property value is in a range between the two specified numbers (inclusive). @@ -409,6 +428,7 @@ String format args: * `{PropertyValue}` – Current value of the property * `{From}` – Lower bound of the range * `{To}` – Upper bound of the range +* `{PropertyPath}` - The full path of the property ## PrecisionScale Validator Checks whether a decimal value has the specified precision and scale. @@ -426,6 +446,7 @@ String format args: * `{ExpectedScale}` – Expected scale * `{Digits}` – Total number of digits in the property value * `{ActualScale}` – Actual scale of the property value +* `{PropertyPath}` - The full path of the property Note that the 3rd parameter of this method is `ignoreTrailingZeros`. When set to `true`, trailing zeros after the decimal point will not count towards the expected number of decimal places. diff --git a/src/FluentValidation.Tests/CustomMessageFormatTester.cs b/src/FluentValidation.Tests/CustomMessageFormatTester.cs index 2a4ab3ef3..55b7d4096 100644 --- a/src/FluentValidation.Tests/CustomMessageFormatTester.cs +++ b/src/FluentValidation.Tests/CustomMessageFormatTester.cs @@ -18,6 +18,7 @@ namespace FluentValidation.Tests; +using System.Collections.Generic; using System.Linq; using Validators; using Xunit; @@ -85,4 +86,17 @@ public class CustomMessageFormatTester { result.Errors.Single().ErrorMessage.ShouldEqual("Was ''"); } + [Fact] + public void Includes_property_path() { + validator.RuleFor(x => x.Surname).NotNull().WithMessage("{PropertyPath}"); + validator.RuleForEach(x => x.Orders).NotNull().WithMessage("{PropertyPath}"); + + var result = validator.Validate(new Person { + Orders = new List {null} + }); + + result.Errors[0].ErrorMessage.ShouldEqual("Surname"); + result.Errors[1].ErrorMessage.ShouldEqual("Orders[0]"); + } + } diff --git a/src/FluentValidation.Tests/ExactLengthValidatorTester.cs b/src/FluentValidation.Tests/ExactLengthValidatorTester.cs index 0098dfc9d..fee6b0a84 100644 --- a/src/FluentValidation.Tests/ExactLengthValidatorTester.cs +++ b/src/FluentValidation.Tests/ExactLengthValidatorTester.cs @@ -74,17 +74,19 @@ public class ExactLengthValidatorTester { error.PropertyName.ShouldEqual("Surname"); error.AttemptedValue.ShouldEqual("test"); - error.FormattedMessagePlaceholderValues.Count.ShouldEqual(5); + error.FormattedMessagePlaceholderValues.Count.ShouldEqual(6); error.FormattedMessagePlaceholderValues.ContainsKey("PropertyName").ShouldBeTrue(); error.FormattedMessagePlaceholderValues.ContainsKey("PropertyValue").ShouldBeTrue(); error.FormattedMessagePlaceholderValues.ContainsKey("MinLength").ShouldBeTrue(); error.FormattedMessagePlaceholderValues.ContainsKey("MaxLength").ShouldBeTrue(); error.FormattedMessagePlaceholderValues.ContainsKey("TotalLength").ShouldBeTrue(); + error.FormattedMessagePlaceholderValues.ContainsKey("PropertyPath").ShouldBeTrue(); error.FormattedMessagePlaceholderValues["PropertyName"].ShouldEqual("Surname"); error.FormattedMessagePlaceholderValues["PropertyValue"].ShouldEqual("test"); error.FormattedMessagePlaceholderValues["MinLength"].ShouldEqual(2); error.FormattedMessagePlaceholderValues["MaxLength"].ShouldEqual(2); error.FormattedMessagePlaceholderValues["TotalLength"].ShouldEqual(4); + error.FormattedMessagePlaceholderValues["PropertyPath"].ShouldEqual("Surname"); } } diff --git a/src/FluentValidation.Tests/PredicateValidatorTester.cs b/src/FluentValidation.Tests/PredicateValidatorTester.cs index 9b7197309..1e250755c 100644 --- a/src/FluentValidation.Tests/PredicateValidatorTester.cs +++ b/src/FluentValidation.Tests/PredicateValidatorTester.cs @@ -73,11 +73,13 @@ public class PredicateValidatorTester { error.AttemptedValue.ShouldEqual("test"); error.ErrorCode.ShouldEqual("PredicateValidator"); - error.FormattedMessagePlaceholderValues.Count.ShouldEqual(2); + error.FormattedMessagePlaceholderValues.Count.ShouldEqual(3); error.FormattedMessagePlaceholderValues.ContainsKey("PropertyName").ShouldBeTrue(); error.FormattedMessagePlaceholderValues.ContainsKey("PropertyValue").ShouldBeTrue(); + error.FormattedMessagePlaceholderValues.ContainsKey("PropertyPath").ShouldBeTrue(); error.FormattedMessagePlaceholderValues["PropertyName"].ShouldEqual("Forename"); error.FormattedMessagePlaceholderValues["PropertyValue"].ShouldEqual("test"); + error.FormattedMessagePlaceholderValues["PropertyPath"].ShouldEqual("Forename"); } } diff --git a/src/FluentValidation/IValidationContext.cs b/src/FluentValidation/IValidationContext.cs index 42410d6b9..82539e2c9 100644 --- a/src/FluentValidation/IValidationContext.cs +++ b/src/FluentValidation/IValidationContext.cs @@ -291,7 +291,7 @@ public ValidationContext(T instanceToValidate, PropertyChain propertyChain, IVal public void AddFailure(string propertyName, string errorMessage) { errorMessage.Guard("An error message must be specified when calling AddFailure.", nameof(errorMessage)); errorMessage = MessageFormatter.BuildMessage(errorMessage); - AddFailure(new ValidationFailure(PropertyChain.BuildPropertyName(propertyName ?? string.Empty), errorMessage)); + AddFailure(new ValidationFailure(PropertyChain.BuildPropertyPath(propertyName ?? string.Empty), errorMessage)); } /// @@ -302,7 +302,7 @@ public ValidationContext(T instanceToValidate, PropertyChain propertyChain, IVal public void AddFailure(string errorMessage) { errorMessage.Guard("An error message must be specified when calling AddFailure.", nameof(errorMessage)); errorMessage = MessageFormatter.BuildMessage(errorMessage); - AddFailure(new ValidationFailure(PropertyName, errorMessage)); + AddFailure(new ValidationFailure(PropertyPath, errorMessage)); } private Func, string> _displayNameFunc; @@ -313,15 +313,18 @@ public ValidationContext(T instanceToValidate, PropertyChain propertyChain, IVal public string DisplayName => _displayNameFunc(this); /// - /// The full name of the current property being validated. + /// The full path of the current property being validated. /// If accessed inside a child validator, this will include the parent's path too. /// - public string PropertyName { get; private set; } + public string PropertyPath { get; private set; } + + [Obsolete("This property has been deprecated due to its misleading name. Use the PropertyPath property instead, which returns the same value.")] + public string PropertyName => PropertyPath; internal string RawPropertyName { get; private set; } - internal void InitializeForPropertyValidator(string propertyName, Func, string> displayNameFunc, string rawPropertyName) { - PropertyName = propertyName; + internal void InitializeForPropertyValidator(string propertyPath, Func, string> displayNameFunc, string rawPropertyName) { + PropertyPath = propertyPath; _displayNameFunc = displayNameFunc; RawPropertyName = rawPropertyName; } diff --git a/src/FluentValidation/Internal/CollectionPropertyRule.cs b/src/FluentValidation/Internal/CollectionPropertyRule.cs index aec89be5b..4ac3184a3 100644 --- a/src/FluentValidation/Internal/CollectionPropertyRule.cs +++ b/src/FluentValidation/Internal/CollectionPropertyRule.cs @@ -97,7 +97,7 @@ public CollectionPropertyRule(MemberInfo member, Func> } // Construct the full name of the property, taking into account overriden property names and the chain (if we're in a nested validator) - string propertyName = context.PropertyChain.BuildPropertyName(PropertyName ?? displayName); + string propertyName = context.PropertyChain.BuildPropertyPath(PropertyName ?? displayName); if (string.IsNullOrEmpty(propertyName)) { propertyName = InferPropertyName(Expression); @@ -164,9 +164,9 @@ public CollectionPropertyRule(MemberInfo member, Func> context.PropertyChain.AddIndexer(indexer, useDefaultIndexFormat); var valueToValidate = element; - var propertyNameToValidate = context.PropertyChain.ToString(); + var propertyPath = context.PropertyChain.ToString(); var totalFailuresInner = context.Failures.Count; - context.InitializeForPropertyValidator(propertyNameToValidate, GetDisplayName, PropertyName); + context.InitializeForPropertyValidator(propertyPath, GetDisplayName, PropertyName); foreach (var component in filteredValidators) { context.MessageFormatter.Reset(); diff --git a/src/FluentValidation/Internal/MessageBuilderContext.cs b/src/FluentValidation/Internal/MessageBuilderContext.cs index 9d6498c6a..5c99b60c2 100644 --- a/src/FluentValidation/Internal/MessageBuilderContext.cs +++ b/src/FluentValidation/Internal/MessageBuilderContext.cs @@ -37,7 +37,7 @@ public IPropertyValidator PropertyValidator // public IValidationRule Rule => _innerContext.Rule; - public string PropertyName => _innerContext.PropertyName; + public string PropertyName => _innerContext.PropertyPath; public string DisplayName => _innerContext.DisplayName; diff --git a/src/FluentValidation/Internal/PropertyChain.cs b/src/FluentValidation/Internal/PropertyChain.cs index 6b63993ec..c8cf4e634 100644 --- a/src/FluentValidation/Internal/PropertyChain.cs +++ b/src/FluentValidation/Internal/PropertyChain.cs @@ -142,10 +142,14 @@ public class PropertyChain { return ToString().StartsWith(parentChain.ToString()); } + [Obsolete("BuildPropertyName is deprecated due to its misleading name. Use BuildPropertyPath instead which does the same thing.")] + public string BuildPropertyName(string propertyName) + => BuildPropertyPath(propertyName); + /// /// Builds a property path. /// - public string BuildPropertyName(string propertyName) { + public string BuildPropertyPath(string propertyName) { if (_memberNames.Count == 0) { return propertyName; } diff --git a/src/FluentValidation/Internal/PropertyRule.cs b/src/FluentValidation/Internal/PropertyRule.cs index 609e3815a..f4853ef17 100644 --- a/src/FluentValidation/Internal/PropertyRule.cs +++ b/src/FluentValidation/Internal/PropertyRule.cs @@ -90,11 +90,11 @@ TProperty PropertyFunc(T instance) } // Construct the full name of the property, taking into account overriden property names and the chain (if we're in a nested validator) - string propertyName = context.PropertyChain.BuildPropertyName(PropertyName ?? displayName); + string propertyPath = context.PropertyChain.BuildPropertyPath(PropertyName ?? displayName); // Ensure that this rule is allowed to run. // The validatselector has the opportunity to veto this before any of the validators execute. - if (!context.Selector.CanExecute(this, propertyName, context)) { + if (!context.Selector.CanExecute(this, propertyPath, context)) { return; } @@ -118,7 +118,7 @@ TProperty PropertyFunc(T instance) var cascade = CascadeMode; var accessor = new Lazy(() => PropertyFunc(context.InstanceToValidate), LazyThreadSafetyMode.None); var totalFailures = context.Failures.Count; - context.InitializeForPropertyValidator(propertyName, GetDisplayName, PropertyName); + context.InitializeForPropertyValidator(propertyPath, GetDisplayName, PropertyName); // Invoke each validator and collect its results. foreach (var component in Components) { diff --git a/src/FluentValidation/Internal/RuleBase.cs b/src/FluentValidation/Internal/RuleBase.cs index a56401e28..28e9b6da7 100644 --- a/src/FluentValidation/Internal/RuleBase.cs +++ b/src/FluentValidation/Internal/RuleBase.cs @@ -286,6 +286,7 @@ public string GetDisplayName(ValidationContext context) protected void PrepareMessageFormatterForValidationError(ValidationContext context, TValue value) { context.MessageFormatter.AppendPropertyName(context.DisplayName); context.MessageFormatter.AppendPropertyValue(value); + context.MessageFormatter.AppendArgument("PropertyPath", context.PropertyPath); // If there's a collection index cached in the root context data then add it // to the message formatter. This happens when a child validator is executed @@ -312,7 +313,7 @@ public string GetDisplayName(ValidationContext context) ? MessageBuilder(new MessageBuilderContext(context, value, component)) : component.GetErrorMessage(context, value); - var failure = new ValidationFailure(context.PropertyName, error, value); + var failure = new ValidationFailure(context.PropertyPath, error, value); failure.FormattedMessagePlaceholderValues = new Dictionary(context.MessageFormatter.PlaceholderValues); failure.ErrorCode = component.ErrorCode ?? ValidatorOptions.Global.ErrorCodeResolver(component.Validator);