Technique for carrying metadata to View Models with AutoMapper
Asked Answered
H

3

16

I use AutoMapper to map my domain objects to my view models. I have metadata in my domain layer, that I would like to carry over to the view layer and into ModelMetadata. (This metadata is not UI logic, but provides necessary information to my views).

Right now, my solution is to use a separate MetadataProvider (independently of ASP.NET MVC), and use conventions to apply the relevant metadata to the ModelMetadata object via an AssociatedMetadataProvider. The problem with this approach is that I have to test for the same conventions when binding the ModelMetadata from the domain as I do with my AutoMapping, and it seems like there should be a way to make this more orthogonal. Can anyone recommend a better way to accomplish this?

Herstein answered 3/4, 2012 at 8:35 Comment(1)
Show some code so we can see what you are doing.Fix
M
15

I use the approach below to automatically copy data annotations from my entities to my view model. This ensures that things like StringLength and Required values are always the same for entity/viewmodel.

It works using the Automapper configuration, so works if the properties are named differently on the viewmodel as long as AutoMapper is setup correctly.

You need to create a custom ModelValidatorProvider and custom ModelMetadataProvider to get this to work. My memory on why is a little foggy, but I believe it's so both server and client side validation work, as well as any other formatting you do based on the metadata (eg an asterix next to required fields).

Note: I have simplified my code slightly as I added it below, so there may be a few small issues.

Metadata Provider

public class MetadataProvider : DataAnnotationsModelMetadataProvider
{        
    private IConfigurationProvider _mapper;

    public MetadataProvider(IConfigurationProvider mapper)
    {           
        _mapper = mapper;
    }

    protected override System.Web.Mvc.ModelMetadata CreateMetadata(IEnumerable<Attribute> attributes, Type containerType, Func<object> modelAccessor, Type modelType, string propertyName)
    {           
        //Grab attributes from the entity columns and copy them to the view model
        var mappedAttributes = _mapper.GetMappedAttributes(containerType, propertyName, attributes);

        return base.CreateMetadata(mappedAttributes, containerType, modelAccessor, modelType, propertyName);

}
}

Validator Provivder

public class ValidatorProvider : DataAnnotationsModelValidatorProvider
{
    private IConfigurationProvider _mapper;

    public ValidatorProvider(IConfigurationProvider mapper) 
    {
        _mapper = mapper;
    }

    protected override System.Collections.Generic.IEnumerable<ModelValidator> GetValidators(System.Web.Mvc.ModelMetadata metadata, ControllerContext context, IEnumerable<Attribute> attributes)
    {   
        var mappedAttributes = _mapper.GetMappedAttributes(metadata.ContainerType, metadata.PropertyName, attributes);
        return base.GetValidators(metadata, context, mappedAttributes);
    }
}

Helper Method Referenced in above 2 classes

public static IEnumerable<Attribute> GetMappedAttributes(this IConfigurationProvider mapper, Type sourceType, string propertyName, IEnumerable<Attribute> existingAttributes)
{
    if (sourceType != null)
    {
        foreach (var typeMap in mapper.GetAllTypeMaps().Where(i => i.SourceType == sourceType))
        {
            foreach (var propertyMap in typeMap.GetPropertyMaps())
            {
                if (propertyMap.IsIgnored() || propertyMap.SourceMember == null)
                    continue;

                if (propertyMap.SourceMember.Name == propertyName)
                {
                    foreach (ValidationAttribute attribute in propertyMap.DestinationProperty.GetCustomAttributes(typeof(ValidationAttribute), true))
                    {
                        if (!existingAttributes.Any(i => i.GetType() == attribute.GetType()))
                            yield return attribute;
                    }
                }
            }
        }
    }

    if (existingAttributes != null)
    {
        foreach (var attribute in existingAttributes)
        {
            yield return attribute;
        }
    }

}

Other Notes

  • If you're using dependency injection, make sure your container isn't already replacing the built in metadata provider or validator provider. In my case I was using the Ninject.MVC3 package which bound one of them after creating the kernel, I then had to rebind it afterwards so my class was actually used. I was getting exceptions about Required only being allowed to be added once, took most of a day to track it down.
