Record type follow ups: (#25218)
* Record type follow ups: * Throw an error if a record type property has validation metadata * Disallow TryUpdateModel on a top-level record type * Ignore previously specified model value when binding a record type * Unskip record type tests * Clean up record type detection * Update src/Mvc/Mvc.Abstractions/src/Resources.resx Co-authored-by: James Newton-King <james@newtonking.com> * Fixup tests * Update src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadata.cs * Update src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadata.cs * Update src/Mvc/Mvc.Abstractions/src/Resources.resx Co-authored-by: Doug Bunting <6431421+dougbu@users.noreply.github.com> * Update src/Mvc/Mvc.Core/src/Resources.resx Co-authored-by: Doug Bunting <6431421+dougbu@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: James Newton-King <james@newtonking.com> Co-authored-by: Doug Bunting <6431421+dougbu@users.noreply.github.com>
This commit is contained in:
parent
748b368d54
commit
7686c0b4e7
|
|
@ -7,8 +7,10 @@ using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||||
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
|
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
|
||||||
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
|
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
|
||||||
using Microsoft.Extensions.Internal;
|
using Microsoft.Extensions.Internal;
|
||||||
|
|
@ -26,11 +28,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly int DefaultOrder = 10000;
|
public static readonly int DefaultOrder = 10000;
|
||||||
|
|
||||||
private static readonly IReadOnlyDictionary<ModelMetadata, ModelMetadata> EmptyParameterMapping = new Dictionary<ModelMetadata, ModelMetadata>(0);
|
|
||||||
|
|
||||||
private int? _hashCode;
|
private int? _hashCode;
|
||||||
private IReadOnlyList<ModelMetadata>? _boundProperties;
|
private IReadOnlyList<ModelMetadata>? _boundProperties;
|
||||||
private IReadOnlyDictionary<ModelMetadata, ModelMetadata>? _parameterMapping;
|
private IReadOnlyDictionary<ModelMetadata, ModelMetadata>? _parameterMapping;
|
||||||
|
private Exception? _recordTypeValidatorsOnPropertiesError;
|
||||||
|
private bool _recordTypeConstructorDetailsCalculated;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new <see cref="ModelMetadata"/>.
|
/// Creates a new <see cref="ModelMetadata"/>.
|
||||||
|
|
@ -137,37 +139,16 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A mapping from parameters to their corresponding properties on a record type.
|
||||||
|
/// </summary>
|
||||||
internal IReadOnlyDictionary<ModelMetadata, ModelMetadata> BoundConstructorParameterMapping
|
internal IReadOnlyDictionary<ModelMetadata, ModelMetadata> BoundConstructorParameterMapping
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (_parameterMapping != null)
|
Debug.Assert(BoundConstructor != null, "This API can be only called for types with bound constructors.");
|
||||||
{
|
CalculateRecordTypeConstructorDetails();
|
||||||
return _parameterMapping;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (BoundConstructor is null)
|
|
||||||
{
|
|
||||||
_parameterMapping = EmptyParameterMapping;
|
|
||||||
return _parameterMapping;
|
|
||||||
}
|
|
||||||
|
|
||||||
var boundParameters = BoundConstructor.BoundConstructorParameters!;
|
|
||||||
var parameterMapping = new Dictionary<ModelMetadata, ModelMetadata>();
|
|
||||||
|
|
||||||
foreach (var parameter in boundParameters)
|
|
||||||
{
|
|
||||||
var property = Properties.FirstOrDefault(p =>
|
|
||||||
string.Equals(p.Name, parameter.ParameterName, StringComparison.Ordinal) &&
|
|
||||||
p.ModelType == parameter.ModelType);
|
|
||||||
|
|
||||||
if (property != null)
|
|
||||||
{
|
|
||||||
parameterMapping[parameter] = property;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_parameterMapping = parameterMapping;
|
|
||||||
return _parameterMapping;
|
return _parameterMapping;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -494,6 +475,62 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual Func<object[], object>? BoundConstructorInvoker => null;
|
public virtual Func<object[], object>? BoundConstructorInvoker => null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value that determines if validators can be constructed using metadata exclusively defined on the property.
|
||||||
|
/// </summary>
|
||||||
|
internal virtual bool PropertyHasValidators => false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Throws if the ModelMetadata is for a record type with validation on properties.
|
||||||
|
/// </summary>
|
||||||
|
internal void ThrowIfRecordTypeHasValidationOnProperties()
|
||||||
|
{
|
||||||
|
CalculateRecordTypeConstructorDetails();
|
||||||
|
if (_recordTypeValidatorsOnPropertiesError != null)
|
||||||
|
{
|
||||||
|
throw _recordTypeValidatorsOnPropertiesError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MemberNotNull(nameof(_parameterMapping))]
|
||||||
|
private void CalculateRecordTypeConstructorDetails()
|
||||||
|
{
|
||||||
|
if (_recordTypeConstructorDetailsCalculated)
|
||||||
|
{
|
||||||
|
Debug.Assert(_parameterMapping != null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var boundParameters = BoundConstructor!.BoundConstructorParameters!;
|
||||||
|
var parameterMapping = new Dictionary<ModelMetadata, ModelMetadata>();
|
||||||
|
|
||||||
|
foreach (var parameter in boundParameters)
|
||||||
|
{
|
||||||
|
var property = Properties.FirstOrDefault(p =>
|
||||||
|
string.Equals(p.Name, parameter.ParameterName, StringComparison.Ordinal) &&
|
||||||
|
p.ModelType == parameter.ModelType);
|
||||||
|
|
||||||
|
if (property != null)
|
||||||
|
{
|
||||||
|
parameterMapping[parameter] = property;
|
||||||
|
|
||||||
|
if (property.PropertyHasValidators)
|
||||||
|
{
|
||||||
|
// When constructing the mapping of paramets -> properties, also determine
|
||||||
|
// if the property has any validators (without looking at metadata on the type).
|
||||||
|
// This will help us throw during validation if a user defines validation attributes
|
||||||
|
// on the property of a record type.
|
||||||
|
_recordTypeValidatorsOnPropertiesError = new InvalidOperationException(
|
||||||
|
Resources.FormatRecordTypeHasValidationOnProperties(ModelType, property.Name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_recordTypeConstructorDetailsCalculated = true;
|
||||||
|
_parameterMapping = parameterMapping;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a display name for the model.
|
/// Gets a display name for the model.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -177,4 +177,7 @@
|
||||||
<data name="BinderType_MustBeIModelBinder" xml:space="preserve">
|
<data name="BinderType_MustBeIModelBinder" xml:space="preserve">
|
||||||
<value>The type '{0}' must implement '{1}' to be used as a model binder.</value>
|
<value>The type '{0}' must implement '{1}' to be used as a model binder.</value>
|
||||||
</data>
|
</data>
|
||||||
</root>
|
<data name="RecordTypeHasValidationOnProperties" xml:space="preserve">
|
||||||
|
<value>Record type '{0}' has validation metadata defined on property '{1}' that will be ignored. '{1}' is a parameter in the record primary constructor and validation metadata must be associated with the constructor parameter.</value>
|
||||||
|
</data>
|
||||||
|
</root>
|
||||||
|
|
|
||||||
|
|
@ -75,32 +75,33 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
|
||||||
var bindingSucceeded = false;
|
var bindingSucceeded = false;
|
||||||
|
|
||||||
var modelMetadata = bindingContext.ModelMetadata;
|
var modelMetadata = bindingContext.ModelMetadata;
|
||||||
|
var boundConstructor = modelMetadata.BoundConstructor;
|
||||||
|
|
||||||
if (bindingContext.Model == null)
|
if (boundConstructor != null)
|
||||||
{
|
{
|
||||||
var boundConstructor = modelMetadata.BoundConstructor;
|
// Only record types are allowed to have a BoundConstructor. Binding a record type requires
|
||||||
if (boundConstructor != null)
|
// instantiating the type. This means we'll ignore a previously assigned bindingContext.Model value.
|
||||||
{
|
// This behaior is identical to input formatting with S.T.Json and Json.NET.
|
||||||
var values = new object[boundConstructor.BoundConstructorParameters.Count];
|
|
||||||
var (attemptedParameterBinding, parameterBindingSucceeded) = await BindParametersAsync(
|
var values = new object[boundConstructor.BoundConstructorParameters.Count];
|
||||||
bindingContext,
|
var (attemptedParameterBinding, parameterBindingSucceeded) = await BindParametersAsync(
|
||||||
propertyData,
|
bindingContext,
|
||||||
boundConstructor.BoundConstructorParameters,
|
propertyData,
|
||||||
values);
|
boundConstructor.BoundConstructorParameters,
|
||||||
|
values);
|
||||||
|
|
||||||
attemptedBinding |= attemptedParameterBinding;
|
attemptedBinding |= attemptedParameterBinding;
|
||||||
bindingSucceeded |= parameterBindingSucceeded;
|
bindingSucceeded |= parameterBindingSucceeded;
|
||||||
|
|
||||||
if (!CreateModel(bindingContext, boundConstructor, values))
|
if (!CreateModel(bindingContext, boundConstructor, values))
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
CreateModel(bindingContext);
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (bindingContext.Model == null)
|
||||||
|
{
|
||||||
|
CreateModel(bindingContext);
|
||||||
|
}
|
||||||
|
|
||||||
var (attemptedPropertyBinding, propertyBindingSucceeded) = await BindPropertiesAsync(
|
var (attemptedPropertyBinding, propertyBindingSucceeded) = await BindPropertiesAsync(
|
||||||
bindingContext,
|
bindingContext,
|
||||||
|
|
|
||||||
|
|
@ -143,8 +143,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
|
||||||
static bool IsRecordType(Type type)
|
static bool IsRecordType(Type type)
|
||||||
{
|
{
|
||||||
// Based on the state of the art as described in https://github.com/dotnet/roslyn/issues/45777
|
// Based on the state of the art as described in https://github.com/dotnet/roslyn/issues/45777
|
||||||
var cloneMethod = type.GetMethod("<Clone>$", BindingFlags.Public | BindingFlags.Instance) ??
|
var cloneMethod = type.GetMethod("<Clone>$", BindingFlags.Public | BindingFlags.Instance);
|
||||||
type.GetMethod("<>Clone", BindingFlags.Public | BindingFlags.Instance);
|
|
||||||
return cloneMethod != null && cloneMethod.ReturnType == type;
|
return cloneMethod != null && cloneMethod.ReturnType == type;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -468,6 +468,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal override bool PropertyHasValidators => ValidationMetadata.PropertyHasValidators;
|
||||||
|
|
||||||
internal static bool CalculateHasValidators(HashSet<DefaultModelMetadata> visited, ModelMetadata metadata)
|
internal static bool CalculateHasValidators(HashSet<DefaultModelMetadata> visited, ModelMetadata metadata)
|
||||||
{
|
{
|
||||||
RuntimeHelpers.EnsureSufficientExecutionStack();
|
RuntimeHelpers.EnsureSufficientExecutionStack();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright (c) .NET Foundation. All rights reserved.
|
// 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.
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
|
@ -40,7 +40,23 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation
|
||||||
if (provider.HasValidators(context.Key.ModelType, context.ValidationMetadata.ValidatorMetadata))
|
if (provider.HasValidators(context.Key.ModelType, context.ValidationMetadata.ValidatorMetadata))
|
||||||
{
|
{
|
||||||
context.ValidationMetadata.HasValidators = true;
|
context.ValidationMetadata.HasValidators = true;
|
||||||
return;
|
|
||||||
|
if (context.Key.MetadataKind == ModelMetadataKind.Property)
|
||||||
|
{
|
||||||
|
// For properties, additionally determine that if there's validators defined exclusively
|
||||||
|
// from property attributes. This is later used to produce a error for record types
|
||||||
|
// where a record type property that is bound as a parameter defines validation attributes.
|
||||||
|
|
||||||
|
if (!(context.PropertyAttributes is IList<object> propertyAttributes))
|
||||||
|
{
|
||||||
|
propertyAttributes = context.PropertyAttributes.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.HasValidators(typeof(object), propertyAttributes))
|
||||||
|
{
|
||||||
|
context.ValidationMetadata.PropertyHasValidators = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,5 +46,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
|
||||||
/// Gets a value that indicates if the model has validators .
|
/// Gets a value that indicates if the model has validators .
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool? HasValidators { get; set; }
|
public bool? HasValidators { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value that determines if validators can be constructed using metadata on properties.
|
||||||
|
/// </summary>
|
||||||
|
internal bool PropertyHasValidators { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -268,6 +268,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
|
||||||
}
|
}
|
||||||
|
|
||||||
var modelMetadata = metadataProvider.GetMetadataForType(modelType);
|
var modelMetadata = metadataProvider.GetMetadataForType(modelType);
|
||||||
|
|
||||||
|
if (modelMetadata.BoundConstructor != null)
|
||||||
|
{
|
||||||
|
throw new NotSupportedException(Resources.FormatTryUpdateModel_RecordTypeNotSupported(nameof(TryUpdateModelAsync), modelType));
|
||||||
|
}
|
||||||
|
|
||||||
var modelState = actionContext.ModelState;
|
var modelState = actionContext.ModelState;
|
||||||
|
|
||||||
var modelBindingContext = DefaultModelBindingContext.CreateBindingContext(
|
var modelBindingContext = DefaultModelBindingContext.CreateBindingContext(
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
_modelMetadata.ThrowIfRecordTypeHasValidationOnProperties();
|
||||||
_parameters = _modelMetadata.BoundConstructor.BoundConstructorParameters;
|
_parameters = _modelMetadata.BoundConstructor.BoundConstructorParameters;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -304,6 +304,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation
|
||||||
else if (metadata.HasValidators == false &&
|
else if (metadata.HasValidators == false &&
|
||||||
ModelState.GetFieldValidationState(key) != ModelValidationState.Invalid)
|
ModelState.GetFieldValidationState(key) != ModelValidationState.Invalid)
|
||||||
{
|
{
|
||||||
|
if (metadata.BoundConstructor != null)
|
||||||
|
{
|
||||||
|
metadata.ThrowIfRecordTypeHasValidationOnProperties();
|
||||||
|
}
|
||||||
|
|
||||||
// No validators will be created for this graph of objects. Mark it as valid if it wasn't previously validated.
|
// No validators will be created for this graph of objects. Mark it as valid if it wasn't previously validated.
|
||||||
var entries = ModelState.FindKeysWithPrefix(key);
|
var entries = ModelState.FindKeysWithPrefix(key);
|
||||||
foreach (var item in entries)
|
foreach (var item in entries)
|
||||||
|
|
|
||||||
|
|
@ -534,4 +534,7 @@
|
||||||
<data name="ValidationStrategy_MappedPropertyNotFound" xml:space="preserve">
|
<data name="ValidationStrategy_MappedPropertyNotFound" xml:space="preserve">
|
||||||
<value>No property found that maps to constructor parameter '{0}' for type '{1}'. Validation requires that each bound parameter of a record type's primary constructor must have a property to read the value.</value>
|
<value>No property found that maps to constructor parameter '{0}' for type '{1}'. Validation requires that each bound parameter of a record type's primary constructor must have a property to read the value.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="TryUpdateModel_RecordTypeNotSupported" xml:space="preserve">
|
||||||
|
<value>{0} cannot update a record type model. If a '{1}' must be updated, include it in an object type.</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,11 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
|
||||||
public override Task JsonInputFormatter_RoundtripsRecordType()
|
public override Task JsonInputFormatter_RoundtripsRecordType()
|
||||||
=> base.JsonInputFormatter_RoundtripsRecordType();
|
=> base.JsonInputFormatter_RoundtripsRecordType();
|
||||||
|
|
||||||
[Fact(Skip = "https://github.com/dotnet/runtime/issues/38539")]
|
[Fact]
|
||||||
public override Task JsonInputFormatter_ValidationWithRecordTypes_NoValidationErrors()
|
public override Task JsonInputFormatter_ValidationWithRecordTypes_NoValidationErrors()
|
||||||
=> base.JsonInputFormatter_ValidationWithRecordTypes_NoValidationErrors();
|
=> base.JsonInputFormatter_ValidationWithRecordTypes_NoValidationErrors();
|
||||||
|
|
||||||
[Fact(Skip = "https://github.com/dotnet/runtime/issues/38539")]
|
[Fact]
|
||||||
public override Task JsonInputFormatter_ValidationWithRecordTypes_ValidationErrors()
|
public override Task JsonInputFormatter_ValidationWithRecordTypes_ValidationErrors()
|
||||||
=> base.JsonInputFormatter_ValidationWithRecordTypes_ValidationErrors();
|
=> base.JsonInputFormatter_ValidationWithRecordTypes_ValidationErrors();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
@ -1145,7 +1146,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TryUpdateModel_RecordTypeModel_DoesNotOverwriteConstructorParameters()
|
public async Task TryUpdateModel_RecordTypeModel_Throws()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var testContext = ModelBindingTestHelper.GetTestContext(request =>
|
var testContext = ModelBindingTestHelper.GetTestContext(request =>
|
||||||
|
|
@ -1160,61 +1161,10 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
|
||||||
};
|
};
|
||||||
var oldModel = model;
|
var oldModel = model;
|
||||||
|
|
||||||
// Act
|
// Act & Assert
|
||||||
var result = await TryUpdateModelAsync(model, string.Empty, testContext);
|
var ex = await Assert.ThrowsAsync<NotSupportedException>(() => TryUpdateModelAsync(model, string.Empty, testContext));
|
||||||
|
Assert.Equal($"TryUpdateModelAsync cannot update a record type model. If a '{model.GetType()}' must be updated, include it in an object type." , ex.Message);
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.True(result);
|
|
||||||
|
|
||||||
// Model
|
|
||||||
Assert.Same(oldModel, model);
|
|
||||||
Assert.Equal("DefaultStreet", model.Street);
|
|
||||||
Assert.Equal("Toronto", model.City);
|
|
||||||
Assert.Equal("98001", model.ZipCode);
|
|
||||||
|
|
||||||
// ModelState
|
|
||||||
Assert.True(modelState.IsValid);
|
|
||||||
Assert.Empty(modelState);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task TryUpdateModel_RecordTypeModel_UpdatesProperties()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var testContext = ModelBindingTestHelper.GetTestContext(request =>
|
|
||||||
{
|
|
||||||
request.QueryString = QueryString.Create("ZipCode", "98007").Add("Street", "SomeStreet");
|
|
||||||
});
|
|
||||||
|
|
||||||
var modelState = testContext.ModelState;
|
|
||||||
var model = new AddressRecord("DefaultStreet", "Toronto")
|
|
||||||
{
|
|
||||||
ZipCode = "98001",
|
|
||||||
};
|
|
||||||
var oldModel = model;
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await TryUpdateModelAsync(model, string.Empty, testContext);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.True(result);
|
|
||||||
|
|
||||||
// Model
|
|
||||||
Assert.Same(oldModel, model);
|
|
||||||
Assert.Equal("DefaultStreet", model.Street);
|
|
||||||
Assert.Equal("Toronto", model.City);
|
|
||||||
Assert.Equal("98007", model.ZipCode);
|
|
||||||
|
|
||||||
// ModelState
|
|
||||||
Assert.True(modelState.IsValid);
|
|
||||||
|
|
||||||
var entry = Assert.Single(modelState);
|
|
||||||
Assert.Equal("ZipCode", entry.Key);
|
|
||||||
var state = entry.Value;
|
|
||||||
Assert.Equal("98007", state.AttemptedValue);
|
|
||||||
Assert.Equal("98007", state.RawValue);
|
|
||||||
Assert.Empty(state.Errors);
|
|
||||||
Assert.Equal(ModelValidationState.Valid, state.ValidationState);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private class ModelWithRecordTypeProperty
|
private class ModelWithRecordTypeProperty
|
||||||
|
|
@ -1269,7 +1219,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TryUpdateModel_RecordTypeProperty_InitializedDoesNotOverwriteConstructorParameters()
|
public async Task TryUpdateModel_RecordTypePropertyIsOverwritten()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var testContext = ModelBindingTestHelper.GetTestContext(request =>
|
var testContext = ModelBindingTestHelper.GetTestContext(request =>
|
||||||
|
|
@ -1297,19 +1247,33 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
|
||||||
Assert.Same(oldModel, model);
|
Assert.Same(oldModel, model);
|
||||||
Assert.NotNull(model.Address);
|
Assert.NotNull(model.Address);
|
||||||
var address = model.Address;
|
var address = model.Address;
|
||||||
Assert.Equal("DefaultStreet", address.Street);
|
Assert.Equal("SomeStreet", address.Street);
|
||||||
Assert.Equal("DefaultCity", address.City);
|
Assert.Null(address.City);
|
||||||
Assert.Equal("98007", address.ZipCode);
|
Assert.Equal("98007", address.ZipCode);
|
||||||
|
|
||||||
// ModelState
|
// ModelState
|
||||||
Assert.True(modelState.IsValid);
|
Assert.True(modelState.IsValid);
|
||||||
|
|
||||||
var entry = Assert.Single(modelState);
|
Assert.Collection(
|
||||||
var state = entry.Value;
|
modelState.OrderBy(kvp => kvp.Key),
|
||||||
Assert.Equal("98007", state.AttemptedValue);
|
kvp =>
|
||||||
Assert.Equal("98007", state.RawValue);
|
{
|
||||||
Assert.Empty(state.Errors);
|
Assert.Equal("Address.Street", kvp.Key);
|
||||||
Assert.Equal(ModelValidationState.Valid, state.ValidationState);
|
var state = kvp.Value;
|
||||||
|
Assert.Equal("SomeStreet", state.AttemptedValue);
|
||||||
|
Assert.Equal("SomeStreet", state.RawValue);
|
||||||
|
Assert.Empty(state.Errors);
|
||||||
|
Assert.Equal(ModelValidationState.Valid, state.ValidationState);
|
||||||
|
},
|
||||||
|
kvp =>
|
||||||
|
{
|
||||||
|
Assert.Equal("Address.ZipCode", kvp.Key);
|
||||||
|
var state = kvp.Value;
|
||||||
|
Assert.Equal("98007", state.AttemptedValue);
|
||||||
|
Assert.Equal("98007", state.RawValue);
|
||||||
|
Assert.Empty(state.Errors);
|
||||||
|
Assert.Equal(ModelValidationState.Valid, state.ValidationState);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateRequest(HttpRequest request, string data, string name)
|
private void UpdateRequest(HttpRequest request, string data, string name)
|
||||||
|
|
|
||||||
|
|
@ -1151,7 +1151,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
|
||||||
Assert.Equal("The value '-123' is not valid.", error.ErrorMessage);
|
Assert.Equal("The value '-123' is not valid.", error.ErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
private record NeverValid(string NeverValidProperty) : IValidatableObject
|
private record NeverValid(string NeverValidProperty) : IValidatableObject
|
||||||
{
|
{
|
||||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||||
{
|
{
|
||||||
|
|
@ -2298,6 +2298,163 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
|
||||||
|
|
||||||
private static void Validation_InifnitelyRecursiveModel_ValidationOnTopLevelParameterMethod([Required] RecursiveModel model) { }
|
private static void Validation_InifnitelyRecursiveModel_ValidationOnTopLevelParameterMethod([Required] RecursiveModel model) { }
|
||||||
|
|
||||||
|
private record RecordTypeWithValidatorsOnProperties(string Property1)
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string Property1 { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Validation_ValidatorsDefinedOnRecordTypeProperties()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var modelType = typeof(RecordTypeWithValidatorsOnProperties);
|
||||||
|
var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider();
|
||||||
|
var modelMetadata = modelMetadataProvider.GetMetadataForType(modelType);
|
||||||
|
var parameterBinder = ModelBindingTestHelper.GetParameterBinder(modelMetadataProvider);
|
||||||
|
var expected = $"Record type '{modelType}' has validation metadata defined on property 'Property1' that will be ignored. " +
|
||||||
|
"'Property1' is a parameter in the record primary constructor and validation metadata must be associated with the constructor parameter.";
|
||||||
|
|
||||||
|
var parameter = new ParameterDescriptor()
|
||||||
|
{
|
||||||
|
Name = "parameter",
|
||||||
|
ParameterType = modelType,
|
||||||
|
};
|
||||||
|
|
||||||
|
var testContext = ModelBindingTestHelper.GetTestContext(request =>
|
||||||
|
{
|
||||||
|
request.QueryString = new QueryString("?Property1=8");
|
||||||
|
});
|
||||||
|
|
||||||
|
var modelState = testContext.ModelState;
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
parameterBinder.BindModelAsync(parameter, testContext, modelMetadataProvider, modelMetadata));
|
||||||
|
|
||||||
|
Assert.Equal(expected, ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record RecordTypeWithValidatorsOnPropertiesAndParameters([Required] string Property1)
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string Property1 { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Validation_ValidatorsDefinedOnRecordTypePropertiesAndParameters()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var modelType = typeof(RecordTypeWithValidatorsOnPropertiesAndParameters);
|
||||||
|
var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider();
|
||||||
|
var modelMetadata = modelMetadataProvider.GetMetadataForType(modelType);
|
||||||
|
var parameterBinder = ModelBindingTestHelper.GetParameterBinder(modelMetadataProvider);
|
||||||
|
var expected = $"Record type '{modelType}' has validation metadata defined on property 'Property1' that will be ignored. " +
|
||||||
|
"'Property1' is a parameter in the record primary constructor and validation metadata must be associated with the constructor parameter.";
|
||||||
|
|
||||||
|
var parameter = new ParameterDescriptor()
|
||||||
|
{
|
||||||
|
Name = "parameter",
|
||||||
|
ParameterType = modelType,
|
||||||
|
};
|
||||||
|
|
||||||
|
var testContext = ModelBindingTestHelper.GetTestContext(request =>
|
||||||
|
{
|
||||||
|
request.QueryString = new QueryString("?Property1=8");
|
||||||
|
});
|
||||||
|
|
||||||
|
var modelState = testContext.ModelState;
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
parameterBinder.BindModelAsync(parameter, testContext, modelMetadataProvider, modelMetadata));
|
||||||
|
|
||||||
|
Assert.Equal(expected, ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record RecordTypeWithValidatorsOnMixOfPropertiesAndParameters([Required] string Property1, string Property2)
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string Property2 { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Validation_ValidatorsDefinedOnMixOfRecordTypePropertiesAndParameters()
|
||||||
|
{
|
||||||
|
// Variation of Validation_ValidatorsDefinedOnRecordTypePropertiesAndParameters, but validators
|
||||||
|
// appear on a mix of properties and parameters.
|
||||||
|
// Arrange
|
||||||
|
var modelType = typeof(RecordTypeWithValidatorsOnMixOfPropertiesAndParameters);
|
||||||
|
var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider();
|
||||||
|
var modelMetadata = modelMetadataProvider.GetMetadataForType(modelType);
|
||||||
|
var parameterBinder = ModelBindingTestHelper.GetParameterBinder(modelMetadataProvider);
|
||||||
|
var expected = $"Record type '{modelType}' has validation metadata defined on property 'Property2' that will be ignored. " +
|
||||||
|
"'Property2' is a parameter in the record primary constructor and validation metadata must be associated with the constructor parameter.";
|
||||||
|
|
||||||
|
var parameter = new ParameterDescriptor()
|
||||||
|
{
|
||||||
|
Name = "parameter",
|
||||||
|
ParameterType = modelType,
|
||||||
|
};
|
||||||
|
|
||||||
|
var testContext = ModelBindingTestHelper.GetTestContext(request =>
|
||||||
|
{
|
||||||
|
request.QueryString = new QueryString("?Property1=8");
|
||||||
|
});
|
||||||
|
|
||||||
|
var modelState = testContext.ModelState;
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
parameterBinder.BindModelAsync(parameter, testContext, modelMetadataProvider, modelMetadata));
|
||||||
|
|
||||||
|
Assert.Equal(expected, ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record RecordTypeWithPropertiesAndParameters([Required] string Property1)
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string Property2 { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Validation_ValidatorsOnParametersAndProperties()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var modelType = typeof(RecordTypeWithPropertiesAndParameters);
|
||||||
|
var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider();
|
||||||
|
var modelMetadata = modelMetadataProvider.GetMetadataForType(modelType);
|
||||||
|
var parameterBinder = ModelBindingTestHelper.GetParameterBinder(modelMetadataProvider);
|
||||||
|
|
||||||
|
var parameter = new ParameterDescriptor()
|
||||||
|
{
|
||||||
|
Name = "parameter",
|
||||||
|
ParameterType = modelType,
|
||||||
|
};
|
||||||
|
|
||||||
|
var testContext = ModelBindingTestHelper.GetTestContext(request =>
|
||||||
|
{
|
||||||
|
request.QueryString = new QueryString("?Property1=SomeValue");
|
||||||
|
});
|
||||||
|
|
||||||
|
var modelState = testContext.ModelState;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext);
|
||||||
|
|
||||||
|
Assert.Equal(2, modelState.Count);
|
||||||
|
Assert.Equal(1, modelState.ErrorCount);
|
||||||
|
Assert.False(modelState.IsValid);
|
||||||
|
|
||||||
|
var entry = Assert.Single(modelState, e => e.Key == "Property1").Value;
|
||||||
|
Assert.Equal("SomeValue", entry.AttemptedValue);
|
||||||
|
Assert.Equal("SomeValue", entry.RawValue);
|
||||||
|
Assert.Equal(ModelValidationState.Valid, entry.ValidationState);
|
||||||
|
|
||||||
|
entry = Assert.Single(modelState, e => e.Key == "Property2").Value;
|
||||||
|
Assert.Equal(ModelValidationState.Invalid, entry.ValidationState);
|
||||||
|
}
|
||||||
|
|
||||||
private static void AssertRequiredError(string key, ModelError error)
|
private static void AssertRequiredError(string key, ModelError error)
|
||||||
{
|
{
|
||||||
Assert.Equal(ValidationAttributeUtil.GetRequiredErrorMessage(key), error.ErrorMessage);
|
Assert.Equal(ValidationAttributeUtil.GetRequiredErrorMessage(key), error.ErrorMessage);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue