Add `[ValidateNever]` and `IPropertyValidationFilter`

- #5642
- lazy-load `ValidationEntry.Model`
  - avoids `Exception`s when moving to a property that will not be validated

nits:
- remove duplicate code in `ValidationVisitor`
- clarify "all properties of" doc comments
  - also add missing `<param>` doc in `ViewDataInfo`
This commit is contained in:
Doug Bunting 2017-01-03 15:22:09 -08:00
parent 07c22f2b29
commit ce53675b87
19 changed files with 793 additions and 89 deletions

View File

@ -8,6 +8,7 @@ using System.ComponentModel;
using System.Diagnostics;
using System.Reflection;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Microsoft.Extensions.Internal;
namespace Microsoft.AspNetCore.Mvc.ModelBinding
@ -295,6 +296,13 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
/// </summary>
public abstract string TemplateHint { get; }
/// <summary>
/// Gets an <see cref="IPropertyValidationFilter"/> implementation that indicates whether this model should be
/// validated. If <c>null</c>, properties with this <see cref="ModelMetadata"/> are validated.
/// </summary>
/// <value>Defaults to <c>null</c>.</value>
public virtual IPropertyValidationFilter PropertyValidationFilter => null;
/// <summary>
/// Gets a value that indicates whether properties or elements of the model should be validated.
/// </summary>

View File

@ -0,0 +1,22 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation
{
/// <summary>
/// Contract for attributes that determine whether associated properties should be validated. When the attribute is
/// applied to a property, the validation system calls <see cref="ShouldValidateEntry"/> to determine whether to
/// validate that property. When applied to a type, the validation system calls <see cref="ShouldValidateEntry"/>
/// for each property that type defines to determine whether to validate it.
/// </summary>
public interface IPropertyValidationFilter
{
/// <summary>
/// Gets an indication whether the <paramref name="entry"/> should be validated.
/// </summary>
/// <param name="entry"><see cref="ValidationEntry"/> to check.</param>
/// <param name="parentEntry"><see cref="ValidationEntry"/> containing <paramref name="entry"/>.</param>
/// <returns><c>true</c> if <paramref name="entry"/> should be validated; <c>false</c> otherwise.</returns>
bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEntry);
}
}

View File

@ -10,6 +10,9 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation
/// </summary>
public struct ValidationEntry
{
private object _model;
private Func<object> _modelAccessor;
/// <summary>
/// Creates a new <see cref="ValidationEntry"/>.
/// </summary>
@ -30,7 +33,37 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation
Metadata = metadata;
Key = key;
Model = model;
_model = model;
_modelAccessor = null;
}
/// <summary>
/// Creates a new <see cref="ValidationEntry"/>.
/// </summary>
/// <param name="metadata">The <see cref="ModelMetadata"/> associated with the <see cref="Model"/>.</param>
/// <param name="key">The model prefix associated with the <see cref="Model"/>.</param>
/// <param name="modelAccessor">A delegate that will return the <see cref="Model"/>.</param>
public ValidationEntry(ModelMetadata metadata, string key, Func<object> modelAccessor)
{
if (metadata == null)
{
throw new ArgumentNullException(nameof(metadata));
}
if (key == null)
{
throw new ArgumentNullException(nameof(key));
}
if (modelAccessor == null)
{
throw new ArgumentNullException(nameof(modelAccessor));
}
Metadata = metadata;
Key = key;
_model = null;
_modelAccessor = modelAccessor;
}
/// <summary>
@ -46,6 +79,18 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation
/// <summary>
/// The model object.
/// </summary>
public object Model { get; }
public object Model
{
get
{
if (_modelAccessor != null)
{
_model = _modelAccessor();
_modelAccessor = null;
}
return _model;
}
}
}
}

View File

