From b6c78de4ea197088feeb4b12a1fbba9215aa7ea1 Mon Sep 17 00:00:00 2001 From: Pranav K Date: Sun, 23 Feb 2014 15:23:58 -0800 Subject: [PATCH] Reintroduce model binding --- .../Binders/ArrayModelBinder.cs | 24 ++ .../Binders/CollectionModelBinder.cs | 186 ++++++++++ .../Binders/ComplexModelDto.cs | 38 ++ .../Binders/ComplexModelDtoModelBinder.cs | 37 ++ .../Binders/ComplexModelDtoResult.cs | 21 ++ .../Binders/CompositeModelBinder.cs | 112 ++++++ .../Binders/DictionaryModelBinder.cs | 36 ++ .../Binders/GenericModelBinder.cs | 125 +++++++ .../Binders/IModelBinder.cs | 12 + .../Binders/KeyValuePairModelBinder.cs | 49 +++ .../Binders/MutableObjectModelBinder.cs | 331 ++++++++++++++++++ .../Binders/TypeConverterModelBinder.cs | 68 ++++ .../Binders/TypeMatchModelBinder.cs | 52 +++ .../IValueProvider.cs | 8 - .../Internal/CollectionExtensions.cs | 38 ++ .../Internal/CollectionModelBinderUtil.cs | 40 +++ .../Internal/EfficientTypePropertyKey.cs | 20 ++ .../Internal/Error.cs | 56 +++ .../Internal/ModelBindingHelper.cs | 89 +++++ .../Internal/PrefixContainer.cs | 218 ++++++++++++ .../Internal/TypeExtensions.cs | 36 +- .../Metadata/AssociatedMetadataProvider.cs | 226 ++++++++++++ ...CachedDataAnnotationsMetadataAttributes.cs | 29 ++ .../CachedDataAnnotationsModelMetadata.cs | 89 +++++ .../Metadata/CachedModelMetadata.cs | 141 ++++++++ .../DataAnnotationsModelMetadataProvider.cs | 24 ++ .../Metadata/DisplayAttribute.cs | 20 ++ .../Metadata/DisplayFormatAttribute.cs | 12 + .../Metadata/DisplayNameAttribute.cs | 9 + .../Metadata/EditableAttribute.cs | 12 + .../Metadata/EmptyModelMetadataProvider.cs | 21 ++ .../Metadata/IModelMetadataProvider.cs | 17 + .../Metadata/ModelMetadata.cs | 182 ++++++++++ .../Metadata/ReadOnlyAttribute.cs | 10 + .../ModelBindingContext.cs | 113 ++++++ .../ModelError.cs | 33 ++ .../ModelErrorCollection.cs | 18 + .../ModelState.cs | 15 + .../ModelStateDictionary.cs | 65 ++++ .../Resources.Designer.cs | 110 +++++- .../Resources.resx | 36 ++ .../ValueProviders/CompositeValueProvider.cs | 88 +++++ .../DictionaryBasedValueProvider.cs | 38 ++ .../ValueProviders/ElementalValueProvider.cs | 35 ++ .../IEnumerableValueProvider.cs | 9 + .../ValueProviders/IValueProviderFactory.cs | 13 + .../ValueProviders/IValueProviders.cs | 23 ++ .../NameValuePairsValueProvider.cs | 90 +++++ .../QueryStringValueProvider.cs | 13 + .../QueryStringValueProviderFactory.cs | 31 ++ .../RouteValueValueProviderFactory.cs | 11 + .../ValueProviders/ValueProviderResult.cs | 184 ++++++++++ .../ViewDataOfTModel.cs | 2 +- .../project.json | 3 +- .../DefaultActionSelector.cs | 2 +- .../ModelBinding/IValueProviderFactory.cs | 9 - .../QueryStringValueProviderFactory.cs | 29 -- .../RouteValueValueProviderFactory.cs | 12 - .../ModelBinding/ValueProvider.cs | 21 -- .../Binders/ArrayModelBinderTest.cs | 95 +++++ .../Binders/CollectionModelBinderTest.cs | 180 ++++++++++ .../Binders/ComplexModelDtoResultTest.cs | 38 ++ .../Binders/ComplexModelDtoTest.cs | 50 +++ .../Binders/CompositeModelBinderTest.cs | 235 +++++++++++++ .../Binders/DictionaryModelBinderTest.cs | 59 ++++ .../Binders/KeyValuePairModelBinderTest.cs | 161 +++++++++ .../Binders/ModelBindingContextTest.cs | 90 +++++ .../Binders/TypeConverterModelBinderTest.cs | 163 +++++++++ .../CultureReplacer.cs | 71 ++++ .../ExceptionAssert.cs | 170 +++++++++ .../AssociatedMetadataProviderTest.cs | 281 +++++++++++++++ .../Metadata/ModelMetadataTest.cs | 215 ++++++++++++ .../ModelBindingContextTest.cs | 126 +++++++ .../Utils/SimpleHttpValueProvider.cs | 70 ++++ .../NameValuePairsValueProviderTest.cs | 249 +++++++++++++ .../QueryStringValueProviderFactoryTest.cs | 39 +++ .../ValueProviders/ValueProviderResultTest.cs | 28 ++ .../project.json | 4 +- 78 files changed, 5628 insertions(+), 87 deletions(-) create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Binders/ArrayModelBinder.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Binders/CollectionModelBinder.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Binders/ComplexModelDto.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Binders/ComplexModelDtoModelBinder.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Binders/ComplexModelDtoResult.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Binders/CompositeModelBinder.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Binders/DictionaryModelBinder.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Binders/GenericModelBinder.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Binders/IModelBinder.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Binders/KeyValuePairModelBinder.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Binders/MutableObjectModelBinder.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Binders/TypeConverterModelBinder.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Binders/TypeMatchModelBinder.cs delete mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/IValueProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Internal/CollectionExtensions.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Internal/CollectionModelBinderUtil.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Internal/EfficientTypePropertyKey.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Internal/Error.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Internal/ModelBindingHelper.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Internal/PrefixContainer.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/AssociatedMetadataProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedDataAnnotationsMetadataAttributes.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedDataAnnotationsModelMetadata.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedModelMetadata.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/DataAnnotationsModelMetadataProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/DisplayAttribute.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/DisplayFormatAttribute.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/DisplayNameAttribute.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/EditableAttribute.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/EmptyModelMetadataProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/IModelMetadataProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ReadOnlyAttribute.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/ModelBindingContext.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/ModelError.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/ModelErrorCollection.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/ModelState.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/ModelStateDictionary.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/CompositeValueProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/DictionaryBasedValueProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/ElementalValueProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/IEnumerableValueProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/IValueProviderFactory.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/IValueProviders.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/NameValuePairsValueProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/QueryStringValueProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/QueryStringValueProviderFactory.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/RouteValueValueProviderFactory.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/ValueProviderResult.cs delete mode 100644 src/Microsoft.AspNet.Mvc/ModelBinding/IValueProviderFactory.cs delete mode 100644 src/Microsoft.AspNet.Mvc/ModelBinding/QueryStringValueProviderFactory.cs delete mode 100644 src/Microsoft.AspNet.Mvc/ModelBinding/RouteValueValueProviderFactory.cs delete mode 100644 src/Microsoft.AspNet.Mvc/ModelBinding/ValueProvider.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/ArrayModelBinderTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/CollectionModelBinderTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/ComplexModelDtoResultTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/ComplexModelDtoTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/CompositeModelBinderTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/DictionaryModelBinderTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/KeyValuePairModelBinderTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/ModelBindingContextTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/TypeConverterModelBinderTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/CultureReplacer.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/ExceptionAssert.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/AssociatedMetadataProviderTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/ModelMetadataTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/ModelBindingContextTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/Utils/SimpleHttpValueProvider.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/ValueProviders/NameValuePairsValueProviderTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/ValueProviders/QueryStringValueProviderFactoryTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/ValueProviders/ValueProviderResultTest.cs diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/ArrayModelBinder.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/ArrayModelBinder.cs new file mode 100644 index 0000000000..5e8bcdfd46 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/ArrayModelBinder.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class ArrayModelBinder : CollectionModelBinder + { + public override bool BindModel(ModelBindingContext bindingContext) + { + if (bindingContext.ModelMetadata.IsReadOnly) + { + return false; + } + + return base.BindModel(bindingContext); + } + + protected override bool CreateOrReplaceCollection(ModelBindingContext bindingContext, IList newCollection) + { + bindingContext.Model = newCollection.ToArray(); + return true; + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/CollectionModelBinder.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/CollectionModelBinder.cs new file mode 100644 index 0000000000..3ccb9c5523 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/CollectionModelBinder.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class CollectionModelBinder : IModelBinder + { + public virtual bool BindModel(ModelBindingContext bindingContext) + { + ModelBindingHelper.ValidateBindingContext(bindingContext); + + if (!bindingContext.ValueProvider.ContainsPrefix(bindingContext.ModelName)) + { + return false; + } + + ValueProviderResult valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + List boundCollection = (valueProviderResult != null) + ? BindSimpleCollection(bindingContext, valueProviderResult.RawValue, valueProviderResult.Culture) + : BindComplexCollection(bindingContext); + + bool retVal = CreateOrReplaceCollection(bindingContext, boundCollection); + return retVal; + } + + // TODO: Make this method internal + // Used when the ValueProvider contains the collection to be bound as a single element, e.g. the raw value + // is [ "1", "2" ] and needs to be converted to an int[]. + public List BindSimpleCollection(ModelBindingContext bindingContext, + object rawValue, + CultureInfo culture) + { + if (rawValue == null) + { + return null; // nothing to do + } + + List boundCollection = new List(); + + object[] rawValueArray = RawValueToObjectArray(rawValue); + foreach (object rawValueElement in rawValueArray) + { + ModelBindingContext innerBindingContext = new ModelBindingContext(bindingContext) + { + ModelMetadata = bindingContext.MetadataProvider.GetMetadataForType(null, typeof(TElement)), + ModelName = bindingContext.ModelName, + ValueProvider = new CompositeValueProvider + { + // our temporary provider goes at the front of the list + new ElementalValueProvider(bindingContext.ModelName, rawValueElement, culture), + bindingContext.ValueProvider + } + }; + + object boundValue = null; + if (bindingContext.ModelBinder.BindModel(innerBindingContext)) + { + boundValue = innerBindingContext.Model; + // TODO: validation + // bindingContext.ValidationNode.ChildNodes.Add(innerBindingContext.ValidationNode); + } + boundCollection.Add(ModelBindingHelper.CastOrDefault(boundValue)); + } + + return boundCollection; + } + + // Used when the ValueProvider contains the collection to be bound as multiple elements, e.g. foo[0], foo[1]. + private List BindComplexCollection(ModelBindingContext bindingContext) + { + string indexPropertyName = ModelBindingHelper.CreatePropertyModelName(bindingContext.ModelName, "index"); + ValueProviderResult valueProviderResultIndex = bindingContext.ValueProvider.GetValue(indexPropertyName); + IEnumerable indexNames = CollectionModelBinderUtil.GetIndexNamesFromValueProviderResult(valueProviderResultIndex); + return BindComplexCollectionFromIndexes(bindingContext, indexNames); + } + + // TODO: Convert to internal + public List BindComplexCollectionFromIndexes(ModelBindingContext bindingContext, + IEnumerable indexNames) + { + bool indexNamesIsFinite; + if (indexNames != null) + { + indexNamesIsFinite = true; + } + else + { + indexNamesIsFinite = false; + indexNames = Enumerable.Range(0, Int32.MaxValue) + .Select(i => i.ToString(CultureInfo.InvariantCulture)); + } + + List boundCollection = new List(); + foreach (string indexName in indexNames) + { + string fullChildName = ModelBindingHelper.CreateIndexModelName(bindingContext.ModelName, indexName); + var childBindingContext = new ModelBindingContext(bindingContext) + { + ModelMetadata = bindingContext.MetadataProvider.GetMetadataForType(null, typeof(TElement)), + ModelName = fullChildName + }; + + bool didBind = false; + object boundValue = null; + + Type modelType = bindingContext.ModelType; + + if (bindingContext.ModelBinder.BindModel(childBindingContext)) + { + didBind = true; + boundValue = childBindingContext.Model; + + // TODO: Validation + // merge validation up + // bindingContext.ValidationNode.ChildNodes.Add(childBindingContext.ValidationNode); + } + + // infinite size collection stops on first bind failure + if (!didBind && !indexNamesIsFinite) + { + break; + } + + boundCollection.Add(ModelBindingHelper.CastOrDefault(boundValue)); + } + + return boundCollection; + } + + // Extensibility point that allows the bound collection to be manipulated or transformed before + // being returned from the binder. + protected virtual bool CreateOrReplaceCollection(ModelBindingContext bindingContext, IList newCollection) + { + CreateOrReplaceCollection(bindingContext, newCollection, () => new List()); + return true; + } + + internal static object[] RawValueToObjectArray(object rawValue) + { + // precondition: rawValue is not null + + // Need to special-case String so it's not caught by the IEnumerable check which follows + if (rawValue is string) + { + return new[] { rawValue }; + } + + object[] rawValueAsObjectArray = rawValue as object[]; + if (rawValueAsObjectArray != null) + { + return rawValueAsObjectArray; + } + + IEnumerable rawValueAsEnumerable = rawValue as IEnumerable; + if (rawValueAsEnumerable != null) + { + return rawValueAsEnumerable.Cast().ToArray(); + } + + // fallback + return new[] { rawValue }; + } + + internal static void CreateOrReplaceCollection(ModelBindingContext bindingContext, + IEnumerable incomingElements, + Func> creator) + { + ICollection collection = bindingContext.Model as ICollection; + if (collection == null || collection.IsReadOnly) + { + collection = creator(); + bindingContext.Model = collection; + } + + collection.Clear(); + foreach (TElement element in incomingElements) + { + collection.Add(element); + } + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/ComplexModelDto.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/ComplexModelDto.cs new file mode 100644 index 0000000000..8293659af3 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/ComplexModelDto.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + // Describes a complex model, but uses a collection rather than individual properties as the data store. + public class ComplexModelDto + { + public ComplexModelDto(ModelMetadata modelMetadata, IEnumerable propertyMetadata) + { + if (modelMetadata == null) + { + throw Error.ArgumentNull("modelMetadata"); + } + + if (propertyMetadata == null) + { + throw Error.ArgumentNull("propertyMetadata"); + } + + ModelMetadata = modelMetadata; + PropertyMetadata = new Collection(propertyMetadata.ToList()); + Results = new Dictionary(); + } + + public ModelMetadata ModelMetadata { get; private set; } + + public Collection PropertyMetadata { get; private set; } + + // Contains entries corresponding to each property against which binding was + // attempted. If binding failed, the entry's value will be null. If binding + // was never attempted, this dictionary will not contain a corresponding + // entry. + public IDictionary Results { get; private set; } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/ComplexModelDtoModelBinder.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/ComplexModelDtoModelBinder.cs new file mode 100644 index 0000000000..d3206c0b40 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/ComplexModelDtoModelBinder.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public sealed class ComplexModelDtoModelBinder : IModelBinder + { + public bool BindModel(ModelBindingContext bindingContext) + { + if (bindingContext.ModelType == typeof(ComplexModelDto)) + { + ModelBindingHelper.ValidateBindingContext(bindingContext, typeof(ComplexModelDto), allowNullModel: false); + + ComplexModelDto dto = (ComplexModelDto)bindingContext.Model; + foreach (ModelMetadata propertyMetadata in dto.PropertyMetadata) + { + ModelBindingContext propertyBindingContext = new ModelBindingContext(bindingContext) + { + ModelMetadata = propertyMetadata, + ModelName = ModelBindingHelper.CreatePropertyModelName(bindingContext.ModelName, propertyMetadata.PropertyName) + }; + + // bind and propagate the values + // If we can't bind, then leave the result missing (don't add a null). + + if (bindingContext.ModelBinder.BindModel(propertyBindingContext)) + { + dto.Results[propertyMetadata] = new ComplexModelDtoResult(propertyBindingContext.Model/*, propertyBindingContext.ValidationNode*/); + } + } + + return true; + } + + return false; + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/ComplexModelDtoResult.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/ComplexModelDtoResult.cs new file mode 100644 index 0000000000..52b0febea1 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/ComplexModelDtoResult.cs @@ -0,0 +1,21 @@ +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public sealed class ComplexModelDtoResult + { + public ComplexModelDtoResult(object model/*, ModelValidationNode validationNode*/) + { + // TODO: Validation + //if (validationNode == null) + //{ + // throw Error.ArgumentNull("validationNode"); + //} + + Model = model; + //ValidationNode = validationNode; + } + + public object Model { get; private set; } + + //public ModelValidationNode ValidationNode { get; private set; } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/CompositeModelBinder.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/CompositeModelBinder.cs new file mode 100644 index 0000000000..a231befcb6 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/CompositeModelBinder.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + /// + /// This class is an that delegates to one of a collection of + /// instances. + /// + /// + /// If no binder is available and the allows it, + /// this class tries to find a binder using an empty prefix. + /// + public class CompositeModelBinder : IModelBinder + { + public CompositeModelBinder(IEnumerable binders) + : this(binders.ToArray()) + { + } + + public CompositeModelBinder(params IModelBinder[] binders) + { + Binders = binders; + } + + private IModelBinder[] Binders { get; set; } + + public virtual bool BindModel(ModelBindingContext bindingContext) + { + ModelBindingContext newBindingContext = CreateNewBindingContext(bindingContext, bindingContext.ModelName); + + bool boundSuccessfully = TryBind(newBindingContext); + if (!boundSuccessfully && !String.IsNullOrEmpty(bindingContext.ModelName) + && bindingContext.FallbackToEmptyPrefix) + { + // fallback to empty prefix? + newBindingContext = CreateNewBindingContext(bindingContext, modelName: String.Empty); + boundSuccessfully = TryBind(newBindingContext); + } + + if (!boundSuccessfully) + { + return false; // something went wrong + } + + // run validation and return the model + // If we fell back to an empty prefix above and are dealing with simple types, + // propagate the non-blank model name through for user clarity in validation errors. + // Complex types will reveal their individual properties as model names and do not require this. + // TODO: Validation + //if (!newBindingContext.ModelMetadata.IsComplexType && String.IsNullOrEmpty(newBindingContext.ModelName)) + //{ + // newBindingContext.ValidationNode = new Validation.ModelValidationNode(newBindingContext.ModelMetadata, bindingContext.ModelName); + //} + + //newBindingContext.ValidationNode.Validate(context, null /* parentNode */); + bindingContext.Model = newBindingContext.Model; + return true; + } + + private bool TryBind(ModelBindingContext bindingContext) + { + // TODO: The body of this method existed as HttpActionContextExtensions.Bind. We might have to refactor it into + // something that is shared. + if (bindingContext == null) + { + throw Error.ArgumentNull("bindingContext"); + } + + // TODO: RuntimeHelpers.EnsureSufficientExecutionStack does not exist in the CoreCLR. + // Protects against stack overflow for deeply nested model binding + // RuntimeHelpers.EnsureSufficientExecutionStack(); + + bool requiresBodyBinder = bindingContext.ModelMetadata.IsFromBody; + foreach (IModelBinder binder in Binders) + { + if (binder.BindModel(bindingContext)) + { + return true; + } + } + + // Either we couldn't find a binder, or the binder couldn't bind. Distinction is not important. + return false; + } + + private static ModelBindingContext CreateNewBindingContext(ModelBindingContext oldBindingContext, string modelName) + { + var newBindingContext = new ModelBindingContext + { + ModelMetadata = oldBindingContext.ModelMetadata, + ModelName = modelName, + ModelState = oldBindingContext.ModelState, + ValueProvider = oldBindingContext.ValueProvider, + MetadataProvider = oldBindingContext.MetadataProvider, + ModelBinder = oldBindingContext.ModelBinder, + HttpContext = oldBindingContext.HttpContext + }; + + // TODO: Validation + //// validation is expensive to create, so copy it over if we can + //if (Object.ReferenceEquals(modelName, oldBindingContext.ModelName)) + //{ + // newBindingContext.ValidationNode = oldBindingContext.ValidationNode; + //} + + return newBindingContext; + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/DictionaryModelBinder.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/DictionaryModelBinder.cs new file mode 100644 index 0000000000..07031d3bab --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/DictionaryModelBinder.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class DictionaryModelBinder : CollectionModelBinder> + { + protected override bool CreateOrReplaceCollection(ModelBindingContext bindingContext, + IList> newCollection) + { + CreateOrReplaceDictionary(bindingContext, newCollection, () => new Dictionary()); + return true; + } + + private static void CreateOrReplaceDictionary(ModelBindingContext bindingContext, + IEnumerable> incomingElements, + Func> creator) + { + IDictionary dictionary = bindingContext.Model as IDictionary; + if (dictionary == null || dictionary.IsReadOnly) + { + dictionary = creator(); + bindingContext.Model = dictionary; + } + + dictionary.Clear(); + foreach (var element in incomingElements) + { + if (element.Key != null) + { + dictionary[element.Key] = element.Value; + } + } + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/GenericModelBinder.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/GenericModelBinder.cs new file mode 100644 index 0000000000..49d67be0c7 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/GenericModelBinder.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Reflection; +using Microsoft.AspNet.DependencyInjection; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class GenericModelBinder : IModelBinder + { + private readonly IServiceProvider _serviceProvider; + + public GenericModelBinder(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public bool BindModel(ModelBindingContext bindingContext) + { + Type binderType = ResolveBinderType(bindingContext.ModelType); + if (binderType != null) + { + var binder = ActivatorUtilities.CreateInstance(_serviceProvider); + return binder.BindModel(bindingContext); + } + + return false; + } + + private static Type ResolveBinderType(Type modelType) + { + return GetArrayBinder(modelType) ?? + GetCollectionBinder(modelType) ?? + GetDictionaryBinder(modelType) ?? + GetKeyValuePairBinder(modelType); + } + + private static Type GetArrayBinder(Type modelType) + { + if (modelType.IsArray) + { + Type elementType = modelType.GetElementType(); + return typeof(ArrayModelBinder<>).MakeGenericType(elementType); + } + return null; + } + + private static Type GetCollectionBinder(Type modelType) + { + return GetGenericBinderType( + typeof(ICollection<>), + typeof(List<>), + typeof(CollectionModelBinder<>), + modelType); + } + + private static Type GetDictionaryBinder(Type modelType) + { + return GetGenericBinderType( + typeof(IDictionary<,>), + typeof(Dictionary<,>), + typeof(DictionaryModelBinder<,>), + modelType); + } + + private static Type GetKeyValuePairBinder(Type modelType) + { + return ModelBindingHelper.GetPossibleBinderInstanceType( + closedModelType: modelType, + openModelType: typeof(KeyValuePair<,>), + openBinderType: typeof(KeyValuePairModelBinder<,>)); + } + + + /// + /// Example: GetGenericBinder(typeof(IList<>), typeof(List<>), typeof(ListBinder<>), ...) means that the ListBinder + /// type can update models that implement IList, and if for some reason the existing model instance is not + /// updatable the binder will create a List object and bind to that instead. This method will return ListBinder + /// or null, depending on whether the type and updatability checks succeed. + /// + private static Type GetGenericBinderType(Type supportedInterfaceType, Type newInstanceType, Type openBinderType, Type modelType) + { + Contract.Assert(supportedInterfaceType != null); + Contract.Assert(openBinderType != null); + Contract.Assert(modelType != null); + + Type[] modelTypeArguments = GetGenericBinderTypeArgs(supportedInterfaceType, modelType); + + if (modelTypeArguments == null) + { + return null; + } + + Type closedNewInstanceType = newInstanceType.MakeGenericType(modelTypeArguments); + if (!modelType.GetTypeInfo().IsAssignableFrom(closedNewInstanceType.GetTypeInfo())) + { + return null; + } + + return openBinderType.MakeGenericType(modelTypeArguments); + } + + // Get the generic arguments for the binder, based on the model type. Or null if not compatible. + private static Type[] GetGenericBinderTypeArgs(Type supportedInterfaceType, Type modelType) + { + TypeInfo modelTypeInfo = modelType.GetTypeInfo(); + if (!modelTypeInfo.IsGenericType || modelTypeInfo.IsGenericTypeDefinition) + { + // not a closed generic type + return null; + } + + Type[] modelTypeArguments = modelTypeInfo.GenericTypeArguments; + if (modelTypeArguments.Length != supportedInterfaceType.GetTypeInfo().GenericTypeParameters.Length) + { + // wrong number of generic type arguments + return null; + } + + return modelTypeArguments; + } + + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/IModelBinder.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/IModelBinder.cs new file mode 100644 index 0000000000..4be6c8d05b --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/IModelBinder.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNet.Abstractions; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + /// + /// Interface for model binding. + /// + public interface IModelBinder + { + bool BindModel(ModelBindingContext bindingContext); + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/KeyValuePairModelBinder.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/KeyValuePairModelBinder.cs new file mode 100644 index 0000000000..d602ab933e --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/KeyValuePairModelBinder.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public sealed class KeyValuePairModelBinder : IModelBinder + { + public bool BindModel(ModelBindingContext bindingContext) + { + ModelBindingHelper.ValidateBindingContext(bindingContext, typeof(KeyValuePair), allowNullModel: true); + + TKey key; + bool keyBindingSucceeded = TryBindStrongModel(bindingContext, "key", out key); + + TValue value; + bool valueBindingSucceeded = TryBindStrongModel(bindingContext, "value", out value); + + if (keyBindingSucceeded && valueBindingSucceeded) + { + bindingContext.Model = new KeyValuePair(key, value); + } + return keyBindingSucceeded || valueBindingSucceeded; + } + + // TODO: Make this internal + public bool TryBindStrongModel(ModelBindingContext parentBindingContext, + string propertyName, + out TModel model) + { + ModelBindingContext propertyBindingContext = new ModelBindingContext(parentBindingContext) + { + ModelMetadata = parentBindingContext.MetadataProvider.GetMetadataForType(modelAccessor: null, modelType: typeof(TModel)), + ModelName = ModelBindingHelper.CreatePropertyModelName(parentBindingContext.ModelName, propertyName) + }; + + if (propertyBindingContext.ModelBinder.BindModel(propertyBindingContext)) + { + object untypedModel = propertyBindingContext.Model; + model = ModelBindingHelper.CastOrDefault(untypedModel); + // TODO: Revive once we get validation + // parentBindingContext.ValidationNode.ChildNodes.Add(propertyBindingContext.ValidationNode); + return true; + } + + model = default(TModel); + return false; + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/MutableObjectModelBinder.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/MutableObjectModelBinder.cs new file mode 100644 index 0000000000..4c1b64f1ea --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/MutableObjectModelBinder.cs @@ -0,0 +1,331 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class MutableObjectModelBinder : IModelBinder + { + public virtual bool BindModel(ModelBindingContext bindingContext) + { + ModelBindingHelper.ValidateBindingContext(bindingContext); + + if (!CanBindType(bindingContext.ModelType) || + !bindingContext.ValueProvider.ContainsPrefix(bindingContext.ModelName)) + { + return false; + } + + EnsureModel(bindingContext); + IEnumerable propertyMetadatas = GetMetadataForProperties(bindingContext); + ComplexModelDto dto = CreateAndPopulateDto(bindingContext, propertyMetadatas); + + // post-processing, e.g. property setters and hooking up validation + ProcessDto(bindingContext, dto); + // TODO: Validation + // bindingContext.ValidationNode.ValidateAllProperties = true; // complex models require full validation + return true; + } + + protected virtual bool CanUpdateProperty(ModelMetadata propertyMetadata) + { + return CanUpdatePropertyInternal(propertyMetadata); + } + + private static bool CanBindType(Type modelType) + { + // Simple types cannot use this binder + bool isComplexType = !modelType.HasStringConverter(); + if (!isComplexType) + { + return false; + } + + if (modelType == typeof(ComplexModelDto)) + { + // forbidden type - will cause a stack overflow if we try binding this type + return false; + } + return true; + } + + internal static bool CanUpdatePropertyInternal(ModelMetadata propertyMetadata) + { + return !propertyMetadata.IsReadOnly || CanUpdateReadOnlyProperty(propertyMetadata.ModelType); + } + + private 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; + } + + private ComplexModelDto CreateAndPopulateDto(ModelBindingContext bindingContext, IEnumerable propertyMetadatas) + { + // create a DTO and call into the DTO binder + ComplexModelDto originalDto = new ComplexModelDto(bindingContext.ModelMetadata, propertyMetadatas); + ModelBindingContext dtoBindingContext = new ModelBindingContext(bindingContext) + { + ModelMetadata = bindingContext.MetadataProvider.GetMetadataForType(() => originalDto, typeof(ComplexModelDto)), + ModelName = bindingContext.ModelName + }; + + bindingContext.ModelBinder.BindModel(dtoBindingContext); + return (ComplexModelDto)dtoBindingContext.Model; + } + + protected virtual object CreateModel(ModelBindingContext bindingContext) + { + // If the Activator 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. + return Activator.CreateInstance(bindingContext.ModelType); + } + + //// Called when the property setter null check failed, allows us to add our own error message to ModelState. + //internal static EventHandler CreateNullCheckFailedHandler(ModelMetadata modelMetadata, object incomingValue) + //{ + // return (sender, e) => + // { + // ModelValidationNode validationNode = (ModelValidationNode)sender; + // ModelStateDictionary modelState = e.ActionContext.ModelState; + + // if (modelState.IsValidField(validationNode.ModelStateKey)) + // { + // string errorMessage = ModelBinderConfig.ValueRequiredErrorMessageProvider(e.ActionContext, modelMetadata, incomingValue); + // if (errorMessage != null) + // { + // modelState.AddModelError(validationNode.ModelStateKey, errorMessage); + // } + // } + // }; + //} + + protected virtual void EnsureModel(ModelBindingContext bindingContext) + { + if (bindingContext.Model == null) + { + bindingContext.ModelMetadata.Model = CreateModel(bindingContext); + } + } + + protected virtual IEnumerable GetMetadataForProperties(ModelBindingContext bindingContext) + { + // TODO: Revive required properties. This has a dependency on HttpBindingAttribute and DataAnnotations + // keep a set of the required properties so that we can cross-reference bound properties later + HashSet requiredProperties = new HashSet(); + // Dictionary requiredValidators; + HashSet skipProperties = new HashSet(); + // GetRequiredPropertiesCollection(bindingContext, out requiredProperties, out skipProperties); + + return from propertyMetadata in bindingContext.ModelMetadata.Properties + let propertyName = propertyMetadata.PropertyName + let shouldUpdateProperty = requiredProperties.Contains(propertyName) || !skipProperties.Contains(propertyName) + where shouldUpdateProperty && CanUpdateProperty(propertyMetadata) + select propertyMetadata; + } + + private static object GetPropertyDefaultValue(PropertyInfo propertyInfo) + { + DefaultValueAttribute attr = propertyInfo.GetCustomAttribute(); + return (attr != null) ? attr.Value : null; + } + + //internal static void GetRequiredPropertiesCollection(ModelBindingContext bindingContext, out HashSet requiredProperties, out HashSet skipProperties) + //{ + // requiredProperties = new HashSet(StringComparer.OrdinalIgnoreCase); + // // requiredValidators = new Dictionary(StringComparer.OrdinalIgnoreCase); + // skipProperties = new HashSet(StringComparer.OrdinalIgnoreCase); + + // // Use attributes on the property before attributes on the type. + // Type modelType = bindingContext.ModelType; + // ICustomTypeDescriptor modelDescriptor = new AssociatedMetadataTypeTypeDescriptionProvider(modelType) + // .GetTypeDescriptor(modelType); + + // PropertyDescriptorCollection propertyDescriptors = modelDescriptor.GetProperties(); + + // // TODO: Revive HttpBindingBehavior + // // HttpBindingBehaviorAttribute typeAttr = modelDescriptor.GetAttributes().OfType().SingleOrDefault(); + + // foreach (PropertyDescriptor propertyDescriptor in propertyDescriptors) + // { + // string propertyName = propertyDescriptor.Name; + // ModelMetadata propertyMetadata = bindingContext.PropertyMetadata[propertyName]; + // // ModelValidator requiredValidator = context.GetValidators(propertyMetadata).Where(v => v.IsRequired).FirstOrDefault(); + // // requiredValidators[propertyName] = requiredValidator; + + // HttpBindingBehaviorAttribute propAttr = propertyDescriptor.Attributes.OfType().SingleOrDefault(); + // HttpBindingBehaviorAttribute workingAttr = propAttr ?? typeAttr; + // if (workingAttr != null) + // { + // switch (workingAttr.Behavior) + // { + // case HttpBindingBehavior.Required: + // requiredProperties.Add(propertyName); + // break; + + // case HttpBindingBehavior.Never: + // skipProperties.Add(propertyName); + // break; + // } + // } + // else if (requiredValidator != null) + // { + // requiredProperties.Add(propertyName); + // } + // } + //} + + internal void ProcessDto(ModelBindingContext bindingContext, ComplexModelDto dto) + { + // TODO: Uncomment this once we revive validation + + //HashSet requiredProperties; + //// Dictionary requiredValidators; + //HashSet skipProperties; + //GetRequiredPropertiesCollection(context, bindingContext, out requiredProperties, out requiredValidators, out skipProperties); + + //// Eliminate provided properties from requiredProperties; leaving just *missing* required properties. + //requiredProperties.ExceptWith(dto.Results.Select(r => r.Key.PropertyName)); + + //foreach (string missingRequiredProperty in requiredProperties) + //{ + // string modelStateKey = ModelBindingHelper.CreatePropertyModelName( + // bindingContext.ValidationNode.ModelStateKey, missingRequiredProperty); + + // // Update Model as SetProperty() would: Place null value where validator will check for non-null. This + // // ensures a failure result from a required validator (if any) even for a non-nullable property. + // // (Otherwise, propertyMetadata.Model is likely already null.) + // ModelMetadata propertyMetadata = bindingContext.PropertyMetadata[missingRequiredProperty]; + // propertyMetadata.Model = null; + + // // Execute validator (if any) to get custom error message. + // ModelValidator validator = requiredValidators[missingRequiredProperty]; + // bool addedError = RunValidator(validator, bindingContext, propertyMetadata, modelStateKey); + + // // Fall back to default message if HttpBindingBehaviorAttribute required this property or validator + // // (oddly) succeeded. + // if (!addedError) + // { + // bindingContext.ModelState.AddModelError(modelStateKey, + // Error.Format(SRResources.MissingRequiredMember, missingRequiredProperty)); + // } + //} + + // for each property that was bound, call the setter, recording exceptions as necessary + foreach (var entry in dto.Results) + { + ModelMetadata propertyMetadata = entry.Key; + + ComplexModelDtoResult dtoResult = entry.Value; + if (dtoResult != null) + { + SetProperty(bindingContext, propertyMetadata, dtoResult); + // TODO: Validation + // bindingContext.ValidationNode.ChildNodes.Add(dtoResult.ValidationNode); + } + } + } + + [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "We're recording this exception so that we can act on it later.")] + protected virtual void SetProperty(ModelBindingContext bindingContext, + ModelMetadata propertyMetadata, + ComplexModelDtoResult dtoResult + /*, ModelValidator requiredValidator*/) + { + // TODO: This used TypeDescriptor which is no longer available. Lookups performed using System.ComponentModel were + // cached. To maintain parity, we'll need to cache property lookups. + PropertyInfo property = bindingContext.ModelType + .GetRuntimeProperties() + .FirstOrDefault(p => p.Name.Equals(propertyMetadata.PropertyName, StringComparison.OrdinalIgnoreCase)); + + if (property == null || !property.CanWrite) + { + // nothing to do + return; + } + + object value = dtoResult.Model ?? GetPropertyDefaultValue(property); + propertyMetadata.Model = value; + + + //// 'Required' validators need to run first so that we can provide useful error messages if + //// the property setters throw, e.g. if we're setting entity keys to null. See comments in + //// DefaultModelBinder.SetProperty() for more information. + //if (value == null) + //{ + // string modelStateKey = dtoResult.ValidationNode.ModelStateKey; + // if (bindingContext.ModelState.IsValidField(modelStateKey)) + // { + // RunValidator(requiredValidator, bindingContext, propertyMetadata, modelStateKey); + // } + //} + + if (value != null || property.PropertyType.AllowsNullValue()) + { + try + { + property.SetValue(bindingContext.Model, value); + } + catch (Exception ex) + { + // don't display a duplicate error message if a binding error has already occurred for this field + //string modelStateKey = dtoResult.ValidationNode.ModelStateKey; + //if (bindingContext.ModelState.IsValidField(modelStateKey)) + //{ + bindingContext.ModelState.AddModelError(bindingContext.ModelName, ex); + //} + } + } + else + { + // trying to set a non-nullable value type to null, need to make sure there's a message + //string modelStateKey = dtoResult.ValidationNode.ModelStateKey; + //if (bindingContext.ModelState.IsValidField(modelStateKey)) + //{ + // dtoResult.ValidationNode.Validated += CreateNullCheckFailedHandler(propertyMetadata, value); + //} + } + } + + //// Returns true if validator execution adds a model error. + //private static bool RunValidator(ModelValidator validator, ModelBindingContext bindingContext, + // ModelMetadata propertyMetadata, string modelStateKey) + //{ + // bool addedError = false; + // if (validator != null) + // { + // foreach (ModelValidationResult validationResult in validator.Validate(propertyMetadata, bindingContext.Model)) + // { + // bindingContext.ModelState.AddModelError(modelStateKey, validationResult.Message); + // addedError = true; + // } + // } + + // return addedError; + //} + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/TypeConverterModelBinder.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/TypeConverterModelBinder.cs new file mode 100644 index 0000000000..46dd0c37b3 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/TypeConverterModelBinder.cs @@ -0,0 +1,68 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Contracts; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public sealed class TypeConverterModelBinder : IModelBinder + { + [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "The exception is recorded to be acted upon later.")] + [SuppressMessage("Microsoft.Globalization", "CA1304:SpecifyCultureInfo", MessageId = "System.Web.Http.ValueProviders.ValueProviderResult.ConvertTo(System.Type)", Justification = "The ValueProviderResult already has the necessary context to perform a culture-aware conversion.")] + public bool BindModel(ModelBindingContext bindingContext) + { + ModelBindingHelper.ValidateBindingContext(bindingContext); + + if (!bindingContext.ModelType.HasStringConverter()) + { + // this type cannot be converted + return false; + } + + ValueProviderResult valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + if (valueProviderResult == null) + { + return false; // no entry + } + + // Provider should have verified this before creating + Contract.Assert(bindingContext.ModelType.HasStringConverter()); + + object newModel; + bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult); + try + { + newModel = valueProviderResult.ConvertTo(bindingContext.ModelType); + } + catch (Exception ex) + { + if (IsFormatException(ex)) + { + // there was a type conversion failure + bindingContext.ModelState.AddModelError(bindingContext.ModelName, ex.Message); + } + else + { + bindingContext.ModelState.AddModelError(bindingContext.ModelName, ex); + } + return false; + } + + ModelBindingHelper.ReplaceEmptyStringWithNull(bindingContext.ModelMetadata, ref newModel); + bindingContext.Model = newModel; + return true; + } + + private static bool IsFormatException(Exception ex) + { + for (; ex != null; ex = ex.InnerException) + { + if (ex is FormatException) + { + return true; + } + } + return false; + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/TypeMatchModelBinder.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/TypeMatchModelBinder.cs new file mode 100644 index 0000000000..00cdbabcae --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/TypeMatchModelBinder.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public sealed class TypeMatchModelBinder : IModelBinder + { + public bool BindModel(ModelBindingContext bindingContext) + { + ValueProviderResult valueProviderResult = GetCompatibleValueProviderResult(bindingContext); + if (valueProviderResult == null) + { + // conversion would have failed + return false; + } + + bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult); + object model = valueProviderResult.RawValue; + ModelBindingHelper.ReplaceEmptyStringWithNull(bindingContext.ModelMetadata, ref model); + bindingContext.Model = model; + if (bindingContext.ModelMetadata.IsComplexType) + { + // TODO: Validation + //IBodyModelValidator validator = services.GetBodyModelValidator(); + //ModelMetadataProvider metadataProvider = services.GetModelMetadataProvider(); + //if (validator != null && metadataProvider != null) + //{ + // validator.Validate(model, bindingContext.ModelType, metadataProvider, context, bindingContext.ModelName); + //} + } + + return true; + } + + internal static ValueProviderResult GetCompatibleValueProviderResult(ModelBindingContext bindingContext) + { + ModelBindingHelper.ValidateBindingContext(bindingContext); + + ValueProviderResult valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + if (valueProviderResult == null) + { + return null; // the value doesn't exist + } + + if (!bindingContext.ModelType.IsCompatibleWith(valueProviderResult.RawValue)) + { + return null; // value is of incompatible type + } + + return valueProviderResult; + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/IValueProvider.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/IValueProvider.cs deleted file mode 100644 index a0a7e8ef76..0000000000 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/IValueProvider.cs +++ /dev/null @@ -1,8 +0,0 @@ - -namespace Microsoft.AspNet.Mvc.ModelBinding -{ - public interface IValueProvider - { - bool ContainsPrefix(string key); - } -} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/CollectionExtensions.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/CollectionExtensions.cs new file mode 100644 index 0000000000..6e67400c20 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/CollectionExtensions.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Internal +{ + public static class CollectionExtensions + { + /// + /// Convert an ICollection to an array, removing null values. Fast path for case where there are no null values. + /// + public static T[] ToArrayWithoutNulls(this ICollection collection) where T : class + { + Contract.Assert(collection != null); + + T[] result = new T[collection.Count]; + int count = 0; + foreach (T value in collection) + { + if (value != null) + { + result[count] = value; + count++; + } + } + if (count == collection.Count) + { + return result; + } + else + { + T[] trimmedResult = new T[count]; + Array.Copy(result, trimmedResult, count); + return trimmedResult; + } + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/CollectionModelBinderUtil.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/CollectionModelBinderUtil.cs new file mode 100644 index 0000000000..177aef44b2 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/CollectionModelBinderUtil.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Internal +{ + public static class CollectionModelBinderUtil + { + public static IEnumerable GetIndexNamesFromValueProviderResult(ValueProviderResult valueProviderResultIndex) + { + IEnumerable indexNames = null; + if (valueProviderResultIndex != null) + { + string[] indexes = (string[])valueProviderResultIndex.ConvertTo(typeof(string[])); + if (indexes != null && indexes.Length > 0) + { + indexNames = indexes; + } + } + return indexNames; + } + + public static void CreateOrReplaceCollection(ModelBindingContext bindingContext, + IEnumerable incomingElements, + Func> creator) + { + var collection = bindingContext.Model as ICollection; + if (collection == null || collection.IsReadOnly) + { + collection = creator(); + bindingContext.Model = collection; + } + + collection.Clear(); + foreach (TElement element in incomingElements) + { + collection.Add(element); + } + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/EfficientTypePropertyKey.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/EfficientTypePropertyKey.cs new file mode 100644 index 0000000000..a320ec27d7 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/EfficientTypePropertyKey.cs @@ -0,0 +1,20 @@ +using System; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + internal class EfficientTypePropertyKey : Tuple + { + private int _hashCode; + + public EfficientTypePropertyKey(T1 item1, T2 item2) + : base(item1, item2) + { + _hashCode = base.GetHashCode(); + } + + public override int GetHashCode() + { + return _hashCode; + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/Error.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/Error.cs new file mode 100644 index 0000000000..8945888076 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/Error.cs @@ -0,0 +1,56 @@ +using System; +using System.Globalization; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Internal +{ + internal static class Error + { + internal static ArgumentException ArgumentNull(string paramName) + { + throw new ArgumentNullException(paramName); + } + + internal static Exception InvalidOperation(string message, params object[] args) + { + throw new InvalidOperationException(String.Format(CultureInfo.CurrentCulture, message, args)); + } + + internal static Exception InvalidOperation(Exception ex, string message, params object[] args) + { + throw new InvalidOperationException(String.Format(CultureInfo.CurrentCulture, message, args), ex); + } + + /// + /// Creates an with the provided properties. + /// + /// A composite format string explaining the reason for the exception. + /// An object array that contains zero or more objects to format. + /// The logged . + internal static ArgumentException Argument(string messageFormat, params object[] messageArgs) + { + return new ArgumentException(String.Format(CultureInfo.CurrentCulture, messageFormat, messageArgs)); + } + + /// + /// Creates an with the provided properties. + /// + /// The name of the parameter that caused the current exception. + /// A composite format string explaining the reason for the exception. + /// An object array that contains zero or more objects to format. + /// The logged . + internal static ArgumentException Argument(string parameterName, string messageFormat, params object[] messageArgs) + { + return new ArgumentException(String.Format(CultureInfo.CurrentCulture, messageFormat, messageArgs), parameterName); + } + + /// + /// Creates an with a default message. + /// + /// The name of the parameter that caused the current exception. + /// The logged . + internal static ArgumentException ArgumentNullOrEmpty(string parameterName) + { + return Error.Argument(parameterName, Resources.ArgumentNullOrEmpty, parameterName); + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/ModelBindingHelper.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/ModelBindingHelper.cs new file mode 100644 index 0000000000..23d564f35e --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/ModelBindingHelper.cs @@ -0,0 +1,89 @@ +using System; +using System.Globalization; +using System.Reflection; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Internal +{ + public static class ModelBindingHelper + { + internal static TModel CastOrDefault(object model) + { + return (model is TModel) ? (TModel)model : default(TModel); + } + + internal static string CreateIndexModelName(string parentName, int index) + { + return CreateIndexModelName(parentName, index.ToString(CultureInfo.InvariantCulture)); + } + + internal static string CreateIndexModelName(string parentName, string index) + { + return (parentName.Length == 0) ? "[" + index + "]" : parentName + "[" + index + "]"; + } + + internal static string CreatePropertyModelName(string prefix, string propertyName) + { + if (String.IsNullOrEmpty(prefix)) + { + return propertyName ?? String.Empty; + } + else if (String.IsNullOrEmpty(propertyName)) + { + return prefix ?? String.Empty; + } + else + { + return prefix + "." + propertyName; + } + } + + internal static Type GetPossibleBinderInstanceType(Type closedModelType, Type openModelType, Type openBinderType) + { + Type[] typeArguments = TypeExtensions.GetTypeArgumentsIfMatch(closedModelType, openModelType); + return (typeArguments != null) ? openBinderType.MakeGenericType(typeArguments) : null; + } + + internal static void ReplaceEmptyStringWithNull(ModelMetadata modelMetadata, ref object model) + { + if (model is string && + modelMetadata.ConvertEmptyStringToNull && + String.IsNullOrWhiteSpace(model as string)) + { + model = null; + } + } + + internal static void ValidateBindingContext(ModelBindingContext bindingContext) + { + if (bindingContext == null) + { + throw Error.ArgumentNull("bindingContext"); + } + + if (bindingContext.ModelMetadata == null) + { + throw Error.Argument("bindingContext", Resources.ModelBinderUtil_ModelMetadataCannotBeNull); + } + } + + internal static void ValidateBindingContext(ModelBindingContext bindingContext, Type requiredType, bool allowNullModel) + { + ValidateBindingContext(bindingContext); + + if (bindingContext.ModelType != requiredType) + { + throw Error.Argument("bindingContext", Resources.ModelBinderUtil_ModelTypeIsWrong, bindingContext.ModelType, requiredType); + } + + if (!allowNullModel && bindingContext.Model == null) + { + throw Error.Argument("bindingContext", Resources.ModelBinderUtil_ModelCannotBeNull, requiredType); + } + + if (bindingContext.Model != null && !bindingContext.ModelType.GetTypeInfo().IsAssignableFrom(requiredType.GetTypeInfo())) + { + throw Error.Argument("bindingContext", Resources.ModelBinderUtil_ModelInstanceIsWrong, bindingContext.Model.GetType(), requiredType); + } + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/PrefixContainer.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/PrefixContainer.cs new file mode 100644 index 0000000000..bff1266b74 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/PrefixContainer.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Internal +{ + /// + /// This is a container for prefix values. It normalizes all the values into dotted-form and then stores + /// them in a sorted array. All queries for prefixes are also normalized to dotted-form, and searches + /// for ContainsPrefix are done with a binary search. + /// + public class PrefixContainer + { + private readonly ICollection _originalValues; + private readonly string[] _sortedValues; + + internal PrefixContainer(ICollection values) + { + if (values == null) + { + throw new ArgumentNullException("values"); + } + + _originalValues = values; + _sortedValues = _originalValues.ToArrayWithoutNulls(); + Array.Sort(_sortedValues, StringComparer.OrdinalIgnoreCase); + } + + internal bool ContainsPrefix(string prefix) + { + if (prefix == null) + { + throw new ArgumentNullException("prefix"); + } + + if (prefix.Length == 0) + { + return _sortedValues.Length > 0; // only match empty string when we have some value + } + + PrefixComparer prefixComparer = new PrefixComparer(prefix); + bool containsPrefix = Array.BinarySearch(_sortedValues, prefix, prefixComparer) > -1; + if (!containsPrefix) + { + // If there's something in the search boundary that starts with the same name + // as the collection prefix that we're trying to find, the binary search would actually fail. + // For example, let's say we have foo.a, foo.bE and foo.b[0]. Calling Array.BinarySearch + // will fail to find foo.b because it will land on foo.bE, then look at foo.a and finally + // failing to find the prefix which is actually present in the container (foo.b[0]). + // Here we're doing another pass looking specifically for collection prefix. + containsPrefix = Array.BinarySearch(_sortedValues, prefix + "[", prefixComparer) > -1; + } + return containsPrefix; + } + + // Given "foo.bar", "foo.hello", "something.other", foo[abc].baz and asking for prefix "foo" will return: + // - "bar"/"foo.bar" + // - "hello"/"foo.hello" + // - "abc"/"foo[abc]" + internal IDictionary GetKeysFromPrefix(string prefix) + { + IDictionary result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in _originalValues) + { + if (entry != null) + { + if (entry.Length == prefix.Length) + { + // No key in this entry + continue; + } + + if (prefix.Length == 0) + { + GetKeyFromEmptyPrefix(entry, result); + } + else if (entry.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + GetKeyFromNonEmptyPrefix(prefix, entry, result); + } + } + } + + return result; + } + + private static void GetKeyFromEmptyPrefix(string entry, IDictionary results) + { + string key; + int dotPosition = entry.IndexOf('.'); + int bracketPosition = entry.IndexOf('['); + int delimiterPosition = -1; + + if (dotPosition == -1) + { + if (bracketPosition != -1) + { + delimiterPosition = bracketPosition; + } + } + else + { + if (bracketPosition == -1) + { + delimiterPosition = dotPosition; + } + else + { + delimiterPosition = Math.Min(dotPosition, bracketPosition); + } + } + + key = delimiterPosition == -1 ? entry : entry.Substring(0, delimiterPosition); + results[key] = key; + } + + private static void GetKeyFromNonEmptyPrefix(string prefix, string entry, IDictionary results) + { + string key = null; + string fullName = null; + int keyPosition = prefix.Length + 1; + + switch (entry[prefix.Length]) + { + case '.': + int dotPosition = entry.IndexOf('.', keyPosition); + if (dotPosition == -1) + { + dotPosition = entry.Length; + } + + key = entry.Substring(keyPosition, dotPosition - keyPosition); + fullName = entry.Substring(0, dotPosition); + break; + + case '[': + int bracketPosition = entry.IndexOf(']', keyPosition); + if (bracketPosition == -1) + { + // Malformed for dictionary + return; + } + + key = entry.Substring(keyPosition, bracketPosition - keyPosition); + fullName = entry.Substring(0, bracketPosition + 1); + break; + + default: + return; + } + + if (!results.ContainsKey(key)) + { + results.Add(key, fullName); + } + } + + internal static bool IsPrefixMatch(string prefix, string testString) + { + if (testString == null) + { + return false; + } + + if (prefix.Length == 0) + { + return true; // shortcut - non-null testString matches empty prefix + } + + if (prefix.Length > testString.Length) + { + return false; // not long enough + } + + if (!testString.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return false; // prefix doesn't match + } + + if (testString.Length == prefix.Length) + { + return true; // exact match + } + + // invariant: testString.Length > prefix.Length + switch (testString[prefix.Length]) + { + case '.': + case '[': + return true; // known delimiters + + default: + return false; // not known delimiter + } + } + + private sealed class PrefixComparer : IComparer + { + private string _prefix; + + public PrefixComparer(string prefix) + { + _prefix = prefix; + } + + public int Compare(string x, string y) + { + string testString = Object.ReferenceEquals(x, _prefix) ? y : x; + if (IsPrefixMatch(_prefix, testString)) + { + return 0; + } + + return StringComparer.OrdinalIgnoreCase.Compare(x, y); + } + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/TypeExtensions.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/TypeExtensions.cs index 96086d2c19..b4e18196e1 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/TypeExtensions.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/TypeExtensions.cs @@ -5,9 +5,10 @@ namespace Microsoft.AspNet.Mvc.ModelBinding.Internal { public static class TypeExtensions { - public static bool IsCompatibleObject(this object value) + public static bool IsCompatibleWith(this Type type, object value) { - return (value is T || (value == null && TypeAllowsNullValue(typeof(T)))); + return (value == null && AllowsNullValue(type)) || + type.GetTypeInfo().IsAssignableFrom(value.GetType().GetTypeInfo()); } public static bool IsNullableValueType(this Type type) @@ -15,9 +16,38 @@ namespace Microsoft.AspNet.Mvc.ModelBinding.Internal return Nullable.GetUnderlyingType(type) != null; } - public static bool TypeAllowsNullValue(this Type type) + public static bool AllowsNullValue(this Type type) { return (!type.GetTypeInfo().IsValueType || IsNullableValueType(type)); } + + public static bool HasStringConverter(this Type type) + { + // TODO: This depends on TypeConverter which does not exist in the CoreCLR. + // return TypeDescriptor.GetConverter(type).CanConvertFrom(typeof(string)); + TypeInfo typeInfo = type.GetTypeInfo(); + if (typeInfo.IsPrimitive || type == typeof(string)) + { + return true; + } + if (IsNullableValueType(type) && HasStringConverter(type.GenericTypeArguments[0])) + { + // Nullable where T is a primitive type or has a type converter + return true; + } + return false; + } + + public static Type[] GetTypeArgumentsIfMatch(Type closedType, Type matchingOpenType) + { + TypeInfo closedTypeInfo = closedType.GetTypeInfo(); + if (!closedTypeInfo.IsGenericType) + { + return null; + } + + Type openType = closedType.GetGenericTypeDefinition(); + return (matchingOpenType == openType) ? closedTypeInfo.GenericTypeArguments : null; + } } } diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/AssociatedMetadataProvider.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/AssociatedMetadataProvider.cs new file mode 100644 index 0000000000..a5d6af6da5 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/AssociatedMetadataProvider.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public abstract class AssociatedMetadataProvider : IModelMetadataProvider + where TModelMetadata : ModelMetadata + { + private readonly ConcurrentDictionary _typeInfoCache = new ConcurrentDictionary(); + + public IEnumerable GetMetadataForProperties(object container, Type containerType) + { + if (containerType == null) + { + throw Error.ArgumentNull("containerType"); + } + + return GetMetadataForPropertiesCore(container, containerType); + } + + public ModelMetadata GetMetadataForProperty(Func modelAccessor, Type containerType, string propertyName) + { + if (containerType == null) + { + throw Error.ArgumentNull("containerType"); + } + if (String.IsNullOrEmpty(propertyName)) + { + throw Error.ArgumentNullOrEmpty("propertyName"); + } + + TypeInformation typeInfo = GetTypeInformation(containerType); + PropertyInformation propertyInfo; + if (!typeInfo.Properties.TryGetValue(propertyName, out propertyInfo)) + { + throw Error.Argument("propertyName", Resources.Common_PropertyNotFound, containerType, propertyName); + } + + return CreateMetadataFromPrototype(propertyInfo.Prototype, modelAccessor); + } + + public ModelMetadata GetMetadataForType(Func modelAccessor, Type modelType) + { + if (modelType == null) + { + throw Error.ArgumentNull("modelType"); + } + + TModelMetadata prototype = GetTypeInformation(modelType).Prototype; + return CreateMetadataFromPrototype(prototype, modelAccessor); + } + + public ModelMetadata GetMetadataForParameter(ParameterInfo parameter) + { + if (parameter == null) + { + throw Error.ArgumentNull("parameter"); + } + + TModelMetadata prototype = GetTypeInformation(parameter.ParameterType, parameter.GetCustomAttributes()).Prototype; + return CreateMetadataFromPrototype(prototype, modelAccessor: null); + } + + // Override for creating the prototype metadata (without the accessor) + protected abstract TModelMetadata CreateMetadataPrototype(IEnumerable attributes, + Type containerType, + Type modelType, + string propertyName); + + // Override for applying the prototype + modelAccess to yield the final metadata + protected abstract TModelMetadata CreateMetadataFromPrototype(TModelMetadata prototype, + Func modelAccessor); + + private IEnumerable GetMetadataForPropertiesCore(object container, Type containerType) + { + TypeInformation typeInfo = GetTypeInformation(containerType); + foreach (KeyValuePair kvp in typeInfo.Properties) + { + PropertyInformation propertyInfo = kvp.Value; + Func modelAccessor = null; + if (container != null) + { + Func propertyGetter = propertyInfo.ValueAccessor; + modelAccessor = () => propertyGetter(container); + } + yield return CreateMetadataFromPrototype(propertyInfo.Prototype, modelAccessor); + } + } + + private TypeInformation GetTypeInformation(Type type, IEnumerable associatedAttributes = null) + { + // This retrieval is implemented as a TryGetValue/TryAdd instead of a GetOrAdd to avoid the performance cost of creating instance delegates + TypeInformation typeInfo; + if (!_typeInfoCache.TryGetValue(type, out typeInfo)) + { + typeInfo = CreateTypeInformation(type, associatedAttributes); + _typeInfoCache.TryAdd(type, typeInfo); + } + return typeInfo; + } + + private TypeInformation CreateTypeInformation(Type type, IEnumerable associatedAttributes) + { + TypeInfo typeInfo = type.GetTypeInfo(); + IEnumerable attributes = typeInfo.GetCustomAttributes(); + if (associatedAttributes != null) + { + attributes = attributes.Concat(associatedAttributes); + } + TypeInformation info = new TypeInformation + { + Prototype = CreateMetadataPrototype(attributes, containerType: null, modelType: type, propertyName: null) + }; + // TODO: Determine if we need this. TypeDescriptor does not exist in CoreCLR. + //ICustomTypeDescriptor typeDescriptor = TypeDescriptorHelper.Get(type); + //info.TypeDescriptor = typeDescriptor; + + Dictionary properties = new Dictionary(); + + // TODO: Figure out if there's a better way to identify public non-static properties + foreach (PropertyInfo property in type.GetRuntimeProperties().Where(p => p.GetMethod.IsPublic && !p.GetMethod.IsStatic)) + { + // Avoid re-generating a property descriptor if one has already been generated for the property name + if (!properties.ContainsKey(property.Name)) + { + properties.Add(property.Name, CreatePropertyInformation(type, property)); + } + } + info.Properties = properties; + + return info; + } + + private PropertyInformation CreatePropertyInformation(Type containerType, PropertyInfo property) + { + PropertyInformation info = new PropertyInformation(); + info.ValueAccessor = CreatePropertyValueAccessor(property); + info.Prototype = CreateMetadataPrototype(property.GetCustomAttributes().Cast(), containerType, property.PropertyType, property.Name); + return info; + } + + private static Func CreatePropertyValueAccessor(PropertyInfo property) + { + Type declaringType = property.DeclaringType; + TypeInfo declaringTypeInfo = declaringType.GetTypeInfo(); + if (declaringTypeInfo.IsVisible) + { + if (property.CanRead) + { + MethodInfo getMethodInfo = property.GetMethod; + if (getMethodInfo != null) + { + return CreateDynamicValueAccessor(getMethodInfo, declaringType, property.Name); + } + } + } + + // If either the type isn't public or we can't find a public getter, use the slow Reflection path + return container => property.GetValue(container); + } + + // Uses Lightweight Code Gen to generate a tiny delegate that gets the property value + // This is an optimization to avoid having to go through the much slower System.Reflection APIs + // e.g. generates (object o) => (Person)o.Id + private static Func CreateDynamicValueAccessor(MethodInfo getMethodInfo, Type declaringType, string propertyName) + { + Contract.Assert(getMethodInfo != null && getMethodInfo.IsPublic && !getMethodInfo.IsStatic); + + TypeInfo declaringTypeInfo = declaringType.GetTypeInfo(); + Type propertyType = getMethodInfo.ReturnType; + DynamicMethod dynamicMethod = new DynamicMethod("Get" + propertyName + "From" + declaringType.Name, typeof(object), new Type[] { typeof(object) }); + ILGenerator ilg = dynamicMethod.GetILGenerator(); + + // Load the container onto the stack, convert from object => declaring type for the property + ilg.Emit(OpCodes.Ldarg_0); + if (declaringTypeInfo.IsValueType) + { + ilg.Emit(OpCodes.Unbox, declaringType); + } + else + { + ilg.Emit(OpCodes.Castclass, declaringType); + } + + // if declaring type is value type, we use Call : structs don't have inheritance + // if get method is sealed or isn't virtual, we use Call : it can't be overridden + if (declaringTypeInfo.IsValueType || !getMethodInfo.IsVirtual || getMethodInfo.IsFinal) + { + ilg.Emit(OpCodes.Call, getMethodInfo); + } + else + { + ilg.Emit(OpCodes.Callvirt, getMethodInfo); + } + + // Box if the property type is a value type, so it can be returned as an object + if (propertyType.GetTypeInfo().IsValueType) + { + ilg.Emit(OpCodes.Box, propertyType); + } + + // Return property value + ilg.Emit(OpCodes.Ret); + + return (Func)dynamicMethod.CreateDelegate(typeof(Func)); + } + + private sealed class TypeInformation + { + public TModelMetadata Prototype { get; set; } + public Dictionary Properties { get; set; } + } + + private sealed class PropertyInformation + { + public Func ValueAccessor { get; set; } + public TModelMetadata Prototype { get; set; } + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedDataAnnotationsMetadataAttributes.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedDataAnnotationsMetadataAttributes.cs new file mode 100644 index 0000000000..11835bad16 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedDataAnnotationsMetadataAttributes.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class CachedDataAnnotationsMetadataAttributes + { + public CachedDataAnnotationsMetadataAttributes(IEnumerable attributes) + { + Display = attributes.OfType().FirstOrDefault(); + DisplayName = attributes.OfType().FirstOrDefault(); + DisplayFormat = attributes.OfType().FirstOrDefault(); + Editable = attributes.OfType().FirstOrDefault(); + ReadOnly = attributes.OfType().FirstOrDefault(); + } + + public DisplayAttribute Display { get; protected set; } + + public DisplayNameAttribute DisplayName { get; protected set; } + + public DisplayFormatAttribute DisplayFormat { get; protected set; } + + public EditableAttribute Editable { get; protected set; } + + public ReadOnlyAttribute ReadOnly { get; protected set; } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedDataAnnotationsModelMetadata.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedDataAnnotationsModelMetadata.cs new file mode 100644 index 0000000000..75fbe318b8 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedDataAnnotationsModelMetadata.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class CachedDataAnnotationsModelMetadata : CachedModelMetadata + { + public CachedDataAnnotationsModelMetadata(CachedDataAnnotationsModelMetadata prototype, + Func modelAccessor) + : base(prototype, modelAccessor) + { + } + + public CachedDataAnnotationsModelMetadata(DataAnnotationsModelMetadataProvider provider, + Type containerType, + Type modelType, + string propertyName, + IEnumerable attributes) + : base(provider, + containerType, + modelType, + propertyName, + new CachedDataAnnotationsMetadataAttributes(attributes)) + { + } + + protected override bool ComputeConvertEmptyStringToNull() + { + return PrototypeCache.DisplayFormat != null + ? PrototypeCache.DisplayFormat.ConvertEmptyStringToNull + : base.ComputeConvertEmptyStringToNull(); + } + + protected override string ComputeDescription() + { + return PrototypeCache.Display != null + ? PrototypeCache.Display.GetDescription() + : base.ComputeDescription(); + } + + protected override bool ComputeIsReadOnly() + { + if (PrototypeCache.Editable != null) + { + return !PrototypeCache.Editable.AllowEdit; + } + + if (PrototypeCache.ReadOnly != null) + { + return PrototypeCache.ReadOnly.IsReadOnly; + } + + return base.ComputeIsReadOnly(); + } + + public override string GetDisplayName() + { + // DisplayName could be provided by either the DisplayAttribute, or DisplayNameAttribute. If neither of + // those supply a name, then we fall back to the property name (in base.GetDisplayName()). + // + // DisplayName has lower precedence than Display.Name, for consistency with MVC. + + // DisplayAttribute doesn't require you to set a name, so this could be null. + if (PrototypeCache.Display != null) + { + string name = PrototypeCache.Display.GetName(); + if (name != null) + { + return name; + } + } + + // It's also possible for DisplayNameAttribute to be used without setting a name. If a user does that, then DisplayName will + // return the empty string - but for consistency with MVC we allow it. We do fallback to the property name in the (unlikely) + // scenario that the user sets null as the DisplayName, again, for consistency with MVC. + if (PrototypeCache.DisplayName != null) + { + string name = PrototypeCache.DisplayName.DisplayName; + if (name != null) + { + return name; + } + } + + // If neither attribute specifies a name, we'll fall back to the property name. + return base.GetDisplayName(); + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedModelMetadata.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedModelMetadata.cs new file mode 100644 index 0000000000..8dab52cbeb --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedModelMetadata.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + // This class assumes that model metadata is expensive to create, and allows the user to + // stash a cache object that can be copied around as a prototype to make creation and + // computation quicker. It delegates the retrieval of values to getter methods, the results + // of which are cached on a per-metadata-instance basis. + // + // This allows flexible caching strategies: either caching the source of information across + // instances or caching of the actual information itself, depending on what the developer + // decides to put into the prototype cache. + public abstract class CachedModelMetadata : ModelMetadata + { + private bool _convertEmptyStringToNull; + private string _description; + private bool _isReadOnly; + private bool _isComplexType; + + private bool _convertEmptyStringToNullComputed; + private bool _descriptionComputed; + private bool _isReadOnlyComputed; + private bool _isComplexTypeComputed; + + // Constructor for creating real instances of the metadata class based on a prototype + protected CachedModelMetadata(CachedModelMetadata prototype, Func modelAccessor) + : base(prototype.Provider, prototype.ContainerType, modelAccessor, prototype.ModelType, prototype.PropertyName) + { + CacheKey = prototype.CacheKey; + PrototypeCache = prototype.PrototypeCache; + + _isComplexType = prototype.IsComplexType; + _isComplexTypeComputed = true; + } + + // Constructor for creating the prototype instances of the metadata class + protected CachedModelMetadata(DataAnnotationsModelMetadataProvider provider, Type containerType, Type modelType, string propertyName, TPrototypeCache prototypeCache) + : base(provider, containerType, null /* modelAccessor */, modelType, propertyName) + { + PrototypeCache = prototypeCache; + } + + public sealed override bool ConvertEmptyStringToNull + { + get + { + if (!_convertEmptyStringToNullComputed) + { + _convertEmptyStringToNull = ComputeConvertEmptyStringToNull(); + _convertEmptyStringToNullComputed = true; + } + return _convertEmptyStringToNull; + } + set + { + _convertEmptyStringToNull = value; + _convertEmptyStringToNullComputed = true; + } + } + + public sealed override string Description + { + get + { + if (!_descriptionComputed) + { + _description = ComputeDescription(); + _descriptionComputed = true; + } + return _description; + } + set + { + _description = value; + _descriptionComputed = true; + } + } + + public sealed override bool IsReadOnly + { + get + { + if (!_isReadOnlyComputed) + { + _isReadOnly = ComputeIsReadOnly(); + _isReadOnlyComputed = true; + } + return _isReadOnly; + } + set + { + _isReadOnly = value; + _isReadOnlyComputed = true; + } + } + + public sealed override bool IsComplexType + { + get + { + if (!_isComplexTypeComputed) + { + _isComplexType = ComputeIsComplexType(); + _isComplexTypeComputed = true; + } + return _isComplexType; + } + } + + protected TPrototypeCache PrototypeCache { get; set; } + + protected virtual bool ComputeConvertEmptyStringToNull() + { + return base.ConvertEmptyStringToNull; + } + + protected virtual string ComputeDescription() + { + return base.Description; + } + + protected virtual bool ComputeIsReadOnly() + { + return base.IsReadOnly; + } + + protected virtual bool ComputeIsComplexType() + { + return base.IsComplexType; + } + + protected virtual bool ComputeIsFromBody() + { + return base.IsFromBody; + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/DataAnnotationsModelMetadataProvider.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/DataAnnotationsModelMetadataProvider.cs new file mode 100644 index 0000000000..a2ad68c8fe --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/DataAnnotationsModelMetadataProvider.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class DataAnnotationsModelMetadataProvider : AssociatedMetadataProvider + { + protected override CachedDataAnnotationsModelMetadata CreateMetadataPrototype( + IEnumerable attributes, + Type containerType, + Type modelType, + string propertyName) + { + return new CachedDataAnnotationsModelMetadata(this, containerType, modelType, propertyName, attributes); + } + + protected override CachedDataAnnotationsModelMetadata CreateMetadataFromPrototype( + CachedDataAnnotationsModelMetadata prototype, + Func modelAccessor) + { + return new CachedDataAnnotationsModelMetadata(prototype, modelAccessor); + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/DisplayAttribute.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/DisplayAttribute.cs new file mode 100644 index 0000000000..d446d074e1 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/DisplayAttribute.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class DisplayAttribute : Attribute + { + internal string GetDescription() + { + throw new NotImplementedException(); + } + + internal string GetName() + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/DisplayFormatAttribute.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/DisplayFormatAttribute.cs new file mode 100644 index 0000000000..a064ae7134 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/DisplayFormatAttribute.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class DisplayFormatAttribute : Attribute + { + public bool ConvertEmptyStringToNull { get; set; } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/DisplayNameAttribute.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/DisplayNameAttribute.cs new file mode 100644 index 0000000000..649424b891 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/DisplayNameAttribute.cs @@ -0,0 +1,9 @@ +using System; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class DisplayNameAttribute : Attribute + { + public virtual string DisplayName { get; set; } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/EditableAttribute.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/EditableAttribute.cs new file mode 100644 index 0000000000..ccc5dd5465 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/EditableAttribute.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class EditableAttribute : Attribute + { + public bool AllowEdit { get; set; } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/EmptyModelMetadataProvider.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/EmptyModelMetadataProvider.cs new file mode 100644 index 0000000000..3e9639e6b1 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/EmptyModelMetadataProvider.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class EmptyModelMetadataProvider : AssociatedMetadataProvider + { + protected override ModelMetadata CreateMetadataPrototype(IEnumerable attributes, + Type containerType, + Type modelType, + string propertyName) + { + return new ModelMetadata(this, containerType, null, modelType, propertyName); + } + + protected override ModelMetadata CreateMetadataFromPrototype(ModelMetadata prototype, Func modelAccessor) + { + return new ModelMetadata(this, prototype.ContainerType, modelAccessor, prototype.ModelType, prototype.PropertyName); + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/IModelMetadataProvider.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/IModelMetadataProvider.cs new file mode 100644 index 0000000000..dc1d4b8c65 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/IModelMetadataProvider.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public interface IModelMetadataProvider + { + IEnumerable GetMetadataForProperties(object container, Type containerType); + + ModelMetadata GetMetadataForProperty(Func modelAccessor, Type containerType, string propertyName); + + ModelMetadata GetMetadataForType(Func modelAccessor, Type modelType); + + ModelMetadata GetMetadataForParameter(ParameterInfo parameterInfo); + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs new file mode 100644 index 0000000000..d9c523159e --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class ModelMetadata + { + private readonly Type _containerType; + private readonly Type _modelType; + private readonly string _propertyName; + private EfficientTypePropertyKey _cacheKey; + + private bool _convertEmptyStringToNull = true; + private object _model; + private Func _modelAccessor; + private IEnumerable _properties; + private Type _realModelType; + + public ModelMetadata(IModelMetadataProvider provider, + Type containerType, + Func modelAccessor, + Type modelType, + string propertyName) + { + if (provider == null) + { + throw Error.ArgumentNull("provider"); + } + if (modelType == null) + { + throw Error.ArgumentNull("modelType"); + } + + Provider = provider; + + _containerType = containerType; + _modelAccessor = modelAccessor; + _modelType = modelType; + _propertyName = propertyName; + } + + public Type ContainerType + { + get { return _containerType; } + } + + public virtual bool ConvertEmptyStringToNull + { + get { return _convertEmptyStringToNull; } + set { _convertEmptyStringToNull = value; } + } + + public virtual string Description { get; set; } + + /// + /// Determines if the model needs to be consumed from the body. + /// + public virtual bool IsFromBody { get; set; } + + public virtual bool IsComplexType + { + get { return !ModelType.HasStringConverter(); } + } + + public bool IsNullableValueType + { + get { return ModelType.IsNullableValueType(); } + } + + public virtual bool IsReadOnly { get; set; } + + public object Model + { + get + { + if (_modelAccessor != null) + { + _model = _modelAccessor(); + _modelAccessor = null; + } + return _model; + } + set + { + _model = value; + _modelAccessor = null; + _properties = null; + _realModelType = null; + } + } + + public Type ModelType + { + get { return _modelType; } + } + + public virtual IEnumerable Properties + { + get + { + if (_properties == null) + { + _properties = Provider.GetMetadataForProperties(Model, RealModelType); + } + return _properties; + } + } + + public string PropertyName + { + get { return _propertyName; } + } + + protected IModelMetadataProvider Provider { get; set; } + + /// + /// Gets TModel if ModelType is Nullable(TModel), ModelType otherwise. + /// + internal Type RealModelType + { + get + { + if (_realModelType == null) + { + _realModelType = ModelType; + + // Don't call GetType() if the model is Nullable, because it will + // turn Nullable into T for non-null values + if (Model != null && !ModelType.IsNullableValueType()) + { + _realModelType = Model.GetType(); + } + } + + return _realModelType; + } + } + + internal EfficientTypePropertyKey CacheKey + { + get + { + if (_cacheKey == null) + { + _cacheKey = CreateCacheKey(ContainerType, ModelType, PropertyName); + } + + return _cacheKey; + } + set + { + _cacheKey = value; + } + } + + [SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate", Justification = "The method is a delegating helper to choose among multiple property values")] + public virtual string GetDisplayName() + { + return PropertyName ?? ModelType.Name; + } + + // TODO: Revive ModelValidators + //public virtual IEnumerable GetValidators(IEnumerable validatorProviders) + //{ + // if (validatorProviders == null) + // { + // throw Error.ArgumentNull("validatorProviders"); + // } + + // return validatorProviders.SelectMany(provider => provider.GetValidators(this, validatorProviders)); + //} + + private static EfficientTypePropertyKey CreateCacheKey(Type containerType, Type modelType, string propertyName) + { + // If metadata is for a property then containerType != null && propertyName != null + // If metadata is for a type then containerType == null && propertyName == null, so we have to use modelType for the cache key. + return new EfficientTypePropertyKey(containerType ?? modelType, propertyName); + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ReadOnlyAttribute.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ReadOnlyAttribute.cs new file mode 100644 index 0000000000..b4c4817823 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ReadOnlyAttribute.cs @@ -0,0 +1,10 @@ +#if K10 + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class ReadOnlyAttribute + { + public bool IsReadOnly { get; set; } + } +} +#endif \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ModelBindingContext.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ModelBindingContext.cs new file mode 100644 index 0000000000..2c4b5517fc --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ModelBindingContext.cs @@ -0,0 +1,113 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNet.Abstractions; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class ModelBindingContext + { + private string _modelName; + private ModelStateDictionary _modelState; + + public ModelBindingContext() + { + } + + // copies certain values that won't change between parent and child objects, + // e.g. ValueProvider, ModelState + public ModelBindingContext(ModelBindingContext bindingContext) + { + if (bindingContext != null) + { + ModelState = bindingContext.ModelState; + ValueProvider = bindingContext.ValueProvider; + MetadataProvider = bindingContext.MetadataProvider; + ModelBinder = bindingContext.ModelBinder; + HttpContext = bindingContext.HttpContext; + } + } + + public object Model + { + get + { + EnsureModelMetadata(); + return ModelMetadata.Model; + } + set + { + EnsureModelMetadata(); + ModelMetadata.Model = value; + } + } + + public ModelMetadata ModelMetadata { get; set; } + + public string ModelName + { + get + { + if (_modelName == null) + { + _modelName = String.Empty; + } + return _modelName; + } + set { _modelName = value; } + } + + [SuppressMessage("Microsoft.Usage", "CA2227:CollectionPropertiesShouldBeReadOnly", Justification = "This is writeable to support unit testing")] + public ModelStateDictionary ModelState + { + get + { + if (_modelState == null) + { + _modelState = new ModelStateDictionary(); + } + return _modelState; + } + set { _modelState = value; } + } + + public Type ModelType + { + get + { + EnsureModelMetadata(); + return ModelMetadata.ModelType; + } + } + + public bool FallbackToEmptyPrefix { get; set; } + + public HttpContext HttpContext { get; set; } + + public IValueProvider ValueProvider + { + get; + set; + } + + public IModelBinder ModelBinder + { + get; + set; + } + + public IModelMetadataProvider MetadataProvider + { + get; + set; + } + + private void EnsureModelMetadata() + { + if (ModelMetadata == null) + { + throw Error.InvalidOperation(Resources.ModelBindingContext_ModelMetadataMustBeSet); + } + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ModelError.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ModelError.cs new file mode 100644 index 0000000000..b0207491da --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ModelError.cs @@ -0,0 +1,33 @@ +using System; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class ModelError + { + public ModelError(Exception exception) + : this(exception, errorMessage: null) + { + } + + public ModelError(Exception exception, string errorMessage) + : this(errorMessage) + { + if (exception == null) + { + throw Error.ArgumentNull("exception"); + } + + Exception = exception; + } + + public ModelError(string errorMessage) + { + ErrorMessage = errorMessage ?? String.Empty; + } + + public Exception Exception { get; private set; } + + public string ErrorMessage { get; private set; } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ModelErrorCollection.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ModelErrorCollection.cs new file mode 100644 index 0000000000..9394dd4a77 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ModelErrorCollection.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.ObjectModel; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class ModelErrorCollection : Collection + { + public void Add(Exception exception) + { + Add(new ModelError(exception)); + } + + public void Add(string errorMessage) + { + Add(new ModelError(errorMessage)); + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ModelState.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ModelState.cs new file mode 100644 index 0000000000..45787f2d1f --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ModelState.cs @@ -0,0 +1,15 @@ + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class ModelState + { + private readonly ModelErrorCollection _errors = new ModelErrorCollection(); + + public ValueProviderResult Value { get; set; } + + public ModelErrorCollection Errors + { + get { return _errors; } + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ModelStateDictionary.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ModelStateDictionary.cs new file mode 100644 index 0000000000..e08fb0b27a --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ModelStateDictionary.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class ModelStateDictionary : Dictionary + { + private readonly Dictionary _innerDictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public ModelStateDictionary() + { + } + + public ModelStateDictionary(ModelStateDictionary dictionary) + { + if (dictionary == null) + { + throw Error.ArgumentNull("dictionary"); + } + + foreach (var entry in dictionary) + { + _innerDictionary.Add(entry.Key, entry.Value); + } + } + + public bool IsValid + { + get { return Values.All(modelState => modelState.Errors.Count == 0); } + } + + public void AddModelError(string key, Exception exception) + { + GetModelStateForKey(key).Errors.Add(exception); + } + + public void AddModelError(string key, string errorMessage) + { + GetModelStateForKey(key).Errors.Add(errorMessage); + } + + private ModelState GetModelStateForKey(string key) + { + if (key == null) + { + throw Error.ArgumentNull("key"); + } + + ModelState modelState; + if (!TryGetValue(key, out modelState)) + { + modelState = new ModelState(); + this[key] = modelState; + } + + return modelState; + } + public void SetModelValue(string key, ValueProviderResult value) + { + GetModelStateForKey(key).Value = value; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Resources.Designer.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Resources.Designer.cs index 9bd504bf3c..8a79f8c152 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Resources.Designer.cs @@ -60,6 +60,114 @@ namespace Microsoft.AspNet.Mvc.ModelBinding { } } + /// + /// Looks up a localized string similar to The argument '{0}' is null or empty.. + /// + internal static string ArgumentNullOrEmpty { + get { + return ResourceManager.GetString("ArgumentNullOrEmpty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The property {0}.{1} could not be found.. + /// + internal static string Common_PropertyNotFound { + get { + return ResourceManager.GetString("Common_PropertyNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The key is invalid JQuery syntax because it is missing a closing bracket.. + /// + internal static string JQuerySyntaxMissingClosingBracket { + get { + return ResourceManager.GetString("JQuerySyntaxMissingClosingBracket", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The value '{0}' is not valid for {1}.. + /// + internal static string ModelBinderConfig_ValueInvalid { + get { + return ResourceManager.GetString("ModelBinderConfig_ValueInvalid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A value is required.. + /// + internal static string ModelBinderConfig_ValueRequired { + get { + return ResourceManager.GetString("ModelBinderConfig_ValueRequired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The binding context has a null Model, but this binder requires a non-null model of type '{0}'.. + /// + internal static string ModelBinderUtil_ModelCannotBeNull { + get { + return ResourceManager.GetString("ModelBinderUtil_ModelCannotBeNull", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The binding context has a Model of type '{0}', but this binder can only operate on models of type '{1}'.. + /// + internal static string ModelBinderUtil_ModelInstanceIsWrong { + get { + return ResourceManager.GetString("ModelBinderUtil_ModelInstanceIsWrong", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The binding context cannot have a null ModelMetadata.. + /// + internal static string ModelBinderUtil_ModelMetadataCannotBeNull { + get { + return ResourceManager.GetString("ModelBinderUtil_ModelMetadataCannotBeNull", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The binding context has a ModelType of '{0}', but this binder can only operate on models of type '{1}'.. + /// + internal static string ModelBinderUtil_ModelTypeIsWrong { + get { + return ResourceManager.GetString("ModelBinderUtil_ModelTypeIsWrong", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The ModelMetadata property must be set before accessing this property.. + /// + internal static string ModelBindingContext_ModelMetadataMustBeSet { + get { + return ResourceManager.GetString("ModelBindingContext_ModelMetadataMustBeSet", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The parameter conversion from type '{0}' to type '{1}' failed. See the inner exception for more information.. + /// + internal static string ValueProviderResult_ConversionThrew { + get { + return ResourceManager.GetString("ValueProviderResult_ConversionThrew", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The parameter conversion from type '{0}' to type '{1}' failed because no type converter can convert between these types.. + /// + internal static string ValueProviderResult_NoConverterExists { + get { + return ResourceManager.GetString("ValueProviderResult_NoConverterExists", resourceCulture); + } + } + /// /// Looks up a localized string similar to The model item passed into the ViewData is of type '{0}', but this ViewData instance requires a model item of type '{1}'.. /// @@ -70,7 +178,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding { } /// - /// Looks up a localized string similar to The model item passed into the ViewData is null, but this ViewData instance requires a non-null model item of type '{0}'.. + /// Looks up a localized string similar to The model item passed is null, but this ViewData instance requires a non-null model item of type '{0}'.. /// internal static string ViewDataDictionary_ModelCannotBeNull { get { diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Resources.resx b/src/Microsoft.AspNet.Mvc.ModelBinding/Resources.resx index 2a5225f9e2..3b8cbc9202 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Resources.resx +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Resources.resx @@ -117,6 +117,42 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + The argument '{0}' is null or empty. + + + The property {0}.{1} could not be found. + + + The key is invalid JQuery syntax because it is missing a closing bracket. + + + The value '{0}' is not valid for {1}. + + + A value is required. + + + The binding context has a null Model, but this binder requires a non-null model of type '{0}'. + + + The binding context has a Model of type '{0}', but this binder can only operate on models of type '{1}'. + + + The binding context cannot have a null ModelMetadata. + + + The binding context has a ModelType of '{0}', but this binder can only operate on models of type '{1}'. + + + The ModelMetadata property must be set before accessing this property. + + + The parameter conversion from type '{0}' to type '{1}' failed. See the inner exception for more information. + + + The parameter conversion from type '{0}' to type '{1}' failed because no type converter can convert between these types. + The model item passed is null, but this ViewData instance requires a non-null model item of type '{0}'. diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/CompositeValueProvider.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/CompositeValueProvider.cs new file mode 100644 index 0000000000..1ee8243f00 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/CompositeValueProvider.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + [SuppressMessage("Microsoft.Naming", "CA1710:IdentifiersShouldHaveCorrectSuffix", Justification = "It is more fundamentally a value provider than a collection")] + public class CompositeValueProvider : Collection, IEnumerableValueProvider + { + public CompositeValueProvider() + : base() + { + } + + public CompositeValueProvider(IList list) + : base(list) + { + } + + public virtual bool ContainsPrefix(string prefix) + { + for (int i = 0; i < Count; i++) + { + if (this[i].ContainsPrefix(prefix)) + { + return true; + } + } + return false; + } + + public virtual ValueProviderResult GetValue(string key) + { + // Performance-sensitive + // Caching the count is faster for IList + int itemCount = Items.Count; + for (int i = 0; i < itemCount; i++) + { + IValueProvider vp = Items[i]; + ValueProviderResult result = vp.GetValue(key); + if (result != null) + { + return result; + } + } + return null; + } + + public virtual IDictionary GetKeysFromPrefix(string prefix) + { + foreach (IValueProvider vp in this) + { + IDictionary result = GetKeysFromPrefixFromProvider(vp, prefix); + if (result != null && result.Count > 0) + { + return result; + } + } + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + internal static IDictionary GetKeysFromPrefixFromProvider(IValueProvider provider, string prefix) + { + IEnumerableValueProvider enumeratedProvider = provider as IEnumerableValueProvider; + return (enumeratedProvider != null) ? enumeratedProvider.GetKeysFromPrefix(prefix) : null; + } + + protected override void InsertItem(int index, IValueProvider item) + { + if (item == null) + { + throw Error.ArgumentNull("item"); + } + base.InsertItem(index, item); + } + + protected override void SetItem(int index, IValueProvider item) + { + if (item == null) + { + throw Error.ArgumentNull("item"); + } + base.SetItem(index, item); + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/DictionaryBasedValueProvider.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/DictionaryBasedValueProvider.cs new file mode 100644 index 0000000000..a5af3a7908 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/DictionaryBasedValueProvider.cs @@ -0,0 +1,38 @@ + +using System.Collections.Generic; +using System.Globalization; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class DictionaryBasedValueProvider : IValueProvider + { + private readonly IDictionary _values; + + public DictionaryBasedValueProvider(IDictionary values) + { + _values = values; + } + + public bool ContainsPrefix(string key) + { + return _values.ContainsKey(key); + } + + public ValueProviderResult GetValue(string key) + { + if (key == null) + { + throw Error.ArgumentNull("key"); + } + + object value; + if (_values.TryGetValue(key, out value)) + { + return new ValueProviderResult(value, value.ToString(), CultureInfo.InvariantCulture); + } + + return null; + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/ElementalValueProvider.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/ElementalValueProvider.cs new file mode 100644 index 0000000000..5fcc4e6676 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/ElementalValueProvider.cs @@ -0,0 +1,35 @@ +using System; +using System.Globalization; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + // Represents a value provider that contains a single value. + internal sealed class ElementalValueProvider : IValueProvider + { + public ElementalValueProvider(string name, object rawValue, CultureInfo culture) + { + Name = name; + RawValue = rawValue; + Culture = culture; + } + + public CultureInfo Culture { get; private set; } + + public string Name { get; private set; } + + public object RawValue { get; private set; } + + public bool ContainsPrefix(string prefix) + { + return PrefixContainer.IsPrefixMatch(Name, prefix); + } + + public ValueProviderResult GetValue(string key) + { + return String.Equals(key, Name, StringComparison.OrdinalIgnoreCase) + ? new ValueProviderResult(RawValue, Convert.ToString(RawValue, Culture), Culture) + : null; + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/IEnumerableValueProvider.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/IEnumerableValueProvider.cs new file mode 100644 index 0000000000..88de41fde2 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/IEnumerableValueProvider.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public interface IEnumerableValueProvider : IValueProvider + { + IDictionary GetKeysFromPrefix(string prefix); + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/IValueProviderFactory.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/IValueProviderFactory.cs new file mode 100644 index 0000000000..539720d121 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/IValueProviderFactory.cs @@ -0,0 +1,13 @@ + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public interface IValueProviderFactory + { + /// + /// Get a value provider with values from the given . + /// + /// RequestContext that value provider will populate from + /// a value provider instance or null + IValueProvider GetValueProvider(RequestContext requestContext); + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/IValueProviders.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/IValueProviders.cs new file mode 100644 index 0000000000..36a0a238fb --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/IValueProviders.cs @@ -0,0 +1,23 @@ + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + /// + /// Defines the methods that are required for a value provider. + /// + public interface IValueProvider + { + /// + /// Determines whether the collection contains the specified prefix. + /// + /// The prefix to search for. + /// true if the collection contains the specified prefix; otherwise, false. + bool ContainsPrefix(string prefix); + + /// + /// Retrieves a value object using the specified key. + /// + /// The key of the value object to retrieve. + /// The value object for the specified key. If the exact key is not found, null. + ValueProviderResult GetValue(string key); + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/NameValuePairsValueProvider.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/NameValuePairsValueProvider.cs new file mode 100644 index 0000000000..b217141d07 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/NameValuePairsValueProvider.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.AspNet.Abstractions; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class NameValuePairsValueProvider : IEnumerableValueProvider + { + private readonly CultureInfo _culture; + private PrefixContainer _prefixContainer; + private readonly IReadableStringCollection _values; + + /// + /// Creates a NameValuePairsProvider wrapping an existing set of key value pairs. + /// + /// The key value pairs to wrap. + /// The culture to return with ValueProviderResult instances. + public NameValuePairsValueProvider(IReadableStringCollection values, CultureInfo culture) + { + if (values == null) + { + throw Error.ArgumentNull("values"); + } + + _values = values; + _culture = culture; + } + + public CultureInfo Culture + { + get + { + return _culture; + } + } + + private PrefixContainer PrefixContainer + { + get + { + if (_prefixContainer == null) + { + // Initialization race is OK providing data remains read-only and object identity is not significant + // TODO: Figure out if we can have IReadableStringCollection expose Keys, Count etc + + _prefixContainer = new PrefixContainer(_values.Select(v => v.Key).ToArray()); + } + return _prefixContainer; + } + } + + public virtual bool ContainsPrefix(string prefix) + { + return PrefixContainer.ContainsPrefix(prefix); + } + + public virtual IDictionary GetKeysFromPrefix(string prefix) + { + if (prefix == null) + { + throw Error.ArgumentNull("prefix"); + } + + return PrefixContainer.GetKeysFromPrefix(prefix); + } + + public virtual ValueProviderResult GetValue(string key) + { + if (key == null) + { + throw Error.ArgumentNull("key"); + } + + IList values = _values.GetValues(key); + if (values == null) + { + return null; + } + else if (values.Count == 1) + { + var value = (string)values[0]; + return new ValueProviderResult(value, value, _culture); + } + + return new ValueProviderResult(values, _values.Get(key), _culture); + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/QueryStringValueProvider.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/QueryStringValueProvider.cs new file mode 100644 index 0000000000..b14e05522e --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/QueryStringValueProvider.cs @@ -0,0 +1,13 @@ +using System.Globalization; +using Microsoft.AspNet.Abstractions; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class QueryStringValueProvider : NameValuePairsValueProvider + { + public QueryStringValueProvider(HttpContext context, CultureInfo culture) + : base(context.Request.Query, culture) + { + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/QueryStringValueProviderFactory.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/QueryStringValueProviderFactory.cs new file mode 100644 index 0000000000..c42249d7c4 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/QueryStringValueProviderFactory.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Globalization; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class QueryStringValueProviderFactory : IValueProviderFactory + { + private static readonly object _cacheKey = new object(); + + public IValueProvider GetValueProvider(RequestContext requestContext) + { + if (requestContext == null) + { + throw Error.ArgumentNull("requestContext"); + } + + // Process the query string once-per request. + IDictionary storage = requestContext.HttpContext.Items; + object value; + if (!storage.TryGetValue(_cacheKey, out value)) + { + var provider = new QueryStringValueProvider(requestContext.HttpContext, CultureInfo.InvariantCulture); + storage[_cacheKey] = provider; + return provider; + } + + return (QueryStringValueProvider)value; + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/RouteValueValueProviderFactory.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/RouteValueValueProviderFactory.cs new file mode 100644 index 0000000000..fb7c1aa0b3 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/RouteValueValueProviderFactory.cs @@ -0,0 +1,11 @@ + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class RouteValueValueProviderFactory : IValueProviderFactory + { + public IValueProvider GetValueProvider(RequestContext requestContext) + { + return new DictionaryBasedValueProvider(requestContext.RouteValues); + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/ValueProviderResult.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/ValueProviderResult.cs new file mode 100644 index 0000000000..57d75ac5c5 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/ValueProviderResult.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections; +using System.Globalization; +using System.Reflection; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class ValueProviderResult + { + private static readonly CultureInfo _staticCulture = CultureInfo.InvariantCulture; + private CultureInfo _instanceCulture; + + // default constructor so that subclassed types can set the properties themselves + protected ValueProviderResult() + { + } + + public ValueProviderResult(object rawValue, string attemptedValue, CultureInfo culture) + { + RawValue = rawValue; + AttemptedValue = attemptedValue; + Culture = culture; + } + + public string AttemptedValue { get; protected set; } + + public CultureInfo Culture + { + get + { + if (_instanceCulture == null) + { + _instanceCulture = _staticCulture; + } + return _instanceCulture; + } + protected set { _instanceCulture = value; } + } + + public object RawValue { get; protected set; } + + public object ConvertTo(Type type) + { + return ConvertTo(type, culture: null); + } + + public virtual object ConvertTo(Type type, CultureInfo culture) + { + if (type == null) + { + throw Error.ArgumentNull("type"); + } + + TypeInfo typeInfo = type.GetTypeInfo(); + object value = RawValue; + if (value == null) + { + // treat null route parameters as though they were the default value for the type + return typeInfo.IsValueType ? Activator.CreateInstance(type) : null; + } + + if (value.GetType().GetTypeInfo().IsAssignableFrom(typeInfo)) + { + return value; + } + + CultureInfo cultureToUse = culture ?? Culture; + return UnwrapPossibleArrayType(cultureToUse, value, type); + } + + private static object ConvertSimpleType(CultureInfo culture, object value, TypeInfo destinationType) + { + if (value == null || value.GetType().GetTypeInfo().IsAssignableFrom(destinationType)) + { + return value; + } + + // if this is a user-input value but the user didn't type anything, return no value + string valueAsString = value as string; + + if (valueAsString != null && String.IsNullOrWhiteSpace(valueAsString)) + { + return null; + } + + if (destinationType == typeof(int).GetTypeInfo()) + { + return Convert.ToInt32(value); + } + else if (destinationType == typeof(bool).GetTypeInfo()) + { + return Boolean.Parse(value.ToString()); + } + else if (destinationType == typeof(string).GetTypeInfo()) + { + return Convert.ToString(value); + } + throw Error.InvalidOperation(Resources.ValueProviderResult_NoConverterExists, value.GetType(), destinationType); + + // TODO: Revive once we get TypeConverters + //TypeConverter converter = TypeDescriptor.GetConverter(destinationType); + //bool canConvertFrom = converter.CanConvertFrom(value.GetType()); + //if (!canConvertFrom) + //{ + // converter = TypeDescriptor.GetConverter(value.GetType()); + //} + //if (!(canConvertFrom || converter.CanConvertTo(destinationType))) + //{ + // // EnumConverter cannot convert integer, so we verify manually + // if (destinationType.GetTypeInfo().IsEnum && value is int) + // { + // return Enum.ToObject(destinationType, (int)value); + // } + + // // In case of a Nullable object, we try again with its underlying type. + // Type underlyingType = Nullable.GetUnderlyingType(destinationType); + // if (underlyingType != null) + // { + // return ConvertSimpleType(culture, value, underlyingType); + // } + + // throw Error.InvalidOperation(Resources.ValueProviderResult_NoConverterExists, value.GetType(), destinationType); + //} + + //try + //{ + // return canConvertFrom + // ? converter.ConvertFrom(null, culture, value) + // : converter.ConvertTo(null, culture, value, destinationType); + //} + //catch (Exception ex) + //{ + // throw Error.InvalidOperation(ex, Resources.ValueProviderResult_ConversionThrew, value.GetType(), destinationType); + //} + } + + private static object UnwrapPossibleArrayType(CultureInfo culture, object value, Type destinationType) + { + // array conversion results in four cases, as below + Array valueAsArray = value as Array; + if (destinationType.IsArray) + { + Type destinationElementType = destinationType.GetElementType(); + TypeInfo destElementTypeInfo = destinationElementType.GetTypeInfo(); + if (valueAsArray != null) + { + // case 1: both destination + source type are arrays, so convert each element + IList converted = Array.CreateInstance(destinationElementType, valueAsArray.Length); + for (int i = 0; i < valueAsArray.Length; i++) + { + converted[i] = ConvertSimpleType(culture, valueAsArray.GetValue(i), destElementTypeInfo); + } + return converted; + } + else + { + // case 2: destination type is array but source is single element, so wrap element in array + convert + object element = ConvertSimpleType(culture, value, destElementTypeInfo); + IList converted = Array.CreateInstance(destinationElementType, 1); + converted[0] = element; + return converted; + } + } + else if (valueAsArray != null) + { + // case 3: destination type is single element but source is array, so extract first element + convert + if (valueAsArray.Length > 0) + { + value = valueAsArray.GetValue(0); + return ConvertSimpleType(culture, value, destinationType.GetTypeInfo()); + } + else + { + // case 3(a): source is empty array, so can't perform conversion + return null; + } + } + + // case 4: both destination + source type are single elements, so convert + return ConvertSimpleType(culture, value, destinationType.GetTypeInfo()); + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ViewDataOfTModel.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ViewDataOfTModel.cs index ff1c07054a..108a2aaf5b 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/ViewDataOfTModel.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ViewDataOfTModel.cs @@ -25,7 +25,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding protected override void SetModel(object value) { // IsCompatibleObject verifies if the value is either an instance of TModel or if value happens to be null that TModel is nullable type. - bool castWillSucceed = value.IsCompatibleObject(); + bool castWillSucceed = typeof(TModel).IsCompatibleWith(value); if (castWillSucceed) { diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/project.json b/src/Microsoft.AspNet.Mvc.ModelBinding/project.json index 049faf293e..960a9c7d5d 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/project.json +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/project.json @@ -2,7 +2,8 @@ "version" : "0.1-alpha-*", "dependencies": { "Microsoft.AspNet.DependencyInjection" : "0.1-alpha-*", - "Microsoft.AspNet.Abstractions": "0.1-alpha-*" + "Microsoft.AspNet.Abstractions": "0.1-alpha-*", + "Newtonsoft.Json": "5.0.8" }, "configurations": { "net45": { }, diff --git a/src/Microsoft.AspNet.Mvc/DefaultActionSelector.cs b/src/Microsoft.AspNet.Mvc/DefaultActionSelector.cs index f5f0ac7c04..f60bb2ca4c 100644 --- a/src/Microsoft.AspNet.Mvc/DefaultActionSelector.cs +++ b/src/Microsoft.AspNet.Mvc/DefaultActionSelector.cs @@ -54,7 +54,7 @@ namespace Microsoft.AspNet.Mvc protected virtual ActionDescriptor SelectBestCandidate(RequestContext context, List candidates) { - var valueProviders = _valueProviderFactory.Select(vpf => vpf.CreateValueProvider(context)).ToArray(); + var valueProviders = _valueProviderFactory.Select(vpf => vpf.GetValueProvider(context)).ToArray(); var applicableCandiates = new List(); foreach (var action in candidates) diff --git a/src/Microsoft.AspNet.Mvc/ModelBinding/IValueProviderFactory.cs b/src/Microsoft.AspNet.Mvc/ModelBinding/IValueProviderFactory.cs deleted file mode 100644 index b9e4451843..0000000000 --- a/src/Microsoft.AspNet.Mvc/ModelBinding/IValueProviderFactory.cs +++ /dev/null @@ -1,9 +0,0 @@ - - -namespace Microsoft.AspNet.Mvc.ModelBinding -{ - public interface IValueProviderFactory - { - IValueProvider CreateValueProvider(RequestContext context); - } -} diff --git a/src/Microsoft.AspNet.Mvc/ModelBinding/QueryStringValueProviderFactory.cs b/src/Microsoft.AspNet.Mvc/ModelBinding/QueryStringValueProviderFactory.cs deleted file mode 100644 index a9d88365d1..0000000000 --- a/src/Microsoft.AspNet.Mvc/ModelBinding/QueryStringValueProviderFactory.cs +++ /dev/null @@ -1,29 +0,0 @@ - -using Microsoft.AspNet.Abstractions; - -namespace Microsoft.AspNet.Mvc.ModelBinding -{ - // This is a temporary placeholder - public class QueryStringValueProviderFactory : IValueProviderFactory - { - public IValueProvider CreateValueProvider(RequestContext context) - { - return new QueryStringValueProvider(context.HttpContext.Request.Query); - } - - private class QueryStringValueProvider : IValueProvider - { - private readonly IReadableStringCollection _values; - - public QueryStringValueProvider(IReadableStringCollection values) - { - _values = values; - } - - public bool ContainsPrefix(string key) - { - return _values.Get(key) != null; - } - } - } -} diff --git a/src/Microsoft.AspNet.Mvc/ModelBinding/RouteValueValueProviderFactory.cs b/src/Microsoft.AspNet.Mvc/ModelBinding/RouteValueValueProviderFactory.cs deleted file mode 100644 index 7b56b42553..0000000000 --- a/src/Microsoft.AspNet.Mvc/ModelBinding/RouteValueValueProviderFactory.cs +++ /dev/null @@ -1,12 +0,0 @@ - -namespace Microsoft.AspNet.Mvc.ModelBinding -{ - // This is a temporary placeholder - public class RouteValueValueProviderFactory : IValueProviderFactory - { - public IValueProvider CreateValueProvider(RequestContext context) - { - return new ValueProvider(context.RouteValues); - } - } -} diff --git a/src/Microsoft.AspNet.Mvc/ModelBinding/ValueProvider.cs b/src/Microsoft.AspNet.Mvc/ModelBinding/ValueProvider.cs deleted file mode 100644 index a7f7662e19..0000000000 --- a/src/Microsoft.AspNet.Mvc/ModelBinding/ValueProvider.cs +++ /dev/null @@ -1,21 +0,0 @@ - -using System.Collections.Generic; - -namespace Microsoft.AspNet.Mvc.ModelBinding -{ - // This is a temporary placeholder - public class ValueProvider : IValueProvider - { - private readonly IDictionary _values; - - public ValueProvider(IDictionary values) - { - _values = values; - } - - public bool ContainsPrefix(string key) - { - return _values.ContainsKey(key); - } - } -} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/ArrayModelBinderTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/ArrayModelBinderTest.cs new file mode 100644 index 0000000000..7c4afdf35c --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/ArrayModelBinderTest.cs @@ -0,0 +1,95 @@ +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test +{ + public class ArrayModelBinderTest + { + [Fact] + public void BindModel() + { + // Arrange + var valueProvider = new SimpleHttpValueProvider + { + { "someName[0]", "42" }, + { "someName[1]", "84" } + }; + ModelBindingContext bindingContext = GetBindingContext(valueProvider); + var binder = new ArrayModelBinder(); + + // Act + bool retVal = binder.BindModel(bindingContext); + + // Assert + Assert.True(retVal); + + int[] array = bindingContext.Model as int[]; + Assert.Equal(new[] { 42, 84 }, array); + } + + [Fact] + public void GetBinder_ValueProviderDoesNotContainPrefix_ReturnsNull() + { + // Arrange + ModelBindingContext bindingContext = GetBindingContext(new SimpleHttpValueProvider()); + var binder = new ArrayModelBinder(); + + // Act + bool bound = binder.BindModel(bindingContext); + + // Assert + Assert.False(bound); + } + + [Fact] + public void GetBinder_ModelMetadataReturnsReadOnly_ReturnsNull() + { + // Arrange + var valueProvider = new SimpleHttpValueProvider + { + { "foo[0]", "42" }, + }; + ModelBindingContext bindingContext = GetBindingContext(valueProvider); + bindingContext.ModelMetadata.IsReadOnly = true; + var binder = new ArrayModelBinder(); + + // Act + bool bound = binder.BindModel(bindingContext); + + // Assert + Assert.False(bound); + } + + private static IModelBinder CreateIntBinder() + { + var mockIntBinder = new Mock(); + mockIntBinder + .Setup(o => o.BindModel(It.IsAny())) + .Returns((ModelBindingContext mbc) => + { + var value = mbc.ValueProvider.GetValue(mbc.ModelName); + if (value != null) + { + mbc.Model = value.ConvertTo(mbc.ModelType); + return true; + } + return false; + }); + return mockIntBinder.Object; + } + + private static ModelBindingContext GetBindingContext(IValueProvider valueProvider) + { + var metadataProvider = new EmptyModelMetadataProvider(); + ModelBindingContext bindingContext = new ModelBindingContext + { + ModelMetadata = metadataProvider.GetMetadataForType(null, typeof(int[])), + ModelName = "someName", + ValueProvider = valueProvider, + ModelBinder = CreateIntBinder(), + MetadataProvider = metadataProvider + }; + return bindingContext; + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/CollectionModelBinderTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/CollectionModelBinderTest.cs new file mode 100644 index 0000000000..bcaecbf3d6 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/CollectionModelBinderTest.cs @@ -0,0 +1,180 @@ +using System.Collections.Generic; +using System.Globalization; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test +{ + public class CollectionModelBinderTest + { + [Fact] + public void BindComplexCollectionFromIndexes_FiniteIndexes() + { + // Arrange + var valueProvider = new SimpleHttpValueProvider + { + { "someName[foo]", "42" }, + { "someName[baz]", "200" } + }; + var bindingContext = GetModelBindingContext(valueProvider); + var binder = new CollectionModelBinder(); + + // Act + List boundCollection = binder.BindComplexCollectionFromIndexes(bindingContext, new[] { "foo", "bar", "baz" }); + + // Assert + Assert.Equal(new[] { 42, 0, 200 }, boundCollection.ToArray()); + // TODO: Validation + // Assert.Equal(new[] { "someName[foo]", "someName[baz]" }, bindingContext.ValidationNode.ChildNodes.Select(o => o.ModelStateKey).ToArray()); + } + + [Fact] + public void BindComplexCollectionFromIndexes_InfiniteIndexes() + { + // Arrange + var valueProvider = new SimpleHttpValueProvider + { + { "someName[0]", "42" }, + { "someName[1]", "100" }, + { "someName[3]", "400" } + }; + var bindingContext = GetModelBindingContext(valueProvider); + var binder = new CollectionModelBinder(); + + // Act + List boundCollection = binder.BindComplexCollectionFromIndexes(bindingContext, indexNames: null); + + // Assert + Assert.Equal(new[] { 42, 100 }, boundCollection.ToArray()); + // TODO: Validation + // Assert.Equal(new[] { "someName[0]", "someName[1]" }, bindingContext.ValidationNode.ChildNodes.Select(o => o.ModelStateKey).ToArray()); + } + + [Fact] + public void BindModel_ComplexCollection() + { + // Arrange + var valueProvider = new SimpleHttpValueProvider + { + { "someName.index", new[] { "foo", "bar", "baz" } }, + { "someName[foo]", "42" }, + { "someName[bar]", "100" }, + { "someName[baz]", "200" } + }; + var bindingContext = GetModelBindingContext(valueProvider); + var binder = new CollectionModelBinder(); + + // Act + bool retVal = binder.BindModel(bindingContext); + + // Assert + Assert.Equal(new[] { 42, 100, 200 }, ((List)bindingContext.Model).ToArray()); + } + + [Fact] + public void BindModel_SimpleCollection() + { + // Arrange + var valueProvider = new SimpleHttpValueProvider + { + { "someName", new[] { "42", "100", "200" } } + }; + var bindingContext = GetModelBindingContext(valueProvider); + var binder = new CollectionModelBinder(); + + // Act + bool retVal = binder.BindModel(bindingContext); + + // Assert + Assert.True(retVal); + Assert.Equal(new[] { 42, 100, 200 }, ((List)bindingContext.Model).ToArray()); + } + + [Fact] + public void BindSimpleCollection_RawValueIsEmptyCollection_ReturnsEmptyList() + { + // Arrange + var binder = new CollectionModelBinder(); + + // Act + List boundCollection = binder.BindSimpleCollection(bindingContext: null, rawValue: new object[0], culture: null); + + // Assert + Assert.NotNull(boundCollection); + Assert.Empty(boundCollection); + } + + [Fact] + public void BindSimpleCollection_RawValueIsNull_ReturnsNull() + { + // Arrange + var binder = new CollectionModelBinder(); + + // Act + List boundCollection = binder.BindSimpleCollection(bindingContext: null, rawValue: null, culture: null); + + // Assert + Assert.Null(boundCollection); + } + + [Fact] + public void BindSimpleCollection_SubBindingSucceeds() + { + // Arrange + var culture = CultureInfo.GetCultureInfo("fr-FR"); + var bindingContext = GetModelBindingContext(new SimpleHttpValueProvider()); + + // TODO: Validation + // ModelValidationNode childValidationNode = null; + Mock.Get(bindingContext.ModelBinder) + .Setup(o => o.BindModel(It.IsAny())) + .Returns((ModelBindingContext mbc) => + { + Assert.Equal("someName", mbc.ModelName); + // childValidationNode = mbc.ValidationNode; + mbc.Model = 42; + return true; + }); + var modelBinder = new CollectionModelBinder(); + + // Act + List boundCollection = modelBinder.BindSimpleCollection(bindingContext, new int[1], culture); + + // Assert + Assert.Equal(new[] { 42 }, boundCollection.ToArray()); + // Assert.Equal(new[] { childValidationNode }, bindingContext.ValidationNode.ChildNodes.ToArray()); + } + + private static ModelBindingContext GetModelBindingContext(IValueProvider valueProvider) + { + var metadataProvider = new EmptyModelMetadataProvider(); + var bindingContext = new ModelBindingContext + { + ModelMetadata = metadataProvider.GetMetadataForType(null, typeof(int)), + ModelName = "someName", + ValueProvider = valueProvider, + ModelBinder = CreateIntBinder(), + MetadataProvider = metadataProvider + }; + return bindingContext; + } + + private static IModelBinder CreateIntBinder() + { + Mock mockIntBinder = new Mock(); + mockIntBinder + .Setup(o => o.BindModel(It.IsAny())) + .Returns((ModelBindingContext mbc) => + { + var value = mbc.ValueProvider.GetValue(mbc.ModelName); + if (value != null) + { + mbc.Model = value.ConvertTo(mbc.ModelType); + return true; + } + return false; + }); + return mockIntBinder.Object; + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/ComplexModelDtoResultTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/ComplexModelDtoResultTest.cs new file mode 100644 index 0000000000..7c7c78ee8a --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/ComplexModelDtoResultTest.cs @@ -0,0 +1,38 @@ +using Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test +{ + public class ComplexModelDtoResultTest + { + [Fact] + public void Constructor_ThrowsIfValidationNodeIsNull() + { + // Act & assert + ExceptionAssert.ThrowsArgumentNull( + () => new ComplexModelDtoResult("some string"), + "validationNode"); + } + + // TODO: Validation + //[Fact] + //public void Constructor_SetsProperties() + //{ + // // Arrange + // ModelValidationNode validationNode = GetValidationNode(); + + // // Act + // ComplexModelDtoResult result = new ComplexModelDtoResult("some string", validationNode); + + // // Assert + // Assert.Equal("some string", result.Model); + // Assert.Equal(validationNode, result.ValidationNode); + //} + + //private static ModelValidationNode GetValidationNode() + //{ + // EmptyModelMetadataProvider provider = new EmptyModelMetadataProvider(); + // ModelMetadata metadata = provider.GetMetadataForType(null, typeof(object)); + // return new ModelValidationNode(metadata, "someKey"); + //} + } +} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/ComplexModelDtoTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/ComplexModelDtoTest.cs new file mode 100644 index 0000000000..511d5bed41 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/ComplexModelDtoTest.cs @@ -0,0 +1,50 @@ +using System.Linq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test +{ + public class ComplexModelDtoTest + { + [Fact] + public void ConstructorThrowsIfModelMetadataIsNull() + { + // Act & assert + ExceptionAssert.ThrowsArgumentNull( + () => new ComplexModelDto(null, Enumerable.Empty()), + "modelMetadata"); + } + + [Fact] + public void ConstructorThrowsIfPropertyMetadataIsNull() + { + // Arrange + ModelMetadata modelMetadata = GetModelMetadata(); + + // Act & assert + ExceptionAssert.ThrowsArgumentNull( + () => new ComplexModelDto(modelMetadata, null), + "propertyMetadata"); + } + + [Fact] + public void ConstructorSetsProperties() + { + // Arrange + ModelMetadata modelMetadata = GetModelMetadata(); + ModelMetadata[] propertyMetadata = new ModelMetadata[0]; + + // Act + ComplexModelDto dto = new ComplexModelDto(modelMetadata, propertyMetadata); + + // Assert + Assert.Equal(modelMetadata, dto.ModelMetadata); + Assert.Equal(propertyMetadata, dto.PropertyMetadata.ToArray()); + Assert.Empty(dto.Results); + } + + private static ModelMetadata GetModelMetadata() + { + return new ModelMetadata(new EmptyModelMetadataProvider(), typeof(object), null, typeof(object), "PropertyName"); + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/CompositeModelBinderTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/CompositeModelBinderTest.cs new file mode 100644 index 0000000000..d6a09d58ec --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/CompositeModelBinderTest.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test +{ + public class CompositeModelBinderTest + { + [Fact] + public void BindModel_SuccessfulBind_RunsValidationAndReturnsModel() + { + // Arrange + bool validationCalled = false; + + ModelBindingContext bindingContext = new ModelBindingContext + { + FallbackToEmptyPrefix = true, + ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(null, typeof(int)), + ModelName = "someName", + //ModelState = executionContext.Controller.ViewData.ModelState, + //PropertyFilter = _ => true, + ValueProvider = new SimpleValueProvider + { + { "someName", "dummyValue" } + } + }; + + Mock mockIntBinder = new Mock(); + mockIntBinder + .Setup(o => o.BindModel(It.IsAny())) + .Returns( + delegate(ModelBindingContext context) + { + Assert.Same(bindingContext.ModelMetadata, context.ModelMetadata); + Assert.Equal("someName", context.ModelName); + Assert.Same(bindingContext.ValueProvider, context.ValueProvider); + + context.Model = 42; + // TODO: Validation + // mbc.ValidationNode.Validating += delegate { validationCalled = true; }; + return true; + }); + + //binderProviders.RegisterBinderForType(typeof(int), mockIntBinder.Object, false /* suppressPrefixCheck */); + IModelBinder shimBinder = new CompositeModelBinder(mockIntBinder.Object); + + // Act + bool isBound = shimBinder.BindModel(bindingContext); + + // Assert + Assert.True(isBound); + Assert.Equal(42, bindingContext.Model); + // TODO: Validation + // Assert.True(validationCalled); + Assert.True(bindingContext.ModelState.IsValid); + } + + [Fact] + public void BindModel_SuccessfulBind_ComplexTypeFallback_RunsValidationAndReturnsModel() + { + // Arrange + bool validationCalled = false; + List expectedModel = new List { 1, 2, 3, 4, 5 }; + + ModelBindingContext bindingContext = new ModelBindingContext + { + FallbackToEmptyPrefix = true, + ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(null, typeof(List)), + ModelName = "someName", + //ModelState = executionContext.Controller.ViewData.ModelState, + //PropertyFilter = _ => true, + ValueProvider = new SimpleValueProvider + { + { "someOtherName", "dummyValue" } + } + }; + + Mock mockIntBinder = new Mock(); + mockIntBinder + .Setup(o => o.BindModel(It.IsAny())) + .Returns( + delegate(ModelBindingContext mbc) + { + if (!String.IsNullOrEmpty(mbc.ModelName)) + { + return false; + } + + Assert.Same(bindingContext.ModelMetadata, mbc.ModelMetadata); + Assert.Equal("", mbc.ModelName); + Assert.Same(bindingContext.ValueProvider, mbc.ValueProvider); + + mbc.Model = expectedModel; + // TODO: Validation + // mbc.ValidationNode.Validating += delegate { validationCalled = true; }; + return true; + }); + + //binderProviders.RegisterBinderForType(typeof(List), mockIntBinder.Object, false /* suppressPrefixCheck */); + IModelBinder shimBinder = new CompositeModelBinder(mockIntBinder.Object); + + // Act + bool isBound = shimBinder.BindModel(bindingContext); + + // Assert + Assert.True(isBound); + Assert.Equal(expectedModel, bindingContext.Model); + // TODO: Validation + // Assert.True(validationCalled); + // Assert.True(bindingContext.ModelState.IsValid); + } + + [Fact] + public void BindModel_UnsuccessfulBind_BinderFails_ReturnsNull() + { + // Arrange + Mock mockListBinder = new Mock(); + mockListBinder.Setup(o => o.BindModel(It.IsAny())) + .Returns(false) + .Verifiable(); + + IModelBinder shimBinder = (IModelBinder)mockListBinder.Object; + + ModelBindingContext bindingContext = new ModelBindingContext + { + FallbackToEmptyPrefix = false, + ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(null, typeof(List)), + }; + + // Act + bool isBound = shimBinder.BindModel(bindingContext); + + // Assert + Assert.False(isBound); + Assert.Null(bindingContext.Model); + // TODO: Validation + // Assert.True(bindingContext.ModelState.IsValid); + mockListBinder.Verify(); + } + + [Fact] + public void BindModel_UnsuccessfulBind_SimpleTypeNoFallback_ReturnsNull() + { + // Arrange + var innerBinder = Mock.Of(); + CompositeModelBinder shimBinder = new CompositeModelBinder(innerBinder); + + ModelBindingContext bindingContext = new ModelBindingContext + { + FallbackToEmptyPrefix = true, + ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(null, typeof(int)), + //ModelState = executionContext.Controller.ViewData.ModelState + }; + + // Act + bool isBound = shimBinder.BindModel(bindingContext); + + // Assert + Assert.False(isBound); + Assert.Null(bindingContext.Model); + } + + private class SimpleModel + { + public string FirstName { get; set; } + public string LastName { get; set; } + } + + private class SimpleValueProvider : Dictionary, IValueProvider + { + private readonly CultureInfo _culture; + + public SimpleValueProvider() + : this(null) + { + } + + public SimpleValueProvider(CultureInfo culture) + : base(StringComparer.OrdinalIgnoreCase) + { + _culture = culture ?? CultureInfo.InvariantCulture; + } + + // copied from ValueProviderUtil + public bool ContainsPrefix(string prefix) + { + foreach (string key in Keys) + { + if (key != null) + { + if (prefix.Length == 0) + { + return true; // shortcut - non-null key matches empty prefix + } + + if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + if (key.Length == prefix.Length) + { + return true; // exact match + } + else + { + switch (key[prefix.Length]) + { + case '.': // known separator characters + case '[': + return true; + } + } + } + } + } + + return false; // nothing found + } + + public ValueProviderResult GetValue(string key) + { + object rawValue; + if (TryGetValue(key, out rawValue)) + { + return new ValueProviderResult(rawValue, Convert.ToString(rawValue, _culture), _culture); + } + else + { + // value not found + return null; + } + } + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/DictionaryModelBinderTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/DictionaryModelBinderTest.cs new file mode 100644 index 0000000000..6e6cf879b7 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/DictionaryModelBinderTest.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test +{ + public class DictionaryModelBinderTest + { + [Fact] + public void BindModel() + { + // Arrange + var metadataProvider = new EmptyModelMetadataProvider(); + ModelBindingContext bindingContext = new ModelBindingContext + { + ModelMetadata = metadataProvider.GetMetadataForType(null, typeof(IDictionary)), + ModelName = "someName", + ValueProvider = new SimpleHttpValueProvider + { + { "someName[0]", new KeyValuePair(42, "forty-two") }, + { "someName[1]", new KeyValuePair(84, "eighty-four") } + }, + ModelBinder = CreateKvpBinder(), + MetadataProvider = metadataProvider + }; + var binder = new DictionaryModelBinder(); + + // Act + bool retVal = binder.BindModel(bindingContext); + + // Assert + Assert.True(retVal); + + var dictionary = Assert.IsAssignableFrom>(bindingContext.Model); + Assert.NotNull(dictionary); + Assert.Equal(2, dictionary.Count); + Assert.Equal("forty-two", dictionary[42]); + Assert.Equal("eighty-four", dictionary[84]); + } + + private static IModelBinder CreateKvpBinder() + { + Mock mockKvpBinder = new Mock(); + mockKvpBinder + .Setup(o => o.BindModel(It.IsAny())) + .Returns((ModelBindingContext mbc) => + { + var value = mbc.ValueProvider.GetValue(mbc.ModelName); + if (value != null) + { + mbc.Model = value.ConvertTo(mbc.ModelType); + return true; + } + return false; + }); + return mockKvpBinder.Object; + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/KeyValuePairModelBinderTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/KeyValuePairModelBinderTest.cs new file mode 100644 index 0000000000..0d3c8c1723 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/KeyValuePairModelBinderTest.cs @@ -0,0 +1,161 @@ +using System.Collections.Generic; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test +{ + public class KeyValuePairModelBinderTest + { + [Fact] + public void BindModel_MissingKey_ReturnsFalse() + { + // Arrange + var valueProvider = new SimpleHttpValueProvider(); + ModelBindingContext bindingContext = GetBindingContext(valueProvider, Mock.Of()); + var binder = new KeyValuePairModelBinder(); + + // Act + bool retVal = binder.BindModel(bindingContext); + + // Assert + Assert.False(retVal); + Assert.Null(bindingContext.Model); + // TODO: Validation + // Assert.Empty(bindingContext.ValidationNode.ChildNodes); + } + + [Fact] + public void BindModel_MissingValue_ReturnsTrue() + { + // Arrange + var valueProvider = new SimpleHttpValueProvider(); + ModelBindingContext bindingContext = GetBindingContext(valueProvider); + var binder = new KeyValuePairModelBinder(); + + // Act + bool retVal = binder.BindModel(bindingContext); + + // Assert + Assert.True(retVal); + Assert.Null(bindingContext.Model); + // TODO: Validation + // Assert.Equal(new[] { "someName.key" }, bindingContext.ValidationNode.ChildNodes.Select(n => n.ModelStateKey).ToArray()); + } + + [Fact] + public void BindModel_SubBindingSucceeds() + { + // Arrange + IModelBinder innerBinder = new CompositeModelBinder(CreateStringBinder(), CreateIntBinder()); + var valueProvider = new SimpleHttpValueProvider(); + ModelBindingContext bindingContext = GetBindingContext(valueProvider, innerBinder); + + var binder = new KeyValuePairModelBinder(); + + // Act + bool retVal = binder.BindModel(bindingContext); + + // Assert + Assert.True(retVal); + Assert.Equal(new KeyValuePair(42, "some-value"), bindingContext.Model); + // TODO: Validation + // Assert.Equal(new[] { "someName.key", "someName.value" }, bindingContext.ValidationNode.ChildNodes.Select(n => n.ModelStateKey).ToArray()); + } + + [Fact] + public void TryBindStrongModel_BinderExists_BinderReturnsCorrectlyTypedObject_ReturnsTrue() + { + // Arrange + ModelBindingContext bindingContext = GetBindingContext(new SimpleHttpValueProvider()); + var binder = new KeyValuePairModelBinder(); + + // Act + int model; + bool retVal = binder.TryBindStrongModel(bindingContext, "key", out model); + + // Assert + Assert.True(retVal); + Assert.Equal(42, model); + // TODO: Validation + // Assert.Single(bindingContext.ValidationNode.ChildNodes); + Assert.Empty(bindingContext.ModelState); + } + + [Fact] + public void TryBindStrongModel_BinderExists_BinderReturnsIncorrectlyTypedObject_ReturnsTrue() + { + // Arrange + var innerBinder = new Mock(); + innerBinder + .Setup(o => o.BindModel(It.IsAny())) + .Returns((ModelBindingContext mbc) => + { + Assert.Equal("someName.key", mbc.ModelName); + return true; + }); + var bindingContext = GetBindingContext(new SimpleHttpValueProvider(), innerBinder.Object); + + + var binder = new KeyValuePairModelBinder(); + + // Act + int model; + bool retVal = binder.TryBindStrongModel(bindingContext, "key", out model); + + // Assert + Assert.True(retVal); + Assert.Equal(default(int), model); + // TODO: Validation + // Assert.Single(bindingContext.ValidationNode.ChildNodes); + Assert.Empty(bindingContext.ModelState); + } + + private static ModelBindingContext GetBindingContext(IValueProvider valueProvider, IModelBinder innerBinder = null) + { + var metataProvider = new EmptyModelMetadataProvider(); + ModelBindingContext bindingContext = new ModelBindingContext + { + ModelMetadata = metataProvider.GetMetadataForType(null, typeof(KeyValuePair)), + ModelName = "someName", + ValueProvider = valueProvider, + ModelBinder = innerBinder ?? CreateIntBinder(), + MetadataProvider = metataProvider + }; + return bindingContext; + } + + private static IModelBinder CreateIntBinder() + { + Mock mockIntBinder = new Mock(); + mockIntBinder + .Setup(o => o.BindModel(It.IsAny())) + .Returns((ModelBindingContext mbc) => + { + if (mbc.ModelType == typeof(int)) + { + mbc.Model = 42; + return true; + } + return false; + }); + return mockIntBinder.Object; + } + + private static IModelBinder CreateStringBinder() + { + Mock mockIntBinder = new Mock(); + mockIntBinder + .Setup(o => o.BindModel(It.IsAny())) + .Returns((ModelBindingContext mbc) => + { + if (mbc.ModelType == typeof(string)) + { + mbc.Model = "some-value"; + return true; + } + return false; + }); + return mockIntBinder.Object; + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/ModelBindingContextTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/ModelBindingContextTest.cs new file mode 100644 index 0000000000..b1df4a5b5a --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/ModelBindingContextTest.cs @@ -0,0 +1,90 @@ +using System; +using Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test +{ + public class ModelBindingContextTest + { + [Fact] + public void CopyConstructor() + { + // Arrange + ModelBindingContext originalBindingContext = new ModelBindingContext + { + ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(null, typeof(object)), + ModelName = "theName", + ModelState = new ModelStateDictionary(), + ValueProvider = new SimpleHttpValueProvider() + }; + + // Act + ModelBindingContext newBindingContext = new ModelBindingContext(originalBindingContext); + + // Assert + Assert.Null(newBindingContext.ModelMetadata); + Assert.Equal("", newBindingContext.ModelName); + Assert.Equal(originalBindingContext.ModelState, newBindingContext.ModelState); + Assert.Equal(originalBindingContext.ValueProvider, newBindingContext.ValueProvider); + } + + [Fact] + public void ModelProperty_ThrowsIfModelMetadataDoesNotExist() + { + // Arrange + ModelBindingContext bindingContext = new ModelBindingContext(); + + // Act & assert + ExceptionAssert.Throws( + () => bindingContext.Model = null, + "The ModelMetadata property must be set before accessing this property."); + } + + [Fact] + public void ModelAndModelTypeAreFedFromModelMetadata() + { + // Act + ModelBindingContext bindingContext = new ModelBindingContext + { + ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(() => 42, typeof(int)) + }; + + // Assert + Assert.Equal(42, bindingContext.Model); + Assert.Equal(typeof(int), bindingContext.ModelType); + } + + // TODO: Validation + //[Fact] + //public void ValidationNodeProperty() + //{ + // // Act + // ModelBindingContext bindingContext = new ModelBindingContext + // { + // ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(() => 42, typeof(int)) + // }; + + // // Act & assert + // MemberHelper.TestPropertyWithDefaultInstance(bindingContext, "ValidationNode", new ModelValidationNode(bindingContext.ModelMetadata, "someName")); + //} + + // TODO: Validation + //[Fact] + //public void ValidationNodeProperty_DefaultValues() + //{ + // // Act + // ModelBindingContext bindingContext = new ModelBindingContext + // { + // ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(() => 42, typeof(int)), + // ModelName = "theInt" + // }; + + // // Act + // ModelValidationNode validationNode = bindingContext.ValidationNode; + + // // Assert + // Assert.NotNull(validationNode); + // Assert.Equal(bindingContext.ModelMetadata, validationNode.ModelMetadata); + // Assert.Equal(bindingContext.ModelName, validationNode.ModelStateKey); + //} + } +} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/TypeConverterModelBinderTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/TypeConverterModelBinderTest.cs new file mode 100644 index 0000000000..1802148a18 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/TypeConverterModelBinderTest.cs @@ -0,0 +1,163 @@ +using System; +using System.ComponentModel; +using System.Globalization; +using Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test +{ + public class TypeConverterModelBinderTest + { + // private static readonly ModelBinderErrorMessageProvider = (modelMetadata, incomingValue) => null; + + [Fact] + public void BindModel_Error_FormatExceptionsTurnedIntoStringsInModelState() + { + // Arrange + ModelBindingContext bindingContext = GetBindingContext(typeof(int)); + bindingContext.ValueProvider = new SimpleHttpValueProvider + { + { "theModelName", "not an integer" } + }; + + TypeConverterModelBinder binder = new TypeConverterModelBinder(); + + // Act + bool retVal = binder.BindModel(bindingContext); + + // Assert + Assert.False(retVal); + Assert.Equal("The value 'not an integer' is not valid for Int32.", bindingContext.ModelState["theModelName"].Errors[0].ErrorMessage); + } + + [Fact] + public void BindModel_Error_FormatExceptionsTurnedIntoStringsInModelState_ErrorNotAddedIfCallbackReturnsNull() + { + // Arrange + ModelBindingContext bindingContext = GetBindingContext(typeof(int)); + bindingContext.ValueProvider = new SimpleHttpValueProvider + { + { "theModelName", "not an integer" } + }; + + TypeConverterModelBinder binder = new TypeConverterModelBinder(); + + // Act + bool retVal = binder.BindModel(bindingContext); + + // Assert + Assert.False(retVal); + Assert.Null(bindingContext.Model); + Assert.True(bindingContext.ModelState.IsValid); + } + + // TODO: TypeConverter + //[Fact] + //public void BindModel_Error_GeneralExceptionsSavedInModelState() + //{ + // // Arrange + // ModelBindingContext bindingContext = GetBindingContext(typeof(Dummy)); + // bindingContext.ValueProvider = new SimpleHttpValueProvider + // { + // { "theModelName", "foo" } + // }; + + // TypeConverterModelBinder binder = new TypeConverterModelBinder(); + + // // Act + // bool retVal = binder.BindModel(bindingContext); + + // // Assert + // Assert.False(retVal); + // Assert.Null(bindingContext.Model); + // Assert.Equal("The parameter conversion from type 'System.String' to type 'Microsoft.AspNet.Mvc.ModelBinding.Test.TypeConverterModelBinderTest+Dummy' failed. See the inner exception for more information.", bindingContext.ModelState["theModelName"].Errors[0].Exception.Message); + // Assert.Equal("From DummyTypeConverter: foo", bindingContext.ModelState["theModelName"].Errors[0].Exception.InnerException.Message); + //} + + [Fact] + public void BindModel_NullValueProviderResult_ReturnsFalse() + { + // Arrange + ModelBindingContext bindingContext = GetBindingContext(typeof(int)); + + TypeConverterModelBinder binder = new TypeConverterModelBinder(); + + // Act + bool retVal = binder.BindModel(bindingContext); + + // Assert + Assert.False(retVal, "BindModel should have returned null."); + Assert.Empty(bindingContext.ModelState); + } + + [Fact] + public void BindModel_ValidValueProviderResult_ConvertEmptyStringsToNull() + { + // Arrange + ModelBindingContext bindingContext = GetBindingContext(typeof(string)); + bindingContext.ValueProvider = new SimpleHttpValueProvider + { + { "theModelName", "" } + }; + + TypeConverterModelBinder binder = new TypeConverterModelBinder(); + + // Act + bool retVal = binder.BindModel(bindingContext); + + // Assert + Assert.True(retVal); + Assert.Null(bindingContext.Model); + Assert.True(bindingContext.ModelState.ContainsKey("theModelName")); + } + + [Fact] + public void BindModel_ValidValueProviderResult_ReturnsModel() + { + // Arrange + ModelBindingContext bindingContext = GetBindingContext(typeof(int)); + bindingContext.ValueProvider = new SimpleHttpValueProvider + { + { "theModelName", "42" } + }; + + TypeConverterModelBinder binder = new TypeConverterModelBinder(); + + // Act + bool retVal = binder.BindModel(bindingContext); + + // Assert + Assert.True(retVal); + Assert.Equal(42, bindingContext.Model); + Assert.True(bindingContext.ModelState.ContainsKey("theModelName")); + } + + private static ModelBindingContext GetBindingContext(Type modelType) + { + return new ModelBindingContext + { + ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(null, modelType), + ModelName = "theModelName", + ValueProvider = new SimpleHttpValueProvider() // empty + }; + } + + // TODO: TypeConverter + //[TypeConverter(typeof(DummyTypeConverter))] + //private struct Dummy + //{ + //} + + private sealed class DummyTypeConverter : TypeConverter + { + public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) + { + return (sourceType == typeof(string)) || base.CanConvertFrom(context, sourceType); + } + + public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) + { + throw new InvalidOperationException(String.Format("From DummyTypeConverter: {0}", value)); + } + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/CultureReplacer.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/CultureReplacer.cs new file mode 100644 index 0000000000..efb05c5cf6 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/CultureReplacer.cs @@ -0,0 +1,71 @@ +using System; +using System.Globalization; +using System.Threading; +using Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test +{ + public class CultureReplacer : IDisposable + { + private const string _defaultCultureName = "en-GB"; + private const string _defaultUICultureName = "en-US"; + private static readonly CultureInfo _defaultCulture = CultureInfo.GetCultureInfo(_defaultCultureName); + private readonly CultureInfo _originalCulture; + private readonly CultureInfo _originalUICulture; + private readonly long _threadId; + + // Culture => Formatting of dates/times/money/etc, defaults to en-GB because en-US is the same as InvariantCulture + // We want to be able to find issues where the InvariantCulture is used, but a specific culture should be. + // + // UICulture => Language + public CultureReplacer(string culture = _defaultCultureName, string uiCulture = _defaultUICultureName) + { + _originalCulture = Thread.CurrentThread.CurrentCulture; + _originalUICulture = Thread.CurrentThread.CurrentUICulture; + _threadId = Thread.CurrentThread.ManagedThreadId; + + Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo(culture); + Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(uiCulture); + } + + /// + /// The name of the culture that is used as the default value for Thread.CurrentCulture when CultureReplacer is used. + /// + public static string DefaultCultureName + { + get { return _defaultCultureName; } + } + + /// + /// The name of the culture that is used as the default value for Thread.UICurrentCulture when CultureReplacer is used. + /// + public static string DefaultUICultureName + { + get { return _defaultUICultureName; } + } + + /// + /// The culture that is used as the default value for Thread.CurrentCulture when CultureReplacer is used. + /// + public static CultureInfo DefaultCulture + { + get { return _defaultCulture; } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (disposing) + { + Assert.True(Thread.CurrentThread.ManagedThreadId == _threadId, "The current thread is not the same as the thread invoking the constructor. This should never happen."); + Thread.CurrentThread.CurrentCulture = _originalCulture; + Thread.CurrentThread.CurrentUICulture = _originalUICulture; + } + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/ExceptionAssert.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/ExceptionAssert.cs new file mode 100644 index 0000000000..634944dad4 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/ExceptionAssert.cs @@ -0,0 +1,170 @@ +using System; +using System.Reflection; +using Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test +{ + public static class ExceptionAssert + { + /// + /// Verifies that an exception of the given type (or optionally a derived type) is thrown. + /// + /// The type of the exception expected to be thrown + /// A delegate to the code to be tested + /// The exception that was thrown, when successful + /// Thrown when an exception was not thrown, or when an exception of the incorrect type is thrown + public static TException Throws(Action testCode) + where TException : Exception + { + Type exceptionType = typeof(TException); + Exception exception = RecordException(testCode); + + TargetInvocationException tie = exception as TargetInvocationException; + if (tie != null) + { + exception = tie.InnerException; + } + + Assert.NotNull(exception); + return Assert.IsAssignableFrom(exception); + } + + /// + /// Verifies that an exception of the given type (or optionally a derived type) is thrown. + /// Also verifies that the exception message matches. + /// + /// The type of the exception expected to be thrown + /// A delegate to the code to be tested + /// The exception message to verify + /// The exception that was thrown, when successful + /// Thrown when an exception was not thrown, or when an exception of the incorrect type is thrown + public static TException Throws(Action testCode, string exceptionMessage) + where TException : Exception + { + var ex = Throws(testCode); + VerifyExceptionMessage(ex, exceptionMessage); + return ex; + } + + /// + /// Verifies that an exception of the given type (or optionally a derived type) is thrown. + /// Also verified that the exception message matches. + /// + /// The type of the exception expected to be thrown + /// A delegate to the code to be tested + /// The exception message to verify + /// The exception that was thrown, when successful + /// Thrown when an exception was not thrown, or when an exception of the incorrect type is thrown + public static TException Throws(Func testCode, string exceptionMessage) + where TException : Exception + { + return Throws(() => { testCode(); }, exceptionMessage); + } + + /// + /// Verifies that the code throws an (or optionally any exception which derives from it). + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception message to verify + /// The exception that was thrown, when successful + /// Thrown when an exception was not thrown, or when an exception of the incorrect type is thrown + public static ArgumentException ThrowsArgument(Action testCode, string paramName, string exceptionMessage) + { + var ex = Throws(testCode); + + if (paramName != null) + { + Assert.Equal(paramName, ex.ParamName); + } + + VerifyExceptionMessage(ex, exceptionMessage, partialMatch: true); + + return ex; + } + + /// + /// Verifies that the code throws an ArgumentNullException (or optionally any exception which derives from it). + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception that was thrown, when successful + /// Thrown when an exception was not thrown, or when an exception of the incorrect type is thrown + public static ArgumentNullException ThrowsArgumentNull(Action testCode, string paramName) + { + var ex = Throws(testCode); + + if (paramName != null) + { + Assert.Equal(paramName, ex.ParamName); + } + + return ex; + } + + /// + /// Verifies that the code throws an ArgumentNullException with the expected message that indicates that the value cannot + /// be null or empty. + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception that was thrown, when successful + /// Thrown when an exception was not thrown, or when an exception of the incorrect type is thrown + public static ArgumentException ThrowsArgumentNullOrEmpty(Action testCode, string paramName) + { + return Throws(testCode, "Value cannot be null or empty.\r\nParameter name: " + paramName); + } + + // We've re-implemented all the xUnit.net Throws code so that we can get this + // updated implementation of RecordException which silently unwraps any instances + // of AggregateException. In addition to unwrapping exceptions, this method ensures + // that tests are executed in with a known set of Culture and UICulture. This prevents + // tests from failing when executed on a non-English machine. + private static Exception RecordException(Action testCode) + { + try + { + using (new CultureReplacer()) + { + testCode(); + } + return null; + } + catch (Exception exception) + { + return UnwrapException(exception); + } + } + + private static Exception UnwrapException(Exception exception) + { + AggregateException aggEx = exception as AggregateException; + if (aggEx != null) + { + return aggEx.GetBaseException(); + } + return exception; + } + + private static void VerifyException(Type exceptionType, Exception exception) + { + Assert.NotNull(exception); + Assert.IsAssignableFrom(exceptionType, exception); + } + + private static void VerifyExceptionMessage(Exception exception, string expectedMessage, bool partialMatch = false) + { + if (expectedMessage != null) + { + if (!partialMatch) + { + Assert.Equal(expectedMessage, exception.Message); + } + else + { + Assert.Contains(expectedMessage, exception.Message); + } + } + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/AssociatedMetadataProviderTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/AssociatedMetadataProviderTest.cs new file mode 100644 index 0000000000..d9c53a7117 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/AssociatedMetadataProviderTest.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test.Binders +{ + public class AssociatedMetadataProviderTest + { + // GetMetadataForProperties + + [Fact] + public void GetMetadataForPropertiesNullContainerTypeThrows() + { + // Arrange + TestableAssociatedMetadataProvider provider = new TestableAssociatedMetadataProvider(); + + // Act & Assert + ExceptionAssert.ThrowsArgumentNull( + () => provider.GetMetadataForProperties(new Object(), containerType: null), + "containerType"); + } + + [Fact] + public void GetMetadataForPropertiesCreatesMetadataForAllPropertiesOnModelWithPropertyValues() + { + // Arrange + PropertyModel model = new PropertyModel { LocalAttributes = 42, MetadataAttributes = "hello", MixedAttributes = 21.12 }; + TestableAssociatedMetadataProvider provider = new TestableAssociatedMetadataProvider(); + + // Act + provider.GetMetadataForProperties(model, typeof(PropertyModel)).ToList(); // Call ToList() to force the lazy evaluation to evaluate + + // Assert + CreateMetadataPrototypeParams local = + provider.CreateMetadataPrototypeLog.Single(m => m.ContainerType == typeof(PropertyModel) && + m.PropertyName == "LocalAttributes"); + Assert.Equal(typeof(int), local.ModelType); + Assert.True(local.Attributes.Any(a => a is RequiredAttribute)); + + CreateMetadataPrototypeParams metadata = + provider.CreateMetadataPrototypeLog.Single(m => m.ContainerType == typeof(PropertyModel) && + m.PropertyName == "MetadataAttributes"); + Assert.Equal(typeof(string), metadata.ModelType); + Assert.True(metadata.Attributes.Any(a => a is RangeAttribute)); + + CreateMetadataPrototypeParams mixed = + provider.CreateMetadataPrototypeLog.Single(m => m.ContainerType == typeof(PropertyModel) && + m.PropertyName == "MixedAttributes"); + Assert.Equal(typeof(double), mixed.ModelType); + Assert.True(mixed.Attributes.Any(a => a is RequiredAttribute)); + Assert.True(mixed.Attributes.Any(a => a is RangeAttribute)); + } + + [Fact] + public void GetMetadataForPropertyWithNullContainerReturnsMetadataWithNullValuesForProperties() + { + // Arrange + TestableAssociatedMetadataProvider provider = new TestableAssociatedMetadataProvider(); + + // Act + provider.GetMetadataForProperties(null, typeof(PropertyModel)).ToList(); // Call ToList() to force the lazy evaluation to evaluate + + // Assert + Assert.True(provider.CreateMetadataFromPrototypeLog.Any()); + foreach (var parms in provider.CreateMetadataFromPrototypeLog) + { + Assert.Null(parms.Model); + } + } + + // GetMetadataForProperty + + [Fact] + public void GetMetadataForPropertyNullContainerTypeThrows() + { + // Arrange + TestableAssociatedMetadataProvider provider = new TestableAssociatedMetadataProvider(); + + // Act & Assert + ExceptionAssert.ThrowsArgumentNull( + () => provider.GetMetadataForProperty(modelAccessor: null, containerType: null, propertyName: "propertyName"), + "containerType"); + } + + [Fact] + public void GetMetadataForPropertyNullOrEmptyPropertyNameThrows() + { + // Arrange + TestableAssociatedMetadataProvider provider = new TestableAssociatedMetadataProvider(); + + // Act & Assert + ExceptionAssert.ThrowsArgument( + () => provider.GetMetadataForProperty(modelAccessor: null, containerType: typeof(object), propertyName: null), + "propertyName", + "The argument 'propertyName' is null or empty."); + ExceptionAssert.ThrowsArgument( + () => provider.GetMetadataForProperty(modelAccessor: null, containerType: typeof(object), propertyName: String.Empty), + "propertyName", + "The argument 'propertyName' is null or empty."); + } + + [Fact] + public void GetMetadataForPropertyInvalidPropertyNameThrows() + { + // Arrange + TestableAssociatedMetadataProvider provider = new TestableAssociatedMetadataProvider(); + + // Act & Assert + ExceptionAssert.ThrowsArgument( + () => provider.GetMetadataForProperty(modelAccessor: null, containerType: typeof(object), propertyName: "BadPropertyName"), + "propertyName", + "The property System.Object.BadPropertyName could not be found."); + } + + [Fact] + public void GetMetadataForPropertyWithLocalAttributes() + { + // Arrange + TestableAssociatedMetadataProvider provider = new TestableAssociatedMetadataProvider(); + ModelMetadata metadata = new ModelMetadata(provider, typeof(PropertyModel), null, typeof(int), "LocalAttributes"); + provider.CreateMetadataFromPrototypeReturnValue = metadata; + + // Act + ModelMetadata result = provider.GetMetadataForProperty(null, typeof(PropertyModel), "LocalAttributes"); + + // Assert + Assert.Same(metadata, result); + Assert.True(provider.CreateMetadataPrototypeLog.Single(parameters => parameters.PropertyName == "LocalAttributes").Attributes.Any(a => a is RequiredAttribute)); + } + + [Fact] + public void GetMetadataForPropertyWithMetadataAttributes() + { + // Arrange + TestableAssociatedMetadataProvider provider = new TestableAssociatedMetadataProvider(); + ModelMetadata metadata = new ModelMetadata(provider, typeof(PropertyModel), null, typeof(string), "MetadataAttributes"); + provider.CreateMetadataFromPrototypeReturnValue = metadata; + + // Act + ModelMetadata result = provider.GetMetadataForProperty(null, typeof(PropertyModel), "MetadataAttributes"); + + // Assert + Assert.Same(metadata, result); + CreateMetadataPrototypeParams parms = provider.CreateMetadataPrototypeLog.Single(p => p.PropertyName == "MetadataAttributes"); + Assert.True(parms.Attributes.Any(a => a is RangeAttribute)); + } + + [Fact] + public void GetMetadataForPropertyWithMixedAttributes() + { + // Arrange + TestableAssociatedMetadataProvider provider = new TestableAssociatedMetadataProvider(); + ModelMetadata metadata = new ModelMetadata(provider, typeof(PropertyModel), null, typeof(double), "MixedAttributes"); + provider.CreateMetadataFromPrototypeReturnValue = metadata; + + // Act + ModelMetadata result = provider.GetMetadataForProperty(null, typeof(PropertyModel), "MixedAttributes"); + + // Assert + Assert.Same(metadata, result); + CreateMetadataPrototypeParams parms = provider.CreateMetadataPrototypeLog.Single(p => p.PropertyName == "MixedAttributes"); + Assert.True(parms.Attributes.Any(a => a is RequiredAttribute)); + Assert.True(parms.Attributes.Any(a => a is RangeAttribute)); + } + + // GetMetadataForType + + [Fact] + public void GetMetadataForTypeNullModelTypeThrows() + { + // Arrange + TestableAssociatedMetadataProvider provider = new TestableAssociatedMetadataProvider(); + + // Act & Assert + ExceptionAssert.ThrowsArgumentNull( + () => provider.GetMetadataForType(() => new Object(), modelType: null), + "modelType"); + } + + [Fact] + public void GetMetadataForTypeIncludesAttributesOnType() + { + TestableAssociatedMetadataProvider provider = new TestableAssociatedMetadataProvider(); + ModelMetadata metadata = new ModelMetadata(provider, null, null, typeof(TypeModel), null); + provider.CreateMetadataFromPrototypeReturnValue = metadata; + + // Act + ModelMetadata result = provider.GetMetadataForType(null, typeof(TypeModel)); + + // Assert + Assert.Same(metadata, result); + CreateMetadataPrototypeParams parms = provider.CreateMetadataPrototypeLog.Single(p => p.ModelType == typeof(TypeModel)); + Assert.True(parms.Attributes.Any(a => a is ReadOnlyAttribute)); + } + + // Helpers + + // TODO: This type used System.ComponentModel.MetadataType to separate attribute declaration from property + // declaration. Need to figure out if this is still relevant since the type does not exist in CoreCLR. + private class PropertyModel + { + [Required] + public int LocalAttributes { get; set; } + + [Range(10, 100)] + public string MetadataAttributes { get; set; } + + [Required] + [Range(10, 100)] + public double MixedAttributes { get; set; } + } + + private sealed class RequiredAttribute : Attribute + { } + + private sealed class RangeAttribute : Attribute + { + public RangeAttribute(int min, int max) + { + } + } + + private class ModelWithReadOnlyProperty + { + public int ReadOnlyProperty { get; private set; } + } + + [ReadOnly(true)] + private class TypeModel + { + } + + class TestableAssociatedMetadataProvider : AssociatedMetadataProvider + { + public List CreateMetadataPrototypeLog = new List(); + public List CreateMetadataFromPrototypeLog = new List(); + public ModelMetadata CreateMetadataPrototypeReturnValue = null; + public ModelMetadata CreateMetadataFromPrototypeReturnValue = null; + + protected override ModelMetadata CreateMetadataPrototype(IEnumerable attributes, Type containerType, Type modelType, string propertyName) + { + CreateMetadataPrototypeLog.Add(new CreateMetadataPrototypeParams + { + Attributes = attributes, + ContainerType = containerType, + ModelType = modelType, + PropertyName = propertyName + }); + + return CreateMetadataPrototypeReturnValue; + } + + protected override ModelMetadata CreateMetadataFromPrototype(ModelMetadata prototype, Func modelAccessor) + { + CreateMetadataFromPrototypeLog.Add(new CreateMetadataFromPrototypeParams + { + Prototype = prototype, + Model = modelAccessor == null ? null : modelAccessor() + }); + + return CreateMetadataFromPrototypeReturnValue; + } + } + + class CreateMetadataPrototypeParams + { + public IEnumerable Attributes { get; set; } + public Type ContainerType { get; set; } + public Type ModelType { get; set; } + public string PropertyName { get; set; } + } + + class CreateMetadataFromPrototypeParams + { + public ModelMetadata Prototype { get; set; } + public object Model { get; set; } + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/ModelMetadataTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/ModelMetadataTest.cs new file mode 100644 index 0000000000..19b197c16e --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/ModelMetadataTest.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Moq; +using Xunit; +using Xunit.Extensions; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test +{ + public class ModelMetadataTest + { + // Guard clauses + + [Fact] + public void NullProviderThrows() + { + // Act & Assert + ExceptionAssert.ThrowsArgumentNull( + () => new ModelMetadata(provider: null, containerType: null, modelAccessor: null, modelType: typeof(object), propertyName: null), + "provider"); + } + + [Fact] + public void NullTypeThrows() + { + // Arrange + Mock provider = new Mock(); + + // Act & Assert + ExceptionAssert.ThrowsArgumentNull( + () => new ModelMetadata(provider: provider.Object, containerType: null, modelAccessor: null, modelType: null, propertyName: null), + "modelType"); + } + + // Constructor + + [Fact] + public void DefaultValues() + { + // Arrange + Mock provider = new Mock(); + + // Act + ModelMetadata metadata = new ModelMetadata(provider.Object, typeof(Exception), () => "model", typeof(string), "propertyName"); + + // Assert + Assert.Equal(typeof(Exception), metadata.ContainerType); + Assert.True(metadata.ConvertEmptyStringToNull); + Assert.Null(metadata.Description); + Assert.Equal("model", metadata.Model); + Assert.Equal(typeof(string), metadata.ModelType); + Assert.Equal("propertyName", metadata.PropertyName); + Assert.False(metadata.IsReadOnly); + } + + // IsComplexType + + struct IsComplexTypeModel + { + } + + [Theory] + [InlineData(typeof(string))] + [InlineData(typeof(Nullable))] + [InlineData(typeof(int))] + public void IsComplexTypeTestsReturnsFalseForSimpleTypes(Type type) + { + // Arrange + Mock provider = new Mock(); + + // Act + var modelMetadata = new ModelMetadata(provider.Object, null, null, type, null); + + // Assert + Assert.False(modelMetadata.IsComplexType); + } + + [Theory] + [InlineData(typeof(object))] + [InlineData(typeof(IDisposable))] + [InlineData(typeof(IsComplexTypeModel))] + [InlineData(typeof(Nullable))] + public void IsComplexTypeTestsReturnsTrueForComplexTypes(Type type) + { + // Arrange + Mock provider = new Mock(); + + // Act + var modelMetadata = new ModelMetadata(provider.Object, null, null, type, null); + + // Assert + Assert.True(modelMetadata.IsComplexType); + } + + // IsNullableValueType + + [Fact] + public void IsNullableValueTypeTests() + { + // Arrange + Mock provider = new Mock(); + + // Act & Assert + Assert.False(new ModelMetadata(provider.Object, null, null, typeof(string), null).IsNullableValueType); + Assert.False(new ModelMetadata(provider.Object, null, null, typeof(IDisposable), null).IsNullableValueType); + Assert.True(new ModelMetadata(provider.Object, null, null, typeof(Nullable), null).IsNullableValueType); + Assert.False(new ModelMetadata(provider.Object, null, null, typeof(int), null).IsNullableValueType); + } + + // Properties + + [Fact] + public void PropertiesCallsProvider() + { + // Arrange + Type modelType = typeof(string); + List propertyMetadata = new List(); + Mock provider = new Mock(); + ModelMetadata metadata = new ModelMetadata(provider.Object, null, null, modelType, null); + provider.Setup(p => p.GetMetadataForProperties(null, modelType)) + .Returns(propertyMetadata) + .Verifiable(); + + // Act + IEnumerable result = metadata.Properties; + + // Assert + Assert.Equal(propertyMetadata, result.ToList()); + provider.Verify(); + } + + [Fact] + public void PropertiesListGetsResetWhenModelGetsReset() + { + // Dev10 Bug #923263 + // Arrange + IModelMetadataProvider provider = new EmptyModelMetadataProvider(); + var metadata = new ModelMetadata(provider, null, () => new Class1(), typeof(Class1), null); + + // Act + ModelMetadata[] originalProps = metadata.Properties.ToArray(); + metadata.Model = new Class2(); + ModelMetadata[] newProps = metadata.Properties.ToArray(); + + // Assert + ModelMetadata originalProp = Assert.Single(originalProps); + Assert.Equal(typeof(string), originalProp.ModelType); + Assert.Equal("Prop1", originalProp.PropertyName); + ModelMetadata newProp = Assert.Single(newProps); + Assert.Equal(typeof(int), newProp.ModelType); + Assert.Equal("Prop2", newProp.PropertyName); + } + + class Class1 + { + public string Prop1 { get; set; } + } + + class Class2 + { + public int Prop2 { get; set; } + } + + // GetDisplayName() + + [Fact] + public void ReturnsPropertyNameWhenSetAndDisplayNameIsNull() + { + // Arrange + Mock provider = new Mock(); + ModelMetadata metadata = new ModelMetadata(provider.Object, null, null, typeof(object), "PropertyName"); + + // Act + string result = metadata.GetDisplayName(); + + // Assert + Assert.Equal("PropertyName", result); + } + + [Fact] + public void ReturnsTypeNameWhenPropertyNameAndDisplayNameAreNull() + { + // Arrange + Mock provider = new Mock(); + ModelMetadata metadata = new ModelMetadata(provider.Object, null, null, typeof(object), null); + + // Act + string result = metadata.GetDisplayName(); + + // Assert + Assert.Equal("Object", result); + } + + // Helpers + + private class DummyContactModel + { + public int IntField = 0; + public string FirstName { get; set; } + public string LastName { get; set; } + public Nullable NullableIntValue { get; set; } + public int[] Array { get; set; } + + public string this[int index] + { + get { return "Indexed into " + index; } + } + } + + private class DummyModelContainer + { + public DummyContactModel Model { get; set; } + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/ModelBindingContextTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/ModelBindingContextTest.cs new file mode 100644 index 0000000000..13c209c5de --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/ModelBindingContextTest.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test +{ + //public class ModelBindingContextTest + //{ + // [Fact] + // public void CopyConstructor() + // { + // // Arrange + // ModelBindingContext originalBindingContext = new ModelBindingContext + // { + // ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(null, typeof(object)), + // ModelName = "theName", + // ModelState = new ModelStateDictionary(), + // ValueProvider = new SimpleHttpValueProvider() + // }; + + // // Act + // ModelBindingContext newBindingContext = new ModelBindingContext(originalBindingContext); + + // // Assert + // Assert.Null(newBindingContext.ModelMetadata); + // Assert.Equal("", newBindingContext.ModelName); + // Assert.Equal(originalBindingContext.ModelState, newBindingContext.ModelState); + // Assert.Equal(originalBindingContext.ValueProvider, newBindingContext.ValueProvider); + // } + + // [Fact] + // public void ModelProperty() + // { + // // Arrange + // ModelBindingContext bindingContext = new ModelBindingContext + // { + // ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(null, typeof(int)) + // }; + + // // Act & assert + // MemberHelper.TestPropertyValue(bindingContext, "Model", 42); + // } + + // [Fact] + // public void ModelProperty_ThrowsIfModelMetadataDoesNotExist() + // { + // // Arrange + // ModelBindingContext bindingContext = new ModelBindingContext(); + + // // Act & assert + // Assert.Throws( + // () => bindingContext.Model = null, + // "The ModelMetadata property must be set before accessing this property."); + // } + + // [Fact] + // public void ModelNameProperty() + // { + // // Arrange + // ModelBindingContext bindingContext = new ModelBindingContext(); + + // // Act & assert + // Assert.Reflection.StringProperty(bindingContext, bc => bc.ModelName, String.Empty); + // } + + // [Fact] + // public void ModelStateProperty() + // { + // // Arrange + // ModelBindingContext bindingContext = new ModelBindingContext(); + // ModelStateDictionary modelState = new ModelStateDictionary(); + + // // Act & assert + // MemberHelper.TestPropertyWithDefaultInstance(bindingContext, "ModelState", modelState); + // } + + // [Fact] + // public void ModelAndModelTypeAreFedFromModelMetadata() + // { + // // Act + // ModelBindingContext bindingContext = new ModelBindingContext + // { + // ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(() => 42, typeof(int)) + // }; + + // // Assert + // Assert.Equal(42, bindingContext.Model); + // Assert.Equal(typeof(int), bindingContext.ModelType); + // } + + // [Fact] + // public void ValidationNodeProperty() + // { + // // Act + // ModelBindingContext bindingContext = new ModelBindingContext + // { + // ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(() => 42, typeof(int)) + // }; + + // // Act & assert + // MemberHelper.TestPropertyWithDefaultInstance(bindingContext, "ValidationNode", new ModelValidationNode(bindingContext.ModelMetadata, "someName")); + // } + + // [Fact] + // public void ValidationNodeProperty_DefaultValues() + // { + // // Act + // ModelBindingContext bindingContext = new ModelBindingContext + // { + // ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(() => 42, typeof(int)), + // ModelName = "theInt" + // }; + + // // Act + // ModelValidationNode validationNode = bindingContext.ValidationNode; + + // // Assert + // Assert.NotNull(validationNode); + // Assert.Equal(bindingContext.ModelMetadata, validationNode.ModelMetadata); + // Assert.Equal(bindingContext.ModelName, validationNode.ModelStateKey); + // } + //} +} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Utils/SimpleHttpValueProvider.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Utils/SimpleHttpValueProvider.cs new file mode 100644 index 0000000000..756d05df10 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Utils/SimpleHttpValueProvider.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test +{ + public sealed class SimpleHttpValueProvider : Dictionary, IValueProvider + { + private readonly CultureInfo _culture; + + public SimpleHttpValueProvider() + : this(null) + { + } + + public SimpleHttpValueProvider(CultureInfo culture) + : base(StringComparer.OrdinalIgnoreCase) + { + _culture = culture ?? CultureInfo.InvariantCulture; + } + + // copied from ValueProviderUtil + public bool ContainsPrefix(string prefix) + { + foreach (string key in Keys) + { + if (key != null) + { + if (prefix.Length == 0) + { + return true; // shortcut - non-null key matches empty prefix + } + + if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + if (key.Length == prefix.Length) + { + return true; // exact match + } + else + { + switch (key[prefix.Length]) + { + case '.': // known separator characters + case '[': + return true; + } + } + } + } + } + + return false; // nothing found + } + + public ValueProviderResult GetValue(string key) + { + object rawValue; + if (TryGetValue(key, out rawValue)) + { + return new ValueProviderResult(rawValue, Convert.ToString(rawValue, _culture), _culture); + } + else + { + // value not found + return null; + } + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/ValueProviders/NameValuePairsValueProviderTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/ValueProviders/NameValuePairsValueProviderTest.cs new file mode 100644 index 0000000000..a3b5272036 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/ValueProviders/NameValuePairsValueProviderTest.cs @@ -0,0 +1,249 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.AspNet.Abstractions; +using Microsoft.AspNet.PipelineCore.Collections; +using Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test +{ + public class NameValuePairsValueProviderTest + { + private static readonly IReadableStringCollection _backingStore = new ReadableStringCollection( + new Dictionary + { + {"foo", new[] { "fooValue1", "fooValue2"} }, + {"bar.baz", new[] {"someOtherValue" }}, + {"null_value", null}, + {"prefix.null_value", null} + }); + + [Fact] + public void Constructor_GuardClauses() + { + // Act & assert + ExceptionAssert.ThrowsArgumentNull( + () => new NameValuePairsValueProvider(values: null, culture: CultureInfo.InvariantCulture), + "values"); + } + + [Fact] + public void ContainsPrefix_GuardClauses() + { + // Arrange + var valueProvider = new NameValuePairsValueProvider(_backingStore, null); + + // Act & assert + ExceptionAssert.ThrowsArgumentNull( + () => valueProvider.ContainsPrefix(null), + "prefix"); + } + + [Fact] + public void ContainsPrefix_WithEmptyCollection_ReturnsFalseForEmptyPrefix() + { + // Arrange + var backingStore = new ReadableStringCollection(new Dictionary()); + var valueProvider = new NameValuePairsValueProvider(backingStore, null); + + // Act + bool result = valueProvider.ContainsPrefix(""); + + // Assert + Assert.False(result); + } + + [Fact] + public void ContainsPrefix_WithNonEmptyCollection_ReturnsTrueForEmptyPrefix() + { + // Arrange + var valueProvider = new NameValuePairsValueProvider(_backingStore, null); + + // Act + bool result = valueProvider.ContainsPrefix(""); + + // Assert + Assert.True(result); + } + + [Fact] + public void ContainsPrefix_WithNonEmptyCollection_ReturnsTrueForKnownPrefixes() + { + // Arrange + var valueProvider = new NameValuePairsValueProvider(_backingStore, null); + + // Act & Assert + Assert.True(valueProvider.ContainsPrefix("foo")); + Assert.True(valueProvider.ContainsPrefix("bar")); + Assert.True(valueProvider.ContainsPrefix("bar.baz")); + } + + [Fact] + public void ContainsPrefix_WithNonEmptyCollection_ReturnsFalseForUnknownPrefix() + { + // Arrange + var valueProvider = new NameValuePairsValueProvider(_backingStore, null); + + // Act + bool result = valueProvider.ContainsPrefix("biff"); + + // Assert + Assert.False(result); + } + + [Fact] + public void GetKeysFromPrefix_GuardClauses() + { + // Arrange + var valueProvider = new NameValuePairsValueProvider(_backingStore, null); + + // Act & assert + ExceptionAssert.ThrowsArgumentNull( + () => valueProvider.GetKeysFromPrefix(null), + "prefix"); + } + + [Fact] + public void GetKeysFromPrefix_EmptyPrefix_ReturnsAllPrefixes() + { + // Arrange + var valueProvider = new NameValuePairsValueProvider(_backingStore, null); + + // Act + IDictionary result = valueProvider.GetKeysFromPrefix(""); + + // Assert + Assert.Equal>( + result.OrderBy(kvp => kvp.Key), + new Dictionary { { "bar", "bar" }, { "foo", "foo" }, { "null_value", "null_value" }, { "prefix", "prefix" } }); + } + + [Fact] + public void GetKeysFromPrefix_UnknownPrefix_ReturnsEmptyDictionary() + { + // Arrange + var valueProvider = new NameValuePairsValueProvider(_backingStore, null); + + // Act + IDictionary result = valueProvider.GetKeysFromPrefix("abc"); + + // Assert + Assert.Empty(result); + } + + [Fact] + public void GetKeysFromPrefix_KnownPrefix_ReturnsMatchingItems() + { + // Arrange + var valueProvider = new NameValuePairsValueProvider(_backingStore, null); + + // Act + IDictionary result = valueProvider.GetKeysFromPrefix("bar"); + + // Assert + KeyValuePair kvp = Assert.Single(result); + Assert.Equal("baz", kvp.Key); + Assert.Equal("bar.baz", kvp.Value); + } + + [Fact] + public void GetValue_GuardClauses() + { + // Arrange + var valueProvider = new NameValuePairsValueProvider(_backingStore, null); + + // Act & assert + ExceptionAssert.ThrowsArgumentNull( + () => valueProvider.GetValue(null), + "key"); + } + + [Fact] + public void GetValue_SingleValue() + { + // Arrange + var culture = CultureInfo.GetCultureInfo("fr-FR"); + var valueProvider = new NameValuePairsValueProvider(_backingStore, culture); + + // Act + ValueProviderResult vpResult = valueProvider.GetValue("bar.baz"); + + // Assert + Assert.NotNull(vpResult); + Assert.Equal("someOtherValue", vpResult.RawValue); + Assert.Equal("someOtherValue", vpResult.AttemptedValue); + Assert.Equal(culture, vpResult.Culture); + } + + [Fact] + public void GetValue_MultiValue() + { + // Arrange + var culture = CultureInfo.GetCultureInfo("fr-FR"); + var valueProvider = new NameValuePairsValueProvider(_backingStore, culture); + + // Act + ValueProviderResult vpResult = valueProvider.GetValue("foo"); + + // Assert + Assert.NotNull(vpResult); + Assert.Equal(new [] { "fooValue1", "fooValue2" }, (IList)vpResult.RawValue); + Assert.Equal("fooValue1,fooValue2", vpResult.AttemptedValue); + Assert.Equal(culture, vpResult.Culture); + } + + // TODO: Determine if this is still relevant. Right now the lookup returns null while + // we expect a ValueProviderResult that wraps a null value. + //[Theory] + //[InlineData("null_value")] + //[InlineData("prefix.null_value")] + //public void GetValue_NullValue(string key) + //{ + // // Arrange + // var culture = CultureInfo.GetCultureInfo("fr-FR"); + // var valueProvider = new NameValuePairsValueProvider(_backingStore, culture); + + // // Act + // ValueProviderResult vpResult = valueProvider.GetValue(key); + + // // Assert + // Assert.NotNull(vpResult); + // Assert.Equal(null, vpResult.RawValue); + // Assert.Equal(null, vpResult.AttemptedValue); + // Assert.Equal(culture, vpResult.Culture); + //} + + [Fact] + public void GetValue_NullMultipleValue() + { + // Arrange + var backingStore = new ReadableStringCollection( + new Dictionary + { + { "key", new string[] { null, null, "value" } } + }); + var culture = CultureInfo.GetCultureInfo("fr-FR"); + var valueProvider = new NameValuePairsValueProvider(backingStore, culture); + + // Act + ValueProviderResult vpResult = valueProvider.GetValue("key"); + + // Assert + Assert.Equal(new[] { null, null, "value" }, vpResult.RawValue as IEnumerable); + Assert.Equal(",,value", vpResult.AttemptedValue); + } + + [Fact] + public void GetValue_ReturnsNullIfKeyNotFound() + { + // Arrange + var valueProvider = new NameValuePairsValueProvider(_backingStore, null); + + // Act + ValueProviderResult vpResult = valueProvider.GetValue("bar"); + + // Assert + Assert.Null(vpResult); + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/ValueProviders/QueryStringValueProviderFactoryTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/ValueProviders/QueryStringValueProviderFactoryTest.cs new file mode 100644 index 0000000000..e643a096ee --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/ValueProviders/QueryStringValueProviderFactoryTest.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Globalization; +using Microsoft.AspNet.Abstractions; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test +{ + public class QueryStringValueProviderFactoryTest + { + private readonly QueryStringValueProviderFactory _factory = new QueryStringValueProviderFactory(); + + [Fact] + public void GetValueProvider_WhenrequestContextParameterIsNull_Throws() + { + // Act and Assert + ExceptionAssert.ThrowsArgumentNull(() => _factory.GetValueProvider(requestContext: null), "requestContext"); + } + + [Fact] + public void GetValueProvider_ReturnsQueryStringValueProviderInstaceWithInvariantCulture() + { + // Arrange + var request = new Mock(); + request.SetupGet(f => f.Query).Returns(Mock.Of()); + var context = new Mock(); + context.SetupGet(c => c.Items).Returns(new Dictionary()); + context.SetupGet(c => c.Request).Returns(request.Object); + var requestContext = new RequestContext(context.Object, new Dictionary()); + + // Act + IValueProvider result = _factory.GetValueProvider(requestContext); + + // Assert + var valueProvider = Assert.IsType(result); + Assert.Equal(CultureInfo.InvariantCulture, valueProvider.Culture); + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/ValueProviders/ValueProviderResultTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/ValueProviders/ValueProviderResultTest.cs new file mode 100644 index 0000000000..db9f63c5d0 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/ValueProviders/ValueProviderResultTest.cs @@ -0,0 +1,28 @@ +using System.Globalization; +using Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Test +{ + public class ValueProviderResultTest + { + [Fact] + public void ConvertTo_ReturnsNullForReferenceTypes_WhenValueIsNull() + { + var valueProviderResult = new ValueProviderResult(null, null, CultureInfo.InvariantCulture); + + var convertedValue = valueProviderResult.ConvertTo(typeof(string)); + + Assert.Equal(null, convertedValue); + } + + [Fact] + public void ConvertTo_ReturnsDefaultForValueTypes_WhenValueIsNull() + { + var valueProviderResult = new ValueProviderResult(null, null, CultureInfo.InvariantCulture); + + var convertedValue = valueProviderResult.ConvertTo(typeof(int)); + + Assert.Equal(0, convertedValue); + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/project.json b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/project.json index f011cf2948..b64eb65a71 100644 --- a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/project.json +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/project.json @@ -1,7 +1,9 @@ { "version" : "0.1-alpha-*", "dependencies": { - "Microsoft.AspNet.Mvc.ModelBinding" : "0.1-alpha-*", + "Microsoft.AspNet.Abstractions": "0.1-alpha-*", + "Microsoft.AspNet.PipelineCore": "0.1-alpha-*", + "Microsoft.AspNet.Mvc.ModelBinding" : "", "Moq": "4.0.10827", "Xunit": "1.9.1", "Xunit.extensions": "1.9.1"