From 330328628820324ed5073056cfcd5a97a890723c Mon Sep 17 00:00:00 2001 From: Doug Bunting Date: Thu, 5 Feb 2015 11:24:42 -0800 Subject: [PATCH] Add `ModelMetadata.AdditionalValues` property bag - #1758 - provide the property bag in `ModelMetadata`; seal it in `CachedModelMetadata` - add unit tests - include use of `AdditionalValues` in model binding functional test nits: - expose `AdditionalValues` as an `IDictionary` though MVC 5 uses `Dictionary` - seal `ModelMetadata.Properties` collection as well - cover a few properties previously missed in `CachedDataAnnotationsModelMetadataTest` - correct two `ModelMetadataTest` method names --- .../Metadata/CachedModelMetadata.cs | 23 +++++++ .../Metadata/ModelMetadata.cs | 6 ++ .../ModelBindingTest.cs | 50 +++++++++++++++ .../CachedDataAnnotationsModelMetadataTest.cs | 12 ++++ .../Metadata/ModelMetadataTest.cs | 61 +++++++++++++++++-- .../AdditionalValuesMetadataProvider.cs | 54 ++++++++++++++++ .../Controllers/ModelMetadataController.cs | 39 ++++++++++++ .../ModelMetadataExtensions.cs | 26 ++++++++ test/WebSites/ModelBindingWebSite/Startup.cs | 5 ++ .../ViewModels/VehicleViewModel.cs | 6 +- 10 files changed, 275 insertions(+), 7 deletions(-) create mode 100644 test/WebSites/ModelBindingWebSite/AdditionalValuesMetadataProvider.cs create mode 100644 test/WebSites/ModelBindingWebSite/Controllers/ModelMetadataController.cs create mode 100644 test/WebSites/ModelBindingWebSite/ModelMetadataExtensions.cs diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedModelMetadata.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedModelMetadata.cs index 6854684b12..8e9b37db46 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedModelMetadata.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/CachedModelMetadata.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; namespace Microsoft.AspNet.Mvc.ModelBinding { @@ -82,6 +83,17 @@ namespace Microsoft.AspNet.Mvc.ModelBinding PrototypeCache = prototypeCache; } + // Sealing for consistency with other properties. + // ModelMetadata already caches the collection and we have no use case for a ComputeAdditionalValues() method. + /// + public sealed override IDictionary AdditionalValues + { + get + { + return base.AdditionalValues; + } + } + /// public sealed override IBinderMetadata BinderMetadata { @@ -419,6 +431,17 @@ namespace Microsoft.AspNet.Mvc.ModelBinding } } + // Sealing for consistency with other properties. + // ModelMetadata already caches the collection and we have no use case for a ComputeProperties() method. + /// + public override ModelPropertyCollection Properties + { + get + { + return base.Properties; + } + } + /// public sealed override bool ShowForDisplay { diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs index b841024d50..07e74cda4e 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs @@ -47,6 +47,12 @@ namespace Microsoft.AspNet.Mvc.ModelBinding _isRequired = !modelType.AllowsNullValue(); } + /// + /// Gets a collection of additional information about the model. + /// + public virtual IDictionary AdditionalValues { get; } + = new Dictionary(StringComparer.OrdinalIgnoreCase); + /// /// Gets or sets the name of a model if specified explicitly using . /// diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingTest.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingTest.cs index af986d4cf4..1da95943f7 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingTest.cs +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingTest.cs @@ -1551,5 +1551,55 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests var result = await response.Content.ReadAsStringAsync(); Assert.Equal("The value 'random string' is not valid for birthdate.", result); } + + [Fact] + public async Task OverriddenMetadataProvider_CanChangeAdditionalValues() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + var url = "http://localhost/AdditionalValues"; + var expectedDictionary = new Dictionary + { + { "key1", "7d6d0de2-8d59-49ac-99cc-881423b75a76" }, + { "key2", "value2" }, + }; + + // Act + var response = await client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var responseContent = await response.Content.ReadAsStringAsync(); + var dictionary = JsonConvert.DeserializeObject>(responseContent); + Assert.Equal(expectedDictionary, dictionary); + } + + [Fact] + public async Task OverriddenMetadataProvider_CanUseAttributesToChangeAdditionalValues() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + var url = "http://localhost/GroupNames"; + var expectedDictionary = new Dictionary + { + { "Model", "MakeAndModelGroup" }, + { "Make", "MakeAndModelGroup" }, + { "Vin", null }, + { "Year", null }, + { "InspectedDates", null }, + { "LastUpdatedTrackingId", "TrackingIdGroup" }, + }; + + // Act + var response = await client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var responseContent = await response.Content.ReadAsStringAsync(); + var dictionary = JsonConvert.DeserializeObject>(responseContent); + Assert.Equal(expectedDictionary, dictionary); + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/CachedDataAnnotationsModelMetadataTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/CachedDataAnnotationsModelMetadataTest.cs index 3adf5b448d..ae225ff1b2 100644 --- a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/CachedDataAnnotationsModelMetadataTest.cs +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/CachedDataAnnotationsModelMetadataTest.cs @@ -29,11 +29,18 @@ namespace Microsoft.AspNet.Mvc.ModelBinding attributes: attributes); // Assert + Assert.NotNull(metadata.AdditionalValues); + Assert.Empty(metadata.AdditionalValues); + Assert.Null(metadata.Container); + Assert.Null(metadata.ContainerType); + Assert.True(metadata.ConvertEmptyStringToNull); Assert.False(metadata.HasNonDefaultEditFormat); Assert.False(metadata.HideSurroundingHtml); Assert.True(metadata.HtmlEncode); + Assert.False(metadata.IsCollectionType); Assert.True(metadata.IsComplexType); + Assert.False(metadata.IsNullableValueType); Assert.False(metadata.IsReadOnly); Assert.False(metadata.IsRequired); Assert.True(metadata.ShowForDisplay); @@ -48,6 +55,11 @@ namespace Microsoft.AspNet.Mvc.ModelBinding Assert.Null(metadata.SimpleDisplayText); Assert.Null(metadata.TemplateHint); + Assert.Null(metadata.Model); + Assert.Equal(typeof(object), metadata.ModelType); + Assert.Equal(typeof(object), metadata.RealModelType); + Assert.Null(metadata.PropertyName); + Assert.Equal(ModelMetadata.DefaultOrder, metadata.Order); Assert.Null(metadata.BinderModelName); diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/ModelMetadataTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/ModelMetadataTest.cs index 9f53c03c5e..d61651162d 100644 --- a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/ModelMetadataTest.cs +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/ModelMetadataTest.cs @@ -5,7 +5,6 @@ using System; using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Collections.Specialized; using System.Linq; using System.Reflection; using Xunit; @@ -76,6 +75,8 @@ namespace Microsoft.AspNet.Mvc.ModelBinding new ModelMetadata(provider, typeof(Exception), () => "model", typeof(string), "propertyName"); // Assert + Assert.NotNull(metadata.AdditionalValues); + Assert.Empty(metadata.AdditionalValues); Assert.Equal(typeof(Exception), metadata.ContainerType); Assert.Null(metadata.Container); @@ -114,6 +115,58 @@ namespace Microsoft.AspNet.Mvc.ModelBinding Assert.Null(metadata.PropertyBindingPredicateProvider); } + + // AdditionalValues + + [Fact] + public void AdditionalValues_CreatedOnce() + { + + // Arrange + var provider = new EmptyModelMetadataProvider(); + var metadata = new ModelMetadata( + provider, + containerType: null, + modelAccessor: () => null, + modelType: typeof(object), + propertyName: null); + + // Act + var result1 = metadata.AdditionalValues; + var result2 = metadata.AdditionalValues; + + // Assert + Assert.Same(result1, result2); + } + + [Fact] + public void AdditionalValues_ChangesPersist() + { + // Arrange + var provider = new EmptyModelMetadataProvider(); + var metadata = new ModelMetadata( + provider, + containerType: null, + modelAccessor: () => null, + modelType: typeof(object), + propertyName: null); + var valuesDictionary = new Dictionary + { + { "key1", new object() }, + { "key2", "value2" }, + { "key3", new object() }, + }; + + // Act + foreach (var keyValuePair in valuesDictionary) + { + metadata.AdditionalValues.Add(keyValuePair); + } + + // Assert + Assert.Equal(valuesDictionary, metadata.AdditionalValues); + } + // IsComplexType private struct IsComplexTypeModel @@ -304,7 +357,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding { get { - // ModelMetadata does not reorder properties Reflection returns without an Order override. + // ModelMetadata does not reorder properties the provider returns without an Order override. return new TheoryData, IEnumerable> { { @@ -329,7 +382,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding [Theory] [MemberData(nameof(PropertyNamesTheoryData))] - public void PropertiesProperty_WithDefaultOrder_OrdersPropertyNamesAlphabetically( + public void PropertiesProperty_WithDefaultOrder_OrdersPropertyNamesAsProvided( IEnumerable originalNames, IEnumerable expectedNames) { @@ -418,7 +471,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding [Theory] [MemberData(nameof(PropertyNamesAndOrdersTheoryData))] - public void PropertiesProperty_OrdersPropertyNamesUsingOrder_ThenAlphabetically( + public void PropertiesProperty_OrdersPropertyNamesUsingOrder_ThenAsProvided( IEnumerable> originalNamesAndOrders, IEnumerable expectedNames) { diff --git a/test/WebSites/ModelBindingWebSite/AdditionalValuesMetadataProvider.cs b/test/WebSites/ModelBindingWebSite/AdditionalValuesMetadataProvider.cs new file mode 100644 index 0000000000..59a50670d3 --- /dev/null +++ b/test/WebSites/ModelBindingWebSite/AdditionalValuesMetadataProvider.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Microsoft.AspNet.Mvc.ModelBinding; +using ModelBindingWebSite.Models; + +namespace ModelBindingWebSite +{ + public class AdditionalValuesMetadataProvider : DataAnnotationsModelMetadataProvider + { + public static readonly string GroupNameKey = "__GroupName"; + private static Guid _guid = new Guid("7d6d0de2-8d59-49ac-99cc-881423b75a76"); + + protected override CachedDataAnnotationsModelMetadata CreateMetadataFromPrototype( + CachedDataAnnotationsModelMetadata prototype, + Func modelAccessor) + { + var metadata = base.CreateMetadataFromPrototype(prototype, modelAccessor); + foreach (var keyValuePair in prototype.AdditionalValues) + { + metadata.AdditionalValues.Add(keyValuePair); + } + + return metadata; + } + + protected override CachedDataAnnotationsModelMetadata CreateMetadataPrototype( + IEnumerable attributes, + Type containerType, + Type modelType, + string propertyName) + { + var metadata = base.CreateMetadataPrototype(attributes, containerType, modelType, propertyName); + if (modelType == typeof(LargeModelWithValidation)) + { + metadata.AdditionalValues.Add("key1", _guid); + metadata.AdditionalValues.Add("key2", "value2"); + } + + var displayAttribute = attributes.OfType().FirstOrDefault(); + var groupName = displayAttribute?.GroupName; + if (!string.IsNullOrEmpty(groupName)) + { + metadata.AdditionalValues[GroupNameKey] = groupName; + } + + return metadata; + } + } +} \ No newline at end of file diff --git a/test/WebSites/ModelBindingWebSite/Controllers/ModelMetadataController.cs b/test/WebSites/ModelBindingWebSite/Controllers/ModelMetadataController.cs new file mode 100644 index 0000000000..9af915be98 --- /dev/null +++ b/test/WebSites/ModelBindingWebSite/Controllers/ModelMetadataController.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.AspNet.Mvc; +using Microsoft.AspNet.Mvc.ModelBinding; +using ModelBindingWebSite.Models; +using ModelBindingWebSite.ViewModels; + +namespace ModelBindingWebSite.Controllers +{ + public class ModelMetadataController + { + [HttpGet(template: "/AdditionalValues")] + public IDictionary GetAdditionalValues([FromServices] IModelMetadataProvider provider) + { + var metadata = provider.GetMetadataForType( + modelAccessor: null, + modelType: typeof(LargeModelWithValidation)); + + return metadata.AdditionalValues; + } + + [HttpGet(template: "/GroupNames")] + public IDictionary GetGroupNames([FromServices] IModelMetadataProvider provider) + { + var groupNames = new Dictionary(); + var metadata = provider.GetMetadataForType( + modelAccessor: null, + modelType: typeof(VehicleViewModel)); + foreach (var property in metadata.Properties) + { + groupNames.Add(property.PropertyName, property.GetGroupName()); + } + + return groupNames; + } + } +} \ No newline at end of file diff --git a/test/WebSites/ModelBindingWebSite/ModelMetadataExtensions.cs b/test/WebSites/ModelBindingWebSite/ModelMetadataExtensions.cs new file mode 100644 index 0000000000..f58fffb6e1 --- /dev/null +++ b/test/WebSites/ModelBindingWebSite/ModelMetadataExtensions.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc.ModelBinding; + +namespace ModelBindingWebSite +{ + /// + /// Extensions for . + /// + public static class ModelMetadataExtensions + { + /// + /// Gets the group name associated with given . + /// + /// The + /// Group name associated with given . + public static string GetGroupName(this ModelMetadata modelMetadata) + { + object groupName; + modelMetadata.AdditionalValues.TryGetValue(AdditionalValuesMetadataProvider.GroupNameKey, out groupName); + + return groupName as string; + } + } +} \ No newline at end of file diff --git a/test/WebSites/ModelBindingWebSite/Startup.cs b/test/WebSites/ModelBindingWebSite/Startup.cs index 9d1931cb04..7ceaad66ac 100644 --- a/test/WebSites/ModelBindingWebSite/Startup.cs +++ b/test/WebSites/ModelBindingWebSite/Startup.cs @@ -3,6 +3,7 @@ using Microsoft.AspNet.Builder; using Microsoft.AspNet.Mvc; +using Microsoft.AspNet.Mvc.ModelBinding; using Microsoft.Framework.DependencyInjection; using ModelBindingWebSite.Services; @@ -17,6 +18,10 @@ namespace ModelBindingWebSite // Set up application services app.UseServices(services => { + // Override the IModelMetadataProvider AddMvc would normally use. Make service a singleton though + // AddMvc would configure DataAnnotationsModelMetadataProvider as transient. + services.AddSingleton(); + // Add MVC services to the services container services.AddMvc(configuration) .Configure(m => diff --git a/test/WebSites/ModelBindingWebSite/ViewModels/VehicleViewModel.cs b/test/WebSites/ModelBindingWebSite/ViewModels/VehicleViewModel.cs index db45af459b..32f956d4f4 100644 --- a/test/WebSites/ModelBindingWebSite/ViewModels/VehicleViewModel.cs +++ b/test/WebSites/ModelBindingWebSite/ViewModels/VehicleViewModel.cs @@ -15,10 +15,10 @@ namespace ModelBindingWebSite.ViewModels [StringLength(8)] public string Vin { get; set; } - [Display(Order = 1)] + [Display(Order = 1, GroupName = "MakeAndModelGroup")] public string Make { get; set; } - [Display(Order = 0)] + [Display(Order = 0, GroupName = "MakeAndModelGroup")] public string Model { get; set; } // Placed using default Order (10000). @@ -31,7 +31,7 @@ namespace ModelBindingWebSite.ViewModels [MaxLength(10)] public DateTimeOffset[] InspectedDates { get; set; } - [Display(Order = 20000)] + [Display(Order = 20000, GroupName = "TrackingIdGroup")] public string LastUpdatedTrackingId { get; set; } public IEnumerable Validate(ValidationContext validationContext)