@ -84,36 +84,20 @@ namespace Microsoft.AspNetCore.Mvc.Internal
var propertyName = property.BinderModelName ?? property.PropertyName;
var key = ModelNames.CreatePropertyModelName(_key, propertyName);
object model;
// Our property accessors don't work on Mono 4.0.4 - see https://github.com/aspnet/External/issues/44
// This is a workaround for what the PropertyGetter does in the background.
if (IsMono)
if (_model == null)
{
if (_model == null)
{
model = null;
}
else
{
var propertyInfo = _model.GetType().GetRuntimeProperty(property.PropertyName);
try
{
model = propertyInfo.GetValue(_model);
}
catch (TargetInvocationException ex)
{
throw ex.InnerException;
}
}
// Performance: Never create a delegate when container is null.
_entry = new ValidationEntry(property, key, model: null);
}
else if (IsMono)
{
_entry = new ValidationEntry(property, key, () => GetModelOnMono(_model, property.PropertyName));
}
else
{
model = property.PropertyGetter(_model);
_entry = new ValidationEntry(property, key, () => GetModel(_model, property));
}
_entry = new ValidationEntry(property, key, model);
return true;
}
@ -125,6 +109,26 @@ namespace Microsoft.AspNetCore.Mvc.Internal
{
throw new NotImplementedException();
}
private static object GetModel(object container, ModelMetadata property)
{
return property.PropertyGetter(container);
}
// Our property accessors don't work on Mono 4.0.4 - see https://github.com/aspnet/External/issues/44
// This is a workaround for what the PropertyGetter does in the background.
private static object GetModelOnMono(object container, string propertyName)
{
var propertyInfo = container.GetType().GetRuntimeProperty(propertyName);
try
{
return propertyInfo.GetValue(container);
}
catch (TargetInvocationException ex)
{
throw ex.InnerException;
}
}
}
}
}

View File

@ -2,6 +2,8 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
@ -34,6 +36,24 @@ namespace Microsoft.AspNetCore.Mvc.Internal
}
}
}
// IPropertyValidationFilter attributes on a type affect properties in that type, not properties that have
// that type. Thus, we ignore context.TypeAttributes for properties and not check at all for types.
if (context.Key.MetadataKind == ModelMetadataKind.Property)
{
var validationFilter = context.PropertyAttributes.OfType<IPropertyValidationFilter>().FirstOrDefault();
if (validationFilter == null)
{
// No IPropertyValidationFilter attributes on the property.
// Check if container has such an attribute.
validationFilter = context.Key.ContainerType.GetTypeInfo()
.GetCustomAttributes(inherit: true)
.OfType<IPropertyValidationFilter>()
.FirstOrDefault();
}
context.ValidationMetadata.PropertyValidationFilter = validationFilter;
}
}
}
}

View File

