diff --git a/src/Mvc/Mvc.Abstractions/src/ModelBinding/IPropertyFilterProvider.cs b/src/Mvc/Mvc.Abstractions/src/ModelBinding/IPropertyFilterProvider.cs
index 76ddd463cc..0c364c5cc2 100644
--- a/src/Mvc/Mvc.Abstractions/src/ModelBinding/IPropertyFilterProvider.cs
+++ b/src/Mvc/Mvc.Abstractions/src/ModelBinding/IPropertyFilterProvider.cs
@@ -6,12 +6,17 @@ using System;
namespace Microsoft.AspNetCore.Mvc.ModelBinding
{
///
- /// Provides a predicate which can determines which model properties should be bound by model binding.
+ /// Provides a predicate which can determines which model properties or parameters should be bound by model binding.
///
public interface IPropertyFilterProvider
{
///
+ ///
/// Gets a predicate which can determines which model properties should be bound by model binding.
+ ///
+ ///
+ /// This predicate is also used to determine which parameters are bound when a model's constructor is bound.
+ ///
///
Func PropertyFilter { get; }
}
diff --git a/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelBindingMessageProvider.cs b/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelBindingMessageProvider.cs
index 7992450f37..fa9f3a4ff7 100644
--- a/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelBindingMessageProvider.cs
+++ b/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelBindingMessageProvider.cs
@@ -51,7 +51,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
///
/// Error message the model binding system adds when is of type
/// or , value is known, and error is associated
- /// with a collection element or action parameter.
+ /// with a collection element or parameter.
///
/// Default is "The value '{0}' is not valid.".
public virtual Func NonPropertyAttemptedValueIsInvalidAccessor { get; } = default!;
@@ -67,7 +67,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
///
/// Error message the model binding system adds when is of type
/// or , value is unknown, and error is associated
- /// with a collection element or action parameter.
+ /// with a collection element or parameter.
///
/// Default is "The supplied value is invalid.".
public virtual Func NonPropertyUnknownValueIsInvalidAccessor { get; } = default!;
diff --git a/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelMetadataIdentity.cs b/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelMetadataIdentity.cs
index 5fff9bad3d..b170496a5b 100644
--- a/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelMetadataIdentity.cs
+++ b/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelMetadataIdentity.cs
@@ -16,12 +16,14 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
Type modelType,
string? name = null,
Type? containerType = null,
- object? fieldInfo = null)
+ object? fieldInfo = null,
+ ConstructorInfo? constructorInfo = null)
{
ModelType = modelType;
Name = name;
ContainerType = containerType;
FieldInfo = fieldInfo;
+ ConstructorInfo = constructorInfo;
}
///
@@ -130,6 +132,28 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
return new ModelMetadataIdentity(modelType, parameter.Name, fieldInfo: parameter);
}
+ ///
+ /// Creates a for the provided parameter with the specified
+ /// model type.
+ ///
+ /// The .
+ /// The model type.
+ /// A .
+ public static ModelMetadataIdentity ForConstructor(ConstructorInfo constructor, Type modelType)
+ {
+ if (constructor == null)
+ {
+ throw new ArgumentNullException(nameof(constructor));
+ }
+
+ if (modelType == null)
+ {
+ throw new ArgumentNullException(nameof(modelType));
+ }
+
+ return new ModelMetadataIdentity(modelType, constructor.Name, constructorInfo: constructor);
+ }
+
///
/// Gets the defining the model property represented by the current
/// instance, or null if the current instance does not represent a property.
@@ -152,6 +176,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
{
return ModelMetadataKind.Parameter;
}
+ else if (ConstructorInfo != null)
+ {
+ return ModelMetadataKind.Constructor;
+ }
else if (ContainerType != null && Name != null)
{
return ModelMetadataKind.Property;
@@ -183,6 +211,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
///
public PropertyInfo? PropertyInfo => FieldInfo as PropertyInfo;
+ ///
+ /// Gets a descriptor for the constructor, or null if this instance
+ /// does not represent a constructor.
+ ///
+ public ConstructorInfo? ConstructorInfo { get; }
+
///
public bool Equals(ModelMetadataIdentity other)
{
@@ -191,7 +225,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
ModelType == other.ModelType &&
Name == other.Name &&
ParameterInfo == other.ParameterInfo &&
- PropertyInfo == other.PropertyInfo;
+ PropertyInfo == other.PropertyInfo &&
+ ConstructorInfo == other.ConstructorInfo;
}
///
@@ -210,6 +245,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
hash.Add(Name, StringComparer.Ordinal);
hash.Add(ParameterInfo);
hash.Add(PropertyInfo);
+ hash.Add(ConstructorInfo);
return hash.ToHashCode();
}
}
diff --git a/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelMetadataKind.cs b/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelMetadataKind.cs
index 4756f85226..b964992d1a 100644
--- a/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelMetadataKind.cs
+++ b/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelMetadataKind.cs
@@ -22,5 +22,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
/// Used for for a parameter.
///
Parameter,
+
+ ///
+ /// for a constructor.
+ ///
+ Constructor,
}
-}
\ No newline at end of file
+}
diff --git a/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadata.cs b/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadata.cs
index f04cac166e..4ce3e2afbb 100644
--- a/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadata.cs
+++ b/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadata.cs
@@ -4,8 +4,10 @@
using System;
using System.Collections;
using System.Collections.Generic;
+using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
+using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
@@ -24,7 +26,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
///
public static readonly int DefaultOrder = 10000;
+ private static readonly IReadOnlyDictionary EmptyParameterMapping = new Dictionary(0);
+
private int? _hashCode;
+ private IReadOnlyList? _boundProperties;
+ private IReadOnlyDictionary? _parameterMapping;
///
/// Creates a new .
@@ -83,7 +89,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
///
/// Gets the key for the current instance.
///
- protected ModelMetadataIdentity Identity { get; }
+ protected internal ModelMetadataIdentity Identity { get; }
///
/// Gets a collection of additional information about the model.
@@ -95,6 +101,88 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
///
public abstract ModelPropertyCollection Properties { get; }
+ internal IReadOnlyList BoundProperties
+ {
+ get
+ {
+ // In record types, each constructor parameter in the primary constructor is also a settable property with the same name.
+ // Executing model binding on these parameters twice may have detrimental effects, such as duplicate ModelState entries,
+ // or failures if a model expects to be bound exactly ones.
+ // Consequently when binding to a constructor, we only bind and validate the subset of properties whose names
+ // haven't appeared as parameters.
+ if (BoundConstructor is null)
+ {
+ return Properties;
+ }
+
+ if (_boundProperties is null)
+ {
+ var boundParameters = BoundConstructor.BoundConstructorParameters!;
+ var boundProperties = new List();
+
+ foreach (var metadata in Properties)
+ {
+ if (!boundParameters.Any(p =>
+ string.Equals(p.ParameterName, metadata.PropertyName, StringComparison.Ordinal)
+ && p.ModelType == metadata.ModelType))
+ {
+ boundProperties.Add(metadata);
+ }
+ }
+
+ _boundProperties = boundProperties;
+ }
+
+ return _boundProperties;
+ }
+ }
+
+ internal IReadOnlyDictionary BoundConstructorParameterMapping
+ {
+ get
+ {
+ if (_parameterMapping != null)
+ {
+ return _parameterMapping;
+ }
+
+ if (BoundConstructor is null)
+ {
+ _parameterMapping = EmptyParameterMapping;
+ return _parameterMapping;
+ }
+
+ var boundParameters = BoundConstructor.BoundConstructorParameters!;
+ var parameterMapping = new Dictionary();
+
+ 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;
+ }
+ }
+
+ ///
+ /// Gets instance for a constructor of a record type that is used during binding and validation.
+ ///
+ public virtual ModelMetadata? BoundConstructor { get; }
+
+ ///
+ /// Gets the collection of instances for parameters on a .
+ /// This is only available when is .
+ ///
+ public virtual IReadOnlyList? BoundConstructorParameters { get; }
+
///
/// Gets the name of a model if specified explicitly using .
///
@@ -401,6 +489,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
///
public abstract Action PropertySetter { get; }
+ ///
+ /// Gets a delegate that invokes the bound constructor if non- .
+ ///
+ public virtual Func? BoundConstructorInvoker => null;
+
///
/// Gets a display name for the model.
///
@@ -500,6 +593,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
return $"ModelMetadata (Property: '{ContainerType!.Name}.{PropertyName}' Type: '{ModelType.Name}')";
case ModelMetadataKind.Type:
return $"ModelMetadata (Type: '{ModelType.Name}')";
+ case ModelMetadataKind.Constructor:
+ return $"ModelMetadata (Constructor: '{ModelType.Name}')";
default:
return $"Unsupported MetadataKind '{MetadataKind}'.";
}
diff --git a/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadataProvider.cs b/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadataProvider.cs
index 147ccc45f5..7e22b3cbb6 100644
--- a/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadataProvider.cs
+++ b/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadataProvider.cs
@@ -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.
using System;
@@ -54,5 +54,16 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
{
throw new NotSupportedException();
}
+
+ ///
+ /// Supplies metadata describing a constructor.
+ ///
+ /// The .
+ /// The type declaring the constructor.
+ /// A instance describing the .
+ public virtual ModelMetadata GetMetadataForConstructor(ConstructorInfo constructor, Type modelType)
+ {
+ throw new NotSupportedException();
+ }
}
}
diff --git a/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelStateDictionary.cs b/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelStateDictionary.cs
index 24581e0510..61ef369e37 100644
--- a/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelStateDictionary.cs
+++ b/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelStateDictionary.cs
@@ -298,6 +298,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
// "The value '' is not valid." (when no value was provided, not even an empty string) and
// "The supplied value is invalid for Int32." (when error is for an element or parameter).
var messageProvider = metadata.ModelBindingMessageProvider;
+
var name = metadata.DisplayName ?? metadata.PropertyName;
string errorMessage;
if (entry == null && name == null)
diff --git a/src/Mvc/Mvc.Abstractions/test/ModelBinding/BindingInfoTest.cs b/src/Mvc/Mvc.Abstractions/test/ModelBinding/BindingInfoTest.cs
index 5c6cfca5fd..8d0d0c39dd 100644
--- a/src/Mvc/Mvc.Abstractions/test/ModelBinding/BindingInfoTest.cs
+++ b/src/Mvc/Mvc.Abstractions/test/ModelBinding/BindingInfoTest.cs
@@ -83,7 +83,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
// Arrange
var attributes = new object[]
{
- new ModelBinderAttribute { BinderType = typeof(ComplexTypeModelBinder), Name = "Test" },
+ new ModelBinderAttribute { BinderType = typeof(ComplexObjectModelBinder), Name = "Test" },
};
var modelType = typeof(Guid);
var provider = new TestModelMetadataProvider();
@@ -100,7 +100,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
// Assert
Assert.NotNull(bindingInfo);
- Assert.Same(typeof(ComplexTypeModelBinder), bindingInfo.BinderType);
+ Assert.Same(typeof(ComplexObjectModelBinder), bindingInfo.BinderType);
Assert.Same("Test", bindingInfo.BinderModelName);
}
@@ -110,7 +110,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
// Arrange
var attributes = new object[]
{
- new ModelBinderAttribute(typeof(ComplexTypeModelBinder)),
+ new ModelBinderAttribute(typeof(ComplexObjectModelBinder)),
new ControllerAttribute(),
new BindNeverAttribute(),
};
@@ -129,7 +129,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
// Assert
Assert.NotNull(bindingInfo);
- Assert.Same(typeof(ComplexTypeModelBinder), bindingInfo.BinderType);
+ Assert.Same(typeof(ComplexObjectModelBinder), bindingInfo.BinderType);
Assert.Same("Different", bindingInfo.BinderModelName);
Assert.Same(BindingSource.Custom, bindingInfo.BindingSource);
}
@@ -143,7 +143,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
var provider = new TestModelMetadataProvider();
provider.ForType(modelType).BindingDetails(metadata =>
{
- metadata.BinderType = typeof(ComplexTypeModelBinder);
+ metadata.BinderType = typeof(ComplexObjectModelBinder);
});
var modelMetadata = provider.GetMetadataForType(modelType);
@@ -152,7 +152,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
// Assert
Assert.NotNull(bindingInfo);
- Assert.Same(typeof(ComplexTypeModelBinder), bindingInfo.BinderType);
+ Assert.Same(typeof(ComplexObjectModelBinder), bindingInfo.BinderType);
}
[Fact]
@@ -187,7 +187,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
// Arrange
var attributes = new object[]
{
- new ModelBinderAttribute(typeof(ComplexTypeModelBinder)),
+ new ModelBinderAttribute(typeof(ComplexObjectModelBinder)),
new ControllerAttribute(),
new BindNeverAttribute(),
};
diff --git a/src/Mvc/Mvc.Abstractions/test/ModelBinding/ModelMetadataTest.cs b/src/Mvc/Mvc.Abstractions/test/ModelBinding/ModelMetadataTest.cs
index 2f71dffe32..f9e24ea415 100644
--- a/src/Mvc/Mvc.Abstractions/test/ModelBinding/ModelMetadataTest.cs
+++ b/src/Mvc/Mvc.Abstractions/test/ModelBinding/ModelMetadataTest.cs
@@ -728,6 +728,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
throw new NotImplementedException();
}
}
+
+ public override ModelMetadata BoundConstructor => throw new NotImplementedException();
+
+ public override Func BoundConstructorInvoker => throw new NotImplementedException();
+
+ public override IReadOnlyList BoundConstructorParameters => throw new NotImplementedException();
}
private class CollectionImplementation : ICollection
diff --git a/src/Mvc/Mvc.Analyzers/test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfPropertyWithModelBindingAttributeHasSameNameAsParameter.cs b/src/Mvc/Mvc.Analyzers/test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfPropertyWithModelBindingAttributeHasSameNameAsParameter.cs
index 37d463012b..5f2f1c5ecd 100644
--- a/src/Mvc/Mvc.Analyzers/test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfPropertyWithModelBindingAttributeHasSameNameAsParameter.cs
+++ b/src/Mvc/Mvc.Analyzers/test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfPropertyWithModelBindingAttributeHasSameNameAsParameter.cs
@@ -4,7 +4,7 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFi
{
public class IsProblematicParameter_ReturnsTrue_IfPropertyWithModelBindingAttributeHasSameNameAsParameter
{
- [ModelBinder(typeof(ComplexTypeModelBinder), Name = "model")]
+ [ModelBinder(typeof(ComplexObjectModelBinder), Name = "model")]
public string Different { get; set; }
public void ActionMethod(
diff --git a/src/Mvc/Mvc.Core/src/BindAttribute.cs b/src/Mvc/Mvc.Core/src/BindAttribute.cs
index 409237e40e..691c81ac7c 100644
--- a/src/Mvc/Mvc.Core/src/BindAttribute.cs
+++ b/src/Mvc/Mvc.Core/src/BindAttribute.cs
@@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
namespace Microsoft.AspNetCore.Mvc
{
@@ -56,17 +57,23 @@ namespace Microsoft.AspNetCore.Mvc
{
if (Include != null && Include.Length > 0)
{
- if (_propertyFilter == null)
- {
- _propertyFilter = (m) => Include.Contains(m.PropertyName, StringComparer.Ordinal);
- }
-
+ _propertyFilter ??= PropertyFilter;
return _propertyFilter;
}
else
{
return _default;
}
+
+ bool PropertyFilter(ModelMetadata modelMetadata)
+ {
+ if (modelMetadata.MetadataKind == ModelMetadataKind.Parameter)
+ {
+ return Include.Contains(modelMetadata.ParameterName, StringComparer.Ordinal);
+ }
+
+ return Include.Contains(modelMetadata.PropertyName, StringComparer.Ordinal);
+ }
}
}
diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs b/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs
index a2c0dbdcae..a2b94da118 100644
--- a/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs
+++ b/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs
@@ -72,7 +72,7 @@ namespace Microsoft.AspNetCore.Mvc
options.ModelBinderProviders.Add(new DictionaryModelBinderProvider());
options.ModelBinderProviders.Add(new ArrayModelBinderProvider());
options.ModelBinderProviders.Add(new CollectionModelBinderProvider());
- options.ModelBinderProviders.Add(new ComplexTypeModelBinderProvider());
+ options.ModelBinderProviders.Add(new ComplexObjectModelBinderProvider());
// Set up filters
options.Filters.Add(new UnsupportedContentTypeFilter());
diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/ParameterDefaultValues.cs b/src/Mvc/Mvc.Core/src/Infrastructure/ParameterDefaultValues.cs
index b7259fc9b7..f0bfc817e1 100644
--- a/src/Mvc/Mvc.Core/src/Infrastructure/ParameterDefaultValues.cs
+++ b/src/Mvc/Mvc.Core/src/Infrastructure/ParameterDefaultValues.cs
@@ -10,7 +10,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
{
internal static class ParameterDefaultValues
{
- public static object[] GetParameterDefaultValues(MethodInfo methodInfo)
+ public static object[] GetParameterDefaultValues(MethodBase methodInfo)
{
if (methodInfo == null)
{
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexObjectModelBinder.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexObjectModelBinder.cs
new file mode 100644
index 0000000000..2bd38a4bae
--- /dev/null
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexObjectModelBinder.cs
@@ -0,0 +1,752 @@
+// 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.Diagnostics;
+using System.Linq.Expressions;
+using System.Reflection;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc.Core;
+using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
+{
+ ///
+ /// implementation for binding complex types.
+ ///
+ public sealed class ComplexObjectModelBinder : IModelBinder
+ {
+ // Don't want a new public enum because communication between the private and internal methods of this class
+ // should not be exposed. Can't use an internal enum because types of [TheoryData] values must be public.
+
+ // Model contains only properties that are expected to bind from value providers and no value provider has
+ // matching data.
+ internal const int NoDataAvailable = 0;
+ // If model contains properties that are expected to bind from value providers, no value provider has matching
+ // data. Remaining (greedy) properties might bind successfully.
+ internal const int GreedyPropertiesMayHaveData = 1;
+ // Model contains at least one property that is expected to bind from value providers and a value provider has
+ // matching data.
+ internal const int ValueProviderDataAvailable = 2;
+
+ private readonly IDictionary _propertyBinders;
+ private readonly IReadOnlyList _parameterBinders;
+ private readonly ILogger _logger;
+ private Func _modelCreator;
+
+ internal ComplexObjectModelBinder(
+ IDictionary propertyBinders,
+ IReadOnlyList parameterBinders,
+ ILogger logger)
+ {
+ _propertyBinders = propertyBinders;
+ _parameterBinders = parameterBinders;
+ _logger = logger;
+ }
+
+ public Task BindModelAsync(ModelBindingContext bindingContext)
+ {
+ if (bindingContext == null)
+ {
+ throw new ArgumentNullException(nameof(bindingContext));
+ }
+
+ _logger.AttemptingToBindModel(bindingContext);
+
+ var parameterData = CanCreateModel(bindingContext);
+ if (parameterData == NoDataAvailable)
+ {
+ return Task.CompletedTask;
+ }
+
+ // Perf: separated to avoid allocating a state machine when we don't
+ // need to go async.
+ return BindModelCoreAsync(bindingContext, parameterData);
+ }
+
+ private async Task BindModelCoreAsync(ModelBindingContext bindingContext, int propertyData)
+ {
+ Debug.Assert(propertyData == GreedyPropertiesMayHaveData || propertyData == ValueProviderDataAvailable);
+
+ // Create model first (if necessary) to avoid reporting errors about properties when activation fails.
+ var attemptedBinding = false;
+ var bindingSucceeded = false;
+
+ var modelMetadata = bindingContext.ModelMetadata;
+
+ if (bindingContext.Model == null)
+ {
+ var boundConstructor = modelMetadata.BoundConstructor;
+ if (boundConstructor != null)
+ {
+ var values = new object[boundConstructor.BoundConstructorParameters.Count];
+ var (attemptedParameterBinding, parameterBindingSucceeded) = await BindParametersAsync(
+ bindingContext,
+ propertyData,
+ boundConstructor.BoundConstructorParameters,
+ values);
+
+ attemptedBinding |= attemptedParameterBinding;
+ bindingSucceeded |= parameterBindingSucceeded;
+
+ if (!CreateModel(bindingContext, boundConstructor, values))
+ {
+ return;
+ }
+ }
+ else
+ {
+ CreateModel(bindingContext);
+ }
+ }
+
+ var (attemptedPropertyBinding, propertyBindingSucceeded) = await BindPropertiesAsync(
+ bindingContext,
+ propertyData,
+ modelMetadata.BoundProperties);
+
+ attemptedBinding |= attemptedPropertyBinding;
+ bindingSucceeded |= propertyBindingSucceeded;
+
+ // Have we created a top-level model despite an inability to bind anything in said model and a lack of
+ // other IsBindingRequired errors? Does that violate [BindRequired] on the model? This case occurs when
+ // 1. The top-level model has no public settable properties.
+ // 2. All properties in a [BindRequired] model have [BindNever] or are otherwise excluded from binding.
+ // 3. No data exists for any property.
+ if (!attemptedBinding &&
+ bindingContext.IsTopLevelObject &&
+ modelMetadata.IsBindingRequired)
+ {
+ var messageProvider = modelMetadata.ModelBindingMessageProvider;
+ var message = messageProvider.MissingBindRequiredValueAccessor(bindingContext.FieldName);
+ bindingContext.ModelState.TryAddModelError(bindingContext.ModelName, message);
+ }
+
+ _logger.DoneAttemptingToBindModel(bindingContext);
+
+ // Have all binders failed because no data was available?
+ //
+ // If CanCreateModel determined a property has data, failures are likely due to conversion errors. For
+ // example, user may submit ?[0].id=twenty&[1].id=twenty-one&[2].id=22 for a collection of a complex type
+ // with an int id property. In that case, the bound model should be [ {}, {}, { id = 22 }] and
+ // ModelState should contain errors about both [0].id and [1].id. Do not inform higher-level binders of the
+ // failure in this and similar cases.
+ //
+ // If CanCreateModel could not find data for non-greedy properties, failures indicate greedy binders were
+ // unsuccessful. For example, user may submit file attachments [0].File and [1].File but not [2].File for
+ // a collection of a complex type containing an IFormFile property. In that case, we have exhausted the
+ // attached files and checking for [3].File is likely be pointless. (And, if it had a point, would we stop
+ // after 10 failures, 100, or more -- all adding redundant errors to ModelState?) Inform higher-level
+ // binders of the failure.
+ //
+ // Required properties do not change the logic below. Missed required properties cause ModelState errors
+ // but do not necessarily prevent further attempts to bind.
+ //
+ // This logic is intended to maximize correctness but does not avoid infinite loops or recursion when a
+ // greedy model binder succeeds unconditionally.
+ if (!bindingContext.IsTopLevelObject &&
+ !bindingSucceeded &&
+ propertyData == GreedyPropertiesMayHaveData)
+ {
+ bindingContext.Result = ModelBindingResult.Failed();
+ return;
+ }
+
+ bindingContext.Result = ModelBindingResult.Success(bindingContext.Model);
+ }
+
+ internal static bool CreateModel(ModelBindingContext bindingContext, ModelMetadata boundConstructor, object[] values)
+ {
+ try
+ {
+ bindingContext.Model = boundConstructor.BoundConstructorInvoker(values);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ AddModelError(ex, bindingContext.ModelName, bindingContext);
+ bindingContext.Result = ModelBindingResult.Failed();
+ return false;
+ }
+ }
+
+ ///
+ /// Creates suitable for given .
+ ///
+ /// The .
+ /// An compatible with .
+ internal void CreateModel(ModelBindingContext bindingContext)
+ {
+ if (bindingContext == null)
+ {
+ throw new ArgumentNullException(nameof(bindingContext));
+ }
+
+ // If model creator throws an exception, we want to propagate it back up the call stack, since the
+ // application developer should know that this was an invalid type to try to bind to.
+ if (_modelCreator == null)
+ {
+ // The following check causes the ComplexTypeModelBinder to NOT participate in binding structs as
+ // reflection does not provide information about the implicit parameterless constructor for a struct.
+ // This binder would eventually fail to construct an instance of the struct as the Linq's NewExpression
+ // compile fails to construct it.
+ var modelTypeInfo = bindingContext.ModelType.GetTypeInfo();
+ if (modelTypeInfo.IsAbstract || modelTypeInfo.GetConstructor(Type.EmptyTypes) == null)
+ {
+ var metadata = bindingContext.ModelMetadata;
+ switch (metadata.MetadataKind)
+ {
+ case ModelMetadataKind.Parameter:
+ throw new InvalidOperationException(
+ Resources.FormatComplexObjectModelBinder_NoSuitableConstructor_ForParameter(
+ modelTypeInfo.FullName,
+ metadata.ParameterName));
+ case ModelMetadataKind.Property:
+ throw new InvalidOperationException(
+ Resources.FormatComplexObjectModelBinder_NoSuitableConstructor_ForProperty(
+ modelTypeInfo.FullName,
+ metadata.PropertyName,
+ bindingContext.ModelMetadata.ContainerType.FullName));
+ case ModelMetadataKind.Type:
+ throw new InvalidOperationException(
+ Resources.FormatComplexObjectModelBinder_NoSuitableConstructor_ForType(
+ modelTypeInfo.FullName));
+ }
+ }
+
+ _modelCreator = Expression
+ .Lambda>(Expression.New(bindingContext.ModelType))
+ .Compile();
+ }
+
+ bindingContext.Model = _modelCreator();
+ }
+
+ private async ValueTask<(bool attemptedBinding, bool bindingSucceeded)> BindParametersAsync(
+ ModelBindingContext bindingContext,
+ int propertyData,
+ IReadOnlyList parameters,
+ object[] parameterValues)
+ {
+ var attemptedBinding = false;
+ var bindingSucceeded = false;
+
+ if (parameters.Count == 0)
+ {
+ return (attemptedBinding, bindingSucceeded);
+ }
+
+ var postponePlaceholderBinding = false;
+ for (var i = 0; i < parameters.Count; i++)
+ {
+ var parameter = parameters[i];
+
+ var fieldName = parameter.BinderModelName ?? parameter.ParameterName;
+ var modelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, fieldName);
+
+ if (!CanBindItem(bindingContext, parameter))
+ {
+ continue;
+ }
+
+ var parameterBinder = _parameterBinders[i];
+ if (parameterBinder is PlaceholderBinder)
+ {
+ if (postponePlaceholderBinding)
+ {
+ // Decided to postpone binding properties that complete a loop in the model types when handling
+ // an earlier loop-completing property. Postpone binding this property too.
+ continue;
+ }
+ else if (!bindingContext.IsTopLevelObject &&
+ !bindingSucceeded &&
+ propertyData == GreedyPropertiesMayHaveData)
+ {
+ // Have no confirmation of data for the current instance. Postpone completing the loop until
+ // we _know_ the current instance is useful. Recursion would otherwise occur prior to the
+ // block with a similar condition after the loop.
+ //
+ // Example cases include an Employee class containing
+ // 1. a Manager property of type Employee
+ // 2. an Employees property of type IList
+ postponePlaceholderBinding = true;
+ continue;
+ }
+ }
+
+ var result = await BindParameterAsync(bindingContext, parameter, parameterBinder, fieldName, modelName);
+
+ if (result.IsModelSet)
+ {
+ attemptedBinding = true;
+ bindingSucceeded = true;
+
+ parameterValues[i] = result.Model;
+ }
+ else if (parameter.IsBindingRequired)
+ {
+ attemptedBinding = true;
+ }
+ }
+
+ if (postponePlaceholderBinding && bindingSucceeded)
+ {
+ // Have some data for this instance. Continue with the model type loop.
+ for (var i = 0; i < parameters.Count; i++)
+ {
+ var parameter = parameters[i];
+ if (!CanBindItem(bindingContext, parameter))
+ {
+ continue;
+ }
+
+ var parameterBinder = _parameterBinders[i];
+ if (parameterBinder is PlaceholderBinder)
+ {
+ var fieldName = parameter.BinderModelName ?? parameter.ParameterName;
+ var modelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, fieldName);
+
+ var result = await BindParameterAsync(bindingContext, parameter, parameterBinder, fieldName, modelName);
+
+ if (result.IsModelSet)
+ {
+ parameterValues[i] = result.Model;
+ }
+ }
+ }
+ }
+
+ return (attemptedBinding, bindingSucceeded);
+ }
+
+ private async ValueTask<(bool attemptedBinding, bool bindingSucceeded)> BindPropertiesAsync(
+ ModelBindingContext bindingContext,
+ int propertyData,
+ IReadOnlyList boundProperties)
+ {
+ var attemptedBinding = false;
+ var bindingSucceeded = false;
+
+ if (boundProperties.Count == 0)
+ {
+ return (attemptedBinding, bindingSucceeded);
+ }
+
+ var postponePlaceholderBinding = false;
+ for (var i = 0; i < boundProperties.Count; i++)
+ {
+ var property = boundProperties[i];
+ if (!CanBindItem(bindingContext, property))
+ {
+ continue;
+ }
+
+ var propertyBinder = _propertyBinders[property];
+ if (propertyBinder is PlaceholderBinder)
+ {
+ if (postponePlaceholderBinding)
+ {
+ // Decided to postpone binding properties that complete a loop in the model types when handling
+ // an earlier loop-completing property. Postpone binding this property too.
+ continue;
+ }
+ else if (!bindingContext.IsTopLevelObject &&
+ !bindingSucceeded &&
+ propertyData == GreedyPropertiesMayHaveData)
+ {
+ // Have no confirmation of data for the current instance. Postpone completing the loop until
+ // we _know_ the current instance is useful. Recursion would otherwise occur prior to the
+ // block with a similar condition after the loop.
+ //
+ // Example cases include an Employee class containing
+ // 1. a Manager property of type Employee
+ // 2. an Employees property of type IList
+ postponePlaceholderBinding = true;
+ continue;
+ }
+ }
+
+ var fieldName = property.BinderModelName ?? property.PropertyName;
+ var modelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, fieldName);
+ var result = await BindPropertyAsync(bindingContext, property, propertyBinder, fieldName, modelName);
+
+ if (result.IsModelSet)
+ {
+ attemptedBinding = true;
+ bindingSucceeded = true;
+ }
+ else if (property.IsBindingRequired)
+ {
+ attemptedBinding = true;
+ }
+ }
+
+ if (postponePlaceholderBinding && bindingSucceeded)
+ {
+ // Have some data for this instance. Continue with the model type loop.
+ for (var i = 0; i < boundProperties.Count; i++)
+ {
+ var property = boundProperties[i];
+ if (!CanBindItem(bindingContext, property))
+ {
+ continue;
+ }
+
+ var propertyBinder = _propertyBinders[property];
+ if (propertyBinder is PlaceholderBinder)
+ {
+ var fieldName = property.BinderModelName ?? property.PropertyName;
+ var modelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, fieldName);
+
+ await BindPropertyAsync(bindingContext, property, propertyBinder, fieldName, modelName);
+ }
+ }
+ }
+
+ return (attemptedBinding, bindingSucceeded);
+ }
+
+ internal bool CanBindItem(ModelBindingContext bindingContext, ModelMetadata propertyMetadata)
+ {
+ var metadataProviderFilter = bindingContext.ModelMetadata.PropertyFilterProvider?.PropertyFilter;
+ if (metadataProviderFilter?.Invoke(propertyMetadata) == false)
+ {
+ return false;
+ }
+
+ if (bindingContext.PropertyFilter?.Invoke(propertyMetadata) == false)
+ {
+ return false;
+ }
+
+ if (!propertyMetadata.IsBindingAllowed)
+ {
+ return false;
+ }
+
+ if (propertyMetadata.MetadataKind == ModelMetadataKind.Property && propertyMetadata.IsReadOnly)
+ {
+ // Determine if we can update a readonly property (such as a collection).
+ return CanUpdateReadOnlyProperty(propertyMetadata.ModelType);
+ }
+
+ return true;
+ }
+
+ private async ValueTask BindPropertyAsync(
+ ModelBindingContext bindingContext,
+ ModelMetadata property,
+ IModelBinder propertyBinder,
+ string fieldName,
+ string modelName)
+ {
+ Debug.Assert(property.MetadataKind == ModelMetadataKind.Property);
+
+ // Pass complex (including collection) values down so that binding system does not unnecessarily
+ // recreate instances or overwrite inner properties that are not bound. No need for this with simple
+ // values because they will be overwritten if binding succeeds. Arrays are never reused because they
+ // cannot be resized.
+ object propertyModel = null;
+ if (property.PropertyGetter != null &&
+ property.IsComplexType &&
+ !property.ModelType.IsArray)
+ {
+ propertyModel = property.PropertyGetter(bindingContext.Model);
+ }
+
+ ModelBindingResult result;
+ using (bindingContext.EnterNestedScope(
+ modelMetadata: property,
+ fieldName: fieldName,
+ modelName: modelName,
+ model: propertyModel))
+ {
+ await propertyBinder.BindModelAsync(bindingContext);
+ result = bindingContext.Result;
+ }
+
+ if (result.IsModelSet)
+ {
+ SetProperty(bindingContext, modelName, property, result);
+ }
+ else if (property.IsBindingRequired)
+ {
+ var message = property.ModelBindingMessageProvider.MissingBindRequiredValueAccessor(fieldName);
+ bindingContext.ModelState.TryAddModelError(modelName, message);
+ }
+
+ return result;
+ }
+
+ private async ValueTask BindParameterAsync(
+ ModelBindingContext bindingContext,
+ ModelMetadata parameter,
+ IModelBinder parameterBinder,
+ string fieldName,
+ string modelName)
+ {
+ Debug.Assert(parameter.MetadataKind == ModelMetadataKind.Parameter);
+
+ ModelBindingResult result;
+ using (bindingContext.EnterNestedScope(
+ modelMetadata: parameter,
+ fieldName: fieldName,
+ modelName: modelName,
+ model: null))
+ {
+ await parameterBinder.BindModelAsync(bindingContext);
+ result = bindingContext.Result;
+ }
+
+ if (!result.IsModelSet && parameter.IsBindingRequired)
+ {
+ var message = parameter.ModelBindingMessageProvider.MissingBindRequiredValueAccessor(fieldName);
+ bindingContext.ModelState.TryAddModelError(modelName, message);
+ }
+
+ return result;
+ }
+
+ internal int CanCreateModel(ModelBindingContext bindingContext)
+ {
+ var isTopLevelObject = bindingContext.IsTopLevelObject;
+
+ // If we get here the model is a complex object which was not directly bound by any previous model binder,
+ // so we want to decide if we want to continue binding. This is important to get right to avoid infinite
+ // recursion.
+ //
+ // First, we want to make sure this object is allowed to come from a value provider source as this binder
+ // will only include value provider data. For instance if the model is marked with [FromBody], then we
+ // can just skip it. A greedy source cannot be a value provider.
+ //
+ // If the model isn't marked with ANY binding source, then we assume it's OK also.
+ //
+ // We skip this check if it is a top level object because we want to always evaluate
+ // the creation of top level object (this is also required for ModelBinderAttribute to work.)
+ var bindingSource = bindingContext.BindingSource;
+ if (!isTopLevelObject && bindingSource != null && bindingSource.IsGreedy)
+ {
+ return NoDataAvailable;
+ }
+
+ // Create the object if:
+ // 1. It is a top level model.
+ if (isTopLevelObject)
+ {
+ return ValueProviderDataAvailable;
+ }
+
+ // 2. Any of the model properties can be bound.
+ return CanBindAnyModelItem(bindingContext);
+ }
+
+ private int CanBindAnyModelItem(ModelBindingContext bindingContext)
+ {
+ // If there are no properties on the model, and no constructor parameters, there is nothing to bind. We are here means this is not a top
+ // level object. So we return false.
+ var modelMetadata = bindingContext.ModelMetadata;
+ var performsConstructorBinding = bindingContext.Model == null && modelMetadata.BoundConstructor != null;
+
+ if (modelMetadata.Properties.Count == 0 &&
+ (!performsConstructorBinding || modelMetadata.BoundConstructor.BoundConstructorParameters.Count == 0))
+ {
+ Log.NoPublicSettableItems(_logger, bindingContext);
+ return NoDataAvailable;
+ }
+
+ // We want to check to see if any of the properties of the model can be bound using the value providers or
+ // a greedy binder.
+ //
+ // Because a property might specify a custom binding source ([FromForm]), it's not correct
+ // for us to just try bindingContext.ValueProvider.ContainsPrefixAsync(bindingContext.ModelName);
+ // that may include other value providers - that would lead us to mistakenly create the model
+ // when the data is coming from a source we should use (ex: value found in query string, but the
+ // model has [FromForm]).
+ //
+ // To do this we need to enumerate the properties, and see which of them provide a binding source
+ // through metadata, then we decide what to do.
+ //
+ // If a property has a binding source, and it's a greedy source, then it's always bound.
+ //
+ // If a property has a binding source, and it's a non-greedy source, then we'll filter the
+ // the value providers to just that source, and see if we can find a matching prefix
+ // (see CanBindValue).
+ //
+ // If a property does not have a binding source, then it's fair game for any value provider.
+ //
+ // Bottom line, if any property meets the above conditions and has a value from ValueProviders, then we'll
+ // create the model and try to bind it. Of, if ANY properties of the model have a greedy source,
+ // then we go ahead and create it.
+ var hasGreedyBinders = false;
+ for (var i = 0; i < bindingContext.ModelMetadata.Properties.Count; i++)
+ {
+ var propertyMetadata = bindingContext.ModelMetadata.Properties[i];
+ if (!CanBindItem(bindingContext, propertyMetadata))
+ {
+ continue;
+ }
+
+ // If any property can be bound from a greedy binding source, then success.
+ var bindingSource = propertyMetadata.BindingSource;
+ if (bindingSource != null && bindingSource.IsGreedy)
+ {
+ hasGreedyBinders = true;
+ continue;
+ }
+
+ // Otherwise, check whether the (perhaps filtered) value providers have a match.
+ var fieldName = propertyMetadata.BinderModelName ?? propertyMetadata.PropertyName;
+ var modelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, fieldName);
+ using (bindingContext.EnterNestedScope(
+ modelMetadata: propertyMetadata,
+ fieldName: fieldName,
+ modelName: modelName,
+ model: null))
+ {
+ // If any property can be bound from a value provider, then success.
+ if (bindingContext.ValueProvider.ContainsPrefix(bindingContext.ModelName))
+ {
+ return ValueProviderDataAvailable;
+ }
+ }
+ }
+
+ if (performsConstructorBinding)
+ {
+ var parameters = bindingContext.ModelMetadata.BoundConstructor.BoundConstructorParameters;
+ for (var i = 0; i < parameters.Count; i++)
+ {
+ var parameterMetadata = parameters[i];
+ if (!CanBindItem(bindingContext, parameterMetadata))
+ {
+ continue;
+ }
+
+ // If any parameter can be bound from a greedy binding source, then success.
+ var bindingSource = parameterMetadata.BindingSource;
+ if (bindingSource != null && bindingSource.IsGreedy)
+ {
+ hasGreedyBinders = true;
+ continue;
+ }
+
+ // Otherwise, check whether the (perhaps filtered) value providers have a match.
+ var fieldName = parameterMetadata.BinderModelName ?? parameterMetadata.ParameterName;
+ var modelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, fieldName);
+ using (bindingContext.EnterNestedScope(
+ modelMetadata: parameterMetadata,
+ fieldName: fieldName,
+ modelName: modelName,
+ model: null))
+ {
+ // If any parameter can be bound from a value provider, then success.
+ if (bindingContext.ValueProvider.ContainsPrefix(bindingContext.ModelName))
+ {
+ return ValueProviderDataAvailable;
+ }
+ }
+ }
+ }
+
+ if (hasGreedyBinders)
+ {
+ return GreedyPropertiesMayHaveData;
+ }
+
+ _logger.CannotBindToComplexType(bindingContext);
+
+ return NoDataAvailable;
+ }
+
+ internal static bool CanUpdateReadOnlyProperty(Type propertyType)
+ {
+ // Value types have copy-by-value semantics, which prevents us from updating
+ // properties that are marked readonly.
+ if (propertyType.GetTypeInfo().IsValueType)
+ {
+ return false;
+ }
+
+ // Arrays are strange beasts since their contents are mutable but their sizes aren't.
+ // Therefore we shouldn't even try to update these. Further reading:
+ // http://blogs.msdn.com/ericlippert/archive/2008/09/22/arrays-considered-somewhat-harmful.aspx
+ if (propertyType.IsArray)
+ {
+ return false;
+ }
+
+ // Special-case known immutable reference types
+ if (propertyType == typeof(string))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ internal void SetProperty(
+ ModelBindingContext bindingContext,
+ string modelName,
+ ModelMetadata propertyMetadata,
+ ModelBindingResult result)
+ {
+ if (!result.IsModelSet)
+ {
+ // If we don't have a value, don't set it on the model and trounce a pre-initialized value.
+ return;
+ }
+
+ if (propertyMetadata.IsReadOnly)
+ {
+ // The property should have already been set when we called BindPropertyAsync, so there's
+ // nothing to do here.
+ return;
+ }
+
+ var value = result.Model;
+ try
+ {
+ propertyMetadata.PropertySetter(bindingContext.Model, value);
+ }
+ catch (Exception exception)
+ {
+ AddModelError(exception, modelName, bindingContext);
+ }
+ }
+
+ private static void AddModelError(
+ Exception exception,
+ string modelName,
+ ModelBindingContext bindingContext)
+ {
+ var targetInvocationException = exception as TargetInvocationException;
+ if (targetInvocationException?.InnerException != null)
+ {
+ exception = targetInvocationException.InnerException;
+ }
+
+ // Do not add an error message if a binding error has already occurred for this property.
+ var modelState = bindingContext.ModelState;
+ var validationState = modelState.GetFieldValidationState(modelName);
+ if (validationState == ModelValidationState.Unvalidated)
+ {
+ modelState.AddModelError(modelName, exception, bindingContext.ModelMetadata);
+ }
+ }
+
+ private static class Log
+ {
+ private static readonly Action _noPublicSettableProperties = LoggerMessage.Define(
+ LogLevel.Debug,
+ new EventId(17, "NoPublicSettableItems"),
+ "Could not bind to model with name '{ModelName}' and type '{ModelType}' as the type has no public settable properties or constructor parameters.");
+
+ public static void NoPublicSettableItems(ILogger logger, ModelBindingContext bindingContext)
+ {
+ _noPublicSettableProperties(logger, bindingContext.ModelName, bindingContext.ModelType, null);
+ }
+ }
+ }
+}
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexObjectModelBinderProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexObjectModelBinderProvider.cs
new file mode 100644
index 0000000000..e5f63639f1
--- /dev/null
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexObjectModelBinderProvider.cs
@@ -0,0 +1,64 @@
+// 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 Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
+{
+ ///
+ /// An for complex types.
+ ///
+ public class ComplexObjectModelBinderProvider : IModelBinderProvider
+ {
+ ///
+ public IModelBinder GetBinder(ModelBinderProviderContext context)
+ {
+ if (context == null)
+ {
+ throw new ArgumentNullException(nameof(context));
+ }
+
+ var metadata = context.Metadata;
+ if (metadata.IsComplexType && !metadata.IsCollectionType)
+ {
+ var loggerFactory = context.Services.GetRequiredService();
+ var logger = loggerFactory.CreateLogger();
+ var parameterBinders = GetParameterBinders(context);
+
+ var propertyBinders = new Dictionary();
+ for (var i = 0; i < context.Metadata.Properties.Count; i++)
+ {
+ var property = context.Metadata.Properties[i];
+ propertyBinders.Add(property, context.CreateBinder(property));
+ }
+
+ return new ComplexObjectModelBinder(propertyBinders, parameterBinders, logger);
+ }
+
+ return null;
+ }
+
+ private static IReadOnlyList GetParameterBinders(ModelBinderProviderContext context)
+ {
+ var boundConstructor = context.Metadata.BoundConstructor;
+ if (boundConstructor is null)
+ {
+ return Array.Empty();
+ }
+
+ var parameterBinders = boundConstructor.BoundConstructorParameters.Count == 0 ?
+ Array.Empty() :
+ new IModelBinder[boundConstructor.BoundConstructorParameters.Count];
+
+ for (var i = 0; i < parameterBinders.Length; i++)
+ {
+ parameterBinders[i] = context.CreateBinder(boundConstructor.BoundConstructorParameters[i]);
+ }
+
+ return parameterBinders;
+ }
+ }
+}
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexTypeModelBinder.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexTypeModelBinder.cs
index bd2caaa625..d9c1f0616b 100644
--- a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexTypeModelBinder.cs
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexTypeModelBinder.cs
@@ -16,6 +16,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
///
/// implementation for binding complex types.
///
+ [Obsolete("This type is obsolete and will be removed in a future version. Use ComplexObjectModelBinder instead.")]
public class ComplexTypeModelBinder : IModelBinder
{
// Don't want a new public enum because communication between the private and internal methods of this class
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexTypeModelBinderProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexTypeModelBinderProvider.cs
index 21478b8677..69412231e8 100644
--- a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexTypeModelBinderProvider.cs
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexTypeModelBinderProvider.cs
@@ -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.
using System;
@@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
///
/// An for complex types.
///
+ [Obsolete("This type is obsolete and will be removed in a future version. Use ComplexObjectModelBinderProvider instead.")]
public class ComplexTypeModelBinderProvider : IModelBinderProvider
{
///
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/BindingMetadata.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/BindingMetadata.cs
index 656a6ea2c2..767f326946 100644
--- a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/BindingMetadata.cs
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/BindingMetadata.cs
@@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
+using System.Reflection;
using Microsoft.AspNetCore.Mvc.Core;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
@@ -97,5 +98,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
/// See .
///
public IPropertyFilterProvider PropertyFilterProvider { get; set; }
+
+ ///
+ /// Gets or sets the used to model bind and validate the model type.
+ ///
+ public ConstructorInfo BoundConstructor { get; set; }
}
}
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultBindingMetadataProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultBindingMetadataProvider.cs
index 5cc570394b..eea47efcd6 100644
--- a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultBindingMetadataProvider.cs
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultBindingMetadataProvider.cs
@@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
+using Microsoft.Extensions.Internal;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
{
@@ -72,6 +73,79 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
context.BindingMetadata.IsBindingAllowed = bindingBehavior.Behavior != BindingBehavior.Never;
context.BindingMetadata.IsBindingRequired = bindingBehavior.Behavior == BindingBehavior.Required;
}
+
+ if (GetBoundConstructor(context.Key.ModelType) is ConstructorInfo constructorInfo)
+ {
+ context.BindingMetadata.BoundConstructor = constructorInfo;
+ }
+ }
+
+ internal static ConstructorInfo GetBoundConstructor(Type type)
+ {
+ if (type.IsAbstract || type.IsValueType || type.IsInterface)
+ {
+ return null;
+ }
+
+ var constructors = type.GetConstructors();
+ if (constructors.Length == 0)
+ {
+ return null;
+ }
+
+ return GetRecordTypeConstructor(type, constructors);
+ }
+
+ private static ConstructorInfo GetRecordTypeConstructor(Type type, ConstructorInfo[] constructors)
+ {
+ if (!IsRecordType(type))
+ {
+ return null;
+ }
+
+ // For record types, we will support binding and validating the primary constructor.
+ // There isn't metadata to identify a primary constructor. Our heuristic is:
+ // We require exactly one constructor to be defined on the type, and that every parameter on
+ // that constructor is mapped to a property with the same name and type.
+
+ if (constructors.Length > 1)
+ {
+ return null;
+ }
+
+ var constructor = constructors[0];
+
+ var parameters = constructor.GetParameters();
+ if (parameters.Length == 0)
+ {
+ // We do not need to do special handling for parameterless constructors.
+ return null;
+ }
+
+ var properties = PropertyHelper.GetVisibleProperties(type);
+
+ for (var i = 0; i < parameters.Length; i++)
+ {
+ var parameter = parameters[i];
+ var mappedProperty = properties.FirstOrDefault(property =>
+ string.Equals(property.Name, parameter.Name, StringComparison.Ordinal) &&
+ property.Property.PropertyType == parameter.ParameterType);
+
+ if (mappedProperty is null)
+ {
+ // No property found, this is not a primary constructor.
+ return null;
+ }
+ }
+
+ return constructor;
+
+ static bool IsRecordType(Type type)
+ {
+ // 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);
+ return cloneMethod != null && cloneMethod.ReturnType == type;
+ }
}
private static BindingBehaviorAttribute FindBindingBehavior(BindingMetadataProviderContext context)
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultMetadataDetails.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultMetadataDetails.cs
index cc9b540c5a..5290766553 100644
--- a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultMetadataDetails.cs
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultMetadataDetails.cs
@@ -54,6 +54,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
///
public ModelMetadata[] Properties { get; set; }
+ ///
+ /// Gets or sets the entries for constructor parameters.
+ ///
+ public ModelMetadata[] BoundConstructorParameters { get; set; }
+
///
/// Gets or sets a property getter delegate to get the property value from a model object.
///
@@ -64,6 +69,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
///
public Action PropertySetter { get; set; }
+ ///
+ /// Gets or sets a delegate used to invoke the bound constructor for record types.
+ ///
+ public Func BoundConstructorInvoker { get; set; }
+
///
/// Gets or sets the
///
@@ -74,4 +84,4 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
///
public ModelMetadata ContainerMetadata { get; set; }
}
-}
\ No newline at end of file
+}
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadata.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadata.cs
index 2c2a128e4e..7d57a10f39 100644
--- a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadata.cs
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadata.cs
@@ -25,6 +25,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
private ReadOnlyDictionary _additionalValues;
private ModelMetadata _elementMetadata;
+ private ModelMetadata _constructorMetadata;
private bool? _isBindingRequired;
private bool? _isReadOnly;
private bool? _isRequired;
@@ -386,6 +387,28 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
}
}
+ ///
+ public override ModelMetadata BoundConstructor
+ {
+ get
+ {
+ if (BindingMetadata.BoundConstructor == null)
+ {
+ return null;
+ }
+
+ if (_constructorMetadata == null)
+ {
+ var modelMetadataProvider = (ModelMetadataProvider)_provider;
+ _constructorMetadata = modelMetadataProvider.GetMetadataForConstructor(BindingMetadata.BoundConstructor, ModelType);
+ }
+
+ return _constructorMetadata;
+ }
+ }
+
+ public override IReadOnlyList BoundConstructorParameters => _details.BoundConstructorParameters;
+
///
public override IPropertyFilterProvider PropertyFilterProvider => BindingMetadata.PropertyFilterProvider;
@@ -494,7 +517,16 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
}
else if (defaultModelMetadata.IsComplexType)
{
- foreach (var property in defaultModelMetadata.Properties)
+ var parameters = defaultModelMetadata.BoundConstructor?.BoundConstructorParameters ?? Array.Empty();
+ foreach (var parameter in parameters)
+ {
+ if (CalculateHasValidators(visited, parameter))
+ {
+ return true;
+ }
+ }
+
+ foreach (var property in defaultModelMetadata.BoundProperties)
{
if (CalculateHasValidators(visited, property))
{
@@ -527,6 +559,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
///
public override Action PropertySetter => _details.PropertySetter;
+ public override Func BoundConstructorInvoker => _details.BoundConstructorInvoker;
+
+ internal DefaultMetadataDetails Details => _details;
+
///
public override ModelMetadata GetMetadataForType(Type modelType)
{
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadataProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadataProvider.cs
index 710af66ebc..1d7e3ed9bc 100644
--- a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadataProvider.cs
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadataProvider.cs
@@ -5,6 +5,7 @@ using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
+using System.Linq.Expressions;
using System.Reflection;
using Microsoft.Extensions.Internal;
using Microsoft.Extensions.Options;
@@ -16,7 +17,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
///
public class DefaultModelMetadataProvider : ModelMetadataProvider
{
- private readonly TypeCache _typeCache = new TypeCache();
+ private readonly ModelMetadataCache _modelMetadataCache = new ModelMetadataCache();
private readonly Func _cacheEntryFactory;
private readonly ModelMetadataCacheEntry _metadataCacheEntryForObjectType;
@@ -150,6 +151,18 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
return cacheEntry.Metadata;
}
+
+ ///
+ public override ModelMetadata GetMetadataForConstructor(ConstructorInfo constructorInfo, Type modelType)
+ {
+ if (constructorInfo is null)
+ {
+ throw new ArgumentNullException(nameof(constructorInfo));
+ }
+
+ var cacheEntry = GetCacheEntry(constructorInfo, modelType);
+ return cacheEntry.Metadata;
+ }
private static DefaultModelBindingMessageProvider GetMessageProvider(IOptions optionsAccessor)
{
@@ -174,7 +187,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
{
var key = ModelMetadataIdentity.ForType(modelType);
- cacheEntry = _typeCache.GetOrAdd(key, _cacheEntryFactory);
+ cacheEntry = _modelMetadataCache.GetOrAdd(key, _cacheEntryFactory);
}
return cacheEntry;
@@ -182,22 +195,34 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
private ModelMetadataCacheEntry GetCacheEntry(ParameterInfo parameter, Type modelType)
{
- return _typeCache.GetOrAdd(
+ return _modelMetadataCache.GetOrAdd(
ModelMetadataIdentity.ForParameter(parameter, modelType),
_cacheEntryFactory);
}
private ModelMetadataCacheEntry GetCacheEntry(PropertyInfo property, Type modelType)
{
- return _typeCache.GetOrAdd(
+ return _modelMetadataCache.GetOrAdd(
ModelMetadataIdentity.ForProperty(property, modelType, property.DeclaringType),
_cacheEntryFactory);
}
+ private ModelMetadataCacheEntry GetCacheEntry(ConstructorInfo constructor, Type modelType)
+ {
+ return _modelMetadataCache.GetOrAdd(
+ ModelMetadataIdentity.ForConstructor(constructor, modelType),
+ _cacheEntryFactory);
+ }
+
private ModelMetadataCacheEntry CreateCacheEntry(ModelMetadataIdentity key)
{
DefaultMetadataDetails details;
- if (key.MetadataKind == ModelMetadataKind.Parameter)
+
+ if (key.MetadataKind == ModelMetadataKind.Constructor)
+ {
+ details = CreateConstructorDetails(key);
+ }
+ else if (key.MetadataKind == ModelMetadataKind.Parameter)
{
details = CreateParameterDetails(key);
}
@@ -230,6 +255,73 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
return null;
}
+ private DefaultMetadataDetails CreateConstructorDetails(ModelMetadataIdentity constructorKey)
+ {
+ var constructor = constructorKey.ConstructorInfo;
+ var parameters = constructor.GetParameters();
+ var parameterMetadata = new ModelMetadata[parameters.Length];
+ var parameterTypes = new Type[parameters.Length];
+
+ for (var i = 0; i < parameters.Length; i++)
+ {
+ var parameter = parameters[i];
+ var parameterDetails = CreateParameterDetails(ModelMetadataIdentity.ForParameter(parameter));
+ parameterMetadata[i] = CreateModelMetadata(parameterDetails);
+
+ parameterTypes[i] = parameter.ParameterType;
+ }
+
+ var constructorDetails = new DefaultMetadataDetails(constructorKey, ModelAttributes.Empty);
+ constructorDetails.BoundConstructorParameters = parameterMetadata;
+ constructorDetails.BoundConstructorInvoker = CreateObjectFactory(constructor);
+
+ return constructorDetails;
+
+ static Func CreateObjectFactory(ConstructorInfo constructor)
+ {
+ var args = Expression.Parameter(typeof(object[]), "args");
+ var factoryExpressionBody = BuildFactoryExpression(constructor, args);
+
+ var factoryLamda = Expression.Lambda>(factoryExpressionBody, args);
+
+ return factoryLamda.Compile();
+ }
+ }
+
+ private static Expression BuildFactoryExpression(
+ ConstructorInfo constructor,
+ Expression factoryArgumentArray)
+ {
+ var constructorParameters = constructor.GetParameters();
+ var constructorArguments = new Expression[constructorParameters.Length];
+
+ for (var i = 0; i < constructorParameters.Length; i++)
+ {
+ var constructorParameter = constructorParameters[i];
+ var parameterType = constructorParameter.ParameterType;
+
+ constructorArguments[i] = Expression.ArrayAccess(factoryArgumentArray, Expression.Constant(i));
+ if (ParameterDefaultValue.TryGetDefaultValue(constructorParameter, out var defaultValue))
+ {
+ // We have a default value;
+ }
+ else if (parameterType.IsValueType)
+ {
+ defaultValue = Activator.CreateInstance(parameterType);
+ }
+
+ if (defaultValue != null)
+ {
+ var defaultValueExpression = Expression.Constant(defaultValue);
+ constructorArguments[i] = Expression.Coalesce(constructorArguments[i], defaultValueExpression);
+ }
+
+ constructorArguments[i] = Expression.Convert(constructorArguments[i], parameterType);
+ }
+
+ return Expression.New(constructor, constructorArguments);
+ }
+
private ModelMetadataCacheEntry GetMetadataCacheEntryForObjectType()
{
var key = ModelMetadataIdentity.ForType(typeof(object));
@@ -341,7 +433,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
ModelAttributes.GetAttributesForParameter(key.ParameterInfo, key.ModelType));
}
- private class TypeCache : ConcurrentDictionary
+ private class ModelMetadataCache : ConcurrentDictionary
{
}
@@ -358,4 +450,4 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
public DefaultMetadataDetails Details { get; }
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultValidationMetadataProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultValidationMetadataProvider.cs
index c8df349ccd..304bdd3054 100644
--- a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultValidationMetadataProvider.cs
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultValidationMetadataProvider.cs
@@ -53,6 +53,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
context.ValidationMetadata.PropertyValidationFilter = validationFilter;
}
+ else if (context.Key.MetadataKind == ModelMetadataKind.Parameter)
+ {
+ var validationFilter = context.ParameterAttributes.OfType().FirstOrDefault();
+ context.ValidationMetadata.PropertyValidationFilter = validationFilter;
+ }
}
}
}
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/ModelAttributes.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/ModelAttributes.cs
index b5cc024d22..31916e31cf 100644
--- a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/ModelAttributes.cs
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/ModelAttributes.cs
@@ -13,6 +13,16 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
///
public class ModelAttributes
{
+ internal static readonly ModelAttributes Empty = new ModelAttributes(Array.Empty());
+
+ ///
+ /// Creates a new .
+ ///
+ internal ModelAttributes(IReadOnlyList attributes)
+ {
+ Attributes = attributes;
+ }
+
///
/// Creates a new .
///
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/ParameterBinder.cs b/src/Mvc/Mvc.Core/src/ModelBinding/ParameterBinder.cs
index c39ac3192a..1cd9853fed 100644
--- a/src/Mvc/Mvc.Core/src/ModelBinding/ParameterBinder.cs
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/ParameterBinder.cs
@@ -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.
using System;
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Validation/DefaultComplexObjectValidationStrategy.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Validation/DefaultComplexObjectValidationStrategy.cs
index f82ec567f1..e9a17b5a4b 100644
--- a/src/Mvc/Mvc.Core/src/ModelBinding/Validation/DefaultComplexObjectValidationStrategy.cs
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Validation/DefaultComplexObjectValidationStrategy.cs
@@ -4,7 +4,7 @@
using System;
using System.Collections;
using System.Collections.Generic;
-using System.Reflection;
+using Microsoft.AspNetCore.Mvc.Core;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation
{
@@ -13,8 +13,6 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation
///
internal class DefaultComplexObjectValidationStrategy : IValidationStrategy
{
- private static readonly bool IsMono = Type.GetType("Mono.Runtime") != null;
-
///
/// Gets an instance of .
///
@@ -30,27 +28,42 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation
string key,
object model)
{
- return new Enumerator(metadata.Properties, key, model);
+ return new Enumerator(metadata, key, model);
}
private class Enumerator : IEnumerator
{
private readonly string _key;
private readonly object _model;
- private readonly ModelPropertyCollection _properties;
+ private readonly int _count;
+ private readonly ModelMetadata _modelMetadata;
+ private readonly IReadOnlyList _parameters;
+ private readonly IReadOnlyList _properties;
private ValidationEntry _entry;
private int _index;
public Enumerator(
- ModelPropertyCollection properties,
+ ModelMetadata modelMetadata,
string key,
object model)
{
- _properties = properties;
+ _modelMetadata = modelMetadata;
_key = key;
_model = model;
+ if (_modelMetadata.BoundConstructor == null)
+ {
+ _parameters = Array.Empty();
+ }
+ else
+ {
+ _parameters = _modelMetadata.BoundConstructor.BoundConstructorParameters;
+ }
+
+ _properties = _modelMetadata.BoundProperties;
+ _count = _properties.Count + _parameters.Count;
+
_index = -1;
}
@@ -61,27 +74,48 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation
public bool MoveNext()
{
_index++;
- if (_index >= _properties.Count)
+
+ if (_index >= _count)
{
return false;
}
- var property = _properties[_index];
- var propertyName = property.BinderModelName ?? property.PropertyName;
- var key = ModelNames.CreatePropertyModelName(_key, propertyName);
+ if (_index < _parameters.Count)
+ {
+ var parameter = _parameters[_index];
+ var parameterName = parameter.BinderModelName ?? parameter.ParameterName;
+ var key = ModelNames.CreatePropertyModelName(_key, parameterName);
- if (_model == null)
- {
- // 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));
+ if (_model is null)
+ {
+ _entry = new ValidationEntry(parameter, key, model: null);
+ }
+ else
+ {
+ if (!_modelMetadata.BoundConstructorParameterMapping.TryGetValue(parameter, out var property))
+ {
+ throw new InvalidOperationException(
+ Resources.FormatValidationStrategy_MappedPropertyNotFound(parameter, _modelMetadata.ModelType));
+ }
+
+ _entry = new ValidationEntry(parameter, key, () => GetModel(_model, property));
+ }
}
else
{
- _entry = new ValidationEntry(property, key, () => GetModel(_model, property));
+ var property = _properties[_index - _parameters.Count];
+ var propertyName = property.BinderModelName ?? property.PropertyName;
+ var key = ModelNames.CreatePropertyModelName(_key, propertyName);
+
+ if (_model == null)
+ {
+ // Performance: Never create a delegate when container is null.
+ _entry = new ValidationEntry(property, key, model: null);
+ }
+ else
+ {
+ _entry = new ValidationEntry(property, key, () => GetModel(_model, property));
+ }
}
return true;
@@ -100,21 +134,6 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation
{
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;
- }
- }
}
}
}
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Validation/ValidateNeverAttribute.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Validation/ValidateNeverAttribute.cs
index a0c7181f3f..600349a0c0 100644
--- a/src/Mvc/Mvc.Core/src/ModelBinding/Validation/ValidateNeverAttribute.cs
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Validation/ValidateNeverAttribute.cs
@@ -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.
using System;
@@ -6,11 +6,12 @@ using System;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation
{
///
- /// 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.
+ /// Indicates that a property or parameter should be excluded from validation.
+ /// When applied to a property, the validation system excludes that property.
+ /// When applied to a parameter, the validation system excludes that parameter.
+ /// When applied to a type, the validation system excludes all properties within that type.
///
- [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
+ [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
public sealed class ValidateNeverAttribute : Attribute, IPropertyValidationFilter
{
///
diff --git a/src/Mvc/Mvc.Core/src/Resources.resx b/src/Mvc/Mvc.Core/src/Resources.resx
index 93966b49f7..2ae1743410 100644
--- a/src/Mvc/Mvc.Core/src/Resources.resx
+++ b/src/Mvc/Mvc.Core/src/Resources.resx
@@ -522,4 +522,16 @@
Transformer '{0}' was retrieved from dependency injection with a state value. State can only be specified when the dynamic route is mapped using MapDynamicControllerRoute's state argument together with transient lifetime transformer. Ensure that '{0}' doesn't set its own state and that the transformer is registered with a transient lifetime in dependency injection.
+
+ Could not create an instance of type '{0}'. Model bound complex types must not be abstract or value types and must have a parameterless constructor. Record types must have a single primary constructor. Alternatively, give the '{1}' parameter a non-null default value.
+
+
+ Could not create an instance of type '{0}'. Model bound complex types must not be abstract or value types and must have a parameterless constructor. Record types must have a single primary constructor. Alternatively, set the '{1}' property to a non-null value in the '{2}' constructor.
+
+
+ Could not create an instance of type '{0}'. Model bound complex types must not be abstract or value types and must have a parameterless constructor. Record types must have a single primary constructor.
+
+
+ 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.
+
diff --git a/src/Mvc/Mvc.Core/test/ApplicationModels/DefaultApplicationModelProviderTest.cs b/src/Mvc/Mvc.Core/test/ApplicationModels/DefaultApplicationModelProviderTest.cs
index 78e9dd5daa..efa5a1c065 100644
--- a/src/Mvc/Mvc.Core/test/ApplicationModels/DefaultApplicationModelProviderTest.cs
+++ b/src/Mvc/Mvc.Core/test/ApplicationModels/DefaultApplicationModelProviderTest.cs
@@ -1270,7 +1270,7 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
{
public string Property { get; set; }
- [ModelBinder(typeof(ComplexTypeModelBinder))]
+ [ModelBinder(typeof(ComplexObjectModelBinder))]
public string BinderType { get; set; }
[FromRoute]
@@ -1307,7 +1307,7 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
// Assert
var bindingInfo = property.BindingInfo;
- Assert.Same(typeof(ComplexTypeModelBinder), bindingInfo.BinderType);
+ Assert.Same(typeof(ComplexObjectModelBinder), bindingInfo.BinderType);
}
[Fact]
diff --git a/src/Mvc/Mvc.Core/test/ApplicationModels/InferParameterBindingInfoConventionTest.cs b/src/Mvc/Mvc.Core/test/ApplicationModels/InferParameterBindingInfoConventionTest.cs
index 4a38cbb4af..dccd843b4c 100644
--- a/src/Mvc/Mvc.Core/test/ApplicationModels/InferParameterBindingInfoConventionTest.cs
+++ b/src/Mvc/Mvc.Core/test/ApplicationModels/InferParameterBindingInfoConventionTest.cs
@@ -998,7 +998,7 @@ Environment.NewLine + "int b";
private class ParameterWithBindingInfo
{
[HttpGet("test")]
- public IActionResult Action([ModelBinder(typeof(ComplexTypeModelBinder))] Car car) => null;
+ public IActionResult Action([ModelBinder(typeof(ComplexObjectModelBinder))] Car car) => null;
}
}
}
diff --git a/src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj b/src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj
index 8ebae5504f..4b77a3c2fb 100644
--- a/src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj
+++ b/src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj
@@ -1,8 +1,9 @@
-
+
$(DefaultNetCoreTargetFramework)
Microsoft.AspNetCore.Mvc
+ 9.0
diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexObjectModelBinderProviderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexObjectModelBinderProviderTest.cs
new file mode 100644
index 0000000000..082fe6b74e
--- /dev/null
+++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexObjectModelBinderProviderTest.cs
@@ -0,0 +1,92 @@
+// 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 Moq;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
+{
+ public class ComplexObjectModelBinderProviderTest
+ {
+ [Theory]
+ [InlineData(typeof(string))]
+ [InlineData(typeof(int))]
+ [InlineData(typeof(List))]
+ public void Create_ForNonComplexType_ReturnsNull(Type modelType)
+ {
+ // Arrange
+ var provider = new ComplexObjectModelBinderProvider();
+
+ var context = new TestModelBinderProviderContext(modelType);
+
+ // Act
+ var result = provider.GetBinder(context);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Create_ForSupportedTypes_ReturnsBinder()
+ {
+ // Arrange
+ var provider = new ComplexObjectModelBinderProvider();
+
+ var context = new TestModelBinderProviderContext(typeof(Person));
+ context.OnCreatingBinder(m =>
+ {
+ if (m.ModelType == typeof(int) || m.ModelType == typeof(string))
+ {
+ return Mock.Of();
+ }
+ else
+ {
+ Assert.False(true, "Not the right model type");
+ return null;
+ }
+ });
+
+ // Act
+ var result = provider.GetBinder(context);
+
+ // Assert
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public void Create_ForSupportedType_ReturnsBinder()
+ {
+ // Arrange
+ var provider = new ComplexObjectModelBinderProvider();
+
+ var context = new TestModelBinderProviderContext(typeof(Person));
+ context.OnCreatingBinder(m =>
+ {
+ if (m.ModelType == typeof(int) || m.ModelType == typeof(string))
+ {
+ return Mock.Of();
+ }
+ else
+ {
+ Assert.False(true, "Not the right model type");
+ return null;
+ }
+ });
+
+ // Act
+ var result = provider.GetBinder(context);
+
+ // Assert
+ Assert.IsType(result);
+ }
+
+ private class Person
+ {
+ public string Name { get; set; }
+
+ public int Age { get; set; }
+ }
+ }
+}
diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexObjectModelBinderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexObjectModelBinderTest.cs
new file mode 100644
index 0000000000..fcf5ea537e
--- /dev/null
+++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexObjectModelBinderTest.cs
@@ -0,0 +1,1412 @@
+// 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.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.Serialization;
+using System.Threading.Tasks;
+using Castle.Core.Logging;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
+using Microsoft.AspNetCore.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Moq;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
+{
+ public class ComplexObjectModelBinderTest
+ {
+ private static readonly IModelMetadataProvider _metadataProvider = TestModelMetadataProvider.CreateDefaultProvider();
+ private readonly ILogger _logger = NullLogger.Instance;
+
+ [Theory]
+ [InlineData(true, ComplexObjectModelBinder.ValueProviderDataAvailable)]
+ [InlineData(false, ComplexObjectModelBinder.NoDataAvailable)]
+ public void CanCreateModel_ReturnsTrue_IfIsTopLevelObject(bool isTopLevelObject, int expectedCanCreate)
+ {
+ var bindingContext = CreateContext(GetMetadataForType(typeof(Person)));
+ bindingContext.IsTopLevelObject = isTopLevelObject;
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ var canCreate = binder.CanCreateModel(bindingContext);
+
+ // Assert
+ Assert.Equal(expectedCanCreate, canCreate);
+ }
+
+ [Fact]
+ public void CanCreateModel_ReturnsFalse_IfNotIsTopLevelObjectAndModelIsMarkedWithBinderMetadata()
+ {
+ var modelMetadata = GetMetadataForProperty(typeof(Document), nameof(Document.SubDocument));
+
+ var bindingContext = CreateContext(modelMetadata);
+ bindingContext.IsTopLevelObject = false;
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ var canCreate = binder.CanCreateModel(bindingContext);
+
+ // Assert
+ Assert.Equal(ComplexObjectModelBinder.NoDataAvailable, canCreate);
+ }
+
+ [Fact]
+ public void CanCreateModel_ReturnsTrue_IfIsTopLevelObjectAndModelIsMarkedWithBinderMetadata()
+ {
+ var bindingContext = CreateContext(GetMetadataForType(typeof(Document)));
+ bindingContext.IsTopLevelObject = true;
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ var canCreate = binder.CanCreateModel(bindingContext);
+
+ // Assert
+ Assert.Equal(ComplexObjectModelBinder.ValueProviderDataAvailable, canCreate);
+ }
+
+ [Theory]
+ [InlineData(ComplexObjectModelBinder.ValueProviderDataAvailable)]
+ [InlineData(ComplexObjectModelBinder.GreedyPropertiesMayHaveData)]
+ public void CanCreateModel_CreatesModel_WithAllGreedyProperties(int expectedCanCreate)
+ {
+ var bindingContext = CreateContext(GetMetadataForType(typeof(HasAllGreedyProperties)));
+ bindingContext.IsTopLevelObject = expectedCanCreate == ComplexObjectModelBinder.ValueProviderDataAvailable;
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ var canCreate = binder.CanCreateModel(bindingContext);
+
+ // Assert
+ Assert.Equal(expectedCanCreate, canCreate);
+ }
+
+ [Theory]
+ [InlineData(ComplexObjectModelBinder.ValueProviderDataAvailable)]
+ [InlineData(ComplexObjectModelBinder.NoDataAvailable)]
+ public void CanCreateModel_ReturnsTrue_IfNotIsTopLevelObject_BasedOnValueAvailability(int valueAvailable)
+ {
+ // Arrange
+ var valueProvider = new Mock(MockBehavior.Strict);
+ valueProvider
+ .Setup(provider => provider.ContainsPrefix("SimpleContainer.Simple.Name"))
+ .Returns(valueAvailable == ComplexObjectModelBinder.ValueProviderDataAvailable);
+
+ var modelMetadata = GetMetadataForProperty(typeof(SimpleContainer), nameof(SimpleContainer.Simple));
+ var bindingContext = CreateContext(modelMetadata);
+ bindingContext.IsTopLevelObject = false;
+ bindingContext.ModelName = "SimpleContainer.Simple";
+ bindingContext.ValueProvider = valueProvider.Object;
+ bindingContext.OriginalValueProvider = valueProvider.Object;
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ var canCreate = binder.CanCreateModel(bindingContext);
+
+ // Assert
+ // Result matches whether first Simple property can bind.
+ Assert.Equal(valueAvailable, canCreate);
+ }
+
+ [Fact]
+ public void CanCreateModel_ReturnsFalse_IfNotIsTopLevelObjectAndModelHasNoProperties()
+ {
+ // Arrange
+ var bindingContext = CreateContext(GetMetadataForType(typeof(PersonWithNoProperties)));
+ bindingContext.IsTopLevelObject = false;
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ var canCreate = binder.CanCreateModel(bindingContext);
+
+ // Assert
+ Assert.Equal(ComplexObjectModelBinder.NoDataAvailable, canCreate);
+ }
+
+ [Fact]
+ public void CanCreateModel_ReturnsTrue_IfIsTopLevelObjectAndModelHasNoProperties()
+ {
+ // Arrange
+ var bindingContext = CreateContext(GetMetadataForType(typeof(PersonWithNoProperties)));
+ bindingContext.IsTopLevelObject = true;
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ var canCreate = binder.CanCreateModel(bindingContext);
+
+ // Assert
+ Assert.Equal(ComplexObjectModelBinder.ValueProviderDataAvailable, canCreate);
+ }
+
+ [Theory]
+ [InlineData(typeof(TypeWithNoBinderMetadata), ComplexObjectModelBinder.NoDataAvailable)]
+ [InlineData(typeof(TypeWithNoBinderMetadata), ComplexObjectModelBinder.ValueProviderDataAvailable)]
+ public void CanCreateModel_CreatesModelForValueProviderBasedBinderMetadatas_IfAValueProviderProvidesValue(
+ Type modelType,
+ int valueProviderProvidesValue)
+ {
+ var valueProvider = new Mock();
+ valueProvider
+ .Setup(o => o.ContainsPrefix(It.IsAny()))
+ .Returns(valueProviderProvidesValue == ComplexObjectModelBinder.ValueProviderDataAvailable);
+
+ var bindingContext = CreateContext(GetMetadataForType(modelType));
+ bindingContext.IsTopLevelObject = false;
+ bindingContext.ValueProvider = valueProvider.Object;
+ bindingContext.OriginalValueProvider = valueProvider.Object;
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ var canCreate = binder.CanCreateModel(bindingContext);
+
+ // Assert
+ Assert.Equal(valueProviderProvidesValue, canCreate);
+ }
+
+ [Theory]
+ [InlineData(typeof(TypeWithAtLeastOnePropertyMarkedUsingValueBinderMetadata), ComplexObjectModelBinder.GreedyPropertiesMayHaveData)]
+ [InlineData(typeof(TypeWithAtLeastOnePropertyMarkedUsingValueBinderMetadata), ComplexObjectModelBinder.ValueProviderDataAvailable)]
+ [InlineData(typeof(TypeWithUnmarkedAndBinderMetadataMarkedProperties), ComplexObjectModelBinder.GreedyPropertiesMayHaveData)]
+ [InlineData(typeof(TypeWithUnmarkedAndBinderMetadataMarkedProperties), ComplexObjectModelBinder.ValueProviderDataAvailable)]
+ public void CanCreateModel_CreatesModelForValueProviderBasedBinderMetadatas_IfPropertyHasGreedyBindingSource(
+ Type modelType,
+ int expectedCanCreate)
+ {
+ var valueProvider = new Mock();
+ valueProvider
+ .Setup(o => o.ContainsPrefix(It.IsAny()))
+ .Returns(expectedCanCreate == ComplexObjectModelBinder.ValueProviderDataAvailable);
+
+ var bindingContext = CreateContext(GetMetadataForType(modelType));
+ bindingContext.IsTopLevelObject = false;
+ bindingContext.ValueProvider = valueProvider.Object;
+ bindingContext.OriginalValueProvider = valueProvider.Object;
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ var canCreate = binder.CanCreateModel(bindingContext);
+
+ // Assert
+ Assert.Equal(expectedCanCreate, canCreate);
+ }
+
+ [Theory]
+ [InlineData(typeof(TypeWithAtLeastOnePropertyMarkedUsingValueBinderMetadata), ComplexObjectModelBinder.GreedyPropertiesMayHaveData)]
+ [InlineData(typeof(TypeWithAtLeastOnePropertyMarkedUsingValueBinderMetadata), ComplexObjectModelBinder.ValueProviderDataAvailable)]
+ public void CanCreateModel_ForExplicitValueProviderMetadata_UsesOriginalValueProvider(
+ Type modelType,
+ int expectedCanCreate)
+ {
+ var valueProvider = new Mock();
+ valueProvider
+ .Setup(o => o.ContainsPrefix(It.IsAny()))
+ .Returns(false);
+
+ var originalValueProvider = new Mock();
+ originalValueProvider
+ .Setup(o => o.ContainsPrefix(It.IsAny()))
+ .Returns(expectedCanCreate == ComplexObjectModelBinder.ValueProviderDataAvailable);
+
+ originalValueProvider
+ .Setup(o => o.Filter(It.IsAny()))
+ .Returns(source => source == BindingSource.Query ? originalValueProvider.Object : null);
+
+ var bindingContext = CreateContext(GetMetadataForType(modelType));
+ bindingContext.IsTopLevelObject = false;
+ bindingContext.ValueProvider = valueProvider.Object;
+ bindingContext.OriginalValueProvider = originalValueProvider.Object;
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ var canCreate = binder.CanCreateModel(bindingContext);
+
+ // Assert
+ Assert.Equal(expectedCanCreate, canCreate);
+ }
+
+ [Theory]
+ [InlineData(typeof(TypeWithUnmarkedAndBinderMetadataMarkedProperties), false, ComplexObjectModelBinder.GreedyPropertiesMayHaveData)]
+ [InlineData(typeof(TypeWithUnmarkedAndBinderMetadataMarkedProperties), true, ComplexObjectModelBinder.ValueProviderDataAvailable)]
+ [InlineData(typeof(TypeWithNoBinderMetadata), false, ComplexObjectModelBinder.NoDataAvailable)]
+ [InlineData(typeof(TypeWithNoBinderMetadata), true, ComplexObjectModelBinder.ValueProviderDataAvailable)]
+ public void CanCreateModel_UnmarkedProperties_UsesCurrentValueProvider(
+ Type modelType,
+ bool valueProviderProvidesValue,
+ int expectedCanCreate)
+ {
+ var valueProvider = new Mock();
+ valueProvider
+ .Setup(o => o.ContainsPrefix(It.IsAny()))
+ .Returns(valueProviderProvidesValue);
+
+ var originalValueProvider = new Mock();
+ originalValueProvider
+ .Setup(o => o.ContainsPrefix(It.IsAny()))
+ .Returns(false);
+
+ var bindingContext = CreateContext(GetMetadataForType(modelType));
+ bindingContext.IsTopLevelObject = false;
+ bindingContext.ValueProvider = valueProvider.Object;
+ bindingContext.OriginalValueProvider = originalValueProvider.Object;
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ var canCreate = binder.CanCreateModel(bindingContext);
+
+ // Assert
+ Assert.Equal(expectedCanCreate, canCreate);
+ }
+
+ private IActionResult ActionWithComplexParameter(Person parameter) => null;
+
+ [Fact]
+ public async Task BindModelAsync_CreatesModelAndAddsError_IfIsTopLevelObject_WithNoData()
+ {
+ // Arrange
+ var parameter = typeof(ComplexObjectModelBinderTest)
+ .GetMethod(nameof(ActionWithComplexParameter), BindingFlags.Instance | BindingFlags.NonPublic)
+ .GetParameters()[0];
+ var metadataProvider = new TestModelMetadataProvider();
+ metadataProvider
+ .ForParameter(parameter)
+ .BindingDetails(b => b.IsBindingRequired = true);
+ var metadata = metadataProvider.GetMetadataForParameter(parameter);
+ var bindingContext = new DefaultModelBindingContext
+ {
+ IsTopLevelObject = true,
+ FieldName = "fieldName",
+ ModelMetadata = metadata,
+ ModelName = string.Empty,
+ ValueProvider = new TestValueProvider(new Dictionary()),
+ ModelState = new ModelStateDictionary(),
+ };
+
+ // Mock binder fails to bind all properties.
+ var innerBinder = new StubModelBinder();
+ var binders = new Dictionary();
+ foreach (var property in metadataProvider.GetMetadataForProperties(typeof(Person)))
+ {
+ binders.Add(property, innerBinder);
+ }
+
+ var binder = new ComplexObjectModelBinder(
+ binders,
+ Array.Empty(),
+ _logger);
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ Assert.True(bindingContext.Result.IsModelSet);
+ Assert.IsType(bindingContext.Result.Model);
+
+ var keyValuePair = Assert.Single(bindingContext.ModelState);
+ Assert.Equal(string.Empty, keyValuePair.Key);
+ var error = Assert.Single(keyValuePair.Value.Errors);
+ Assert.Equal("A value for the 'fieldName' parameter or property was not provided.", error.ErrorMessage);
+ }
+
+ private IActionResult ActionWithNoSettablePropertiesParameter(PersonWithNoProperties parameter) => null;
+
+ [Fact]
+ public async Task BindModelAsync_CreatesModelAndAddsError_IfIsTopLevelObject_WithNoSettableProperties()
+ {
+ // Arrange
+ var parameter = typeof(ComplexObjectModelBinderTest)
+ .GetMethod(
+ nameof(ActionWithNoSettablePropertiesParameter),
+ BindingFlags.Instance | BindingFlags.NonPublic)
+ .GetParameters()[0];
+ var metadataProvider = new TestModelMetadataProvider();
+ metadataProvider
+ .ForParameter(parameter)
+ .BindingDetails(b => b.IsBindingRequired = true);
+ var metadata = metadataProvider.GetMetadataForParameter(parameter);
+ var bindingContext = new DefaultModelBindingContext
+ {
+ IsTopLevelObject = true,
+ FieldName = "fieldName",
+ ModelMetadata = metadata,
+ ModelName = string.Empty,
+ ValueProvider = new TestValueProvider(new Dictionary()),
+ ModelState = new ModelStateDictionary(),
+ };
+
+ var binder = new ComplexObjectModelBinder(
+ new Dictionary(),
+ Array.Empty(),
+ _logger);
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ Assert.True(bindingContext.Result.IsModelSet);
+ Assert.IsType(bindingContext.Result.Model);
+
+ var keyValuePair = Assert.Single(bindingContext.ModelState);
+ Assert.Equal(string.Empty, keyValuePair.Key);
+ var error = Assert.Single(keyValuePair.Value.Errors);
+ Assert.Equal("A value for the 'fieldName' parameter or property was not provided.", error.ErrorMessage);
+ }
+
+ private IActionResult ActionWithAllPropertiesExcludedParameter(PersonWithAllPropertiesExcluded parameter) => null;
+
+ [Fact]
+ public async Task BindModelAsync_CreatesModelAndAddsError_IfIsTopLevelObject_WithAllPropertiesExcluded()
+ {
+ // Arrange
+ var parameter = typeof(ComplexObjectModelBinderTest)
+ .GetMethod(
+ nameof(ActionWithAllPropertiesExcludedParameter),
+ BindingFlags.Instance | BindingFlags.NonPublic)
+ .GetParameters()[0];
+ var metadataProvider = new TestModelMetadataProvider();
+ metadataProvider
+ .ForParameter(parameter)
+ .BindingDetails(b => b.IsBindingRequired = true);
+ var metadata = metadataProvider.GetMetadataForParameter(parameter);
+ var bindingContext = new DefaultModelBindingContext
+ {
+ IsTopLevelObject = true,
+ FieldName = "fieldName",
+ ModelMetadata = metadata,
+ ModelName = string.Empty,
+ ValueProvider = new TestValueProvider(new Dictionary()),
+ ModelState = new ModelStateDictionary(),
+ };
+
+ var binder = new ComplexObjectModelBinder(
+ new Dictionary(),
+ Array.Empty(),
+ _logger);
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ Assert.True(bindingContext.Result.IsModelSet);
+ Assert.IsType(bindingContext.Result.Model);
+
+ var keyValuePair = Assert.Single(bindingContext.ModelState);
+ Assert.Equal(string.Empty, keyValuePair.Key);
+ var error = Assert.Single(keyValuePair.Value.Errors);
+ Assert.Equal("A value for the 'fieldName' parameter or property was not provided.", error.ErrorMessage);
+ }
+
+ [Theory]
+ [InlineData(nameof(CollectionContainer.ReadOnlyArray), false)]
+ [InlineData(nameof(CollectionContainer.ReadOnlyDictionary), true)]
+ [InlineData(nameof(CollectionContainer.ReadOnlyList), true)]
+ [InlineData(nameof(CollectionContainer.SettableDictionary), true)]
+ [InlineData(nameof(CollectionContainer.SettableList), true)]
+ public void CanUpdateProperty_CollectionProperty_FalseOnlyForArray(string propertyName, bool expected)
+ {
+ // Arrange
+ var metadataProvider = _metadataProvider;
+ var metadata = metadataProvider.GetMetadataForProperty(typeof(CollectionContainer), propertyName);
+
+ // Act
+ var canUpdate = ComplexObjectModelBinder.CanUpdateReadOnlyProperty(metadata.ModelType);
+
+ // Assert
+ Assert.Equal(expected, canUpdate);
+ }
+
+ private class PersonWithName
+ {
+ public string Name { get; set; }
+ }
+
+ [Fact]
+ public async Task BindModelAsync_ModelIsNotNull_DoesNotCallCreateModel()
+ {
+ // Arrange
+ var bindingContext = CreateContext(GetMetadataForType(typeof(PersonWithName)), new PersonWithName());
+ var originalModel = bindingContext.Model;
+
+ var binders = bindingContext.ModelMetadata.Properties.ToDictionary(
+ keySelector: item => item,
+ elementSelector: item => (IModelBinder)new TestModelBinderProvider(item, ModelBindingResult.Success("Test")));
+
+ var binder = new ComplexObjectModelBinder(
+ binders,
+ Array.Empty(),
+ _logger);
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ Assert.Same(originalModel, bindingContext.Model);
+ }
+
+ [Theory]
+ [InlineData(nameof(PersonWithBindExclusion.FirstName))]
+ [InlineData(nameof(PersonWithBindExclusion.LastName))]
+ public void CanBindProperty_GetSetProperty(string property)
+ {
+ // Arrange
+ var metadata = GetMetadataForProperty(typeof(PersonWithBindExclusion), property);
+ var bindingContext = new DefaultModelBindingContext()
+ {
+ ActionContext = new ActionContext()
+ {
+ HttpContext = new DefaultHttpContext()
+ {
+ RequestServices = new ServiceCollection().BuildServiceProvider(),
+ },
+ },
+ ModelMetadata = GetMetadataForType(typeof(PersonWithBindExclusion)),
+ };
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ var result = binder.CanBindItem(bindingContext, metadata);
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Theory]
+ [InlineData(nameof(PersonWithBindExclusion.NonUpdateableProperty))]
+ public void CanBindProperty_GetOnlyProperty_WithBindNever(string property)
+ {
+ // Arrange
+ var metadata = GetMetadataForProperty(typeof(PersonWithBindExclusion), property);
+ var bindingContext = new DefaultModelBindingContext()
+ {
+ ActionContext = new ActionContext()
+ {
+ HttpContext = new DefaultHttpContext()
+ {
+ RequestServices = new ServiceCollection().BuildServiceProvider(),
+ },
+ },
+ ModelMetadata = GetMetadataForType(typeof(PersonWithBindExclusion)),
+ };
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ var result = binder.CanBindItem(bindingContext, metadata);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Theory]
+ [InlineData(nameof(PersonWithBindExclusion.DateOfBirth))]
+ [InlineData(nameof(PersonWithBindExclusion.DateOfDeath))]
+ public void CanBindProperty_GetSetProperty_WithBindNever(string property)
+ {
+ // Arrange
+ var metadata = GetMetadataForProperty(typeof(PersonWithBindExclusion), property);
+ var bindingContext = new DefaultModelBindingContext()
+ {
+ ActionContext = new ActionContext()
+ {
+ HttpContext = new DefaultHttpContext(),
+ },
+ ModelMetadata = GetMetadataForType(typeof(PersonWithBindExclusion)),
+ };
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ var result = binder.CanBindItem(bindingContext, metadata);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Theory]
+ [InlineData(nameof(TypeWithIncludedPropertiesUsingBindAttribute.IncludedExplicitly1), true)]
+ [InlineData(nameof(TypeWithIncludedPropertiesUsingBindAttribute.IncludedExplicitly2), true)]
+ [InlineData(nameof(TypeWithIncludedPropertiesUsingBindAttribute.ExcludedByDefault1), false)]
+ [InlineData(nameof(TypeWithIncludedPropertiesUsingBindAttribute.ExcludedByDefault2), false)]
+ public void CanBindProperty_WithBindInclude(string property, bool expected)
+ {
+ // Arrange
+ var metadata = GetMetadataForProperty(typeof(TypeWithIncludedPropertiesUsingBindAttribute), property);
+ var bindingContext = new DefaultModelBindingContext()
+ {
+ ActionContext = new ActionContext()
+ {
+ HttpContext = new DefaultHttpContext()
+ },
+ ModelMetadata = GetMetadataForType(typeof(TypeWithIncludedPropertiesUsingBindAttribute)),
+ };
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ var result = binder.CanBindItem(bindingContext, metadata);
+
+ // Assert
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData(nameof(ModelWithMixedBindingBehaviors.Required), true)]
+ [InlineData(nameof(ModelWithMixedBindingBehaviors.Optional), true)]
+ [InlineData(nameof(ModelWithMixedBindingBehaviors.Never), false)]
+ public void CanBindProperty_BindingAttributes_OverridingBehavior(string property, bool expected)
+ {
+ // Arrange
+ var metadata = GetMetadataForProperty(typeof(ModelWithMixedBindingBehaviors), property);
+ var bindingContext = new DefaultModelBindingContext()
+ {
+ ActionContext = new ActionContext()
+ {
+ HttpContext = new DefaultHttpContext(),
+ },
+ ModelMetadata = GetMetadataForType(typeof(ModelWithMixedBindingBehaviors)),
+ };
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ var result = binder.CanBindItem(bindingContext, metadata);
+
+ // Assert
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ [ReplaceCulture]
+ public async Task BindModelAsync_BindRequiredFieldMissing_RaisesModelError()
+ {
+ // Arrange
+ var model = new ModelWithBindRequired
+ {
+ Name = "original value",
+ Age = -20
+ };
+
+ var property = GetMetadataForProperty(model.GetType(), nameof(ModelWithBindRequired.Age));
+
+ var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model);
+
+ var propertyBinder = new TestModelBinderProvider(property, ModelBindingResult.Failed());
+ var binder = CreateBinder(bindingContext.ModelMetadata, options =>
+ {
+ options.ModelBinderProviders.Insert(0, propertyBinder);
+ });
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ var modelStateDictionary = bindingContext.ModelState;
+ Assert.False(modelStateDictionary.IsValid);
+ Assert.Single(modelStateDictionary);
+
+ // Check Age error.
+ Assert.True(modelStateDictionary.TryGetValue("theModel.Age", out var entry));
+ var modelError = Assert.Single(entry.Errors);
+ Assert.Null(modelError.Exception);
+ Assert.NotNull(modelError.ErrorMessage);
+ Assert.Equal("A value for the 'Age' parameter or property was not provided.", modelError.ErrorMessage);
+ }
+
+ private class TestModelBinderProvider : IModelBinderProvider, IModelBinder
+ {
+ private readonly ModelMetadata _modelMetadata;
+ private readonly ModelBindingResult _result;
+
+ public TestModelBinderProvider(ModelMetadata modelMetadata, ModelBindingResult result)
+ {
+ _modelMetadata = modelMetadata;
+ _result = result;
+ }
+
+ public Task BindModelAsync(ModelBindingContext bindingContext)
+ {
+ bindingContext.Result = _result;
+ return Task.CompletedTask;
+ }
+
+ public IModelBinder GetBinder(ModelBinderProviderContext context)
+ {
+ if (context.Metadata == _modelMetadata)
+ {
+ return this;
+ }
+
+ return null;
+ }
+ }
+
+ [Fact]
+ [ReplaceCulture]
+ public async Task BindModelAsync_DataMemberIsRequiredFieldMissing_RaisesModelError()
+ {
+ // Arrange
+ var model = new ModelWithDataMemberIsRequired
+ {
+ Name = "original value",
+ Age = -20
+ };
+
+ var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model);
+
+ var property = GetMetadataForProperty(model.GetType(), nameof(ModelWithDataMemberIsRequired.Age));
+ var propertyBinder = new TestModelBinderProvider(property, ModelBindingResult.Failed());
+
+ var binder = CreateBinder(bindingContext.ModelMetadata, options =>
+ {
+ options.ModelBinderProviders.Insert(0, propertyBinder);
+ });
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ var modelStateDictionary = bindingContext.ModelState;
+ Assert.False(modelStateDictionary.IsValid);
+ Assert.Single(modelStateDictionary);
+
+ // Check Age error.
+ Assert.True(modelStateDictionary.TryGetValue("theModel.Age", out var entry));
+ var modelError = Assert.Single(entry.Errors);
+ Assert.Null(modelError.Exception);
+ Assert.NotNull(modelError.ErrorMessage);
+ Assert.Equal("A value for the 'Age' parameter or property was not provided.", modelError.ErrorMessage);
+ }
+
+ [Fact]
+ [ReplaceCulture]
+ public async Task BindModelAsync_ValueTypePropertyWithBindRequired_SetToNull_CapturesException()
+ {
+ // Arrange
+ var model = new ModelWithBindRequired
+ {
+ Name = "original value",
+ Age = -20
+ };
+
+ var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model);
+
+ // Attempt to set non-Nullable property to null. BindRequiredAttribute should not be relevant in this
+ // case because the property did have a result.
+ var property = GetMetadataForProperty(model.GetType(), nameof(ModelWithBindRequired.Age));
+ var propertyBinder = new TestModelBinderProvider(property, ModelBindingResult.Success(model: null));
+
+ var binder = CreateBinder(bindingContext.ModelMetadata, options =>
+ {
+ options.ModelBinderProviders.Insert(0, propertyBinder);
+ });
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ var modelStateDictionary = bindingContext.ModelState;
+ Assert.False(modelStateDictionary.IsValid);
+ Assert.Single(modelStateDictionary);
+
+ // Check Age error.
+ Assert.True(modelStateDictionary.TryGetValue("theModel.Age", out var entry));
+ Assert.Equal(ModelValidationState.Invalid, entry.ValidationState);
+
+ var modelError = Assert.Single(entry.Errors);
+ Assert.Equal(string.Empty, modelError.ErrorMessage);
+ Assert.IsType(modelError.Exception);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_ValueTypeProperty_WithBindingOptional_NoValueSet_NoError()
+ {
+ // Arrange
+ var model = new BindingOptionalProperty();
+ var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model);
+
+ var property = GetMetadataForProperty(model.GetType(), nameof(BindingOptionalProperty.ValueTypeRequired));
+ var propertyBinder = new TestModelBinderProvider(property, ModelBindingResult.Failed());
+
+ var binder = CreateBinder(bindingContext.ModelMetadata, options =>
+ {
+ options.ModelBinderProviders.Insert(0, propertyBinder);
+ });
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ var modelStateDictionary = bindingContext.ModelState;
+ Assert.True(modelStateDictionary.IsValid);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_NullableValueTypeProperty_NoValueSet_NoError()
+ {
+ // Arrange
+ var model = new NullableValueTypeProperty();
+ var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model);
+
+ var property = GetMetadataForProperty(model.GetType(), nameof(NullableValueTypeProperty.NullableValueType));
+ var propertyBinder = new TestModelBinderProvider(property, ModelBindingResult.Failed());
+
+ var binder = CreateBinder(bindingContext.ModelMetadata, options =>
+ {
+ options.ModelBinderProviders.Insert(0, propertyBinder);
+ });
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ var modelStateDictionary = bindingContext.ModelState;
+ Assert.True(modelStateDictionary.IsValid);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_ValueTypeProperty_NoValue_NoError()
+ {
+ // Arrange
+ var model = new Person();
+ var containerMetadata = GetMetadataForType(model.GetType());
+
+ var bindingContext = CreateContext(containerMetadata, model);
+
+ var property = GetMetadataForProperty(model.GetType(), nameof(Person.ValueTypeRequired));
+ var propertyBinder = new TestModelBinderProvider(property, ModelBindingResult.Failed());
+
+ var binder = CreateBinder(bindingContext.ModelMetadata, options =>
+ {
+ options.ModelBinderProviders.Insert(0, propertyBinder);
+ });
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ Assert.True(bindingContext.ModelState.IsValid);
+ Assert.Equal(0, model.ValueTypeRequired);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_ProvideRequiredField_Success()
+ {
+ // Arrange
+ var model = new Person();
+ var containerMetadata = GetMetadataForType(model.GetType());
+
+ var bindingContext = CreateContext(containerMetadata, model);
+
+ var property = GetMetadataForProperty(model.GetType(), nameof(Person.ValueTypeRequired));
+ var propertyBinder = new TestModelBinderProvider(property, ModelBindingResult.Success(model: 57));
+
+ var binder = CreateBinder(bindingContext.ModelMetadata, options =>
+ {
+ options.ModelBinderProviders.Insert(0, propertyBinder);
+ });
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ Assert.True(bindingContext.ModelState.IsValid);
+ Assert.Equal(57, model.ValueTypeRequired);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_Success()
+ {
+ // Arrange
+ var dob = new DateTime(2001, 1, 1);
+ var model = new PersonWithBindExclusion
+ {
+ DateOfBirth = dob
+ };
+
+ var containerMetadata = GetMetadataForType(model.GetType());
+
+ var bindingContext = CreateContext(containerMetadata, model);
+
+ var binder = CreateBinder(bindingContext.ModelMetadata, options =>
+ {
+ var firstNameProperty = containerMetadata.Properties[nameof(model.FirstName)];
+ options.ModelBinderProviders.Insert(0, new TestModelBinderProvider(firstNameProperty, ModelBindingResult.Success("John")));
+
+ var lastNameProperty = containerMetadata.Properties[nameof(model.LastName)];
+ options.ModelBinderProviders.Insert(0, new TestModelBinderProvider(lastNameProperty, ModelBindingResult.Success("Doe")));
+ });
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ Assert.Equal("John", model.FirstName);
+ Assert.Equal("Doe", model.LastName);
+ Assert.Equal(dob, model.DateOfBirth);
+ Assert.True(bindingContext.ModelState.IsValid);
+ }
+
+ [Fact]
+ public void SetProperty_PropertyHasDefaultValue_DefaultValueAttributeDoesNothing()
+ {
+ // Arrange
+ var model = new Person();
+ var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model);
+
+ var metadata = GetMetadataForType(typeof(Person));
+ var propertyMetadata = metadata.Properties[nameof(model.PropertyWithDefaultValue)];
+
+ var result = ModelBindingResult.Failed();
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ binder.SetProperty(bindingContext, "foo", propertyMetadata, result);
+
+ // Assert
+ var person = Assert.IsType(bindingContext.Model);
+ Assert.Equal(0m, person.PropertyWithDefaultValue);
+ Assert.True(bindingContext.ModelState.IsValid);
+ }
+
+ [Fact]
+ public void SetProperty_PropertyIsPreinitialized_NoValue_DoesNothing()
+ {
+ // Arrange
+ var model = new Person();
+ var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model);
+
+ var metadata = GetMetadataForType(typeof(Person));
+ var propertyMetadata = metadata.Properties[nameof(model.PropertyWithInitializedValue)];
+
+ // The null model value won't be used because IsModelBound = false.
+ var result = ModelBindingResult.Failed();
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ binder.SetProperty(bindingContext, "foo", propertyMetadata, result);
+
+ // Assert
+ var person = Assert.IsType(bindingContext.Model);
+ Assert.Equal("preinitialized", person.PropertyWithInitializedValue);
+ Assert.True(bindingContext.ModelState.IsValid);
+ }
+
+ [Fact]
+ public void SetProperty_PropertyIsPreinitialized_DefaultValueAttributeDoesNothing()
+ {
+ // Arrange
+ var model = new Person();
+ var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model);
+
+ var metadata = GetMetadataForType(typeof(Person));
+ var propertyMetadata = metadata.Properties[nameof(model.PropertyWithInitializedValueAndDefault)];
+
+ // The null model value won't be used because IsModelBound = false.
+ var result = ModelBindingResult.Failed();
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ binder.SetProperty(bindingContext, "foo", propertyMetadata, result);
+
+ // Assert
+ var person = Assert.IsType(bindingContext.Model);
+ Assert.Equal("preinitialized", person.PropertyWithInitializedValueAndDefault);
+ Assert.True(bindingContext.ModelState.IsValid);
+ }
+
+ [Fact]
+ public void SetProperty_PropertyIsReadOnly_DoesNothing()
+ {
+ // Arrange
+ var model = new Person();
+ var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model);
+
+ var metadata = GetMetadataForType(typeof(Person));
+ var propertyMetadata = metadata.Properties[nameof(model.NonUpdateableProperty)];
+
+ var result = ModelBindingResult.Failed();
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ binder.SetProperty(bindingContext, "foo", propertyMetadata, result);
+
+ // Assert
+ // If didn't throw, success!
+ }
+
+ // Property name, property accessor
+ public static TheoryData> MyCanUpdateButCannotSetPropertyData
+ {
+ get
+ {
+ return new TheoryData>
+ {
+ {
+ nameof(MyModelTestingCanUpdateProperty.ReadOnlyObject),
+ model => ((Simple)((MyModelTestingCanUpdateProperty)model).ReadOnlyObject).Name
+ },
+ {
+ nameof(MyModelTestingCanUpdateProperty.ReadOnlySimple),
+ model => ((MyModelTestingCanUpdateProperty)model).ReadOnlySimple.Name
+ },
+ };
+ }
+ }
+
+ [Theory]
+ [MemberData(nameof(MyCanUpdateButCannotSetPropertyData))]
+ public void SetProperty_ValueProvidedAndCanUpdatePropertyTrue_DoesNothing(
+ string propertyName,
+ Func propertyAccessor)
+ {
+ // Arrange
+ var model = new MyModelTestingCanUpdateProperty();
+ var type = model.GetType();
+ var bindingContext = CreateContext(GetMetadataForType(type), model);
+ var modelState = bindingContext.ModelState;
+ var propertyMetadata = bindingContext.ModelMetadata.Properties[propertyName];
+ var result = ModelBindingResult.Success(new Simple { Name = "Hanna" });
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ binder.SetProperty(bindingContext, propertyName, propertyMetadata, result);
+
+ // Assert
+ Assert.Equal("Joe", propertyAccessor(model));
+ Assert.True(modelState.IsValid);
+ Assert.Empty(modelState);
+ }
+
+ [Fact]
+ public void SetProperty_ReadOnlyProperty_IsNoOp()
+ {
+ // Arrange
+ var model = new CollectionContainer();
+ var originalCollection = model.ReadOnlyList;
+
+ var modelMetadata = GetMetadataForType(model.GetType());
+ var propertyMetadata = GetMetadataForProperty(model.GetType(), nameof(CollectionContainer.ReadOnlyList));
+
+ var bindingContext = CreateContext(modelMetadata, model);
+ var result = ModelBindingResult.Success(new List() { "hi" });
+
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ binder.SetProperty(bindingContext, propertyMetadata.PropertyName, propertyMetadata, result);
+
+ // Assert
+ Assert.Same(originalCollection, model.ReadOnlyList);
+ Assert.Empty(model.ReadOnlyList);
+ }
+
+ [Fact]
+ public void SetProperty_PropertyIsSettable_CallsSetter()
+ {
+ // Arrange
+ var model = new Person();
+ var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model);
+ var propertyMetadata = bindingContext.ModelMetadata.Properties[nameof(model.DateOfBirth)];
+
+ var result = ModelBindingResult.Success(new DateTime(2001, 1, 1));
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ binder.SetProperty(bindingContext, "foo", propertyMetadata, result);
+
+ // Assert
+ Assert.True(bindingContext.ModelState.IsValid);
+ Assert.Equal(new DateTime(2001, 1, 1), model.DateOfBirth);
+ }
+
+ [Fact]
+ [ReplaceCulture]
+ public void SetProperty_PropertyIsSettable_SetterThrows_RecordsError()
+ {
+ // Arrange
+ var model = new Person
+ {
+ DateOfBirth = new DateTime(1900, 1, 1)
+ };
+
+ var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model);
+ var propertyMetadata = bindingContext.ModelMetadata.Properties[nameof(model.DateOfDeath)];
+
+ var result = ModelBindingResult.Success(new DateTime(1800, 1, 1));
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ binder.SetProperty(bindingContext, "foo", propertyMetadata, result);
+
+ // Assert
+ Assert.Equal("Date of death can't be before date of birth. (Parameter 'value')",
+ bindingContext.ModelState["foo"].Errors[0].Exception.Message);
+ }
+
+ [Fact]
+ [ReplaceCulture]
+ public void SetProperty_PropertySetterThrows_CapturesException()
+ {
+ // Arrange
+ var model = new ModelWhosePropertySetterThrows();
+ var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model);
+ bindingContext.ModelName = "foo";
+ var propertyMetadata = bindingContext.ModelMetadata.Properties[nameof(model.NameNoAttribute)];
+
+ var result = ModelBindingResult.Success(model: null);
+ var binder = CreateBinder(bindingContext.ModelMetadata);
+
+ // Act
+ binder.SetProperty(bindingContext, "foo.NameNoAttribute", propertyMetadata, result);
+
+ // Assert
+ Assert.False(bindingContext.ModelState.IsValid);
+ Assert.Single(bindingContext.ModelState["foo.NameNoAttribute"].Errors);
+ Assert.Equal("This is a different exception. (Parameter 'value')",
+ bindingContext.ModelState["foo.NameNoAttribute"].Errors[0].Exception.Message);
+ }
+
+ private static ComplexObjectModelBinder CreateBinder(ModelMetadata metadata, Action configureOptions = null)
+ {
+ var options = Options.Create(new MvcOptions());
+ var setup = new MvcCoreMvcOptionsSetup(new TestHttpRequestStreamReaderFactory());
+ setup.Configure(options.Value);
+
+ configureOptions?.Invoke(options.Value);
+
+ var factory = TestModelBinderFactory.Create(options.Value.ModelBinderProviders.ToArray());
+ return (ComplexObjectModelBinder)factory.CreateBinder(new ModelBinderFactoryContext()
+ {
+ Metadata = metadata,
+ BindingInfo = new BindingInfo()
+ {
+ BinderModelName = metadata.BinderModelName,
+ BinderType = metadata.BinderType,
+ BindingSource = metadata.BindingSource,
+ PropertyFilterProvider = metadata.PropertyFilterProvider,
+ },
+ });
+ }
+
+ private static DefaultModelBindingContext CreateContext(ModelMetadata metadata, object model = null)
+ {
+ var valueProvider = new TestValueProvider(new Dictionary());
+ return new DefaultModelBindingContext()
+ {
+ BinderModelName = metadata.BinderModelName,
+ BindingSource = metadata.BindingSource,
+ IsTopLevelObject = true,
+ Model = model,
+ ModelMetadata = metadata,
+ ModelName = "theModel",
+ ModelState = new ModelStateDictionary(),
+ ValueProvider = valueProvider,
+ };
+ }
+
+ private static ModelMetadata GetMetadataForType(Type type)
+ {
+ return _metadataProvider.GetMetadataForType(type);
+ }
+
+ private static ModelMetadata GetMetadataForProperty(Type type, string propertyName)
+ {
+ return _metadataProvider.GetMetadataForProperty(type, propertyName);
+ }
+
+ private class Location
+ {
+ public PointStruct Point { get; set; }
+ }
+
+ private readonly struct PointStruct
+ {
+ public PointStruct(double x, double y)
+ {
+ X = x;
+ Y = y;
+ }
+
+ public double X { get; }
+ public double Y { get; }
+ }
+
+ private class ClassWithNoParameterlessConstructor
+ {
+ public ClassWithNoParameterlessConstructor(string name)
+ {
+ Name = name;
+ }
+
+ public string Name { get; set; }
+ }
+
+ private class BindingOptionalProperty
+ {
+ [BindingBehavior(BindingBehavior.Optional)]
+ public int ValueTypeRequired { get; set; }
+ }
+
+ private class NullableValueTypeProperty
+ {
+ [BindingBehavior(BindingBehavior.Optional)]
+ public int? NullableValueType { get; set; }
+ }
+
+ private class Person
+ {
+ private DateTime? _dateOfDeath;
+
+ [BindingBehavior(BindingBehavior.Optional)]
+ public DateTime DateOfBirth { get; set; }
+
+ public DateTime? DateOfDeath
+ {
+ get { return _dateOfDeath; }
+ set
+ {
+ if (value < DateOfBirth)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value), "Date of death can't be before date of birth.");
+ }
+ _dateOfDeath = value;
+ }
+ }
+
+ [Required(ErrorMessage = "Sample message")]
+ public int ValueTypeRequired { get; set; }
+
+ public string FirstName { get; set; }
+ public string LastName { get; set; }
+ public string NonUpdateableProperty { get; private set; }
+
+ [BindingBehavior(BindingBehavior.Optional)]
+ [DefaultValue(typeof(decimal), "123.456")]
+ public decimal PropertyWithDefaultValue { get; set; }
+
+ public string PropertyWithInitializedValue { get; set; } = "preinitialized";
+
+ [DefaultValue("default")]
+ public string PropertyWithInitializedValueAndDefault { get; set; } = "preinitialized";
+ }
+
+ private class PersonWithNoProperties
+ {
+ public string name = null;
+ }
+
+ private class PersonWithAllPropertiesExcluded
+ {
+ [BindNever]
+ public DateTime DateOfBirth { get; set; }
+
+ [BindNever]
+ public DateTime? DateOfDeath { get; set; }
+
+ [BindNever]
+ public string FirstName { get; set; }
+
+ [BindNever]
+ public string LastName { get; set; }
+
+ public string NonUpdateableProperty { get; private set; }
+ }
+
+ private class PersonWithBindExclusion
+ {
+ [BindNever]
+ public DateTime DateOfBirth { get; set; }
+
+ [BindNever]
+ public DateTime? DateOfDeath { get; set; }
+
+ public string FirstName { get; set; }
+ public string LastName { get; set; }
+ public string NonUpdateableProperty { get; private set; }
+ }
+
+ private class ModelWithBindRequired
+ {
+ public string Name { get; set; }
+
+ [BindRequired]
+ public int Age { get; set; }
+ }
+
+ [DataContract]
+ private class ModelWithDataMemberIsRequired
+ {
+ public string Name { get; set; }
+
+ [DataMember(IsRequired = true)]
+ public int Age { get; set; }
+ }
+
+ [BindRequired]
+ private class ModelWithMixedBindingBehaviors
+ {
+ public string Required { get; set; }
+
+ [BindNever]
+ public string Never { get; set; }
+
+ [BindingBehavior(BindingBehavior.Optional)]
+ public string Optional { get; set; }
+ }
+
+ private sealed class MyModelTestingCanUpdateProperty
+ {
+ public int ReadOnlyInt { get; private set; }
+ public string ReadOnlyString { get; private set; }
+ public object ReadOnlyObject { get; } = new Simple { Name = "Joe" };
+ public string ReadWriteString { get; set; }
+ public Simple ReadOnlySimple { get; } = new Simple { Name = "Joe" };
+ }
+
+ private sealed class ModelWhosePropertySetterThrows
+ {
+ [Required(ErrorMessage = "This message comes from the [Required] attribute.")]
+ public string Name
+ {
+ get { return null; }
+ set { throw new ArgumentException("This is an exception.", "value"); }
+ }
+
+ public string NameNoAttribute
+ {
+ get { return null; }
+ set { throw new ArgumentException("This is a different exception.", "value"); }
+ }
+ }
+
+ private class TypeWithNoBinderMetadata
+ {
+ public int UnMarkedProperty { get; set; }
+ }
+
+ private class HasAllGreedyProperties
+ {
+ [NonValueBinderMetadata]
+ public string MarkedWithABinderMetadata { get; set; }
+ }
+
+ // Not a Metadata poco because there is a property with value binder Metadata.
+ private class TypeWithAtLeastOnePropertyMarkedUsingValueBinderMetadata
+ {
+ [NonValueBinderMetadata]
+ public string MarkedWithABinderMetadata { get; set; }
+
+ [ValueBinderMetadata]
+ public string MarkedWithAValueBinderMetadata { get; set; }
+ }
+
+ // not a Metadata poco because there is an unmarked property.
+ private class TypeWithUnmarkedAndBinderMetadataMarkedProperties
+ {
+ public int UnmarkedProperty { get; set; }
+
+ [NonValueBinderMetadata]
+ public string MarkedWithABinderMetadata { get; set; }
+ }
+
+ [Bind(new[] { nameof(IncludedExplicitly1), nameof(IncludedExplicitly2) })]
+ private class TypeWithIncludedPropertiesUsingBindAttribute
+ {
+ public int ExcludedByDefault1 { get; set; }
+
+ public int ExcludedByDefault2 { get; set; }
+
+ public int IncludedExplicitly1 { get; set; }
+
+ public int IncludedExplicitly2 { get; set; }
+ }
+
+ private class Document
+ {
+ [NonValueBinderMetadata]
+ public string Version { get; set; }
+
+ [NonValueBinderMetadata]
+ public Document SubDocument { get; set; }
+ }
+
+ private class NonValueBinderMetadataAttribute : Attribute, IBindingSourceMetadata
+ {
+ public BindingSource BindingSource
+ {
+ get { return new BindingSource("Special", string.Empty, isGreedy: true, isFromRequest: true); }
+ }
+ }
+
+ private class ValueBinderMetadataAttribute : Attribute, IBindingSourceMetadata
+ {
+ public BindingSource BindingSource { get { return BindingSource.Query; } }
+ }
+
+ private class ExcludedProvider : IPropertyFilterProvider
+ {
+ public Func PropertyFilter
+ {
+ get
+ {
+ return (m) =>
+ !string.Equals("Excluded1", m.PropertyName, StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals("Excluded2", m.PropertyName, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+ }
+
+ private class SimpleContainer
+ {
+ public Simple Simple { get; set; }
+ }
+
+ private class Simple
+ {
+ public string Name { get; set; }
+ }
+
+ private class CollectionContainer
+ {
+ public int[] ReadOnlyArray { get; } = new int[4];
+
+ // Read-only collections get added values.
+ public IDictionary ReadOnlyDictionary { get; } = new Dictionary();
+
+ public IList ReadOnlyList { get; } = new List();
+
+ // Settable values are overwritten.
+ public int[] SettableArray { get; set; } = new int[] { 0, 1 };
+
+ public IDictionary SettableDictionary { get; set; } = new Dictionary
+ {
+ { 0, "zero" },
+ { 25, "twenty-five" },
+ };
+
+ public IList SettableList { get; set; } = new List { 3, 9, 0 };
+ }
+ }
+}
diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexTypeModelBinderProviderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexTypeModelBinderProviderTest.cs
index cf16416641..c154bff7bb 100644
--- a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexTypeModelBinderProviderTest.cs
+++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexTypeModelBinderProviderTest.cs
@@ -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.
using System;
@@ -8,6 +8,7 @@ using Xunit;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
+#pragma warning disable CS0618 // Type or member is obsolete
public class ComplexTypeModelBinderProviderTest
{
[Theory]
@@ -89,4 +90,5 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
public int Age { get; set; }
}
}
+#pragma warning restore CS0618 // Type or member is obsolete
}
diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexTypeModelBinderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexTypeModelBinderTest.cs
index 14db921930..98bf6a1b6c 100644
--- a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexTypeModelBinderTest.cs
+++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexTypeModelBinderTest.cs
@@ -19,6 +19,7 @@ using Xunit;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
+#pragma warning disable CS0618 // Type or member is obsolete
public class ComplexTypeModelBinderTest
{
private static readonly IModelMetadataProvider _metadataProvider = TestModelMetadataProvider.CreateDefaultProvider();
@@ -1229,8 +1230,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
setup.Configure(options.Value);
var lastIndex = options.Value.ModelBinderProviders.Count - 1;
- Assert.IsType(options.Value.ModelBinderProviders[lastIndex]);
- options.Value.ModelBinderProviders.RemoveAt(lastIndex);
+ options.Value.ModelBinderProviders.RemoveType();
options.Value.ModelBinderProviders.Add(new TestableComplexTypeModelBinderProvider());
var factory = TestModelBinderFactory.Create(options.Value.ModelBinderProviders.ToArray());
@@ -1662,4 +1662,5 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
}
}
}
+#pragma warning restore CS0618 // Type or member is obsolete
}
diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DictionaryModelBinderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DictionaryModelBinderTest.cs
index 53075ff94b..936da88b62 100644
--- a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DictionaryModelBinderTest.cs
+++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DictionaryModelBinderTest.cs
@@ -278,12 +278,13 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
var binder = new DictionaryModelBinder(
new SimpleTypeModelBinder(typeof(int), NullLoggerFactory.Instance),
- new ComplexTypeModelBinder(new Dictionary()
+ new ComplexObjectModelBinder(new Dictionary()
{
{ valueMetadata.Properties["Id"], new SimpleTypeModelBinder(typeof(int), NullLoggerFactory.Instance) },
{ valueMetadata.Properties["Name"], new SimpleTypeModelBinder(typeof(string), NullLoggerFactory.Instance) },
},
- NullLoggerFactory.Instance),
+ Array.Empty(),
+ NullLogger.Instance),
NullLoggerFactory.Instance);
// Act
diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/DefaultBindingMetadataProviderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/DefaultBindingMetadataProviderTest.cs
index 3a0756391d..8ab0bef56c 100644
--- a/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/DefaultBindingMetadataProviderTest.cs
+++ b/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/DefaultBindingMetadataProviderTest.cs
@@ -658,6 +658,198 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
Assert.Equal(initialValue, context.BindingMetadata.IsBindingRequired);
}
+ private class DefaultConstructorType { }
+
+ [Fact]
+ public void GetBoundConstructor_DefaultConstructor_ReturnsNull()
+ {
+ // Arrange
+ var type = typeof(DefaultConstructorType);
+
+ // Act
+ var result = DefaultBindingMetadataProvider.GetBoundConstructor(type);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ private class ParameterlessConstructorType
+ {
+ public ParameterlessConstructorType() { }
+ }
+
+ [Fact]
+ public void GetBoundConstructor_ParameterlessConstructor_ReturnsNull()
+ {
+ // Arrange
+ var type = typeof(ParameterlessConstructorType);
+
+ // Act
+ var result = DefaultBindingMetadataProvider.GetBoundConstructor(type);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ private class NonPublicParameterlessConstructorType
+ {
+ protected NonPublicParameterlessConstructorType() { }
+ }
+
+ [Fact]
+ public void GetBoundConstructor_DoesNotReturnsNonPublicParameterlessConstructor()
+ {
+ // Arrange
+ var type = typeof(NonPublicParameterlessConstructorType);
+
+ // Act
+ var result = DefaultBindingMetadataProvider.GetBoundConstructor(type);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ private class MultipleConstructorType
+ {
+ public MultipleConstructorType() { }
+ public MultipleConstructorType(string prop) { }
+ }
+
+ [Fact]
+ public void GetBoundConstructor_ReturnsParameterlessConstructor_ForTypeWithMultipleConstructors()
+ {
+ // Arrange
+ var type = typeof(NonPublicParameterlessConstructorType);
+
+ // Act
+ var result = DefaultBindingMetadataProvider.GetBoundConstructor(type);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ private record RecordTypeWithPrimaryConstructor(string name)
+ {
+ }
+
+ [Fact]
+ public void GetBoundConstructor_ReturnsPrimaryConstructor_ForRecordType()
+ {
+ // Arrange
+ var type = typeof(RecordTypeWithPrimaryConstructor);
+
+ // Act
+ var result = DefaultBindingMetadataProvider.GetBoundConstructor(type);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Collection(
+ result.GetParameters(),
+ p => Assert.Equal("name", p.Name));
+ }
+
+ private record RecordTypeWithDefaultConstructor
+ {
+ public string Name { get; init; }
+
+ public int Age { get; init; }
+ }
+
+ private record RecordTypeWithParameterlessConstructor
+ {
+ public RecordTypeWithParameterlessConstructor() { }
+
+ public string Name { get; init; }
+
+ public int Age { get; init; }
+ }
+
+ [Theory]
+ [InlineData(typeof(RecordTypeWithDefaultConstructor))]
+ [InlineData(typeof(RecordTypeWithParameterlessConstructor))]
+ public void GetBoundConstructor_ReturnsNull_ForRecordTypeWithParameterlessConstructor(Type type)
+ {
+ // Act
+ var result = DefaultBindingMetadataProvider.GetBoundConstructor(type);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ private record RecordTypeWithMultipleConstructors(string Name)
+ {
+ public RecordTypeWithMultipleConstructors(string Name, int age) : this(Name) => Age = age;
+
+ public RecordTypeWithMultipleConstructors(int age) : this(string.Empty, age) { }
+
+ public int Age { get; set; }
+ }
+
+ [Fact]
+ public void GetBoundConstructor_ReturnsNull_ForRecordTypeWithMultipleConstructors()
+ {
+ // Arrange
+ var type = typeof(RecordTypeWithMultipleConstructors);
+
+ // Act
+ var result = DefaultBindingMetadataProvider.GetBoundConstructor(type);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ private record RecordTypeWithConformingSynthesizedConstructor
+ {
+ public RecordTypeWithConformingSynthesizedConstructor(string Name, int Age)
+ {
+ }
+
+ public string Name { get; set; }
+
+ public int Age { get; set; }
+ }
+
+ [Fact]
+ public void GetBoundConstructor_ReturnsConformingSynthesizedConstructor()
+ {
+ // Arrange
+ var type = typeof(RecordTypeWithConformingSynthesizedConstructor);
+
+ // Act
+ var result = DefaultBindingMetadataProvider.GetBoundConstructor(type);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Collection(
+ result.GetParameters(),
+ p => Assert.Equal("Name", p.Name),
+ p => Assert.Equal("Age", p.Name));
+ }
+
+ private record RecordTypeWithNonConformingSynthesizedConstructor
+ {
+ public RecordTypeWithNonConformingSynthesizedConstructor(string name, string age)
+ {
+ }
+
+ public string Name { get; set; }
+
+ public int Age { get; set; }
+ }
+
+ [Fact]
+ public void GetBoundConstructor_ReturnsNull_IfSynthesizedConstructorIsNonConforming()
+ {
+ // Arrange
+ var type = typeof(RecordTypeWithNonConformingSynthesizedConstructor);
+
+ // Act
+ var result = DefaultBindingMetadataProvider.GetBoundConstructor(type);
+
+ // Assert
+ Assert.Null(result);
+ }
+
[BindNever]
private class BindNeverOnClass
{
@@ -704,4 +896,4 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
public string Identifier { get; set; }
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/DefaultModelMetadataTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/DefaultModelMetadataTest.cs
index b3d87dbead..628471df74 100644
--- a/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/DefaultModelMetadataTest.cs
+++ b/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/DefaultModelMetadataTest.cs
@@ -197,7 +197,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
var detailsProvider = new EmptyCompositeMetadataDetailsProvider();
var key = ModelMetadataIdentity.ForProperty(
- typeof(TypeWithProperties).GetProperty(nameof(TypeWithProperties.PublicGetPublicSetProperty)),
+ typeof(TypeWithProperties).GetProperty(nameof(TypeWithProperties.PublicGetPublicSetProperty)),
typeof(string),
typeof(TypeWithProperties));
@@ -626,12 +626,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
var metadata = new DefaultModelMetadata(provider, detailsProvider, cache);
// Act
- var firstPropertiesEvaluation = metadata.Properties;
+ var SinglePropertiesEvaluation = metadata.Properties;
var secondPropertiesEvaluation = metadata.Properties;
// Assert
// Same IEnumerable object.
- Assert.Same(firstPropertiesEvaluation, secondPropertiesEvaluation);
+ Assert.Same(SinglePropertiesEvaluation, secondPropertiesEvaluation);
}
[Fact]
@@ -647,12 +647,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
var metadata = new DefaultModelMetadata(provider, detailsProvider, cache);
// Act
- var firstPropertiesEvaluation = metadata.Properties.ToList();
+ var SinglePropertiesEvaluation = metadata.Properties.ToList();
var secondPropertiesEvaluation = metadata.Properties.ToList();
// Assert
// Identical ModelMetadata objects every time we run through the Properties collection.
- Assert.Equal(firstPropertiesEvaluation, secondPropertiesEvaluation);
+ Assert.Equal(SinglePropertiesEvaluation, secondPropertiesEvaluation);
}
[Fact]
@@ -924,7 +924,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
.GetMethod(nameof(CalculateHasValidators_ParameterMetadata_TypeHasNoValidatorsMethod), BindingFlags.Static | BindingFlags.NonPublic)
.GetParameters()[0];
var modelIdentity = ModelMetadataIdentity.ForParameter(parameter);
- var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), false);
+ var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), hasValidators: false);
// Act
var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata);
@@ -942,7 +942,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
var property = GetType()
.GetProperty(nameof(CalculateHasValidators_PropertyMetadata_TypeHasNoValidatorsProperty), BindingFlags.Static | BindingFlags.NonPublic);
var modelIdentity = ModelMetadataIdentity.ForProperty(property, property.PropertyType, GetType());
- var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), false);
+ var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), hasValidators: false);
// Act
var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata);
@@ -958,7 +958,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
{
// Arrange
var modelIdentity = ModelMetadataIdentity.ForType(typeof(string));
- var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), false);
+ var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), hasValidators: false);
// Act
var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata);
@@ -972,7 +972,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
{
// Arrange
var modelIdentity = ModelMetadataIdentity.ForType(typeof(string));
- var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), true);
+ var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), hasValidators: true);
// Act
var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata);
@@ -986,7 +986,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
{
// Arrange
var modelIdentity = ModelMetadataIdentity.ForType(typeof(string));
- var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), null);
+ var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), hasValidators: null);
// Act
var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata);
@@ -1002,7 +1002,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
var modelType = typeof(TypeWithProperties);
var modelIdentity = ModelMetadataIdentity.ForType(modelType);
var metadataProvider = new Mock();
- var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false);
+ var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false);
var property = typeof(TypeWithProperties).GetProperty(nameof(TypeWithProperties.PublicGetPublicSetProperty));
var propertyIdentity = ModelMetadataIdentity.ForProperty(property, typeof(int), typeof(TypeWithProperties));
@@ -1027,13 +1027,13 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
var modelType = typeof(TypeWithProperties);
var modelIdentity = ModelMetadataIdentity.ForType(modelType);
var metadataProvider = new Mock();
- var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false);
+ var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false);
var property1Identity = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(TypeWithProperties.PublicGetPublicSetProperty)), typeof(int), modelType);
- var property1Metadata = CreateModelMetadata(property1Identity, metadataProvider.Object, false);
+ var property1Metadata = CreateModelMetadata(property1Identity, metadataProvider.Object, hasValidators: false);
var property2Identity = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(TypeWithProperties.PublicGetProtectedSetProperty)), typeof(int), modelType);
- var property2Metadata = CreateModelMetadata(property2Identity, metadataProvider.Object, true);
+ var property2Metadata = CreateModelMetadata(property2Identity, metadataProvider.Object, hasValidators: true);
metadataProvider
.Setup(mp => mp.GetMetadataForProperties(modelType))
@@ -1054,10 +1054,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
var modelType = typeof(TypeWithProperties);
var modelIdentity = ModelMetadataIdentity.ForType(modelType);
var metadataProvider = new Mock();
- var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false);
+ var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false);
var propertyIdentity = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(TypeWithProperties.PublicGetPublicSetProperty)), typeof(int), modelType);
- var propertyMetadata = CreateModelMetadata(propertyIdentity, metadataProvider.Object, null);
+ var propertyMetadata = CreateModelMetadata(propertyIdentity, metadataProvider.Object, hasValidators: null);
metadataProvider
.Setup(mp => mp.GetMetadataForProperties(modelType))
@@ -1078,13 +1078,13 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
var modelType = typeof(TypeWithProperties);
var modelIdentity = ModelMetadataIdentity.ForType(modelType);
var metadataProvider = new Mock();
- var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false);
+ var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false);
var property1Identity = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(TypeWithProperties.PublicGetPublicSetProperty)), typeof(int), modelType);
- var property1Metadata = CreateModelMetadata(property1Identity, metadataProvider.Object, false);
+ var property1Metadata = CreateModelMetadata(property1Identity, metadataProvider.Object, hasValidators: false);
var property2Identity = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(TypeWithProperties.PublicGetProtectedSetProperty)), typeof(int), modelType);
- var property2Metadata = CreateModelMetadata(property2Identity, metadataProvider.Object, false);
+ var property2Metadata = CreateModelMetadata(property2Identity, metadataProvider.Object, hasValidators: false);
metadataProvider
.Setup(mp => mp.GetMetadataForProperties(modelType))
@@ -1105,22 +1105,22 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
var modelType = typeof(Employee);
var modelIdentity = ModelMetadataIdentity.ForType(modelType);
var metadataProvider = new Mock();
- var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false);
+ var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false);
var employeeId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Id)), typeof(int), modelType);
- var employeeIdMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false);
+ var employeeIdMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false);
var employeeUnit = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Unit)), typeof(BusinessUnit), modelType);
- var employeeUnitMetadata = CreateModelMetadata(employeeUnit, metadataProvider.Object, false);
+ var employeeUnitMetadata = CreateModelMetadata(employeeUnit, metadataProvider.Object, hasValidators: false);
var employeeManager = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Manager)), typeof(Employee), modelType);
- var employeeManagerMetadata = CreateModelMetadata(employeeManager, metadataProvider.Object, false);
+ var employeeManagerMetadata = CreateModelMetadata(employeeManager, metadataProvider.Object, hasValidators: false);
var employeeEmployees = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Employees)), typeof(List), modelType);
- var employeeEmployeesMetadata = CreateModelMetadata(employeeEmployees, metadataProvider.Object, false);
+ var employeeEmployeesMetadata = CreateModelMetadata(employeeEmployees, metadataProvider.Object, hasValidators: false);
var unitModel = typeof(BusinessUnit);
var unitHead = ModelMetadataIdentity.ForProperty(unitModel.GetProperty(nameof(BusinessUnit.Head)), typeof(Employee), unitModel);
- var unitHeadMetadata = CreateModelMetadata(unitHead, metadataProvider.Object, false);
+ var unitHeadMetadata = CreateModelMetadata(unitHead, metadataProvider.Object, hasValidators: false);
var unitId = ModelMetadataIdentity.ForProperty(unitModel.GetProperty(nameof(BusinessUnit.Id)), typeof(int), unitModel);
- var unitIdMetadata = CreateModelMetadata(unitId, metadataProvider.Object, true); // BusinessUnit.Id has validators.
+ var unitIdMetadata = CreateModelMetadata(unitId, metadataProvider.Object, hasValidators: true); // BusinessUnit.Id has validators.
metadataProvider
.Setup(mp => mp.GetMetadataForProperties(modelType))
@@ -1146,22 +1146,22 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
var modelType = typeof(Employee);
var modelIdentity = ModelMetadataIdentity.ForType(modelType);
var metadataProvider = new Mock();
- var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false);
+ var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false);
var employeeId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Id)), typeof(int), modelType);
- var employeeIdMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false);
+ var employeeIdMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false);
var employeeUnit = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Unit)), typeof(BusinessUnit), modelType);
- var employeeUnitMetadata = CreateModelMetadata(employeeUnit, metadataProvider.Object, false);
+ var employeeUnitMetadata = CreateModelMetadata(employeeUnit, metadataProvider.Object, hasValidators: false);
var employeeManager = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Manager)), typeof(Employee), modelType);
- var employeeManagerMetadata = CreateModelMetadata(employeeManager, metadataProvider.Object, false);
+ var employeeManagerMetadata = CreateModelMetadata(employeeManager, metadataProvider.Object, hasValidators: false);
var employeeEmployees = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Employees)), typeof(List), modelType);
- var employeeEmployeesMetadata = CreateModelMetadata(employeeEmployees, metadataProvider.Object, false);
+ var employeeEmployeesMetadata = CreateModelMetadata(employeeEmployees, metadataProvider.Object, hasValidators: false);
var unitModel = typeof(BusinessUnit);
var unitHead = ModelMetadataIdentity.ForProperty(unitModel.GetProperty(nameof(BusinessUnit.Head)), typeof(Employee), unitModel);
- var unitHeadMetadata = CreateModelMetadata(unitHead, metadataProvider.Object, true); // BusinessUnit.Head has validators
+ var unitHeadMetadata = CreateModelMetadata(unitHead, metadataProvider.Object, hasValidators: true); // BusinessUnit.Head has validators
var unitId = ModelMetadataIdentity.ForProperty(unitModel.GetProperty(nameof(BusinessUnit.Id)), typeof(int), unitModel);
- var unitIdMetadata = CreateModelMetadata(unitId, metadataProvider.Object, false);
+ var unitIdMetadata = CreateModelMetadata(unitId, metadataProvider.Object, hasValidators: false);
metadataProvider
.Setup(mp => mp.GetMetadataForProperties(modelType))
@@ -1189,12 +1189,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
var modelType = typeof(Employee);
var modelIdentity = ModelMetadataIdentity.ForType(modelType);
var metadataProvider = new Mock();
- var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false);
+ var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false);
var employeeId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Id)), typeof(int), modelType);
var employeeIdMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false);
var employeeEmployees = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Employees)), typeof(List), modelType);
- var employeeEmployeesMetadata = CreateModelMetadata(employeeEmployees, metadataProvider.Object, false);
+ var employeeEmployeesMetadata = CreateModelMetadata(employeeEmployees, metadataProvider.Object, hasValidators: false);
metadataProvider
.Setup(mp => mp.GetMetadataForProperties(modelType))
@@ -1202,7 +1202,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
metadataProvider
.Setup(mp => mp.GetMetadataForType(modelType))
- .Returns(CreateModelMetadata(modelIdentity, metadataProvider.Object, true)); // Employees.Employee has validators
+ .Returns(CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: true)); // Employees.Employee has validators
// Act
var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata);
@@ -1218,22 +1218,22 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
var modelType = typeof(Employee);
var modelIdentity = ModelMetadataIdentity.ForType(modelType);
var metadataProvider = new Mock();
- var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false);
+ var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false);
var employeeId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Id)), typeof(int), modelType);
- var employeeIdMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false);
+ var employeeIdMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false);
var employeeUnit = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Unit)), typeof(BusinessUnit), modelType);
- var employeeUnitMetadata = CreateModelMetadata(employeeUnit, metadataProvider.Object, false);
+ var employeeUnitMetadata = CreateModelMetadata(employeeUnit, metadataProvider.Object, hasValidators: false);
var employeeManager = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Manager)), typeof(Employee), modelType);
- var employeeManagerMetadata = CreateModelMetadata(employeeManager, metadataProvider.Object, false);
+ var employeeManagerMetadata = CreateModelMetadata(employeeManager, metadataProvider.Object, hasValidators: false);
var employeeEmployeesId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Employees)), typeof(List), modelType);
- var employeeEmployeesIdMetadata = CreateModelMetadata(employeeEmployeesId, metadataProvider.Object, false);
+ var employeeEmployeesIdMetadata = CreateModelMetadata(employeeEmployeesId, metadataProvider.Object, hasValidators: false);
var unitModel = typeof(BusinessUnit);
var unitHead = ModelMetadataIdentity.ForProperty(unitModel.GetProperty(nameof(BusinessUnit.Head)), typeof(Employee), unitModel);
- var unitHeadMetadata = CreateModelMetadata(unitHead, metadataProvider.Object, false);
+ var unitHeadMetadata = CreateModelMetadata(unitHead, metadataProvider.Object, hasValidators: false);
var unitId = ModelMetadataIdentity.ForProperty(unitModel.GetProperty(nameof(BusinessUnit.Id)), typeof(int), unitModel);
- var unitIdMetadata = CreateModelMetadata(unitId, metadataProvider.Object, false);
+ var unitIdMetadata = CreateModelMetadata(unitId, metadataProvider.Object, hasValidators: false);
metadataProvider
.Setup(mp => mp.GetMetadataForProperties(modelType))
@@ -1254,8 +1254,277 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
Assert.False(result);
}
+ private record SimpleRecordType(int Property);
+
+ [Fact]
+ public void CalculateHasValidators_RecordType_ParametersWithNoValidators()
+ {
+ // Arrange
+ var modelType = typeof(SimpleRecordType);
+ var constructor = modelType.GetConstructors().Single();
+ var parameter = constructor.GetParameters().Single();
+ var modelIdentity = ModelMetadataIdentity.ForType(modelType);
+ var metadataProvider = new Mock();
+ var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false);
+ modelMetadata.BindingMetadata.BoundConstructor = constructor;
+
+ var propertyId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordType.Property)), typeof(int), modelType);
+ var propertyMetadata = CreateModelMetadata(propertyId, metadataProvider.Object, hasValidators: false);
+
+ var parameterId = ModelMetadataIdentity.ForParameter(parameter);
+ // Parameter has no validation metadata.
+ var parameterMetadata = CreateModelMetadata(parameterId, metadataProvider.Object, hasValidators: false);
+
+ var constructorMetadata = CreateModelMetadata(
+ ModelMetadataIdentity.ForConstructor(constructor, modelType), metadataProvider.Object, hasValidators: null);
+ constructorMetadata.Details.BoundConstructorParameters = new[]
+ {
+ parameterMetadata,
+ };
+
+ metadataProvider
+ .Setup(mp => mp.GetMetadataForConstructor(constructor, modelType))
+ .Returns(constructorMetadata)
+ .Verifiable();
+
+ metadataProvider
+ .Setup(mp => mp.GetMetadataForProperties(modelType))
+ .Returns(new[] { propertyMetadata })
+ .Verifiable();
+
+ // Act
+ var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata);
+
+ // Assert
+ Assert.False(result);
+ metadataProvider.Verify();
+ }
+
+ [Fact]
+ public void CalculateHasValidators_RecordType_ParametersWithValidators()
+ {
+ // Arrange
+ var modelType = typeof(SimpleRecordType);
+ var constructor = modelType.GetConstructors().Single();
+ var parameter = constructor.GetParameters().Single();
+ var modelIdentity = ModelMetadataIdentity.ForType(modelType);
+ var metadataProvider = new Mock();
+ var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false);
+ modelMetadata.BindingMetadata.BoundConstructor = constructor;
+
+ var propertyId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordType.Property)), typeof(int), modelType);
+ var propertyMetadata = CreateModelMetadata(propertyId, metadataProvider.Object, hasValidators: false);
+
+ var parameterId = ModelMetadataIdentity.ForParameter(parameter);
+ // Parameter has some validation metadata.
+ var parameterMetadata = CreateModelMetadata(parameterId, metadataProvider.Object, hasValidators: true);
+
+ var constructorMetadata = CreateModelMetadata(ModelMetadataIdentity.ForConstructor(constructor, modelType), metadataProvider.Object, hasValidators: null);
+ constructorMetadata.Details.BoundConstructorParameters = new[]
+ {
+ parameterMetadata,
+ };
+
+ metadataProvider
+ .Setup(mp => mp.GetMetadataForConstructor(constructor, modelType))
+ .Returns(constructorMetadata);
+
+ metadataProvider
+ .Setup(mp => mp.GetMetadataForProperties(modelType))
+ .Returns(new[] { propertyMetadata });
+
+ // Act
+ var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata);
+
+ // Assert
+ Assert.True(result);
+ }
+
+ private record SimpleRecordTypeWithProperty(int Property)
+ {
+ public int Property2 { get; set; }
+ }
+
+ [Fact]
+ public void CalculateHasValidators_RecordTypeWithProperty_NoValidators()
+ {
+ // Arrange
+ var modelType = typeof(SimpleRecordTypeWithProperty);
+ var constructor = modelType.GetConstructors().Single();
+ var parameter = constructor.GetParameters().Single();
+ var modelIdentity = ModelMetadataIdentity.ForType(modelType);
+ var metadataProvider = new Mock();
+ var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false);
+ modelMetadata.BindingMetadata.BoundConstructor = constructor;
+
+ var propertyId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordTypeWithProperty.Property)), typeof(int), modelType);
+ var propertyMetadata = CreateModelMetadata(propertyId, metadataProvider.Object, hasValidators: false);
+
+ // Property2 has no validators
+ var property2Id = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordTypeWithProperty.Property2)), typeof(int), modelType);
+ var property2Metadata = CreateModelMetadata(property2Id, metadataProvider.Object, hasValidators: false);
+
+ // Parameter named "Property" has no validators
+ var parameterId = ModelMetadataIdentity.ForParameter(parameter);
+ var parameterMetadata = CreateModelMetadata(parameterId, metadataProvider.Object, hasValidators: false);
+
+ var constructorMetadata = CreateModelMetadata(
+ ModelMetadataIdentity.ForConstructor(constructor, modelType), metadataProvider.Object, hasValidators: null);
+ constructorMetadata.Details.BoundConstructorParameters = new[]
+ {
+ parameterMetadata,
+ };
+
+ metadataProvider
+ .Setup(mp => mp.GetMetadataForConstructor(constructor, modelType))
+ .Returns(constructorMetadata);
+
+ metadataProvider
+ .Setup(mp => mp.GetMetadataForProperties(modelType))
+ .Returns(new[] { propertyMetadata, property2Metadata });
+
+ // Act
+ var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void CalculateHasValidators_RecordTypeWithProperty_ParameteryHasValidators()
+ {
+ // Arrange
+ var modelType = typeof(SimpleRecordTypeWithProperty);
+ var constructor = modelType.GetConstructors().Single();
+ var parameter = constructor.GetParameters().Single();
+ var modelIdentity = ModelMetadataIdentity.ForType(modelType);
+ var metadataProvider = new Mock();
+ var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false);
+ modelMetadata.BindingMetadata.BoundConstructor = constructor;
+
+ var propertyId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordTypeWithProperty.Property)), typeof(int), modelType);
+ var propertyMetadata = CreateModelMetadata(propertyId, metadataProvider.Object, hasValidators: false);
+
+ // Property2 has no validators
+ var property2Id = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordTypeWithProperty.Property2)), typeof(int), modelType);
+ var property2Metadata = CreateModelMetadata(property2Id, metadataProvider.Object, hasValidators: false);
+
+ // Parameter named "Property" has validators
+ var parameterId = ModelMetadataIdentity.ForParameter(parameter);
+ var parameterMetadata = CreateModelMetadata(parameterId, metadataProvider.Object, hasValidators: true);
+
+ var constructorMetadata = CreateModelMetadata(
+ ModelMetadataIdentity.ForConstructor(constructor, modelType), metadataProvider.Object, hasValidators: null);
+ constructorMetadata.Details.BoundConstructorParameters = new[]
+ {
+ parameterMetadata,
+ };
+
+ metadataProvider
+ .Setup(mp => mp.GetMetadataForConstructor(constructor, modelType))
+ .Returns(constructorMetadata);
+
+ metadataProvider
+ .Setup(mp => mp.GetMetadataForProperties(modelType))
+ .Returns(new[] { propertyMetadata, property2Metadata });
+
+ // Act
+ var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata);
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void CalculateHasValidators_RecordTypeWithProperty_PropertyHasValidators()
+ {
+ // Arrange
+ var modelType = typeof(SimpleRecordTypeWithProperty);
+ var constructor = modelType.GetConstructors().Single();
+ var parameter = constructor.GetParameters().Single();
+ var modelIdentity = ModelMetadataIdentity.ForType(modelType);
+ var metadataProvider = new Mock();
+ var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false);
+ modelMetadata.BindingMetadata.BoundConstructor = constructor;
+
+ var propertyId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordTypeWithProperty.Property)), typeof(int), modelType);
+ var propertyMetadata = CreateModelMetadata(propertyId, metadataProvider.Object, hasValidators: false);
+
+ // Property2 has some validators
+ var property2Id = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordTypeWithProperty.Property2)), typeof(int), modelType);
+ var property2Metadata = CreateModelMetadata(property2Id, metadataProvider.Object, hasValidators: true);
+
+ // Parameter named "Property" has no validators
+ var parameterId = ModelMetadataIdentity.ForParameter(parameter);
+ var parameterMetadata = CreateModelMetadata(parameterId, metadataProvider.Object, hasValidators: false);
+
+ var constructorMetadata = CreateModelMetadata(
+ ModelMetadataIdentity.ForConstructor(constructor, modelType), metadataProvider.Object, hasValidators: null);
+ constructorMetadata.Details.BoundConstructorParameters = new[]
+ {
+ parameterMetadata,
+ };
+
+ metadataProvider
+ .Setup(mp => mp.GetMetadataForConstructor(constructor, modelType))
+ .Returns(constructorMetadata);
+
+ metadataProvider
+ .Setup(mp => mp.GetMetadataForProperties(modelType))
+ .Returns(new[] { propertyMetadata, property2Metadata });
+
+ // Act
+ var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata);
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void CalculateHasValidators_RecordTypeWithProperty_MappedPropertyHasValidators_ValidatorsAreIgnored()
+ {
+ // Arrange
+ var modelType = typeof(SimpleRecordTypeWithProperty);
+ var constructor = modelType.GetConstructors().Single();
+ var parameter = constructor.GetParameters().Single();
+ var modelIdentity = ModelMetadataIdentity.ForType(modelType);
+ var metadataProvider = new Mock();
+ var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false);
+ modelMetadata.BindingMetadata.BoundConstructor = constructor;
+
+ var propertyId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordTypeWithProperty.Property)), typeof(int), modelType);
+ var propertyMetadata = CreateModelMetadata(propertyId, metadataProvider.Object, hasValidators: true);
+
+ var property2Id = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordTypeWithProperty.Property2)), typeof(int), modelType);
+ var property2Metadata = CreateModelMetadata(property2Id, metadataProvider.Object, hasValidators: false);
+
+ var parameterId = ModelMetadataIdentity.ForParameter(parameter);
+ var parameterMetadata = CreateModelMetadata(parameterId, metadataProvider.Object, hasValidators: false);
+
+ var constructorMetadata = CreateModelMetadata(
+ ModelMetadataIdentity.ForConstructor(constructor, modelType), metadataProvider.Object, hasValidators: null);
+ constructorMetadata.Details.BoundConstructorParameters = new[]
+ {
+ parameterMetadata,
+ };
+
+ metadataProvider
+ .Setup(mp => mp.GetMetadataForConstructor(constructor, modelType))
+ .Returns(constructorMetadata);
+
+ metadataProvider
+ .Setup(mp => mp.GetMetadataForProperties(modelType))
+ .Returns(new[] { propertyMetadata, property2Metadata });
+
+ // Act
+ var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata);
+
+ // Assert
+ Assert.False(result);
+ }
+
private static DefaultModelMetadata CreateModelMetadata(
- ModelMetadataIdentity modelIdentity,
+ ModelMetadataIdentity modelIdentity,
IModelMetadataProvider metadataProvider,
bool? hasValidators)
{
diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/ModelBindingHelperTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/ModelBindingHelperTest.cs
index 6f858059ff..59ed31da72 100644
--- a/src/Mvc/Mvc.Core/test/ModelBinding/ModelBindingHelperTest.cs
+++ b/src/Mvc/Mvc.Core/test/ModelBinding/ModelBindingHelperTest.cs
@@ -54,7 +54,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
var binderProviders = new IModelBinderProvider[]
{
new SimpleTypeModelBinderProvider(),
- new ComplexTypeModelBinderProvider(),
+ new ComplexObjectModelBinderProvider(),
};
var validator = new DataAnnotationsModelValidatorProvider(
@@ -96,7 +96,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
var binderProviders = new IModelBinderProvider[]
{
new SimpleTypeModelBinderProvider(),
- new ComplexTypeModelBinderProvider(),
+ new ComplexObjectModelBinderProvider(),
};
var validator = new DataAnnotationsModelValidatorProvider(
@@ -162,7 +162,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
var binderProviders = new IModelBinderProvider[]
{
new SimpleTypeModelBinderProvider(),
- new ComplexTypeModelBinderProvider(),
+ new ComplexObjectModelBinderProvider(),
};
var validator = new DataAnnotationsModelValidatorProvider(
@@ -242,7 +242,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
var binderProviders = new IModelBinderProvider[]
{
new SimpleTypeModelBinderProvider(),
- new ComplexTypeModelBinderProvider(),
+ new ComplexObjectModelBinderProvider(),
};
var validator = new DataAnnotationsModelValidatorProvider(
@@ -293,7 +293,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
var binderProviders = new IModelBinderProvider[]
{
new SimpleTypeModelBinderProvider(),
- new ComplexTypeModelBinderProvider(),
+ new ComplexObjectModelBinderProvider(),
};
var validator = new DataAnnotationsModelValidatorProvider(
@@ -490,7 +490,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
var binderProviders = new IModelBinderProvider[]
{
new SimpleTypeModelBinderProvider(),
- new ComplexTypeModelBinderProvider(),
+ new ComplexObjectModelBinderProvider(),
};
var validator = new DataAnnotationsModelValidatorProvider(
@@ -570,7 +570,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
var binderProviders = new IModelBinderProvider[]
{
new SimpleTypeModelBinderProvider(),
- new ComplexTypeModelBinderProvider(),
+ new ComplexObjectModelBinderProvider(),
};
var validator = new DataAnnotationsModelValidatorProvider(
diff --git a/src/Mvc/Mvc.DataAnnotations/test/ModelMetadataProviderTest.cs b/src/Mvc/Mvc.DataAnnotations/test/ModelMetadataProviderTest.cs
index 2a40de238d..8b3fff9f32 100644
--- a/src/Mvc/Mvc.DataAnnotations/test/ModelMetadataProviderTest.cs
+++ b/src/Mvc/Mvc.DataAnnotations/test/ModelMetadataProviderTest.cs
@@ -630,7 +630,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations
var attributes = new[]
{
new TestBinderTypeProvider(),
- new TestBinderTypeProvider() { BinderType = typeof(ComplexTypeModelBinder) }
+ new TestBinderTypeProvider() { BinderType = typeof(ComplexObjectModelBinder) }
};
var provider = CreateProvider(attributes);
@@ -639,7 +639,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations
var metadata = provider.GetMetadataForType(typeof(string));
// Assert
- Assert.Same(typeof(ComplexTypeModelBinder), metadata.BinderType);
+ Assert.Same(typeof(ComplexObjectModelBinder), metadata.BinderType);
}
[Fact]
@@ -648,7 +648,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations
// Arrange
var attributes = new[]
{
- new TestBinderTypeProvider() { BinderType = typeof(ComplexTypeModelBinder) },
+ new TestBinderTypeProvider() { BinderType = typeof(ComplexObjectModelBinder) },
new TestBinderTypeProvider() { BinderType = typeof(SimpleTypeModelBinder) }
};
@@ -658,7 +658,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations
var metadata = provider.GetMetadataForType(typeof(string));
// Assert
- Assert.Same(typeof(ComplexTypeModelBinder), metadata.BinderType);
+ Assert.Same(typeof(ComplexObjectModelBinder), metadata.BinderType);
}
[Fact]
diff --git a/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs b/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs
index 6b68683118..eb8d0933b4 100644
--- a/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs
+++ b/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs
@@ -67,7 +67,7 @@ namespace Microsoft.AspNetCore.Mvc
binder => Assert.IsType(binder),
binder => Assert.IsType(binder),
binder => Assert.IsType(binder),
- binder => Assert.IsType(binder));
+ binder => Assert.IsType(binder));
}
[Fact]
diff --git a/src/Mvc/test/Mvc.FunctionalTests/HtmlGenerationTest.cs b/src/Mvc/test/Mvc.FunctionalTests/HtmlGenerationTest.cs
index 75ac9df6d9..98416f442a 100644
--- a/src/Mvc/test/Mvc.FunctionalTests/HtmlGenerationTest.cs
+++ b/src/Mvc/test/Mvc.FunctionalTests/HtmlGenerationTest.cs
@@ -299,6 +299,40 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
#endif
}
+ [Fact]
+ public async Task ValidationTagHelpers_UsingRecords()
+ {
+ // Arrange
+ var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost/Customer/HtmlGeneration_Customer/CustomerWithRecords");
+ var nameValueCollection = new List>
+ {
+ new KeyValuePair("Number", string.Empty),
+ new KeyValuePair("Name", string.Empty),
+ new KeyValuePair("Email", string.Empty),
+ new KeyValuePair("PhoneNumber", string.Empty),
+ new KeyValuePair("Password", string.Empty)
+ };
+ request.Content = new FormUrlEncodedContent(nameValueCollection);
+
+ // Act
+ var response = await Client.SendAsync(request);
+
+ // Assert
+ var document = await response.GetHtmlDocumentAsync();
+
+ var validation = document.RequiredQuerySelector("span[data-valmsg-for=Number]");
+ Assert.Equal("The value '' is invalid.", validation.TextContent);
+
+ validation = document.QuerySelector("span[data-valmsg-for=Name]");
+ Assert.Null(validation);
+
+ validation = document.QuerySelector("span[data-valmsg-for=Email]");
+ Assert.Equal("field-validation-valid", validation.ClassName);
+
+ validation = document.QuerySelector("span[data-valmsg-for=Password]");
+ Assert.Equal("The Password field is required.", validation.TextContent);
+ }
+
[Fact]
public async Task CacheTagHelper_CanCachePortionsOfViewsPartialViewsAndViewComponents()
{
diff --git a/src/Mvc/test/Mvc.FunctionalTests/JsonInputFormatterTestBase.cs b/src/Mvc/test/Mvc.FunctionalTests/JsonInputFormatterTestBase.cs
index 347bca0e25..966d0a51e9 100644
--- a/src/Mvc/test/Mvc.FunctionalTests/JsonInputFormatterTestBase.cs
+++ b/src/Mvc/test/Mvc.FunctionalTests/JsonInputFormatterTestBase.cs
@@ -5,6 +5,7 @@ using System;
using System.Linq;
using System.Net;
using System.Net.Http;
+using System.Net.Http.Json;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
@@ -121,6 +122,71 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
Assert.Equal(expected.StreetName, actual.StreetName);
}
+ [Fact]
+ public virtual async Task JsonInputFormatter_RoundtripsRecordType()
+ {
+ // Arrange
+ var expected = new JsonFormatterController.SimpleRecordModel(18, "James", "JnK");
+
+ // Act
+ var response = await Client.PostAsJsonAsync("http://localhost/JsonFormatter/RoundtripRecordType/", expected);
+ var actual = await response.Content.ReadAsAsync();
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(expected.Id, actual.Id);
+ Assert.Equal(expected.Name, actual.Name);
+ Assert.Equal(expected.StreetName, actual.StreetName);
+ }
+
+ [Fact]
+ public virtual async Task JsonInputFormatter_ValidationWithRecordTypes_ValidationErrors()
+ {
+ // Arrange
+ var expected = new JsonFormatterController.SimpleModelWithValidation(123, "This is a very long name", StreetName: null);
+
+ // Act
+ var response = await Client.PostAsJsonAsync($"JsonFormatter/{nameof(JsonFormatterController.RoundtripModelWithValidation)}", expected);
+
+ // Assert
+ await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest);
+ var problem = await response.Content.ReadFromJsonAsync();
+ Assert.Collection(
+ problem.Errors.OrderBy(e => e.Key),
+ kvp =>
+ {
+ Assert.Equal("Id", kvp.Key);
+ Assert.Equal("The field Id must be between 1 and 100.", Assert.Single(kvp.Value));
+ },
+ kvp =>
+ {
+ Assert.Equal("Name", kvp.Key);
+ Assert.Equal("The field Name must be a string with a minimum length of 2 and a maximum length of 8.", Assert.Single(kvp.Value));
+ },
+ kvp =>
+ {
+ Assert.Equal("StreetName", kvp.Key);
+ Assert.Equal("The StreetName field is required.", Assert.Single(kvp.Value));
+ });
+ }
+
+ [Fact]
+ public virtual async Task JsonInputFormatter_ValidationWithRecordTypes_NoValidationErrors()
+ {
+ // Arrange
+ var expected = new JsonFormatterController.SimpleModelWithValidation(99, "TestName", "Some address");
+
+ // Act
+ var response = await Client.PostAsJsonAsync($"JsonFormatter/{nameof(JsonFormatterController.RoundtripModelWithValidation)}", expected);
+
+ // Assert
+ await response.AssertStatusCodeAsync(HttpStatusCode.OK);
+ var actual = await response.Content.ReadFromJsonAsync();
+ Assert.Equal(expected.Id, actual.Id);
+ Assert.Equal(expected.Name, actual.Name);
+ Assert.Equal(expected.StreetName, actual.StreetName);
+ }
+
[Fact]
public async Task JsonInputFormatter_Returns415UnsupportedMediaType_ForEmptyContentType()
{
diff --git a/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonInputFormatterTest.cs b/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonInputFormatterTest.cs
index 9e09314712..a81adfeb80 100644
--- a/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonInputFormatterTest.cs
+++ b/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonInputFormatterTest.cs
@@ -13,13 +13,16 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
{
}
- [Theory(Skip = "https://github.com/dotnet/corefx/issues/36025")]
- [InlineData("\"I'm a JSON string!\"")]
- [InlineData("true")]
- [InlineData("\"\"")] // Empty string
- public override Task JsonInputFormatter_ReturnsDefaultValue_ForValueTypes(string input)
- {
- return base.JsonInputFormatter_ReturnsDefaultValue_ForValueTypes(input);
- }
+ [Fact(Skip = "https://github.com/dotnet/runtime/issues/38539")]
+ public override Task JsonInputFormatter_RoundtripsRecordType()
+ => base.JsonInputFormatter_RoundtripsRecordType();
+
+ [Fact(Skip = "https://github.com/dotnet/runtime/issues/38539")]
+ public override Task JsonInputFormatter_ValidationWithRecordTypes_NoValidationErrors()
+ => base.JsonInputFormatter_ValidationWithRecordTypes_NoValidationErrors();
+
+ [Fact(Skip = "https://github.com/dotnet/runtime/issues/38539")]
+ public override Task JsonInputFormatter_ValidationWithRecordTypes_ValidationErrors()
+ => base.JsonInputFormatter_ValidationWithRecordTypes_ValidationErrors();
}
}
diff --git a/src/Mvc/test/Mvc.IntegrationTests/ActionParametersIntegrationTest.cs b/src/Mvc/test/Mvc.IntegrationTests/ActionParametersIntegrationTest.cs
index 6f84a74c88..99d82b9b71 100644
--- a/src/Mvc/test/Mvc.IntegrationTests/ActionParametersIntegrationTest.cs
+++ b/src/Mvc/test/Mvc.IntegrationTests/ActionParametersIntegrationTest.cs
@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
+using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
@@ -403,6 +404,72 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
});
var modelState = testContext.ModelState;
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() => parameterBinder.BindModelAsync(parameter, testContext));
+ Assert.Equal(
+ string.Format(
+ "Could not create an instance of type '{0}'. Model bound complex types must not be abstract or " +
+ "value types and must have a parameterless constructor. Record types must have a single primary constructor. " +
+ "Alternatively, set the '{1}' property to a non-null value in the '{2}' constructor.",
+ typeof(ClassWithNoDefaultConstructor).FullName,
+ nameof(Class1.Property1),
+ typeof(Class1).FullName),
+ exception.Message);
+ }
+
+ public record ActionParameter_DefaultValueConstructor(string Name = "test", int Age = 23);
+
+ [Fact]
+ public async Task ActionParameter_UsesDefaultConstructorParameters()
+ {
+ // Arrange
+ var parameterType = typeof(ActionParameter_DefaultValueConstructor);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "p",
+ ParameterType = parameterType
+ };
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = QueryString.Create("Name", "James");
+ });
+ var modelState = testContext.ModelState;
+
+ // Act
+ var result = await parameterBinder.BindModelAsync(parameter, testContext);
+
+ // Assert
+ Assert.True(modelState.IsValid);
+
+ var model = Assert.IsType(result.Model);
+ Assert.Equal("James", model.Name);
+ Assert.Equal(23, model.Age);
+ }
+
+ [Fact]
+ public async Task ActionParameter_UsingComplexTypeModelBinder_ModelPropertyTypeWithNoParameterlessConstructor_ThrowsException()
+ {
+ // Arrange
+ var parameterType = typeof(Class1);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "p",
+ ParameterType = parameterType
+ };
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = QueryString.Create("Name", "James").Add("Property1.City", "Seattle");
+ }, updateOptions: options =>
+ {
+ options.ModelBinderProviders.RemoveType();
+#pragma warning disable CS0618 // Type or member is obsolete
+ options.ModelBinderProviders.Add(new ComplexTypeModelBinderProvider());
+#pragma warning restore CS0618 // Type or member is obsolete
+ });
+ var modelState = testContext.ModelState;
+
// Act & Assert
var exception = await Assert.ThrowsAsync(() => parameterBinder.BindModelAsync(parameter, testContext));
Assert.Equal(
@@ -434,21 +501,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
Assert.Equal(
string.Format(
"Could not create an instance of type '{0}'. Model bound complex types must not be abstract or " +
- "value types and must have a parameterless constructor.",
+ "value types and must have a parameterless constructor. Record types must have a single primary constructor.",
typeof(PointStruct).FullName),
exception.Message);
}
- [Theory]
- [InlineData(typeof(ClassWithNoDefaultConstructor))]
- [InlineData(typeof(AbstractClassWithNoDefaultConstructor))]
- public async Task ActionParameter_BindingToTypeWithNoParameterlessConstructor_ThrowsException(Type parameterType)
+ [Fact]
+ public async Task ActionParameter_BindingToAbstractionType_ThrowsException()
{
// Arrange
var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
var parameter = new ParameterDescriptor()
{
- ParameterType = parameterType,
+ ParameterType = typeof(AbstractClassWithNoDefaultConstructor),
Name = "p"
};
var testContext = ModelBindingTestHelper.GetTestContext();
@@ -458,8 +523,78 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
Assert.Equal(
string.Format(
"Could not create an instance of type '{0}'. Model bound complex types must not be abstract or " +
- "value types and must have a parameterless constructor.",
- parameterType.FullName),
+ "value types and must have a parameterless constructor. Record types must have a single primary constructor.",
+ typeof(AbstractClassWithNoDefaultConstructor).FullName),
+ exception.Message);
+ }
+
+ public class ActionParameter_MultipleConstructorsWithDefaultValues_NoParameterlessConstructorModel
+ {
+ public ActionParameter_MultipleConstructorsWithDefaultValues_NoParameterlessConstructorModel(string name = "default-name") => (Name) = (name);
+
+ public ActionParameter_MultipleConstructorsWithDefaultValues_NoParameterlessConstructorModel(string name, int age) => (Name, Age) = (name, age);
+
+ public string Name { get; init; }
+
+ public int Age { get; init; }
+ }
+
+ [Fact]
+ public async Task ActionParameter_MultipleConstructorsWithDefaultValues_NoParameterlessConstructor_Throws()
+ {
+ // Arrange
+ var parameterType = typeof(ActionParameter_MultipleConstructorsWithDefaultValues_NoParameterlessConstructorModel);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "p",
+ ParameterType = parameterType
+ };
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = QueryString.Create("Name", "James");
+ });
+ var modelState = testContext.ModelState;
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() => parameterBinder.BindModelAsync(parameter, testContext));
+ Assert.Equal(
+ string.Format(
+ "Could not create an instance of type '{0}'. Model bound complex types must not be abstract or " +
+ "value types and must have a parameterless constructor. Record types must have a single primary constructor.",
+ typeof(ActionParameter_MultipleConstructorsWithDefaultValues_NoParameterlessConstructorModel).FullName),
+ exception.Message);
+ }
+
+ public record ActionParameter_RecordTypeWithMultipleConstructors(string Name, int Age)
+ {
+ public ActionParameter_RecordTypeWithMultipleConstructors(string Name) : this(Name, 0) { }
+ }
+
+ [Fact]
+ public async Task ActionParameter_RecordTypeWithMultipleConstructors_Throws()
+ {
+ // Arrange
+ var parameterType = typeof(ActionParameter_RecordTypeWithMultipleConstructors);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "p",
+ ParameterType = parameterType
+ };
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = QueryString.Create("Name", "James").Add("Age", "29");
+ });
+ var modelState = testContext.ModelState;
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() => parameterBinder.BindModelAsync(parameter, testContext));
+ Assert.Equal(
+ string.Format(
+ "Could not create an instance of type '{0}'. Model bound complex types must not be abstract or " +
+ "value types and must have a parameterless constructor. Record types must have a single primary constructor.",
+ typeof(ActionParameter_RecordTypeWithMultipleConstructors).FullName),
exception.Message);
}
@@ -527,6 +662,46 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
Assert.True(modelState.IsValid);
}
+ [Fact]
+ public async Task ActionParameter_WithValidateNever_DoesNotGetValidated()
+ {
+ // Arrange
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
+ var parameter = new ParameterDescriptor()
+ {
+ Name = ParameterWithValidateNever.ValidateNeverParameterInfo.Name,
+ ParameterType = typeof(ModelWithIValidatableObject)
+ };
+
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = QueryString.Create(nameof(ModelWithIValidatableObject.FirstName), "TestName");
+ });
+
+ var modelState = testContext.ModelState;
+ var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider();
+ var modelMetadata = modelMetadataProvider
+ .GetMetadataForParameter(ParameterWithValidateNever.ValidateNeverParameterInfo);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ parameter,
+ testContext,
+ modelMetadataProvider,
+ modelMetadata);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("TestName", model.FirstName);
+
+ // No validation errors are expected.
+ // Assert.True(modelState.IsValid);
+
+ // Tracking bug to enable this scenario: https://github.com/dotnet/aspnetcore/issues/24241
+ Assert.False(modelState.IsValid);
+ }
+
[Theory]
[InlineData(123, true)]
[InlineData(null, false)]
@@ -800,6 +975,29 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
}
}
+ private class ParameterWithValidateNever
+ {
+ public void MyAction([Required] string Name, [ValidateNever] ModelWithIValidatableObject validatableObject)
+ {
+ }
+
+ private static MethodInfo MyActionMethodInfo
+ => typeof(ParameterWithValidateNever).GetMethod(nameof(MyAction));
+
+ public static ParameterInfo NameParamterInfo
+ => MyActionMethodInfo.GetParameters()[0];
+
+ public static ParameterInfo ValidateNeverParameterInfo
+ => MyActionMethodInfo.GetParameters()[1];
+
+ public static ParameterInfo GetParameterInfo(string parameterName)
+ {
+ return MyActionMethodInfo
+ .GetParameters()
+ .Single(p => p.Name.Equals(parameterName, StringComparison.Ordinal));
+ }
+ }
+
private class CustomReadOnlyCollection : ICollection
{
private ICollection _original;
@@ -865,7 +1063,9 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
// By default the ComplexTypeModelBinder fails to construct models for types with no parameterless constructor,
// but a developer could change this behavior by overriding CreateModel
+#pragma warning disable CS0618 // Type or member is obsolete
private class CustomComplexTypeModelBinder : ComplexTypeModelBinder
+#pragma warning restore CS0618 // Type or member is obsolete
{
public CustomComplexTypeModelBinder(IDictionary propertyBinders)
: base(propertyBinders, NullLoggerFactory.Instance)
diff --git a/src/Mvc/test/Mvc.IntegrationTests/ComplexObjectIntegrationTest.cs b/src/Mvc/test/Mvc.IntegrationTests/ComplexObjectIntegrationTest.cs
new file mode 100644
index 0000000000..0f8fccce4f
--- /dev/null
+++ b/src/Mvc/test/Mvc.IntegrationTests/ComplexObjectIntegrationTest.cs
@@ -0,0 +1,13 @@
+// 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 Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
+
+namespace Microsoft.AspNetCore.Mvc.IntegrationTests
+{
+ public class ComplexObjectIntegrationTest : ComplexTypeIntegrationTestBase
+ {
+ protected override Type ExpectedModelBinderType => typeof(ComplexObjectModelBinder);
+ }
+}
diff --git a/src/Mvc/test/Mvc.IntegrationTests/ComplexRecordIntegrationTest.cs b/src/Mvc/test/Mvc.IntegrationTests/ComplexRecordIntegrationTest.cs
new file mode 100644
index 0000000000..f533be2812
--- /dev/null
+++ b/src/Mvc/test/Mvc.IntegrationTests/ComplexRecordIntegrationTest.cs
@@ -0,0 +1,4269 @@
+// 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.Globalization;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.Abstractions;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
+using Microsoft.AspNetCore.Testing;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Primitives;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Mvc.IntegrationTests
+{
+ // A clone of ComplexTypeIntegrationTestBase performed using record types
+ public class ComplexRecordIntegrationTest
+ {
+ private const string AddressBodyContent = "{ \"street\" : \"" + AddressStreetContent + "\" }";
+ private const string AddressStreetContent = "1 Microsoft Way";
+
+ private static readonly byte[] ByteArrayContent = Encoding.BigEndianUnicode.GetBytes("abcd");
+ private static readonly string ByteArrayEncoded = Convert.ToBase64String(ByteArrayContent);
+
+ private record Order1(int ProductId, Person1 Customer);
+
+ private record Person1(string Name, [FromBody] Address1 Address);
+
+ private record Address1(string Street);
+
+ [Fact]
+ public async Task BindsNestedPOCO_WithBodyModelBinder_WithPrefix_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order1)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?parameter.Customer.Name=bill");
+ SetJsonBodyContent(request, AddressBodyContent);
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model.Customer);
+ Assert.Equal("bill", model.Customer.Name);
+ Assert.NotNull(model.Customer.Address);
+ Assert.Equal(AddressStreetContent, model.Customer.Address.Street);
+
+ Assert.Single(modelState);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsNestedPOCO_WithBodyModelBinder_WithEmptyPrefix_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order1)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?Customer.Name=bill");
+ SetJsonBodyContent(request, AddressBodyContent);
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model.Customer);
+ Assert.Equal("bill", model.Customer.Name);
+ Assert.NotNull(model.Customer.Address);
+ Assert.Equal(AddressStreetContent, model.Customer.Address.Street);
+
+ Assert.Single(modelState);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "Customer.Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsNestedPOCO_WithBodyModelBinder_WithPrefix_NoBodyData()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order1)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?parameter.Customer.Name=bill");
+ request.ContentType = "application/json";
+ });
+
+ testContext.MvcOptions.AllowEmptyInputInBodyModelBinding = true;
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model.Customer);
+ Assert.Equal("bill", model.Customer.Name);
+ Assert.Null(model.Customer.Address);
+
+ Assert.Single(modelState);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsNestedPOCO_WithBodyModelBinder_WithPrefix_NoBodyData_ValueInQuery()
+ {
+ // With record types, constructor parameters also appear as settable properties.
+ // In this case, we will only attempt to bind the parameter and not the property.
+
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order1)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?parameter.Customer.Name=bill¶mater.Customer.Address=not-used");
+ request.ContentType = "application/json";
+ });
+
+ testContext.MvcOptions.AllowEmptyInputInBodyModelBinding = true;
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model.Customer);
+ Assert.Equal("bill", model.Customer.Name);
+ Assert.Null(model.Customer.Address);
+
+ Assert.Single(modelState);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsNestedPOCO_WithBodyModelBinder_WithPrefix_PartialData()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order1)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?parameter.ProductId=10");
+ SetJsonBodyContent(request, AddressBodyContent);
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model.Customer);
+ Assert.Equal("1 Microsoft Way", model.Customer.Address.Street);
+
+ Assert.Equal(10, model.ProductId);
+
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState).Value;
+ Assert.Equal("10", entry.AttemptedValue);
+ Assert.Equal("10", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsNestedPOCO_WithBodyModelBinder_WithPrefix_NoData()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order1)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?");
+ SetJsonBodyContent(request, AddressBodyContent);
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model.Customer);
+ Assert.Equal("1 Microsoft Way", model.Customer.Address.Street);
+
+ Assert.Empty(modelState);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+ }
+
+ private record Order3(int ProductId, Person3 Customer);
+
+ private record Person3(string Name, byte[] Token);
+
+ [Fact]
+ public async Task BindsNestedPOCO_WithByteArrayModelBinder_WithPrefix_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order3)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString =
+ new QueryString("?parameter.Customer.Name=bill¶meter.Customer.Token=" + ByteArrayEncoded);
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model.Customer);
+ Assert.Equal("bill", model.Customer.Name);
+ Assert.Equal(ByteArrayContent, model.Customer.Token);
+
+ Assert.Equal(2, modelState.Count);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Token").Value;
+ Assert.Equal(ByteArrayEncoded, entry.AttemptedValue);
+ Assert.Equal(ByteArrayEncoded, entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsNestedPOCO_WithByteArrayModelBinder_WithEmptyPrefix_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order3)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?Customer.Name=bill&Customer.Token=" + ByteArrayEncoded);
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model.Customer);
+ Assert.Equal("bill", model.Customer.Name);
+ Assert.Equal(ByteArrayContent, model.Customer.Token);
+
+ Assert.Equal(2, modelState.Count);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "Customer.Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "Customer.Token").Value;
+ Assert.Equal(ByteArrayEncoded, entry.AttemptedValue);
+ Assert.Equal(ByteArrayEncoded, entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsNestedPOCO_WithByteArrayModelBinder_WithPrefix_NoData()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order3)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?parameter.Customer.Name=bill");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model.Customer);
+ Assert.Equal("bill", model.Customer.Name);
+ Assert.Null(model.Customer.Token);
+
+ Assert.Single(modelState);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+ }
+
+ private record Order4(int ProductId, Person4 Customer);
+
+ private record Person4(string Name, IEnumerable Documents);
+
+ [Fact]
+ public async Task BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order4)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?parameter.Customer.Name=bill");
+ SetFormFileBodyContent(request, "Hello, World!", "parameter.Customer.Documents");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model.Customer);
+ Assert.Equal("bill", model.Customer.Name);
+ Assert.Single(model.Customer.Documents);
+
+ Assert.Equal(2, modelState.Count);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Documents").Value;
+ Assert.Null(entry.AttemptedValue); // FormFile entries for body don't include original text.
+ Assert.Null(entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsNestedPOCO_WithFormFileModelBinder_WithEmptyPrefix_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order4),
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?Customer.Name=bill");
+ SetFormFileBodyContent(request, "Hello, World!", "Customer.Documents");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model.Customer);
+ Assert.Equal("bill", model.Customer.Name);
+ Assert.Single(model.Customer.Documents);
+
+ Assert.Equal(2, modelState.Count);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "Customer.Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "Customer.Documents").Value;
+ Assert.Null(entry.AttemptedValue); // FormFile entries don't include the model.
+ Assert.Null(entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_NoBodyData()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order4)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?parameter.Customer.Name=bill");
+
+ // Deliberately leaving out any form data.
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model.Customer);
+ Assert.Equal("bill", model.Customer.Name);
+ Assert.Null(model.Customer.Documents);
+
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var kvp = Assert.Single(modelState);
+ Assert.Equal("parameter.Customer.Name", kvp.Key);
+ var entry = kvp.Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_PartialData()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order4)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?parameter.ProductId=10");
+ SetFormFileBodyContent(request, "Hello, World!", "parameter.Customer.Documents");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model.Customer);
+
+ var document = Assert.Single(model.Customer.Documents);
+ Assert.Equal("text.txt", document.FileName);
+ using (var reader = new StreamReader(document.OpenReadStream()))
+ {
+ Assert.Equal("Hello, World!", await reader.ReadToEndAsync());
+ }
+
+ Assert.Equal(10, model.ProductId);
+
+ Assert.Equal(2, modelState.Count);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ Assert.Single(modelState, e => e.Key == "parameter.Customer.Documents");
+ var entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId").Value;
+ Assert.Equal("10", entry.AttemptedValue);
+ Assert.Equal("10", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_NoData()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order4)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?");
+ SetFormFileBodyContent(request, "Hello, World!", "Customer.Documents");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model.Customer);
+
+ var document = Assert.Single(model.Customer.Documents);
+ Assert.Equal("text.txt", document.FileName);
+ using (var reader = new StreamReader(document.OpenReadStream()))
+ {
+ Assert.Equal("Hello, World!", await reader.ReadToEndAsync());
+ }
+
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState);
+ Assert.Equal("Customer.Documents", entry.Key);
+ }
+
+ private record Order5(string Name, int[] ProductIds);
+
+ [Fact]
+ public async Task BindsArrayProperty_WithPrefix_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order5)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString =
+ new QueryString("?parameter.Name=bill¶meter.ProductIds[0]=10¶meter.ProductIds[1]=11");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("bill", model.Name);
+ Assert.Equal(new int[] { 10, 11 }, model.ProductIds);
+
+ Assert.Equal(3, modelState.Count);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[0]").Value;
+ Assert.Equal("10", entry.AttemptedValue);
+ Assert.Equal("10", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[1]").Value;
+ Assert.Equal("11", entry.AttemptedValue);
+ Assert.Equal("11", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsArrayProperty_EmptyPrefix_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order5)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?Name=bill&ProductIds[0]=10&ProductIds[1]=11");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("bill", model.Name);
+ Assert.Equal(new int[] { 10, 11 }, model.ProductIds);
+
+ Assert.Equal(3, modelState.Count);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "ProductIds[0]").Value;
+ Assert.Equal("10", entry.AttemptedValue);
+ Assert.Equal("10", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "ProductIds[1]").Value;
+ Assert.Equal("11", entry.AttemptedValue);
+ Assert.Equal("11", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsArrayProperty_NoCollectionData()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order5)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?parameter.Name=bill");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("bill", model.Name);
+ Assert.Null(model.ProductIds);
+
+ Assert.Single(modelState);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsArrayProperty_NoData()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order5)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Null(model.Name);
+ Assert.Null(model.ProductIds);
+
+ Assert.Empty(modelState);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+ }
+
+ private record Order6(string Name, List ProductIds);
+
+ [Fact]
+ public async Task BindsListProperty_WithPrefix_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order6)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString =
+ new QueryString("?parameter.Name=bill¶meter.ProductIds[0]=10¶meter.ProductIds[1]=11");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("bill", model.Name);
+ Assert.Equal(new List() { 10, 11 }, model.ProductIds);
+
+ Assert.Equal(3, modelState.Count);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[0]").Value;
+ Assert.Equal("10", entry.AttemptedValue);
+ Assert.Equal("10", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[1]").Value;
+ Assert.Equal("11", entry.AttemptedValue);
+ Assert.Equal("11", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsListProperty_EmptyPrefix_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order6)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?Name=bill&ProductIds[0]=10&ProductIds[1]=11");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("bill", model.Name);
+ Assert.Equal(new List() { 10, 11 }, model.ProductIds);
+
+ Assert.Equal(3, modelState.Count);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "ProductIds[0]").Value;
+ Assert.Equal("10", entry.AttemptedValue);
+ Assert.Equal("10", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "ProductIds[1]").Value;
+ Assert.Equal("11", entry.AttemptedValue);
+ Assert.Equal("11", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsListProperty_NoCollectionData()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order6)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?parameter.Name=bill");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("bill", model.Name);
+ Assert.Null(model.ProductIds);
+
+ Assert.Single(modelState);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsListProperty_NoData()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order6)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Null(model.Name);
+ Assert.Null(model.ProductIds);
+
+ Assert.Empty(modelState);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+ }
+
+ private record Order7(string Name, Dictionary ProductIds);
+
+ [Fact]
+ public async Task BindsDictionaryProperty_WithPrefix_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order7)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString =
+ new QueryString("?parameter.Name=bill¶meter.ProductIds[0].Key=key0¶meter.ProductIds[0].Value=10");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("bill", model.Name);
+ Assert.Equal(new Dictionary() { { "key0", 10 } }, model.ProductIds);
+
+ Assert.Equal(3, modelState.Count);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[0].Key").Value;
+ Assert.Equal("key0", entry.AttemptedValue);
+ Assert.Equal("key0", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[0].Value").Value;
+ Assert.Equal("10", entry.AttemptedValue);
+ Assert.Equal("10", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsDictionaryProperty_EmptyPrefix_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order7)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?Name=bill&ProductIds[0].Key=key0&ProductIds[0].Value=10");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("bill", model.Name);
+ Assert.Equal(new Dictionary() { { "key0", 10 } }, model.ProductIds);
+
+ Assert.Equal(3, modelState.Count);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "ProductIds[0].Key").Value;
+ Assert.Equal("key0", entry.AttemptedValue);
+ Assert.Equal("key0", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "ProductIds[0].Value").Value;
+ Assert.Equal("10", entry.AttemptedValue);
+ Assert.Equal("10", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsDictionaryProperty_NoCollectionData()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order7)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?parameter.Name=bill");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("bill", model.Name);
+ Assert.Null(model.ProductIds);
+
+ Assert.Single(modelState);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsDictionaryProperty_NoData()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order7)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Null(model.Name);
+ Assert.Null(model.ProductIds);
+
+ Assert.Empty(modelState);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+ }
+
+ // Dictionary property with an IEnumerable<> value type
+ private record Car1(string Name, Dictionary> Specs);
+
+ // Dictionary property with an Array value type
+ private record Car2(string Name, Dictionary Specs);
+
+ private record Car3(string Name, IEnumerable>> Specs);
+
+ private record SpecDoc(string Name);
+
+ [Fact]
+ public async Task BindsDictionaryProperty_WithIEnumerableComplexTypeValue_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "p",
+ ParameterType = typeof(Car1)
+ };
+
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ var queryString = "?p.Name=Accord"
+ + "&p.Specs[0].Key=camera_specs"
+ + "&p.Specs[0].Value[0].Name=camera_spec1.txt"
+ + "&p.Specs[0].Value[1].Name=camera_spec2.txt"
+ + "&p.Specs[1].Key=tyre_specs"
+ + "&p.Specs[1].Value[0].Name=tyre_spec1.txt"
+ + "&p.Specs[1].Value[1].Name=tyre_spec2.txt";
+ request.QueryString = new QueryString(queryString);
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("Accord", model.Name);
+
+ Assert.Collection(
+ model.Specs,
+ (e) =>
+ {
+ Assert.Equal("camera_specs", e.Key);
+ Assert.Collection(
+ e.Value,
+ (s) =>
+ {
+ Assert.Equal("camera_spec1.txt", s.Name);
+ },
+ (s) =>
+ {
+ Assert.Equal("camera_spec2.txt", s.Name);
+ });
+ },
+ (e) =>
+ {
+ Assert.Equal("tyre_specs", e.Key);
+ Assert.Collection(
+ e.Value,
+ (s) =>
+ {
+ Assert.Equal("tyre_spec1.txt", s.Name);
+ },
+ (s) =>
+ {
+ Assert.Equal("tyre_spec2.txt", s.Name);
+ });
+ });
+
+ Assert.Equal(7, modelState.Count);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "p.Name").Value;
+ Assert.Equal("Accord", entry.AttemptedValue);
+ Assert.Equal("Accord", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Key").Value;
+ Assert.Equal("camera_specs", entry.AttemptedValue);
+ Assert.Equal("camera_specs", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[0].Name").Value;
+ Assert.Equal("camera_spec1.txt", entry.AttemptedValue);
+ Assert.Equal("camera_spec1.txt", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[1].Name").Value;
+ Assert.Equal("camera_spec2.txt", entry.AttemptedValue);
+ Assert.Equal("camera_spec2.txt", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Key").Value;
+ Assert.Equal("tyre_specs", entry.AttemptedValue);
+ Assert.Equal("tyre_specs", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[0].Name").Value;
+ Assert.Equal("tyre_spec1.txt", entry.AttemptedValue);
+ Assert.Equal("tyre_spec1.txt", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[1].Name").Value;
+ Assert.Equal("tyre_spec2.txt", entry.AttemptedValue);
+ Assert.Equal("tyre_spec2.txt", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsDictionaryProperty_WithArrayOfComplexTypeValue_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "p",
+ ParameterType = typeof(Car2)
+ };
+
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ var queryString = "?p.Name=Accord"
+ + "&p.Specs[0].Key=camera_specs"
+ + "&p.Specs[0].Value[0].Name=camera_spec1.txt"
+ + "&p.Specs[0].Value[1].Name=camera_spec2.txt"
+ + "&p.Specs[1].Key=tyre_specs"
+ + "&p.Specs[1].Value[0].Name=tyre_spec1.txt"
+ + "&p.Specs[1].Value[1].Name=tyre_spec2.txt";
+ request.QueryString = new QueryString(queryString);
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("Accord", model.Name);
+
+ Assert.Collection(
+ model.Specs,
+ (e) =>
+ {
+ Assert.Equal("camera_specs", e.Key);
+ Assert.Collection(
+ e.Value,
+ (s) =>
+ {
+ Assert.Equal("camera_spec1.txt", s.Name);
+ },
+ (s) =>
+ {
+ Assert.Equal("camera_spec2.txt", s.Name);
+ });
+ },
+ (e) =>
+ {
+ Assert.Equal("tyre_specs", e.Key);
+ Assert.Collection(
+ e.Value,
+ (s) =>
+ {
+ Assert.Equal("tyre_spec1.txt", s.Name);
+ },
+ (s) =>
+ {
+ Assert.Equal("tyre_spec2.txt", s.Name);
+ });
+ });
+
+ Assert.Equal(7, modelState.Count);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "p.Name").Value;
+ Assert.Equal("Accord", entry.AttemptedValue);
+ Assert.Equal("Accord", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Key").Value;
+ Assert.Equal("camera_specs", entry.AttemptedValue);
+ Assert.Equal("camera_specs", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[0].Name").Value;
+ Assert.Equal("camera_spec1.txt", entry.AttemptedValue);
+ Assert.Equal("camera_spec1.txt", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[1].Name").Value;
+ Assert.Equal("camera_spec2.txt", entry.AttemptedValue);
+ Assert.Equal("camera_spec2.txt", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Key").Value;
+ Assert.Equal("tyre_specs", entry.AttemptedValue);
+ Assert.Equal("tyre_specs", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[0].Name").Value;
+ Assert.Equal("tyre_spec1.txt", entry.AttemptedValue);
+ Assert.Equal("tyre_spec1.txt", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[1].Name").Value;
+ Assert.Equal("tyre_spec2.txt", entry.AttemptedValue);
+ Assert.Equal("tyre_spec2.txt", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsDictionaryProperty_WithIEnumerableOfKeyValuePair_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "p",
+ ParameterType = typeof(Car3)
+ };
+
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ var queryString = "?p.Name=Accord"
+ + "&p.Specs[0].Key=camera_specs"
+ + "&p.Specs[0].Value[0].Name=camera_spec1.txt"
+ + "&p.Specs[0].Value[1].Name=camera_spec2.txt"
+ + "&p.Specs[1].Key=tyre_specs"
+ + "&p.Specs[1].Value[0].Name=tyre_spec1.txt"
+ + "&p.Specs[1].Value[1].Name=tyre_spec2.txt";
+ request.QueryString = new QueryString(queryString);
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("Accord", model.Name);
+
+ Assert.Collection(
+ model.Specs,
+ (e) =>
+ {
+ Assert.Equal("camera_specs", e.Key);
+ Assert.Collection(
+ e.Value,
+ (s) =>
+ {
+ Assert.Equal("camera_spec1.txt", s.Name);
+ },
+ (s) =>
+ {
+ Assert.Equal("camera_spec2.txt", s.Name);
+ });
+ },
+ (e) =>
+ {
+ Assert.Equal("tyre_specs", e.Key);
+ Assert.Collection(
+ e.Value,
+ (s) =>
+ {
+ Assert.Equal("tyre_spec1.txt", s.Name);
+ },
+ (s) =>
+ {
+ Assert.Equal("tyre_spec2.txt", s.Name);
+ });
+ });
+
+ Assert.Equal(7, modelState.Count);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "p.Name").Value;
+ Assert.Equal("Accord", entry.AttemptedValue);
+ Assert.Equal("Accord", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Key").Value;
+ Assert.Equal("camera_specs", entry.AttemptedValue);
+ Assert.Equal("camera_specs", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[0].Name").Value;
+ Assert.Equal("camera_spec1.txt", entry.AttemptedValue);
+ Assert.Equal("camera_spec1.txt", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[1].Name").Value;
+ Assert.Equal("camera_spec2.txt", entry.AttemptedValue);
+ Assert.Equal("camera_spec2.txt", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Key").Value;
+ Assert.Equal("tyre_specs", entry.AttemptedValue);
+ Assert.Equal("tyre_specs", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[0].Name").Value;
+ Assert.Equal("tyre_spec1.txt", entry.AttemptedValue);
+ Assert.Equal("tyre_spec1.txt", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[1].Name").Value;
+ Assert.Equal("tyre_spec2.txt", entry.AttemptedValue);
+ Assert.Equal("tyre_spec2.txt", entry.RawValue);
+ }
+
+ private record Order8(KeyValuePair ProductId, string Name = default!);
+
+ [Fact]
+ public async Task BindsKeyValuePairProperty_WithPrefix_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order8)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString =
+ new QueryString("?parameter.Name=bill¶meter.ProductId.Key=key0¶meter.ProductId.Value=10");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("bill", model.Name);
+ Assert.Equal(new KeyValuePair("key0", 10), model.ProductId);
+
+ Assert.Equal(3, modelState.Count);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId.Key").Value;
+ Assert.Equal("key0", entry.AttemptedValue);
+ Assert.Equal("key0", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId.Value").Value;
+ Assert.Equal("10", entry.AttemptedValue);
+ Assert.Equal("10", entry.RawValue);
+ }
+
+ [Fact]
+ public async Task BindsKeyValuePairProperty_EmptyPrefix_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order8)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?Name=bill&ProductId.Key=key0&ProductId.Value=10");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("bill", model.Name);
+ Assert.Equal(new KeyValuePair("key0", 10), model.ProductId);
+
+ Assert.Equal(3, modelState.Count);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "Name").Value;
+ Assert.Equal("bill", entry.AttemptedValue);
+ Assert.Equal("bill", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "ProductId.Key").Value;
+ Assert.Equal("key0", entry.AttemptedValue);
+ Assert.Equal("key0", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "ProductId.Value").Value;
+ Assert.Equal("10", entry.AttemptedValue);
+ Assert.Equal("10", entry.RawValue);
+ }
+
+ private record Car4(string Name, KeyValuePair> Specs);
+
+ [Fact]
+ public async Task Foo_BindsKeyValuePairProperty_WithPrefix_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "p",
+ ParameterType = typeof(Car4)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ var queryString = "?p.Name=Accord"
+ + "&p.Specs.Key=camera_specs"
+ + "&p.Specs.Value[0].Key=spec1"
+ + "&p.Specs.Value[0].Value=spec1.txt"
+ + "&p.Specs.Value[1].Key=spec2"
+ + "&p.Specs.Value[1].Value=spec2.txt";
+
+ request.QueryString = new QueryString(queryString);
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("Accord", model.Name);
+
+ Assert.Collection(
+ model.Specs.Value,
+ (e) =>
+ {
+ Assert.Equal("spec1", e.Key);
+ Assert.Equal("spec1.txt", e.Value);
+ },
+ (e) =>
+ {
+ Assert.Equal("spec2", e.Key);
+ Assert.Equal("spec2.txt", e.Value);
+ });
+
+ Assert.Equal(6, modelState.Count);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "p.Name").Value;
+ Assert.Equal("Accord", entry.AttemptedValue);
+ Assert.Equal("Accord", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs.Key").Value;
+ Assert.Equal("camera_specs", entry.AttemptedValue);
+ Assert.Equal("camera_specs", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs.Value[0].Key").Value;
+ Assert.Equal("spec1", entry.AttemptedValue);
+ Assert.Equal("spec1", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs.Value[0].Value").Value;
+ Assert.Equal("spec1.txt", entry.AttemptedValue);
+ Assert.Equal("spec1.txt", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs.Value[1].Key").Value;
+ Assert.Equal("spec2", entry.AttemptedValue);
+ Assert.Equal("spec2", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "p.Specs.Value[1].Value").Value;
+ Assert.Equal("spec2.txt", entry.AttemptedValue);
+ Assert.Equal("spec2.txt", entry.RawValue);
+ }
+
+ private record Order9(Person9 Customer);
+
+ private record Person9([FromBody] Address1 Address);
+
+ // If a nested POCO object has all properties bound from a greedy source, then it should be populated
+ // if the top-level object is created.
+ [Fact]
+ public async Task BindsNestedPOCO_WithAllGreedyBoundProperties()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order9)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?");
+ SetJsonBodyContent(request, AddressBodyContent);
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model.Customer);
+
+ Assert.NotNull(model.Customer.Address);
+ Assert.Equal(AddressStreetContent, model.Customer.Address.Street);
+
+ Assert.Empty(modelState);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+ }
+
+ private record Order10([BindRequired] Person10 Customer);
+
+ private record Person10(string Name);
+
+ [Fact]
+ public async Task WithRequiredComplexProperty_NoData_GetsErrors()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order10)
+ };
+
+ // No Data
+ var testContext = ModelBindingTestHelper.GetTestContext();
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Null(model.Customer);
+
+ Assert.Single(modelState);
+ Assert.Equal(1, modelState.ErrorCount);
+ Assert.False(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "Customer").Value;
+ Assert.Null(entry.RawValue);
+ Assert.Null(entry.AttemptedValue);
+ var error = Assert.Single(modelState["Customer"].Errors);
+ Assert.Equal("A value for the 'Customer' parameter or property was not provided.", error.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task WithBindRequired_NoData_AndCustomizedMessage_AddsGivenMessage()
+ {
+ // Arrange
+ var parameterInfo = typeof(Order10).GetConstructor(new[] { typeof(Person10) }).GetParameters()[0];
+ var metadataProvider = new TestModelMetadataProvider();
+ metadataProvider
+ .ForParameter(parameterInfo)
+ .BindingDetails((Action)(binding =>
+ {
+ // A real details provider could customize message based on BindingMetadataProviderContext.
+ binding.ModelBindingMessageProvider.SetMissingBindRequiredValueAccessor(
+ name => $"Hurts when '{ name }' is not provided.");
+ }));
+
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order10)
+ };
+
+ // No Data
+ var testContext = ModelBindingTestHelper.GetTestContext(metadataProvider: metadataProvider);
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Null(model.Customer);
+
+ Assert.Single(modelState);
+ Assert.Equal(1, modelState.ErrorCount);
+ Assert.False(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "Customer").Value;
+ Assert.Null(entry.RawValue);
+ Assert.Null(entry.AttemptedValue);
+ var error = Assert.Single(modelState["Customer"].Errors);
+ Assert.Equal("Hurts when 'Customer' is not provided.", error.ErrorMessage);
+ }
+
+ private record Order11(Person11 Customer);
+
+ private record Person11(int Id, [BindRequired] string Name);
+
+ [Fact]
+ public async Task WithNestedRequiredProperty_WithPartialData_GetsErrors()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order11)
+ };
+
+ // No Data
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?parameter.Customer.Id=123");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model.Customer);
+ Assert.Equal(123, model.Customer.Id);
+ Assert.Null(model.Customer.Name);
+
+ Assert.Equal(2, modelState.Count);
+ Assert.Equal(1, modelState.ErrorCount);
+ Assert.False(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Id").Value;
+ Assert.Equal("123", entry.RawValue);
+ Assert.Equal("123", entry.AttemptedValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value;
+ Assert.Null(entry.RawValue);
+ Assert.Null(entry.AttemptedValue);
+ var error = Assert.Single(modelState["parameter.Customer.Name"].Errors);
+ Assert.Equal("A value for the 'Name' parameter or property was not provided.", error.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task WithNestedRequiredProperty_WithData_EmptyPrefix_GetsErrors()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order11)
+ };
+
+ // No Data
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?Customer.Id=123");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model.Customer);
+ Assert.Equal(123, model.Customer.Id);
+ Assert.Null(model.Customer.Name);
+
+ Assert.Equal(2, modelState.Count);
+ Assert.Equal(1, modelState.ErrorCount);
+ Assert.False(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "Customer.Id").Value;
+ Assert.Equal("123", entry.RawValue);
+ Assert.Equal("123", entry.AttemptedValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "Customer.Name").Value;
+ Assert.Null(entry.RawValue);
+ Assert.Null(entry.AttemptedValue);
+ var error = Assert.Single(modelState["Customer.Name"].Errors);
+ Assert.Equal("A value for the 'Name' parameter or property was not provided.", error.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task WithNestedRequiredProperty_WithData_CustomPrefix_GetsErrors()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order11),
+ BindingInfo = new BindingInfo()
+ {
+ BinderModelName = "customParameter"
+ }
+ };
+
+ // No Data
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?customParameter.Customer.Id=123");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model.Customer);
+ Assert.Equal(123, model.Customer.Id);
+ Assert.Null(model.Customer.Name);
+
+ Assert.Equal(2, modelState.Count);
+ Assert.Equal(1, modelState.ErrorCount);
+ Assert.False(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "customParameter.Customer.Id").Value;
+ Assert.Equal("123", entry.RawValue);
+ Assert.Equal("123", entry.AttemptedValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "customParameter.Customer.Name").Value;
+ Assert.Null(entry.RawValue);
+ Assert.Null(entry.AttemptedValue);
+ var error = Assert.Single(modelState["customParameter.Customer.Name"].Errors);
+ Assert.Equal("A value for the 'Name' parameter or property was not provided.", error.ErrorMessage);
+ }
+
+ private record Order12([BindRequired] string ProductName);
+
+ [Fact]
+ public async Task WithRequiredProperty_NoData_GetsErrors()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order12)
+ };
+
+ // No Data
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Null(model.ProductName);
+
+ Assert.Single(modelState);
+ Assert.Equal(1, modelState.ErrorCount);
+ Assert.False(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "ProductName").Value;
+ Assert.Null(entry.RawValue);
+ Assert.Null(entry.AttemptedValue);
+ var error = Assert.Single(modelState["ProductName"].Errors);
+ Assert.Equal("A value for the 'ProductName' parameter or property was not provided.", error.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task WithRequiredProperty_NoData_CustomPrefix_GetsErrors()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order12),
+ BindingInfo = new BindingInfo()
+ {
+ BinderModelName = "customParameter"
+ }
+ };
+
+ // No Data
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Null(model.ProductName);
+
+ Assert.Single(modelState);
+ Assert.Equal(1, modelState.ErrorCount);
+ Assert.False(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "customParameter.ProductName").Value;
+ Assert.Null(entry.RawValue);
+ Assert.Null(entry.AttemptedValue);
+ var error = Assert.Single(modelState["customParameter.ProductName"].Errors);
+ Assert.Equal("A value for the 'ProductName' parameter or property was not provided.", error.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task WithRequiredProperty_WithData_EmptyPrefix_GetsBound()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order12),
+ };
+
+ // No Data
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?ProductName=abc");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("abc", model.ProductName);
+
+ Assert.Single(modelState);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "ProductName").Value;
+ Assert.Equal("abc", entry.RawValue);
+ Assert.Equal("abc", entry.AttemptedValue);
+ }
+
+ private record Order13([BindRequired] List OrderIds);
+
+ [Fact]
+ public async Task WithRequiredCollectionProperty_NoData_GetsErrors()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order13)
+ };
+
+ // No Data
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Null(model.OrderIds);
+
+ Assert.Single(modelState);
+ Assert.Equal(1, modelState.ErrorCount);
+ Assert.False(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "OrderIds").Value;
+ Assert.Null(entry.RawValue);
+ Assert.Null(entry.AttemptedValue);
+ var error = Assert.Single(modelState["OrderIds"].Errors);
+ Assert.Equal("A value for the 'OrderIds' parameter or property was not provided.", error.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task WithRequiredCollectionProperty_NoData_CustomPrefix_GetsErrors()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order13),
+ BindingInfo = new BindingInfo()
+ {
+ BinderModelName = "customParameter"
+ }
+ };
+
+ // No Data
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Null(model.OrderIds);
+
+ Assert.Single(modelState);
+ Assert.Equal(1, modelState.ErrorCount);
+ Assert.False(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "customParameter.OrderIds").Value;
+ Assert.Null(entry.RawValue);
+ Assert.Null(entry.AttemptedValue);
+ var error = Assert.Single(modelState["customParameter.OrderIds"].Errors);
+ Assert.Equal("A value for the 'OrderIds' parameter or property was not provided.", error.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task WithRequiredCollectionProperty_WithData_EmptyPrefix_GetsBound()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order13),
+ };
+
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?OrderIds[0]=123");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal(new[] { 123 }, model.OrderIds.ToArray());
+
+ Assert.Single(modelState);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "OrderIds[0]").Value;
+ Assert.Equal("123", entry.RawValue);
+ Assert.Equal("123", entry.AttemptedValue);
+ }
+
+ private record Order14(int ProductId);
+
+ // This covers the case where a key is present, but has an empty value. The type converter
+ // will report an error.
+ [Fact]
+ public async Task BindsPOCO_TypeConvertedPropertyNonConvertibleValue_GetsError()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order14)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?parameter.ProductId=");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model);
+ Assert.Equal(0, model.ProductId);
+
+ Assert.Single(modelState);
+ Assert.Equal(1, modelState.ErrorCount);
+ Assert.False(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId").Value;
+ Assert.Equal(string.Empty, entry.AttemptedValue);
+ Assert.Equal(string.Empty, entry.RawValue);
+
+ var error = Assert.Single(entry.Errors);
+ Assert.Equal("The value '' is invalid.", error.ErrorMessage);
+ Assert.Null(error.Exception);
+ }
+
+ // This covers the case where a key is present, but has no value. The model binder will
+ // report and error because it's a value type (non-nullable).
+ [Fact]
+ [ReplaceCulture]
+ public async Task BindsPOCO_TypeConvertedPropertyWithEmptyValue_Error()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Order14)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString("?parameter.ProductId");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model);
+ Assert.Equal(0, model.ProductId);
+
+ var entry = Assert.Single(modelState);
+ Assert.Equal("parameter.ProductId", entry.Key);
+ Assert.Equal(string.Empty, entry.Value.AttemptedValue);
+
+ var error = Assert.Single(entry.Value.Errors);
+ Assert.Equal("The value '' is invalid.", error.ErrorMessage, StringComparer.Ordinal);
+ Assert.Null(error.Exception);
+
+ Assert.Equal(1, modelState.ErrorCount);
+ Assert.False(modelState.IsValid);
+ }
+
+ private record Person12(Address12 Address);
+
+ [ModelBinder(Name = "HomeAddress")]
+ private record Address12(string Street);
+
+ // Make sure the metadata is honored when a [ModelBinder] attribute is associated with a class somewhere in the
+ // type hierarchy of an action parameter. This should behave identically to such an attribute on a property in
+ // the type hierarchy.
+ [Theory]
+ [MemberData(
+ nameof(BinderTypeBasedModelBinderIntegrationTest.NullAndEmptyBindingInfo),
+ MemberType = typeof(BinderTypeBasedModelBinderIntegrationTest))]
+ public async Task ModelNameOnPropertyType_WithData_Succeeds(BindingInfo bindingInfo)
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor
+ {
+ Name = "parameter-name",
+ BindingInfo = bindingInfo,
+ ParameterType = typeof(Person12),
+ };
+
+ var testContext = ModelBindingTestHelper.GetTestContext(
+ request => request.QueryString = new QueryString("?HomeAddress.Street=someStreet"));
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+ var person = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(person.Address);
+ Assert.Equal("someStreet", person.Address.Street, StringComparer.Ordinal);
+
+ Assert.True(modelState.IsValid);
+ var kvp = Assert.Single(modelState);
+ Assert.Equal("HomeAddress.Street", kvp.Key);
+ var entry = kvp.Value;
+ Assert.NotNull(entry);
+ Assert.Empty(entry.Errors);
+ Assert.Equal(ModelValidationState.Valid, entry.ValidationState);
+ }
+
+ // Make sure the metadata is honored when a [ModelBinder] attribute is associated with an action parameter's
+ // type. This should behave identically to such an attribute on an action parameter.
+ [Theory]
+ [MemberData(
+ nameof(BinderTypeBasedModelBinderIntegrationTest.NullAndEmptyBindingInfo),
+ MemberType = typeof(BinderTypeBasedModelBinderIntegrationTest))]
+ public async Task ModelNameOnParameterType_WithData_Succeeds(BindingInfo bindingInfo)
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor
+ {
+ Name = "parameter-name",
+ BindingInfo = bindingInfo,
+ ParameterType = typeof(Address12),
+ };
+
+ var testContext = ModelBindingTestHelper.GetTestContext(
+ request => request.QueryString = new QueryString("?HomeAddress.Street=someStreet"));
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+ var address = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("someStreet", address.Street, StringComparer.Ordinal);
+
+ Assert.True(modelState.IsValid);
+ var kvp = Assert.Single(modelState);
+ Assert.Equal("HomeAddress.Street", kvp.Key);
+ var entry = kvp.Value;
+ Assert.NotNull(entry);
+ Assert.Empty(entry.Errors);
+ Assert.Equal(ModelValidationState.Valid, entry.ValidationState);
+ }
+
+ private record Person13(Address13 Address);
+
+ [Bind("Street")]
+ private record Address13(int Number, string Street, string City, string State);
+
+ // Make sure the metadata is honored when a [Bind] attribute is associated with a class somewhere in the type
+ // hierarchy of an action parameter. This should behave identically to such an attribute on a property in the
+ // type hierarchy. (Test is similar to ModelNameOnPropertyType_WithData_Succeeds() but covers implementing
+ // IPropertyFilterProvider, not IModelNameProvider.)
+ [Theory]
+ [MemberData(
+ nameof(BinderTypeBasedModelBinderIntegrationTest.NullAndEmptyBindingInfo),
+ MemberType = typeof(BinderTypeBasedModelBinderIntegrationTest))]
+ public async Task BindAttributeOnPropertyType_WithData_Succeeds(BindingInfo bindingInfo)
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor
+ {
+ Name = "parameter-name",
+ BindingInfo = bindingInfo,
+ ParameterType = typeof(Person13),
+ };
+
+ var testContext = ModelBindingTestHelper.GetTestContext(
+ request => request.QueryString = new QueryString(
+ "?Address.Number=23&Address.Street=someStreet&Address.City=Redmond&Address.State=WA"));
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+ var person = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(person.Address);
+ Assert.Null(person.Address.City);
+ Assert.Equal(0, person.Address.Number);
+ Assert.Null(person.Address.State);
+ Assert.Equal("someStreet", person.Address.Street, StringComparer.Ordinal);
+
+ Assert.True(modelState.IsValid);
+ var kvp = Assert.Single(modelState);
+ Assert.Equal("Address.Street", kvp.Key);
+ var entry = kvp.Value;
+ Assert.NotNull(entry);
+ Assert.Empty(entry.Errors);
+ Assert.Equal(ModelValidationState.Valid, entry.ValidationState);
+ }
+
+ // Make sure the metadata is honored when a [Bind] attribute is associated with an action parameter's type.
+ // This should behave identically to such an attribute on an action parameter. (Test is similar
+ // to ModelNameOnParameterType_WithData_Succeeds() but covers implementing IPropertyFilterProvider, not
+ // IModelNameProvider.)
+ [Theory]
+ [MemberData(
+ nameof(BinderTypeBasedModelBinderIntegrationTest.NullAndEmptyBindingInfo),
+ MemberType = typeof(BinderTypeBasedModelBinderIntegrationTest))]
+ public async Task BindAttributeOnParameterType_WithData_Succeeds(BindingInfo bindingInfo)
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor
+ {
+ Name = "parameter-name",
+ BindingInfo = bindingInfo,
+ ParameterType = typeof(Address13),
+ };
+
+ var testContext = ModelBindingTestHelper.GetTestContext(
+ request => request.QueryString = new QueryString("?Number=23&Street=someStreet&City=Redmond&State=WA"));
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+ var address = Assert.IsType(modelBindingResult.Model);
+ Assert.Null(address.City);
+ Assert.Equal(0, address.Number);
+ Assert.Null(address.State);
+ Assert.Equal("someStreet", address.Street, StringComparer.Ordinal);
+
+ Assert.True(modelState.IsValid);
+ var kvp = Assert.Single(modelState);
+ Assert.Equal("Street", kvp.Key);
+ var entry = kvp.Value;
+ Assert.NotNull(entry);
+ Assert.Empty(entry.Errors);
+ Assert.Equal(ModelValidationState.Valid, entry.ValidationState);
+ }
+
+ private record Product(int ProductId)
+ {
+ public string Name { get; }
+
+ public IList Aliases { get; }
+ }
+
+ [Theory]
+ [InlineData("?parameter.ProductId=10")]
+ [InlineData("?parameter.ProductId=10¶meter.Name=Camera")]
+ [InlineData("?parameter.ProductId=10¶meter.Name=Camera¶meter.Aliases[0]=Camera1")]
+ public async Task BindsSettableProperties(string queryString)
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Product)
+ };
+
+ // Need to have a key here so that the ComplexTypeModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.QueryString = new QueryString(queryString);
+ SetJsonBodyContent(request, AddressBodyContent);
+ });
+
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.NotNull(model);
+ Assert.Equal(10, model.ProductId);
+ Assert.Null(model.Name);
+ Assert.Null(model.Aliases);
+ }
+
+ private record Photo(string Id, KeyValuePair Info);
+
+ private record LocationInfo([FromHeader] string GpsCoordinates, int Zipcode);
+
+ [Fact]
+ public async Task BindsKeyValuePairProperty_HavingFromHeaderProperty_Success()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(Photo)
+ };
+
+ // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements.
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ request.Headers.Add("GpsCoordinates", "10,20");
+ request.QueryString = new QueryString("?Id=1&Info.Key=location1&Info.Value.Zipcode=98052");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ // Model
+ var model = Assert.IsType(modelBindingResult.Model);
+ Assert.Equal("1", model.Id);
+ Assert.Equal("location1", model.Info.Key);
+ Assert.NotNull(model.Info.Value);
+ Assert.Equal("10,20", model.Info.Value.GpsCoordinates);
+ Assert.Equal(98052, model.Info.Value.Zipcode);
+
+ // ModelState
+ Assert.Equal(4, modelState.Count);
+ Assert.Equal(0, modelState.ErrorCount);
+ Assert.True(modelState.IsValid);
+
+ var entry = Assert.Single(modelState, e => e.Key == "Id").Value;
+ Assert.Equal("1", entry.AttemptedValue);
+ Assert.Equal("1", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "Info.Key").Value;
+ Assert.Equal("location1", entry.AttemptedValue);
+ Assert.Equal("location1", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "Info.Value.Zipcode").Value;
+ Assert.Equal("98052", entry.AttemptedValue);
+ Assert.Equal("98052", entry.RawValue);
+
+ entry = Assert.Single(modelState, e => e.Key == "Info.Value.GpsCoordinates").Value;
+ Assert.Equal("10,20", entry.AttemptedValue);
+ Assert.Equal("10,20", entry.RawValue);
+ }
+
+ private record Person5(string Name, IFormFile Photo);
+
+ // Regression test for #4802.
+ [Fact]
+ public async Task ReportsFailureToCollectionModelBinder()
+ {
+ // Arrange
+ var parameter = new ParameterDescriptor()
+ {
+ Name = "parameter",
+ ParameterType = typeof(IList),
+ };
+
+ var testContext = ModelBindingTestHelper.GetTestContext(request =>
+ {
+ SetFormFileBodyContent(request, "Hello world!", "[0].Photo");
+
+ // CollectionModelBinder binds an empty collection when value providers are all empty.
+ request.QueryString = new QueryString("?a=b");
+ });
+
+ var modelState = testContext.ModelState;
+ var metadata = GetMetadata(testContext, parameter);
+ var modelBinder = GetModelBinder(testContext, parameter, metadata);
+ var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
+ var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext);
+
+ // Act
+ var modelBindingResult = await parameterBinder.BindModelAsync(
+ testContext,
+ modelBinder,
+ valueProvider,
+ parameter,
+ metadata,
+ value: null);
+
+ // Assert
+ Assert.True(modelBindingResult.IsModelSet);
+
+ var model = Assert.IsType