// Copyright (c) Microsoft Corporation. All rights reserved. See License.txt in the project root for license information. using System; using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; using System.Reflection; using System.Web.Http; using System.Web.Http.Controllers; namespace Microsoft.Web.Http.Data { /// /// Represents a set of changes to be processed by a . /// public sealed class ChangeSet { private IEnumerable _changeSetEntries; /// /// Initializes a new instance of the ChangeSet class /// /// The set of items this represents. /// if is null. public ChangeSet(IEnumerable changeSetEntries) { if (changeSetEntries == null) { throw Error.ArgumentNull("changeSetEntries"); } // ensure the changeset is valid ValidateChangeSetEntries(changeSetEntries); _changeSetEntries = changeSetEntries; } /// /// Gets the set of items this represents. /// public ReadOnlyCollection ChangeSetEntries { get { return _changeSetEntries.ToList().AsReadOnly(); } } /// /// Gets a value indicating whether any of the items has an error. /// public bool HasError { get { return _changeSetEntries.Any(op => op.HasConflict || (op.ValidationErrors != null && op.ValidationErrors.Any())); } } /// /// Returns the original unmodified entity for the provided . /// /// /// Note that only members marked with will be set /// in the returned instance. /// /// The entity type. /// The client modified entity. /// The original unmodified entity for the provided . /// if is null. /// if is not in the change set. public TEntity GetOriginal(TEntity clientEntity) where TEntity : class { if (clientEntity == null) { throw Error.ArgumentNull("clientEntity"); } ChangeSetEntry entry = _changeSetEntries.FirstOrDefault(p => Object.ReferenceEquals(p.Entity, clientEntity)); if (entry == null) { throw Error.Argument(Resource.ChangeSet_ChangeSetEntryNotFound); } if (entry.Operation == ChangeOperation.Insert) { throw Error.InvalidOperation(Resource.ChangeSet_OriginalNotValidForInsert); } return (TEntity)entry.OriginalEntity; } /// /// Validates that the specified entries are well formed. /// /// The changeset entries to validate. private static void ValidateChangeSetEntries(IEnumerable changeSetEntries) { HashSet idSet = new HashSet(); HashSet entitySet = new HashSet(); foreach (ChangeSetEntry entry in changeSetEntries) { // ensure Entity is not null if (entry.Entity == null) { throw Error.InvalidOperation(Resource.InvalidChangeSet, Resource.InvalidChangeSet_NullEntity); } // ensure unique client IDs if (idSet.Contains(entry.Id)) { throw Error.InvalidOperation(Resource.InvalidChangeSet, Resource.InvalidChangeSet_DuplicateId); } idSet.Add(entry.Id); // ensure unique entity instances - there can only be a single entry // for a given entity instance if (entitySet.Contains(entry.Entity)) { throw Error.InvalidOperation(Resource.InvalidChangeSet, Resource.InvalidChangeSet_DuplicateEntity); } entitySet.Add(entry.Entity); // entities must be of the same type if (entry.OriginalEntity != null && !(entry.Entity.GetType() == entry.OriginalEntity.GetType())) { throw Error.InvalidOperation(Resource.InvalidChangeSet, Resource.InvalidChangeSet_MustBeSameType); } if (entry.Operation == ChangeOperation.Insert && entry.OriginalEntity != null) { throw Error.InvalidOperation(Resource.InvalidChangeSet, Resource.InvalidChangeSet_InsertsCantHaveOriginal); } } // now that we have the full Id space, we can validate associations foreach (ChangeSetEntry entry in changeSetEntries) { if (entry.Associations != null) { ValidateAssociationMap(entry.Entity.GetType(), idSet, entry.Associations); } if (entry.OriginalAssociations != null) { ValidateAssociationMap(entry.Entity.GetType(), idSet, entry.OriginalAssociations); } } } /// /// Validates the specified association map. /// /// The entity type the association is on. /// The set of all unique Ids in the changeset. /// The association map to validate. private static void ValidateAssociationMap(Type entityType, HashSet idSet, IDictionary associationMap) { PropertyDescriptorCollection properties = TypeDescriptor.GetProperties(entityType); foreach (var associationItem in associationMap) { // ensure that the member is an association member string associationMemberName = associationItem.Key; PropertyDescriptor associationMember = properties[associationMemberName]; if (associationMember == null || associationMember.Attributes[typeof(AssociationAttribute)] == null) { throw Error.InvalidOperation(Resource.InvalidChangeSet, String.Format(CultureInfo.CurrentCulture, Resource.InvalidChangeSet_InvalidAssociationMember, entityType, associationMemberName)); } // ensure that the id collection is not null if (associationItem.Value == null) { throw Error.InvalidOperation(Resource.InvalidChangeSet, String.Format(CultureInfo.CurrentCulture, Resource.InvalidChangeSet_AssociatedIdsCannotBeNull, entityType, associationMemberName)); } // ensure that each Id specified is in the changeset foreach (int id in associationItem.Value) { if (!idSet.Contains(id)) { throw Error.InvalidOperation(Resource.InvalidChangeSet, String.Format(CultureInfo.CurrentCulture, Resource.InvalidChangeSet_AssociatedIdNotInChangeset, id, entityType, associationMemberName)); } } } } /// /// Reestablish associations based on Id lists by adding the referenced entities /// to their association members /// internal void SetEntityAssociations() { // create a unique map from Id to entity instances, and update operations // so Ids map to the same instances, since during deserialization reference // identity is not maintained. var entityIdMap = _changeSetEntries.ToDictionary(p => p.Id, p => new { Entity = p.Entity, OriginalEntity = p.OriginalEntity }); foreach (ChangeSetEntry changeSetEntry in _changeSetEntries) { object entity = entityIdMap[changeSetEntry.Id].Entity; if (changeSetEntry.Entity != entity) { changeSetEntry.Entity = entity; } object original = entityIdMap[changeSetEntry.Id].OriginalEntity; if (original != null && changeSetEntry.OriginalEntity != original) { changeSetEntry.OriginalEntity = original; } } // for all entities with associations, reestablish the associations by mapping the Ids // to entity instances and adding them to the association members HashSet visited = new HashSet(); foreach (var entityGroup in _changeSetEntries.Where(p => (p.Associations != null && p.Associations.Count > 0) || (p.OriginalAssociations != null && p.OriginalAssociations.Count > 0)).GroupBy(p => p.Entity.GetType())) { Dictionary associationMemberMap = TypeDescriptor.GetProperties(entityGroup.Key).Cast().Where(p => p.Attributes[typeof(AssociationAttribute)] != null).ToDictionary(p => p.Name); foreach (ChangeSetEntry changeSetEntry in entityGroup) { if (visited.Contains(changeSetEntry.Id)) { continue; } visited.Add(changeSetEntry.Id); // set current associations if (changeSetEntry.Associations != null) { foreach (var associationItem in changeSetEntry.Associations) { PropertyDescriptor assocMember = associationMemberMap[associationItem.Key]; IEnumerable children = associationItem.Value.Select(p => entityIdMap[p].Entity); SetAssociationMember(changeSetEntry.Entity, assocMember, children); } } } } } internal bool Validate(HttpActionContext actionContext) { // Validate all entries except those with type None or Delete (since we don't want to validate // entites we're going to delete). bool success = true; IEnumerable entriesToValidate = ChangeSetEntries.Where( p => (p.ActionDescriptor != null && p.Operation != ChangeOperation.None && p.Operation != ChangeOperation.Delete) || (p.EntityActions != null && p.EntityActions.Any())); foreach (ChangeSetEntry entry in entriesToValidate) { // TODO: optimize by determining whether a type actually requires any validation? // TODO: support for method level / parameter validation? List validationErrors = new List(); if (!DataControllerValidation.ValidateObject(entry.Entity, validationErrors, actionContext)) { entry.ValidationErrors = validationErrors.Distinct(EqualityComparer.Default).ToList(); success = false; } // clear after each validate call, since we've already // copied over the errors actionContext.ModelState.Clear(); } return success; } /// /// Adds the specified associated entities to the specified association member for the specified entity. /// /// The entity /// The association member (singleton or collection) /// Collection of associated entities private static void SetAssociationMember(object entity, PropertyDescriptor associationProperty, IEnumerable associatedEntities) { if (associatedEntities.Count() == 0) { return; } object associationValue = associationProperty.GetValue(entity); if (typeof(IEnumerable).IsAssignableFrom(associationProperty.PropertyType)) { if (associationValue == null) { throw Error.InvalidOperation(Resource.DataController_AssociationCollectionPropertyIsNull, associationProperty.ComponentType.Name, associationProperty.Name); } IList list = associationValue as IList; IEnumerable associationSequence = null; MethodInfo addMethod = null; if (list == null) { // not an IList, so we have to use reflection Type associatedEntityType = TypeUtility.GetElementType(associationValue.GetType()); addMethod = associationValue.GetType().GetMethod("Add", BindingFlags.Public | BindingFlags.Instance, null, new Type[] { associatedEntityType }, null); if (addMethod == null) { throw Error.InvalidOperation(Resource.DataController_InvalidCollectionMember, associationProperty.Name); } associationSequence = ((IEnumerable)associationValue).Cast(); } foreach (object associatedEntity in associatedEntities) { // add the entity to the collection if it's not already there if (list != null) { if (!list.Contains(associatedEntity)) { list.Add(associatedEntity); } } else { if (!associationSequence.Contains(associatedEntity)) { addMethod.Invoke(associationValue, new object[] { associatedEntity }); } } } } else { // set the reference if it's not already set object associatedEntity = associatedEntities.Single(); object currentValue = associationProperty.GetValue(entity); if (!Object.Equals(currentValue, associatedEntity)) { associationProperty.SetValue(entity, associatedEntity); } } } } }