@ -7,8 +7,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
{
/// <summary>
/// Indicates that a property should be excluded from model binding. When applied to a property, the model binding
/// system excludes that property. When applied to a type, the model binding system excludes all properties of that
/// type.
/// system excludes that property. When applied to a type, the model binding system excludes all properties that
/// type defines.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class BindNeverAttribute : BindingBehaviorAttribute

View File

@ -8,7 +8,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
/// <summary>
/// Indicates that a property is required for model binding. When applied to a property, the model binding system
/// requires a value for that property. When applied to a type, the model binding system requires values for all
/// properties of that type.
/// properties that type defines.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class BindRequiredAttribute : BindingBehaviorAttribute

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
{
@ -531,6 +532,15 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
}
}
/// <inheritdoc />
public override IPropertyValidationFilter PropertyValidationFilter
{
get
{
return ValidationMetadata.PropertyValidationFilter;
}
}
/// <inheritdoc />
public override bool ValidateChildren
{

View File

@ -18,7 +18,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
/// Creates a new <see cref="ExcludeBindingMetadataProvider"/> for the given <paramref name="type"/>.
/// </summary>
/// <param name="type">
/// The <see cref="Type"/>. All properties of this <see cref="Type"/> will have
/// The <see cref="Type"/>. All properties with this <see cref="Type"/> will have
/// <see cref="ModelMetadata.IsBindingAllowed"/> set to <c>false</c>.
/// </param>
public ExcludeBindingMetadataProvider(Type type)

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
{
@ -18,9 +19,15 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
/// </summary>
public bool? IsRequired { get; set; }
/// <summary>
/// Gets or sets an <see cref="IPropertyValidationFilter"/> implementation that indicates whether this model
/// should be validated. See <see cref="ModelMetadata.PropertyValidationFilter"/>.
/// </summary>
public IPropertyValidationFilter PropertyValidationFilter { get; set; }
/// <summary>
/// Gets or sets a value that indicates whether children of the model should be validated. If <c>null</c>
/// then <see cref="ModelMetadata.ValidateChildren"/> will be <c>true</c> if either of
/// then <see cref="ModelMetadata.ValidateChildren"/> will be <c>true</c> if either of
/// <see cref="ModelMetadata.IsComplexType"/> or <see cref="ModelMetadata.IsEnumerableType"/> is <c>true</c>;
/// <c>false</c> otherwise.
/// </summary>

View File

@ -0,0 +1,22 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation
{
/// <summary>
/// <see cref="IPropertyValidationFilter"/> implementation that unconditionally indicates a property should be
/// excluded from validation. When applied to a property, the validation system excludes that property. When
/// applied to a type, the validation system excludes all properties within that type.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class ValidateNeverAttribute : Attribute, IPropertyValidationFilter
{
/// <inheritdoc />
public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEntry)
{
return false;
}
}
}

View File

@ -185,52 +185,26 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation
{
if (_metadata.IsEnumerableType)
{
return VisitEnumerableType();
return VisitComplexType(DefaultCollectionValidationStrategy.Instance);
}
else if (_metadata.IsComplexType)
if (_metadata.IsComplexType)
{
return VisitComplexType();
}
else
{
return VisitSimpleType();
return VisitComplexType(DefaultComplexObjectValidationStrategy.Instance);
}
return VisitSimpleType();
}
}
private bool VisitEnumerableType()
// Covers everything VisitSimpleType does not i.e. both enumerations and complex types.
private bool VisitComplexType(IValidationStrategy defaultStrategy)
{
var isValid = true;
if (_model != null && _metadata.ValidateChildren)
{
var strategy = _strategy ?? DefaultCollectionValidationStrategy.Instance;
isValid = VisitChildren(strategy);
}
else if (_model != null)
{
// Suppress validation for the entries matching this prefix. This will temporarily set
// the current node to 'skipped' but we're going to visit it right away, so subsequent
// code will set it to 'valid' or 'invalid'
SuppressValidation(_key);
}
// Double-checking HasReachedMaxErrors just in case this model has no elements.
if (isValid && !_modelState.HasReachedMaxErrors)
{
isValid &= ValidateNode();
}
return isValid;
}
private bool VisitComplexType()
{
var isValid = true;
if (_model != null && _metadata.ValidateChildren)
{
var strategy = _strategy ?? DefaultComplexObjectValidationStrategy.Instance;
var strategy = _strategy ?? defaultStrategy;
isValid = VisitChildren(strategy);
}
else if (_model != null)
@ -265,14 +239,20 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation
{
var isValid = true;
var enumerator = strategy.GetChildren(_metadata, _key, _model);
var parentEntry = new ValidationEntry(_metadata, _key, _model);
while (enumerator.MoveNext())
{
var metadata = enumerator.Current.Metadata;
var model = enumerator.Current.Model;
var key = enumerator.Current.Key;
var entry = enumerator.Current;
var metadata = entry.Metadata;
var key = entry.Key;
if (metadata.PropertyValidationFilter?.ShouldValidateEntry(entry, parentEntry) == false)
{
SuppressValidation(key);
continue;
}
isValid &= Visit(metadata, key, model);
isValid &= Visit(metadata, key, entry.Model);
}
return isValid;

View File

@ -6,8 +6,8 @@ using System;
namespace Microsoft.AspNetCore.Mvc
{
/// <summary>
/// Indicates associated property or all properties of associated type should be edited using an &lt;input&gt;
/// element of type "hidden".
/// Indicates associated property or all properties with the associated type should be edited using an
/// &lt;input&gt; element of type "hidden".
/// </summary>
/// <remarks>
/// When overriding a <see cref="HiddenInputAttribute"/> inherited from a base class, should apply both

View File

@ -27,8 +27,8 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
/// <summary>
/// Initializes a new instance of the <see cref="ViewDataInfo"/> class with info about a
/// <see cref="ViewDataDictionary"/> lookup which is evaluated when <see cref="Value"/> is read.
/// It uses <see cref="System.Reflection.PropertyInfo.GetValue(object)"/> on <paramref name="propertyInfo"/>
/// <see cref="ViewDataDictionary"/> lookup which is evaluated when <see cref="Value"/> is read.
/// It uses <see cref="System.Reflection.PropertyInfo.GetValue(object)"/> on <paramref name="propertyInfo"/>
/// passing parameter <paramref name="container"/> to lazily evaluate the value.
/// </summary>
/// <param name="container">The <see cref="object"/> that <see cref="Value"/> will be evaluated from.</param>
@ -45,7 +45,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
/// </summary>
/// <param name="container">The <see cref="object"/> that has the <see cref="Value"/>.</param>
/// <param name="propertyInfo">The <see cref="PropertyInfo"/> that represents <see cref="Value"/>'s property.</param>
/// <param name="valueAccessor"></param>
/// <param name="valueAccessor">A delegate that will return the <see cref="Value"/>.</param>
public ViewDataInfo(object container, PropertyInfo propertyInfo, Func<object> valueAccessor)
{
Container = container;

View File

@ -6,6 +6,7 @@ using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.ModelBinding
@ -576,6 +577,14 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
}
}
public override IPropertyValidationFilter PropertyValidationFilter
{
get
{
throw new NotImplementedException();
}
}
public override bool ValidateChildren
{
get

View File

@ -1,6 +1,7 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc.ModelBinding;
@ -12,7 +13,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal
public class DefaultComplexObjectValidationStrategyTest
{
[Fact]
public void EnumerateElements()
public void GetChildren_ReturnsExpectedElements()
{
// Arrange
var model = new Person()
@ -31,23 +32,92 @@ namespace Microsoft.AspNetCore.Mvc.Internal
// Assert
Assert.Collection(
BufferEntries(enumerator).OrderBy(e => e.Key),
e =>
entry =>
{
Assert.Equal("prefix.Age", e.Key);
Assert.Equal(23, e.Model);
Assert.Same(metadata.Properties["Age"], e.Metadata);
Assert.Equal("prefix.Age", entry.Key);
Assert.Equal(23, entry.Model);
Assert.Same(metadata.Properties["Age"], entry.Metadata);
},
e =>
entry =>
{
Assert.Equal("prefix.Id", e.Key);
Assert.Equal(1, e.Model);
Assert.Same(metadata.Properties["Id"], e.Metadata);
Assert.Equal("prefix.Id", entry.Key);
Assert.Equal(1, entry.Model);
Assert.Same(metadata.Properties["Id"], entry.Metadata);
},
e =>
entry =>
{
Assert.Equal("prefix.Name", e.Key);
Assert.Equal("Joey", e.Model);
Assert.Same(metadata.Properties["Name"], e.Metadata);
Assert.Equal("prefix.Name", entry.Key);
Assert.Equal("Joey", entry.Model);
Assert.Same(metadata.Properties["Name"], entry.Metadata);
});
}
[Fact]
public void GetChildren_SetsModelNull_IfContainerNull()
{
// Arrange
Person model = null;
var metadata = TestModelMetadataProvider.CreateDefaultProvider().GetMetadataForType(typeof(Person));
var strategy = DefaultComplexObjectValidationStrategy.Instance;
// Act
var enumerator = strategy.GetChildren(metadata, "prefix", model);
// Assert
Assert.Collection(
BufferEntries(enumerator).OrderBy(e => e.Key),
entry =>
{
Assert.Equal("prefix.Age", entry.Key);
Assert.Null(entry.Model);
Assert.Same(metadata.Properties["Age"], entry.Metadata);
},
entry =>
{
Assert.Equal("prefix.Id", entry.Key);
Assert.Null(entry.Model);
Assert.Same(metadata.Properties["Id"], entry.Metadata);
},
entry =>
{
Assert.Equal("prefix.Name", entry.Key);
Assert.Null(entry.Model);
Assert.Same(metadata.Properties["Name"], entry.Metadata);
});
}
[Fact]
public void GetChildren_LazyLoadsModel()
{
// Arrange
var model = new LazyPerson(input: null);
var metadata = TestModelMetadataProvider.CreateDefaultProvider().GetMetadataForType(typeof(LazyPerson));
var strategy = DefaultComplexObjectValidationStrategy.Instance;
// Act
var enumerator = strategy.GetChildren(metadata, "prefix", model);
// Assert
// Note: NREs are not thrown until the Model property is accessed.
Assert.Collection(
BufferEntries(enumerator).OrderBy(e => e.Key),
entry =>
{
Assert.Equal("prefix.Age", entry.Key);
Assert.Equal(23, entry.Model);
Assert.Same(metadata.Properties["Age"], entry.Metadata);
},
entry =>
{
Assert.Equal("prefix.Id", entry.Key);
Assert.Throws<NullReferenceException>(() => entry.Model);
Assert.Same(metadata.Properties["Id"], entry.Metadata);
},
entry =>
{
Assert.Equal("prefix.Name", entry.Key);
Assert.Throws<NullReferenceException>(() => entry.Model);
Assert.Same(metadata.Properties["Name"], entry.Metadata);
});
}
@ -70,5 +140,21 @@ namespace Microsoft.AspNetCore.Mvc.Internal
public string Name { get; set; }
}
private class LazyPerson
{
private string _string;
public LazyPerson(string input)
{
_string = input;
}
public int Id => _string.Length;
public int Age => 23;
public string Name => _string.Substring(3, 5);
}
}
}

View File

@ -8,6 +8,7 @@ using System.Collections.ObjectModel;
using System.Linq;
using System.Xml;
using Microsoft.AspNetCore.Mvc.Internal;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Moq;
using Xunit;
@ -50,6 +51,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
Assert.False(metadata.IsRequired); // Defaults to false for reference types
Assert.True(metadata.ShowForDisplay);
Assert.True(metadata.ShowForEdit);
Assert.False(metadata.ValidateChildren); // Defaults to true for complex and enumerable types.
Assert.Null(metadata.DataTypeName);
Assert.Null(metadata.Description);
@ -60,9 +62,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
Assert.Null(metadata.EnumGroupedDisplayNamesAndValues);
Assert.Null(metadata.EnumNamesAndValues);
Assert.Null(metadata.NullDisplayText);
Assert.Null(metadata.TemplateHint);
Assert.Null(metadata.PropertyValidationFilter);
Assert.Null(metadata.SimpleDisplayProperty);
Assert.Null(metadata.Placeholder);
Assert.Null(metadata.TemplateHint);
Assert.Equal(10000, ModelMetadata.DefaultOrder);
Assert.Equal(ModelMetadata.DefaultOrder, metadata.Order);
@ -659,6 +662,42 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
Assert.True(validateChildren);
}
public static TheoryData<IPropertyValidationFilter> ValidationFilterData
{
get
{
return new TheoryData<IPropertyValidationFilter>
{
null,
new ValidateNeverAttribute(),
};
}
}
[Theory]
[MemberData(nameof(ValidationFilterData))]
public void PropertyValidationFilter_ReflectsFilter_FromValidationMetadata(IPropertyValidationFilter value)
{
// Arrange
var detailsProvider = new EmptyCompositeMetadataDetailsProvider();
var provider = new DefaultModelMetadataProvider(detailsProvider);
var key = ModelMetadataIdentity.ForType(typeof(int));
var cache = new DefaultMetadataDetails(key, new ModelAttributes(new object[0]));
cache.ValidationMetadata = new ValidationMetadata
{
PropertyValidationFilter = value,
};
var metadata = new DefaultModelMetadata(provider, detailsProvider, cache);
// Act
var validationFilter = metadata.PropertyValidationFilter;
// Assert
Assert.Same(value, validationFilter);
}
[Fact]
public void ValidateChildren_OverrideTrue()
{

View File

@ -11,6 +11,104 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
{
public class DefaultValidationMetadataProviderTest
{
[Fact]
public void PropertyValidationFilter_ShouldValidateEntry_False_IfPropertyHasValidateNever()
{
// Arrange
var provider = new DefaultValidationMetadataProvider();
var attributes = new Attribute[] { new ValidateNeverAttribute() };
var key = ModelMetadataIdentity.ForProperty(typeof(int), "Length", typeof(string));
var context = new ValidationMetadataProviderContext(key, new ModelAttributes(attributes, new object[0]));
// Act
provider.CreateValidationMetadata(context);
// Assert
Assert.NotNull(context.ValidationMetadata.PropertyValidationFilter);
Assert.False(context.ValidationMetadata.PropertyValidationFilter.ShouldValidateEntry(
new ValidationEntry(),
new ValidationEntry()));
}
[Fact]
public void PropertyValidationFilter_Null_IfPropertyHasValidateNeverOnItsType()
{
// Arrange
var provider = new DefaultValidationMetadataProvider();
var attributes = new Attribute[] { new ValidateNeverAttribute() };
var key = ModelMetadataIdentity.ForProperty(typeof(int), "Length", typeof(string));
var context = new ValidationMetadataProviderContext(key, new ModelAttributes(new object[0], attributes));
// Act
provider.CreateValidationMetadata(context);
// Assert
Assert.Null(context.ValidationMetadata.PropertyValidationFilter);
}
[Fact]
public void PropertyValidationFilter_Null_ForType()
{
// Arrange
var provider = new DefaultValidationMetadataProvider();
var attributes = new Attribute[] { new ValidateNeverAttribute() };
var key = ModelMetadataIdentity.ForType(typeof(ValidateNeverClass));
var context = new ValidationMetadataProviderContext(key, new ModelAttributes(attributes));
// Act
provider.CreateValidationMetadata(context);
// Assert
Assert.Null(context.ValidationMetadata.PropertyValidationFilter);
}
[Fact]
public void PropertyValidationFilter_ShouldValidateEntry_False_IfContainingTypeHasValidateNever()
{
// Arrange
var provider = new DefaultValidationMetadataProvider();
var key = ModelMetadataIdentity.ForProperty(
typeof(string),
nameof(ValidateNeverClass.ClassName),
typeof(ValidateNeverClass));
var context = new ValidationMetadataProviderContext(key, new ModelAttributes(new object[0], new object[0]));
// Act
provider.CreateValidationMetadata(context);
// Assert
Assert.NotNull(context.ValidationMetadata.PropertyValidationFilter);
Assert.False(context.ValidationMetadata.PropertyValidationFilter.ShouldValidateEntry(
new ValidationEntry(),
new ValidationEntry()));
}
[Fact]
public void PropertyValidationFilter_ShouldValidateEntry_False_IfContainingTypeInheritsValidateNever()
{
// Arrange
var provider = new DefaultValidationMetadataProvider();
var key = ModelMetadataIdentity.ForProperty(
typeof(string),
nameof(ValidateNeverSubclass.SubclassName),
typeof(ValidateNeverSubclass));
var context = new ValidationMetadataProviderContext(key, new ModelAttributes(new object[0], new object[0]));
// Act
provider.CreateValidationMetadata(context);
// Assert
Assert.NotNull(context.ValidationMetadata.PropertyValidationFilter);
Assert.False(context.ValidationMetadata.PropertyValidationFilter.ShouldValidateEntry(
new ValidationEntry(),
new ValidationEntry()));
}
[Fact]
public void GetValidationDetails_MarkedWithClientValidator_ReturnsValidator()
{
@ -69,6 +167,17 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
Assert.Same(attribute, validatorMetadata);
}
[ValidateNever]
private class ValidateNeverClass
{
public string ClassName { get; set; }
}
private class ValidateNeverSubclass : ValidateNeverClass
{
public string SubclassName { get; set; }
}
private class TestModelValidationAttribute : Attribute, IModelValidator
{
public IEnumerable<ModelValidationResult> Validate(ModelValidationContext context)

View File

@ -12,6 +12,8 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Newtonsoft.Json.Linq;
using Xunit;
@ -1222,6 +1224,347 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
Assert.Equal("The value '-123' is not valid for Zip.", error.ErrorMessage);
}
private class NeverValid : IValidatableObject
{
public string NeverValidProperty { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
return new[] { new ValidationResult("This is not valid.") };
}
}
private class NeverValidAttribute : ValidationAttribute
{
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
// By default, ValidationVisitor visits _all_ properties within a non-null complex object.
// But, like most reasonable ValidationAttributes, NeverValidAttribute ignores null property values.
if (value == null)
{
return ValidationResult.Success;
}
return new ValidationResult("Properties with this are not valid.");
}
}
private class ValidateSomeProperties
{
public NeverValid NeverValid { get; set; }
[NeverValid]
public string NeverValidBecauseAttribute { get; set; }
[ValidateNever]
[NeverValid]
public string ValidateNever { get; set; }
[ValidateNever]
public int ValidateNeverLength => ValidateNever.Length;
}
[ValidateNever]
private class ValidateNoProperties : ValidateSomeProperties
{
}
[Fact]
public async Task IValidatableObject_IsValidated()
{
// Arrange
var parameter = new ParameterDescriptor
{
Name = "parameter",
ParameterType = typeof(ValidateSomeProperties),
};
var testContext = ModelBindingTestHelper.GetTestContext(
request => request.QueryString
= new QueryString($"?{nameof(ValidateSomeProperties.NeverValid)}.{nameof(NeverValid.NeverValidProperty)}=1"));
var argumentBinder = ModelBindingTestHelper.GetArgumentBinder();
var modelState = testContext.ModelState;
// Act
var result = await argumentBinder.BindModelAsync(parameter, testContext);
// Assert
Assert.True(result.IsModelSet);
var model = Assert.IsType<ValidateSomeProperties>(result.Model);
Assert.Equal("1", model.NeverValid.NeverValidProperty);
Assert.False(modelState.IsValid);
Assert.Equal(1, modelState.ErrorCount);
Assert.Collection(
modelState,
state =>
{
Assert.Equal(nameof(ValidateSomeProperties.NeverValid), state.Key);
Assert.Equal(ModelValidationState.Invalid, state.Value.ValidationState);
var error = Assert.Single(state.Value.Errors);
Assert.Equal("This is not valid.", error.ErrorMessage);
Assert.Null(error.Exception);
},
state =>
{
Assert.Equal(
$"{nameof(ValidateSomeProperties.NeverValid)}.{nameof(NeverValid.NeverValidProperty)}",
state.Key);
Assert.Equal(ModelValidationState.Valid, state.Value.ValidationState);
});
}
[Fact]
public async Task CustomValidationAttribute_IsValidated()
{
// Arrange
var parameter = new ParameterDescriptor
{
Name = "parameter",
ParameterType = typeof(ValidateSomeProperties),
};
var testContext = ModelBindingTestHelper.GetTestContext(
request => request.QueryString
= new QueryString($"?{nameof(ValidateSomeProperties.NeverValidBecauseAttribute)}=1"));
var argumentBinder = ModelBindingTestHelper.GetArgumentBinder();
var modelState = testContext.ModelState;
// Act
var result = await argumentBinder.BindModelAsync(parameter, testContext);
// Assert
Assert.True(result.IsModelSet);
var model = Assert.IsType<ValidateSomeProperties>(result.Model);
Assert.Equal("1", model.NeverValidBecauseAttribute);
Assert.False(modelState.IsValid);
Assert.Equal(1, modelState.ErrorCount);
var kvp = Assert.Single(modelState);
Assert.Equal(nameof(ValidateSomeProperties.NeverValidBecauseAttribute), kvp.Key);
var state = kvp.Value;
Assert.NotNull(state);
Assert.Equal(ModelValidationState.Invalid, state.ValidationState);
var error = Assert.Single(state.Errors);
Assert.Equal("Properties with this are not valid.", error.ErrorMessage);
Assert.Null(error.Exception);
}
[Fact]
public async Task ValidateNeverProperty_IsSkipped()
{
// Arrange
var parameter = new ParameterDescriptor
{
Name = "parameter",
ParameterType = typeof(ValidateSomeProperties),
};
var testContext = ModelBindingTestHelper.GetTestContext(
request => request.QueryString
= new QueryString($"?{nameof(ValidateSomeProperties.ValidateNever)}=1"));
var argumentBinder = ModelBindingTestHelper.GetArgumentBinder();
var modelState = testContext.ModelState;
// Act
var result = await argumentBinder.BindModelAsync(parameter, testContext);
// Assert
Assert.True(result.IsModelSet);
var model = Assert.IsType<ValidateSomeProperties>(result.Model);
Assert.Equal("1", model.ValidateNever);
Assert.True(modelState.IsValid);
var kvp = Assert.Single(modelState);
Assert.Equal(nameof(ValidateSomeProperties.ValidateNever), kvp.Key);
var state = kvp.Value;
Assert.NotNull(state);
Assert.Equal(ModelValidationState.Skipped, state.ValidationState);
}
[Fact]
public async Task ValidateNeverProperty_IsSkippedWithoutAccessingModel()
{
// Arrange
var parameter = new ParameterDescriptor
{
Name = "parameter",
ParameterType = typeof(ValidateSomeProperties),
};
var testContext = ModelBindingTestHelper.GetTestContext();
var argumentBinder = ModelBindingTestHelper.GetArgumentBinder();
var modelState = testContext.ModelState;
// Act
var result = await argumentBinder.BindModelAsync(parameter, testContext);
// Assert
Assert.True(result.IsModelSet);
var model = Assert.IsType<ValidateSomeProperties>(result.Model);
// Note this Exception is not thrown earlier.
Assert.Throws<NullReferenceException>(() => model.ValidateNeverLength);
Assert.True(modelState.IsValid);
Assert.Empty(modelState);
}
[Theory]
[InlineData(nameof(ValidateSomeProperties.NeverValid) + "." + nameof(NeverValid.NeverValidProperty))]
[InlineData(nameof(ValidateSomeProperties.NeverValidBecauseAttribute))]
[InlineData(nameof(ValidateSomeProperties.ValidateNever))]
public async Task PropertyWithinValidateNeverType_IsSkipped(string propertyName)
{
// Arrange
var parameter = new ParameterDescriptor
{
Name = "parameter",
ParameterType = typeof(ValidateNoProperties),
};
var testContext = ModelBindingTestHelper.GetTestContext(
request => request.QueryString = new QueryString($"?{propertyName}=1"));
var argumentBinder = ModelBindingTestHelper.GetArgumentBinder();
var modelState = testContext.ModelState;
// Act
var result = await argumentBinder.BindModelAsync(parameter, testContext);
// Assert
Assert.True(result.IsModelSet);
Assert.IsType<ValidateNoProperties>(result.Model);
Assert.True(modelState.IsValid);
var kvp = Assert.Single(modelState);
Assert.Equal(propertyName, kvp.Key);
var state = kvp.Value;
Assert.NotNull(state);
Assert.Equal(ModelValidationState.Skipped, state.ValidationState);
}
private class ValidateSometimesAttribute : Attribute, IPropertyValidationFilter
{
private readonly string _otherProperty;
public ValidateSometimesAttribute(string otherProperty)
{
// Would null-check otherProperty in real life.
_otherProperty = otherProperty;
}
public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEntry)
{
if (entry.Metadata.MetadataKind == ModelMetadataKind.Property &&
parentEntry.Metadata != null)
{
// In real life, would throw an InvalidOperationException if otherProperty were null i.e. the
// property was not known. Could also assert container is non-null (see ValidationVisitor).
var container = parentEntry.Model;
var otherProperty = parentEntry.Metadata.Properties[_otherProperty];
if (otherProperty.PropertyGetter(container) == null)
{
return false;
}
}
return true;
}
}
private class ValidateSomePropertiesSometimes
{
public string Control { get; set; }
[ValidateSometimes(nameof(Control))]
public int ControlLength => Control.Length;
}
[Fact]
public async Task PropertyToSometimesSkip_IsSkipped_IfControlIsNull()
{
// Arrange
var parameter = new ParameterDescriptor
{
Name = "parameter",
ParameterType = typeof(ValidateSomePropertiesSometimes),
};
var testContext = ModelBindingTestHelper.GetTestContext();
var argumentBinder = ModelBindingTestHelper.GetArgumentBinder();
var modelState = testContext.ModelState;
// Add an entry for the ControlLength property so that we can observe Skipped versus Valid states.
modelState.SetModelValue(
nameof(ValidateSomePropertiesSometimes.ControlLength),
rawValue: null,
attemptedValue: null);
// Act
var result = await argumentBinder.BindModelAsync(parameter, testContext);
// Assert
Assert.True(result.IsModelSet);
var model = Assert.IsType<ValidateSomePropertiesSometimes>(result.Model);
Assert.Null(model.Control);
// Note this Exception is not thrown earlier.
Assert.Throws<NullReferenceException>(() => model.ControlLength);
Assert.True(modelState.IsValid);
var kvp = Assert.Single(modelState);
Assert.Equal(nameof(ValidateSomePropertiesSometimes.ControlLength), kvp.Key);
Assert.Equal(ModelValidationState.Skipped, kvp.Value.ValidationState);
}
[Fact]
public async Task PropertyToSometimesSkip_IsValidated_IfControlIsNotNull()
{
// Arrange
var parameter = new ParameterDescriptor
{
Name = "parameter",
ParameterType = typeof(ValidateSomePropertiesSometimes),
};
var testContext = ModelBindingTestHelper.GetTestContext(
request => request.QueryString = new QueryString(
$"?{nameof(ValidateSomePropertiesSometimes.Control)}=1"));
var argumentBinder = ModelBindingTestHelper.GetArgumentBinder();
var modelState = testContext.ModelState;
// Add an entry for the ControlLength property so that we can observe Skipped versus Valid states.
modelState.SetModelValue(
nameof(ValidateSomePropertiesSometimes.ControlLength),
rawValue: null,
attemptedValue: null);
// Act
var result = await argumentBinder.BindModelAsync(parameter, testContext);
// Assert
Assert.True(result.IsModelSet);
var model = Assert.IsType<ValidateSomePropertiesSometimes>(result.Model);
Assert.Equal("1", model.Control);
Assert.Equal(1, model.ControlLength);
Assert.True(modelState.IsValid);
Assert.Collection(
modelState,
state => Assert.Equal(nameof(ValidateSomePropertiesSometimes.Control), state.Key),
state =>
{
Assert.Equal(nameof(ValidateSomePropertiesSometimes.ControlLength), state.Key);
Assert.Equal(ModelValidationState.Valid, state.Value.ValidationState);
});
}
private class Order11
{
public IEnumerable<Address> ShippingAddresses { get; set; }