Morphology answered 11/4, 2012 at 4:58 Comment(18)
this is helpful. I have a similar approach that I use right now, but with my own metadata source (not AutoMapper's). It could be extended to do what yours does as well. Help me understand something: You're passing in metadata.ContainerType as the source type, but it seems like it would be looking for the type of your business object. This makes me think you are (a) getting ModelMetadata for your business object and copying the view model attributes, or (b) mapping your view models to your business objects with AutoMapper (my use case is the opposite). Can you clear this up?Herstein
It looks like the IConfigurationProvider is the place to work with. Looking at the source, it seems like a better approach for my scenario would be wiring up to event EventHandler<TypeMapCreatedEventArgs> TypeMapCreated; in my IoC. Have you tried that kind of approach? It looks like it fires every time a type is created, so I can hook that into my existing metadata providerHerstein
I map both directions with AutoMapper. This code is for applying the metadata from the business objects to my viewmodels, however I am using the mappings that go the other direction to find the metadata. No particular reason that I'm aware of, and now that you mention it is does seem a tad odd.Morphology
I hadn't actually seen that event before. I could use it to create and cache the metadata mappings, however still need both providers for MVC to consume it. I could easily just add caching to my extension method instead so I don't really see the benefit of using the event. If you do end up using it I'd love to hear about it.Morphology
metadata.ContainerType is the type of the ViewModel. It would be a trivial change to look for that type in the mapping destination instead of the mapping source.Morphology
Okay, I get what your doing now. I use AutoMapper for uni-directional mapping, so it wasn't immediately clear what you were getting at.Herstein
Have you figured it all out now, or do you still need some help?Morphology
I haven't coded it yet, but I'm pretty sure I've got what I need. I haven't selected your answer, because as written, it doesn't directly answer my question, and I'm afraid it would be misleading to other users. However, I'll be happy to select it if you add the additional knowledge from our comment-exchange into your answer.Herstein
tbh it'd probably be better if you post your own answer once you've coded it up.Morphology
Trying to use your code sample above, but the compiler (and ReSharper) can't figure out where GetCustomAttributes(typeof(ValidationAttribute), true) lives -- is that an AutoMapper extension method that might have gone away in v3.0?Natachanatal
It's built into .NET System.Reflection.ICustomAttributeProvider.GetCustomAttributes, however it's possible that DestinationProperty isn't an IMemberAccessor anymore.Morphology
Looks like AutoMapper's IMemberGetter interface no longer inherits ICustomAttributeProvider - looks like he removed it in Feb of 2013. No idea if there's some other means of getting the same functionality or not, but I'm definitely bummed as I'd like to be able to use your solution!Natachanatal
Think I found a way forward -- DestinationProperty now has a MemberInfo property that is an ICustomAttributeProvider so the code changes to propertyMap.DestinationProperty.MemberInfo.GetCustomAttributes(typeof (ValidationAttribute), true))Natachanatal
Could somebody be so kind and demonstrate how to use the solution provided here? I've placed a sample at github github.com/draptik/AutoMappingAnnotationsDemo including some very simple tests.Accidental
@Betty, I've used your example to successfully inherit the Required data annotations. Additionally, I'm implementing IValidatableObject in my domain models and specifying validation rules in the Validate method, but these don't apply. Am I expecting too much!?Sac
@ChrisHaines yes this will only copy validation attributes.Morphology
@Morphology I have posted an answer that extends your idea to include this.Sac
So close to getting this to work. Getting the "multiple required attributes" error that @Morphology mentioned in Other Notes, but I don't quite understand what her solution is.Sweat
B
1

if your metadata are provided with attributes define the attributes in MetaDataTypes, then apply the same MetaDataType to both your domain class and to your viewmodels. You can define all MetaDataTypes in a separate dll that is reference by both layers. There are some issues with this approach if your ViewModel classes have not some properties that is used in the MetaDataType, but this can be fixed with a custom Provider(I have the code if youlike this approach).

Brachiate answered 5/4, 2012 at 18:53 Comment(4)
The metadata comes from various locations, and I do not currently have any dedicated 'Metadata' classes (nor do I want them). I think this may be a good place for me to look for an extensibility point though. ThanksHerstein
You might write a kind of "broker" metadata provider that simply rediredt the metadata retrieval from a type to another. Then you might the "redirecting" the ViewModel class to the domain type.Brachiate
That's basically what I have going on right now. The problem is that the 'broker' evaluates the ModelMetadata object to get a reference to the Metadata for the mapped Domain Object. And, the logic required to know what the view is a projection of is pretty much the same logic that I use for AutoMapping. I want to get rid of the redundancy.Herstein
Give a look to automapper ITypeConverter github.com/AutoMapper/AutoMapper/wiki/Custom-type-converters It allows you to customize the mapping. This way you can add the logics to retrieve the metadata from the source type an you can put it into a dictionary indexed by the pair (type/propertyName or PropertyInfo). Then a custom metadata provider access this dictionary to retrieve metadata ... it is a non trivial job ...but it appears to me feasibleBrachiate
S
1

Betty's solution is excellent for "inheriting" data annotations. I have extended this idea to also include validation provided by IValidatableObject.

public class MappedModelValidatorProvider : DataAnnotationsModelValidatorProvider
{
    private readonly IMapper _mapper;

    public MappedModelValidatorProvider(IMapper mapper)
    {
        _mapper = mapper;
    }

    protected override IEnumerable<ModelValidator> GetValidators(ModelMetadata metadata, ControllerContext context, IEnumerable<Attribute> attributes)
    {
        var mappedAttributes = _mapper.ConfigurationProvider.GetMappedAttributes(metadata.ContainerType, metadata.PropertyName, attributes);
        foreach (var validator in base.GetValidators(metadata, context, mappedAttributes))
        {
            yield return validator;
        }
        foreach (var typeMap in _mapper.ConfigurationProvider.GetAllTypeMaps().Where(i => i.SourceType == metadata.ModelType))
        {
            if (typeof(IValidatableObject).IsAssignableFrom(typeMap.DestinationType))
            {
                var model = _mapper.Map(metadata.Model, typeMap.SourceType, typeMap.DestinationType);
                var modelMetadata = ModelMetadataProviders.Current.GetMetadataForType(() => model, typeMap.DestinationType);
                yield return new ValidatableObjectAdapter(modelMetadata, context);
            }
        }
    }
}

Then in Global.asax.cs:

ModelValidatorProviders.Providers.Clear();
ModelValidatorProviders.Providers.Add(new MappedModelValidatorProvider(Mapper.Instance));
Sac answered 3/3, 2017 at 9:36 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.