diff --git a/src/Umbraco.Core/Events/DeleteEventArgs.cs b/src/Umbraco.Core/Events/DeleteEventArgs.cs index df13363b95..64cd4720cd 100644 --- a/src/Umbraco.Core/Events/DeleteEventArgs.cs +++ b/src/Umbraco.Core/Events/DeleteEventArgs.cs @@ -3,6 +3,10 @@ using System.Collections.Generic; namespace Umbraco.Core.Events { + [SupersedeEvent(typeof(SaveEventArgs<>))] + [SupersedeEvent(typeof(PublishEventArgs<>))] + [SupersedeEvent(typeof(MoveEventArgs<>))] + [SupersedeEvent(typeof(CopyEventArgs<>))] public class DeleteEventArgs : CancellableObjectEventArgs>, IEquatable>, IDeletingMediaFilesEventArgs { /// diff --git a/src/Umbraco.Core/Events/ScopeEventDispatcherBase.cs b/src/Umbraco.Core/Events/ScopeEventDispatcherBase.cs index 14990cad73..01371f97c6 100644 --- a/src/Umbraco.Core/Events/ScopeEventDispatcherBase.cs +++ b/src/Umbraco.Core/Events/ScopeEventDispatcherBase.cs @@ -8,6 +8,7 @@ namespace Umbraco.Core.Events { public abstract class ScopeEventDispatcherBase : IEventDispatcher { + //events will be enlisted in the order they are raised private List _events; private readonly bool _raiseCancelable; @@ -70,72 +71,161 @@ namespace Umbraco.Core.Events switch (filter) { case EventDefinitionFilter.All: - UpdateToLatestEntity(_events); - return _events; + return FilterSupersededAndUpdateToLatestEntity(_events); case EventDefinitionFilter.FirstIn: var l1 = new OrderedHashSet(); foreach (var e in _events) { l1.Add(e); } - UpdateToLatestEntity(l1); - return l1; + return FilterSupersededAndUpdateToLatestEntity(l1); case EventDefinitionFilter.LastIn: var l2 = new OrderedHashSet(keepOldest: false); foreach (var e in _events) { l2.Add(e); } - UpdateToLatestEntity(l2); - return l2; + return FilterSupersededAndUpdateToLatestEntity(l2); default: throw new ArgumentOutOfRangeException("filter", filter, null); } } - - private void UpdateToLatestEntity(IEnumerable events) + + private class EventDefinitionTypeData { - //used to keep the 'latest' entity - var allEntities = new List(); + public IEventDefinition EventDefinition { get; set; } + public Type EventArgType { get; set; } + public SupersedeEventAttribute[] SupersedeAttributes { get; set; } + } + + /// + /// This will iterate over the events (latest first) and filter out any events or entities in event args that are included + /// in more recent events that Supersede previous ones. For example, If an Entity has been Saved and then Deleted, we don't want + /// to raise the Saved event (well actually we just don't want to include it in the args for that saved event) + /// + /// + /// + private static IEnumerable FilterSupersededAndUpdateToLatestEntity(IReadOnlyList events) + { + //used to keep the 'latest' entity and associated event definition data + var allEntities = new List>(); + + //tracks all CancellableObjectEventArgs instances in the events which is the only type of args we can work with var cancelableArgs = new List(); - - foreach (var eventDefinition in events) + + var result = new List(); + + //This will eagerly load all of the event arg types and their attributes so we don't have to continuously look this data up + var allArgTypesWithAttributes = events.Select(x => x.Args.GetType()) + .Distinct() + .ToDictionary(x => x, x => x.GetCustomAttributes(false).ToArray()); + + //Iterate all events and collect the actual entities in them and relates them to their corresponding EventDefinitionTypeData + //we'll process the list in reverse because events are added in the order they are raised and we want to filter out + //any entities from event args that are not longer relevant + //(i.e. if an item is Deleted after it's Saved, we won't include the item in the Saved args) + for (var index = events.Count - 1; index >= 0; index--) { + var eventDefinition = events[index]; + + var argType = eventDefinition.Args.GetType(); + var attributes = allArgTypesWithAttributes[eventDefinition.Args.GetType()]; + + var meta = new EventDefinitionTypeData + { + EventDefinition = eventDefinition, + EventArgType = argType, + SupersedeAttributes = attributes + }; + var args = eventDefinition.Args as CancellableObjectEventArgs; if (args != null) { - cancelableArgs.Add(args); - var list = TypeHelper.CreateGenericEnumerableFromOjbect(args.EventObject); - + if (list == null) { //extract the event object var obj = args.EventObject as IEntity; if (obj != null) { - allEntities.Add(obj); + //Now check if this entity already exists in other event args that supersede this current event arg type + if (IsFiltered(obj, meta, allEntities) == false) + { + //if it's not filtered we can adde these args to the response + cancelableArgs.Add(args); + result.Add(eventDefinition); + //track the entity + allEntities.Add(Tuple.Create(obj, meta)); + } } } else { + var toRemove = new List(); foreach (var entity in list) { //extract the event object var obj = entity as IEntity; if (obj != null) { - allEntities.Add(obj); + //Now check if this entity already exists in other event args that supersede this current event arg type + if (IsFiltered(obj, meta, allEntities)) + { + //track it to be removed + toRemove.Add(obj); + } + else + { + //track the entity, it's not filtered + allEntities.Add(Tuple.Create(obj, meta)); + } } } + + //remove anything that has been filtered + foreach (var entity in toRemove) + { + list.Remove(entity); + } + + //track the event and include in the response if there's still entities remaining in the list + if (list.Count > 0) + { + if (toRemove.Count > 0) + { + //re-assign if the items have changed + args.EventObject = list; + } + cancelableArgs.Add(args); + result.Add(eventDefinition); + } } } + else + { + //it's not a cancelable event arg so we just include it in the result + result.Add(eventDefinition); + } } + //Now we'll deal with ensuring that only the latest(non stale) entities are used throughout all event args + UpdateToLatestEntities(allEntities, cancelableArgs); + + //we need to reverse the result since we've been adding by latest added events first! + result.Reverse(); + + return result; + } + + private static void UpdateToLatestEntities(IEnumerable> allEntities, IEnumerable cancelableArgs) + { + //Now we'll deal with ensuring that only the latest(non stale) entities are used throughout all event args + var latestEntities = new OrderedHashSet(keepOldest: true); - foreach (var entity in allEntities.OrderByDescending(entity => entity.UpdateDate)) + foreach (var entity in allEntities.OrderByDescending(entity => entity.Item1.UpdateDate)) { - latestEntities.Add(entity); + latestEntities.Add(entity.Item1); } foreach (var args in cancelableArgs) @@ -155,7 +245,7 @@ namespace Umbraco.Core.Events else { var updated = false; - + for (int i = 0; i < list.Count; i++) { //try to find the args entity in the latest entity - based on the equality operators, this will @@ -177,6 +267,55 @@ namespace Umbraco.Core.Events } } + /// + /// This will check against all of the processed entity/events (allEntities) to see if this entity already exists in + /// event args that supersede the event args being passed in and if so returns true. + /// + /// + /// + /// + /// + private static bool IsFiltered( + IEntity entity, + EventDefinitionTypeData eventDef, + List> allEntities) + { + var argType = eventDef.EventDefinition.Args.GetType(); + + //check if the entity is found in any processed event data that could possible supersede this one + var foundByEntity = allEntities + .Where(x => x.Item2.SupersedeAttributes.Length > 0 + //if it's the same arg type than it cannot supersede + && x.Item2.EventArgType != argType + && Equals(x.Item1, entity)) + .ToArray(); + + //no args have been processed with this entity so it should not be filtered + if (foundByEntity.Length == 0) + return false; + + if (argType.IsGenericType) + { + var supercededBy = foundByEntity + .FirstOrDefault(x => + x.Item2.SupersedeAttributes.Any(y => + //if the attribute type is a generic type def then compare with the generic type def of the event arg + (y.SupersededEventArgsType.IsGenericTypeDefinition && y.SupersededEventArgsType == argType.GetGenericTypeDefinition()) + //if the attribute type is not a generic type def then compare with the normal type of the event arg + || (y.SupersededEventArgsType.IsGenericTypeDefinition == false && y.SupersededEventArgsType == argType))); + return supercededBy != null; + } + else + { + var supercededBy = foundByEntity + .FirstOrDefault(x => + x.Item2.SupersedeAttributes.Any(y => + //since the event arg type is not a generic type, then we just compare type 1:1 + y.SupersededEventArgsType == argType)); + return supercededBy != null; + } + } + public void ScopeExit(bool completed) { if (_events == null) return; diff --git a/src/Umbraco.Core/Events/SupersedeEventAttribute.cs b/src/Umbraco.Core/Events/SupersedeEventAttribute.cs new file mode 100644 index 0000000000..c7a14ea158 --- /dev/null +++ b/src/Umbraco.Core/Events/SupersedeEventAttribute.cs @@ -0,0 +1,20 @@ +using System; + +namespace Umbraco.Core.Events +{ + /// + /// This is used to know if the event arg attributed should supersede another event arg type when + /// tracking events for the same entity. If one event args supercedes another then the event args that have been superseded + /// will mean that the event will not be dispatched or the args will be filtered to exclude the entity. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + internal class SupersedeEventAttribute : Attribute + { + public Type SupersededEventArgsType { get; private set; } + + public SupersedeEventAttribute(Type supersededEventArgsType) + { + SupersededEventArgsType = supersededEventArgsType; + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 74cbe5d534..eae33101f8 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -339,6 +339,7 @@ + diff --git a/src/Umbraco.Tests/Scoping/ScopeEventDispatcherTests.cs b/src/Umbraco.Tests/Scoping/ScopeEventDispatcherTests.cs index a4e48c07fe..b3b1ab271f 100644 --- a/src/Umbraco.Tests/Scoping/ScopeEventDispatcherTests.cs +++ b/src/Umbraco.Tests/Scoping/ScopeEventDispatcherTests.cs @@ -92,6 +92,55 @@ namespace Umbraco.Tests.Scoping } } + [Test] + public void SupersededEvents() + { + DoSaveForContent += OnDoThingFail; + DoDeleteForContent += OnDoThingFail; + DoForTestArgs += OnDoThingFail; + DoForTestArgs2 += OnDoThingFail; + + var contentType = MockedContentTypes.CreateBasicContentType(); + + var content1 = MockedContent.CreateBasicContent(contentType); + content1.Id = 123; + + var content2 = MockedContent.CreateBasicContent(contentType); + content2.Id = 456; + + var content3 = MockedContent.CreateBasicContent(contentType); + content3.Id = 789; + + var scopeProvider = new ScopeProvider(Mock.Of()); + using (var scope = scopeProvider.CreateScope(eventDispatcher: new PassiveEventDispatcher())) + { + + //content1 will be filtered from the args + scope.Events.Dispatch(DoSaveForContent, this, new SaveEventArgs(new[]{ content1 , content3})); + scope.Events.Dispatch(DoDeleteForContent, this, new DeleteEventArgs(content1)); + scope.Events.Dispatch(DoSaveForContent, this, new SaveEventArgs(content2)); + //this entire event will be filtered + scope.Events.Dispatch(DoForTestArgs, this, new TestEventArgs(content1)); + scope.Events.Dispatch(DoForTestArgs2, this, new TestEventArgs2(content1)); + + // events have been queued + var events = scope.Events.GetEvents(EventDefinitionFilter.All).ToArray(); + Assert.AreEqual(4, events.Length); + + Assert.AreEqual(typeof(SaveEventArgs), events[0].Args.GetType()); + Assert.AreEqual(1, ((SaveEventArgs)events[0].Args).SavedEntities.Count()); + Assert.AreEqual(content3.Id, ((SaveEventArgs)events[0].Args).SavedEntities.First().Id); + + Assert.AreEqual(typeof(DeleteEventArgs), events[1].Args.GetType()); + Assert.AreEqual(content1.Id, ((DeleteEventArgs) events[1].Args).DeletedEntities.First().Id); + + Assert.AreEqual(typeof(SaveEventArgs), events[2].Args.GetType()); + Assert.AreEqual(content2.Id, ((SaveEventArgs)events[2].Args).SavedEntities.First().Id); + + Assert.AreEqual(typeof(TestEventArgs2), events[3].Args.GetType()); + } + } + /// /// This will test that when we track events that before we Get the events we normalize all of the /// event entities to be the latest one (most current) found amongst the event so that there is @@ -100,7 +149,7 @@ namespace Umbraco.Tests.Scoping [Test] public void LatestEntities() { - DoThingForContent += OnDoThingFail; + DoSaveForContent += OnDoThingFail; var now = DateTime.Now; var contentType = MockedContentTypes.CreateBasicContentType(); @@ -116,11 +165,10 @@ namespace Umbraco.Tests.Scoping var scopeProvider = new ScopeProvider(Mock.Of()); using (var scope = scopeProvider.CreateScope(eventDispatcher: new PassiveEventDispatcher())) - { - - scope.Events.Dispatch(DoThingForContent, this, new SaveEventArgs(content1)); - scope.Events.Dispatch(DoThingForContent, this, new SaveEventArgs(content2)); - scope.Events.Dispatch(DoThingForContent, this, new SaveEventArgs(content3)); + { + scope.Events.Dispatch(DoSaveForContent, this, new SaveEventArgs(content1)); + scope.Events.Dispatch(DoSaveForContent, this, new SaveEventArgs(content2)); + scope.Events.Dispatch(DoSaveForContent, this, new SaveEventArgs(content3)); // events have been queued var events = scope.Events.GetEvents(EventDefinitionFilter.All).ToArray(); @@ -225,14 +273,41 @@ namespace Umbraco.Tests.Scoping Assert.Fail(); } - public static event EventHandler> DoThingForContent; - + public static event EventHandler> DoSaveForContent; + public static event EventHandler> DoDeleteForContent; + public static event EventHandler DoForTestArgs; + public static event EventHandler DoForTestArgs2; public static event EventHandler> DoThing1; public static event EventHandler> DoThing2; public static event TypedEventHandler> DoThing3; + public class TestEventArgs : CancellableObjectEventArgs + { + public TestEventArgs(object eventObject) : base(eventObject) + { + } + + public object MyEventObject + { + get { return EventObject; } + } + } + + [SupersedeEvent(typeof(TestEventArgs))] + public class TestEventArgs2 : CancellableObjectEventArgs + { + public TestEventArgs2(object eventObject) : base(eventObject) + { + } + + public object MyEventObject + { + get { return EventObject; } + } + } + public class PassiveEventDispatcher : ScopeEventDispatcherBase { public PassiveEventDispatcher()