From eaa853f623825371a98fa8fdc6431f7dce6956f2 Mon Sep 17 00:00:00 2001 From: jokokko Date: Thu, 20 Oct 2016 14:59:56 +0300 Subject: [PATCH 1/3] Unify aggregator lookup in ProjectionCollection to use the same aggregator lookup as EventStore.AggregateStream (instead of hardcoded Aggregator). Allows for immutable projections (as demonstrated in unit test). --- .../Events/CustomAggregatorLookupTests.cs | 53 ++++++++++++++++++- .../Projections/ProjectionCollection.cs | 4 +- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/Marten.Testing/Events/CustomAggregatorLookupTests.cs b/src/Marten.Testing/Events/CustomAggregatorLookupTests.cs index 1435e01a26..61a788a500 100644 --- a/src/Marten.Testing/Events/CustomAggregatorLookupTests.cs +++ b/src/Marten.Testing/Events/CustomAggregatorLookupTests.cs @@ -5,13 +5,17 @@ using Baseline; using Marten.Events; using Marten.Events.Projections; +using Marten.Services; +using Marten.Testing.Events.Projections; using Marten.Util; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; using Shouldly; using Xunit; namespace Marten.Testing.Events { - public class CustomAggregatorLookupTests + public class CustomAggregatorLookupTests : DocumentSessionFixture { private readonly EventGraph theGraph = new EventGraph(new StoreOptions()); @@ -21,6 +25,15 @@ public CustomAggregatorLookupTests() // Registering an aggregator lookup that provides aggregator supporting private Apply([Event Type]) methods theGraph.UseAggregatorLookup(new AggregatorLookup(type => typeof(AggregatorUsePrivateApply<>).CloseAndBuildAs(type))); // ENDSAMPLE + + StoreOptions(options => + { + var serializer = new JsonNetSerializer(); + serializer.Customize(c => c.ContractResolver = new ResolvePrivateSetters()); + options.Serializer(serializer); + options.Events.UseAggregatorLookup(new AggregatorLookup(type => typeof(AggregatorUsePrivateApply<>).CloseAndBuildAs(type))); + options.Events.InlineProjections.AggregateStreamsWith(); + }); } [Fact] @@ -34,7 +47,20 @@ public void can_lookup_private_apply_methods() var party = aggregator.Build(stream.Events, null); party.Name.ShouldBe("Destroy the Ring"); - } + } + + + [Fact] + public void can_use_custom_aggregator_with_inline_projection() + { + var quest = new QuestStarted {Name = "Destroy the Ring"}; + var questId = Guid.NewGuid(); + theSession.Events.StartStream(questId, quest); + theSession.SaveChanges(); + + var projection = theSession.Load(questId); + projection.Name.ShouldBe("Destroy the Ring"); + } } public class AggregatorUsePrivateApply : IAggregator where T : class, new() @@ -119,4 +145,27 @@ private void Apply(QuestStarted started) public string Name { get; private set; } } + + internal class ResolvePrivateSetters : DefaultContractResolver + { + protected override JsonProperty CreateProperty( + MemberInfo member, + MemberSerialization memberSerialization) + { + //TODO: Maybe cache + var prop = base.CreateProperty(member, memberSerialization); + + if (!prop.Writable) + { + var property = member as PropertyInfo; + if (property != null) + { + var hasPrivateSetter = property.GetSetMethod(true) != null; + prop.Writable = hasPrivateSetter; + } + } + + return prop; + } + } } \ No newline at end of file diff --git a/src/Marten/Events/Projections/ProjectionCollection.cs b/src/Marten/Events/Projections/ProjectionCollection.cs index 193420fd9c..7b84257c18 100644 --- a/src/Marten/Events/Projections/ProjectionCollection.cs +++ b/src/Marten/Events/Projections/ProjectionCollection.cs @@ -26,8 +26,8 @@ IEnumerator IEnumerable.GetEnumerator() } public AggregationProjection AggregateStreamsWith() where T : class, new() - { - var aggregator = new Aggregator(); + { + var aggregator = _options.Events.AggregateFor(); var finder = new AggregateFinder(); var projection = new AggregationProjection(finder, aggregator); From 95a8f398cbb3b9ec4176447252e452dd30bd21c4 Mon Sep 17 00:00:00 2001 From: jokokko Date: Fri, 21 Oct 2016 10:39:47 +0300 Subject: [PATCH 2/3] Provide shorthand extension method for choosing between two built-in aggregation lookups. Document said extension method. --- .../documentation/events/projections/index.md | 7 +- .../Events/CustomAggregatorLookupTests.cs | 116 ++++++------------ src/Marten/Events/EventGraphExtensions.cs | 23 ++++ src/Marten/Events/Projections/Aggregator.cs | 23 ++-- .../Projections/AggregatorApplyPrivate.cs | 16 +++ .../Events/AggregationLookupStrategy.cs | 37 ++++++ 6 files changed, 134 insertions(+), 88 deletions(-) create mode 100644 src/Marten/Events/EventGraphExtensions.cs create mode 100644 src/Marten/Events/Projections/AggregatorApplyPrivate.cs create mode 100644 src/Marten/Services/Events/AggregationLookupStrategy.cs diff --git a/documentation/documentation/events/projections/index.md b/documentation/documentation/events/projections/index.md index cd28585504..c24fb71bb0 100644 --- a/documentation/documentation/events/projections/index.md +++ b/documentation/documentation/events/projections/index.md @@ -77,6 +77,11 @@ At this point, you would be able to query against `QuestParty` as just another d `EventGraph.UseAggregatorLookup(IAggregatorLookup aggregatorLookup)` can be used to register an `IAggregatorLookup` that is used to look up `IAggregator` for aggregations. This allows e.g. for generic aggregation strategy to be used, rathen than registering aggregators -case-by-case through `EventGraphAddAggregator(IAggregator aggregator)`. +case-by-case through `EventGraphAddAggregator(IAggregator aggregator)`. + +A shorthand extension method `EventGraph.UseAggregatorLookup(this EventGraph eventGraph, AggregationLookupStrategy strategy)` can be used to set default aggregation lookup, whereby + +- `AggregationLookupStrategy.UsePublicApply` resolves aggregators that use public Apply +- `AggregationLookupStrategy.UsePrivateApply` resolves aggregators that use private Apply <[sample:register-custom-aggregator-lookup]> \ No newline at end of file diff --git a/src/Marten.Testing/Events/CustomAggregatorLookupTests.cs b/src/Marten.Testing/Events/CustomAggregatorLookupTests.cs index 61a788a500..fa1593c95f 100644 --- a/src/Marten.Testing/Events/CustomAggregatorLookupTests.cs +++ b/src/Marten.Testing/Events/CustomAggregatorLookupTests.cs @@ -6,6 +6,7 @@ using Marten.Events; using Marten.Events.Projections; using Marten.Services; +using Marten.Services.Events; using Marten.Testing.Events.Projections; using Marten.Util; using Newtonsoft.Json; @@ -16,22 +17,15 @@ namespace Marten.Testing.Events { public class CustomAggregatorLookupTests : DocumentSessionFixture - { - private readonly EventGraph theGraph = new EventGraph(new StoreOptions()); - + { public CustomAggregatorLookupTests() - { - // SAMPLE: register-custom-aggregator-lookup - // Registering an aggregator lookup that provides aggregator supporting private Apply([Event Type]) methods - theGraph.UseAggregatorLookup(new AggregatorLookup(type => typeof(AggregatorUsePrivateApply<>).CloseAndBuildAs(type))); - // ENDSAMPLE - + { StoreOptions(options => { var serializer = new JsonNetSerializer(); serializer.Customize(c => c.ContractResolver = new ResolvePrivateSetters()); options.Serializer(serializer); - options.Events.UseAggregatorLookup(new AggregatorLookup(type => typeof(AggregatorUsePrivateApply<>).CloseAndBuildAs(type))); + options.Events.UseAggregatorLookup(new AggregatorLookup(type => typeof(AggregatorApplyPrivate<>).CloseAndBuildAs(type))); options.Events.InlineProjections.AggregateStreamsWith(); }); } @@ -39,6 +33,9 @@ public CustomAggregatorLookupTests() [Fact] public void can_lookup_private_apply_methods() { + var theGraph = new EventGraph(new StoreOptions()); + theGraph.UseAggregatorLookup(new AggregatorLookup(type => typeof(AggregatorApplyPrivate<>).CloseAndBuildAs(type))); + var aggregator = theGraph.AggregateFor(); var stream = new EventStream(Guid.NewGuid(), false) @@ -51,89 +48,54 @@ public void can_lookup_private_apply_methods() [Fact] - public void can_use_custom_aggregator_with_inline_projection() - { - var quest = new QuestStarted {Name = "Destroy the Ring"}; - var questId = Guid.NewGuid(); - theSession.Events.StartStream(questId, quest); - theSession.SaveChanges(); - - var projection = theSession.Load(questId); - projection.Name.ShouldBe("Destroy the Ring"); - } - } - - public class AggregatorUsePrivateApply : IAggregator where T : class, new() - { - public static readonly string ApplyMethod = "Apply"; - - private readonly IDictionary _aggregations = new Dictionary(); - - - public AggregatorUsePrivateApply() + public void can_set_private_apply_aggregator_through_extension_methods_and_strategy() { - typeof(T).GetMethods(BindingFlags.Instance | BindingFlags.NonPublic) - .Where(x => x.Name == ApplyMethod && x.GetParameters().Length == 1) - .Each(method => - { - var eventType = method.GetParameters().Single().ParameterType; - var step = typeof(AggregationStep<,>) - .CloseAndBuildAs(method, typeof(T), eventType); - - _aggregations.Add(eventType, step); - }); - - Alias = typeof(T).Name.ToTableAlias(); - } - - public Type AggregateType => typeof(T); + var theGraph = new EventGraph(new StoreOptions()); + // SAMPLE: register-custom-aggregator-lookup + // Registering an aggregator lookup that provides aggregator supporting private Apply([Event Type]) methods + theGraph.UseAggregatorLookup(AggregationLookupStrategy.UsePrivateApply); + // ENDSAMPLE - public string Alias { get; } + var aggregator = theGraph.AggregateFor(); - public T Build(IEnumerable events, IDocumentSession session) - { - var state = new T(); + var stream = new EventStream(Guid.NewGuid(), false) + .Add(new QuestStarted { Name = "Destroy the Ring" }); - events.Each(x => x.Apply(state, this)); + var party = aggregator.Build(stream.Events, null); - return state; + party.Name.ShouldBe("Destroy the Ring"); } - public Type[] EventTypes => _aggregations.Keys.ToArray(); - - public AggregatorUsePrivateApply Add(IAggregation aggregation) + [Fact] + public void can_set_aggregator_through_extension_methods_and_strategy() { - if (_aggregations.ContainsKey(typeof(TEvent))) - { - _aggregations[typeof(TEvent)] = aggregation; - } - else - { - _aggregations.Add(typeof(TEvent), aggregation); - } + var theGraph = new EventGraph(new StoreOptions()); + theGraph.UseAggregatorLookup(AggregationLookupStrategy.UsePublicApply); - return this; - } + var aggregator = theGraph.AggregateFor(); - public AggregatorUsePrivateApply Add(Action application) - { - return Add(new AggregationStep(application)); - } + var stream = new EventStream(Guid.NewGuid(), false) + .Add(new QuestStarted { Name = "Destroy the Ring" }); - public IAggregation AggregatorFor() - { - return _aggregations.ContainsKey(typeof(TEvent)) - ? _aggregations[typeof(TEvent)].As>() - : null; + var party = aggregator.Build(stream.Events, null); + + party.Name.ShouldBe("Destroy the Ring"); } + [Fact] + public void can_use_custom_aggregator_with_inline_projection() + { + var quest = new QuestStarted {Name = "Destroy the Ring"}; + var questId = Guid.NewGuid(); + theSession.Events.StartStream(questId, quest); + theSession.SaveChanges(); - public bool AppliesTo(EventStream stream) - { - return stream.Events.Any(x => _aggregations.ContainsKey(x.Data.GetType())); - } + var projection = theSession.Load(questId); + projection.Name.ShouldBe("Destroy the Ring"); + } } + public class AggregateWithPrivateEventApply { public Guid Id { get; set; } diff --git a/src/Marten/Events/EventGraphExtensions.cs b/src/Marten/Events/EventGraphExtensions.cs new file mode 100644 index 0000000000..2476ea1780 --- /dev/null +++ b/src/Marten/Events/EventGraphExtensions.cs @@ -0,0 +1,23 @@ +using Marten.Events.Projections; +using Marten.Services.Events; +using Baseline; + +namespace Marten.Events +{ + public static class EventGraphExtensions + { + public static EventGraph UseAggregatorLookup(this EventGraph eventGraph, AggregationLookupStrategy strategy) + { + if (strategy == AggregationLookupStrategy.UsePublicApply) + { + eventGraph.UseAggregatorLookup(new AggregatorLookup(type => typeof(Aggregator<>).CloseAndBuildAs(type))); + } + else if (strategy == AggregationLookupStrategy.UsePrivateApply) + { + eventGraph.UseAggregatorLookup(new AggregatorLookup(type => typeof(AggregatorApplyPrivate<>).CloseAndBuildAs(type))); + } + + return eventGraph; + } + } +} \ No newline at end of file diff --git a/src/Marten/Events/Projections/Aggregator.cs b/src/Marten/Events/Projections/Aggregator.cs index 41eb2cc287..b71c8f6419 100644 --- a/src/Marten/Events/Projections/Aggregator.cs +++ b/src/Marten/Events/Projections/Aggregator.cs @@ -14,10 +14,15 @@ namespace Marten.Events.Projections private readonly IDictionary _aggregations = new Dictionary(); - public Aggregator() + public Aggregator() : this(typeof(T).GetMethods() + .Where(x => x.Name == ApplyMethod && x.GetParameters().Length == 1)) + { + Alias = typeof(T).Name.ToTableAlias(); + } + + protected Aggregator(IEnumerable overrideMethodLookup) { - typeof (T).GetMethods() - .Where(x => x.Name == ApplyMethod && x.GetParameters().Length == 1) + overrideMethodLookup .Each(method => { object step = null; @@ -36,11 +41,9 @@ public Aggregator() _aggregations.Add(eventType, step); }); - - Alias = typeof (T).Name.ToTableAlias(); } - public Type AggregateType => typeof (T); + public Type AggregateType => typeof(T); public string Alias { get; } @@ -57,9 +60,9 @@ public T Build(IEnumerable events, IDocumentSession session) public Aggregator Add(IAggregation aggregation) { - if (_aggregations.ContainsKey(typeof (TEvent))) + if (_aggregations.ContainsKey(typeof(TEvent))) { - _aggregations[typeof (TEvent)] = aggregation; + _aggregations[typeof(TEvent)] = aggregation; } else { @@ -76,8 +79,8 @@ public Aggregator Add(Action application) public IAggregation AggregatorFor() { - return _aggregations.ContainsKey(typeof (TEvent)) - ? _aggregations[typeof (TEvent)].As>() + return _aggregations.ContainsKey(typeof(TEvent)) + ? _aggregations[typeof(TEvent)].As>() : null; } diff --git a/src/Marten/Events/Projections/AggregatorApplyPrivate.cs b/src/Marten/Events/Projections/AggregatorApplyPrivate.cs new file mode 100644 index 0000000000..2c0a7cc1f0 --- /dev/null +++ b/src/Marten/Events/Projections/AggregatorApplyPrivate.cs @@ -0,0 +1,16 @@ +using System.Linq; +using System.Reflection; + +namespace Marten.Events.Projections +{ + /// + /// Customize behaviour of by using private Apply methods in aggregation. + /// + public class AggregatorApplyPrivate : Aggregator where T : class, new() + { + public AggregatorApplyPrivate() : base(typeof(T).GetMethods(BindingFlags.Instance | BindingFlags.NonPublic) + .Where(x => x.Name == ApplyMethod && x.GetParameters().Length == 1)) + { + } + } +} \ No newline at end of file diff --git a/src/Marten/Services/Events/AggregationLookupStrategy.cs b/src/Marten/Services/Events/AggregationLookupStrategy.cs new file mode 100644 index 0000000000..ce2c0f37a7 --- /dev/null +++ b/src/Marten/Services/Events/AggregationLookupStrategy.cs @@ -0,0 +1,37 @@ +namespace Marten.Services.Events +{ + public sealed class AggregationLookupStrategy + { + public readonly short Value; + + private bool Equals(AggregationLookupStrategy other) + { + return Value == other.Value; + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + return obj is AggregationLookupStrategy && Equals((AggregationLookupStrategy) obj); + } + + public override int GetHashCode() + { + return Value.GetHashCode(); + } + + public static readonly AggregationLookupStrategy UsePublicApply = new AggregationLookupStrategy(0); + public static readonly AggregationLookupStrategy UsePrivateApply = new AggregationLookupStrategy(1); + + public static implicit operator short(AggregationLookupStrategy item) + { + return item.Value; + } + + private AggregationLookupStrategy(short value) + { + Value = value; + } + } +} \ No newline at end of file From 7895a07190b91b491975efb73cb70caa5a87e0f0 Mon Sep 17 00:00:00 2001 From: jokokko Date: Fri, 21 Oct 2016 12:01:55 +0300 Subject: [PATCH 3/3] This commit would add a new section in the docs called "Scenarios". It could be used to document specific use cases or patterns that are too long / specific in general usage docs. Adds a scenario "Immutable projections as read model". --- documentation/documentation/order.txt | 2 +- .../immutable_projections_readmodel.md | 23 +++++++++++++++++++ .../documentation/scenarios/index.md | 8 +++++++ .../Events/CustomAggregatorLookupTests.cs | 20 ++++++++++------ 4 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 documentation/documentation/scenarios/immutable_projections_readmodel.md create mode 100644 documentation/documentation/scenarios/index.md diff --git a/documentation/documentation/order.txt b/documentation/documentation/order.txt index 6bc8da5d58..ccfb015651 100644 --- a/documentation/documentation/order.txt +++ b/documentation/documentation/order.txt @@ -3,4 +3,4 @@ cli documents events precompiling - +scenarios diff --git a/documentation/documentation/scenarios/immutable_projections_readmodel.md b/documentation/documentation/scenarios/immutable_projections_readmodel.md new file mode 100644 index 0000000000..c90ad8b5b0 --- /dev/null +++ b/documentation/documentation/scenarios/immutable_projections_readmodel.md @@ -0,0 +1,23 @@ + + +This use case demonstrates how to create immutable projections from event streams. + +## Scenario + +To make projections immutable, the event application methods invoked by aggregators need to be made private, as well as any property setters. + +<[sample:scenarios-immutableprojections-projection]> + +To run aggregators against such projections, aggregator lookup strategy is configured to use aggregators that look for private `Apply([Event Type])` methods. Furthermore, document deserialization is configured to look for private property setters, allowing hydration of the projected objects from the database. + +This can be done in the store configuration as follows: + +<[sample:scenarios-immutableprojections-storesetup]> + +The serializer contract applied customises the default behaviour of the Json.NET serializer: + +<[sample:scenarios-immutableprojections-serializer]> + +Given the setup, a stream can now be projected using `AggregateWithPrivateEventApply` shown above. Furthermore, the created projection can be hydrated from the document store: + +<[sample:scenarios-immutableprojections-projectstream]> \ No newline at end of file diff --git a/documentation/documentation/scenarios/index.md b/documentation/documentation/scenarios/index.md new file mode 100644 index 0000000000..7cd9e62c6d --- /dev/null +++ b/documentation/documentation/scenarios/index.md @@ -0,0 +1,8 @@ + + + +This page documents various use cases with a sample implementation using Marten. + +## Scenarios + +<[TableOfContents]> \ No newline at end of file diff --git a/src/Marten.Testing/Events/CustomAggregatorLookupTests.cs b/src/Marten.Testing/Events/CustomAggregatorLookupTests.cs index fa1593c95f..347db3c592 100644 --- a/src/Marten.Testing/Events/CustomAggregatorLookupTests.cs +++ b/src/Marten.Testing/Events/CustomAggregatorLookupTests.cs @@ -22,11 +22,13 @@ public CustomAggregatorLookupTests() { StoreOptions(options => { + // SAMPLE: scenarios-immutableprojections-storesetup var serializer = new JsonNetSerializer(); serializer.Customize(c => c.ContractResolver = new ResolvePrivateSetters()); options.Serializer(serializer); - options.Events.UseAggregatorLookup(new AggregatorLookup(type => typeof(AggregatorApplyPrivate<>).CloseAndBuildAs(type))); + options.Events.UseAggregatorLookup(AggregationLookupStrategy.UsePrivateApply); options.Events.InlineProjections.AggregateStreamsWith(); + // ENDSAMPLE }); } @@ -84,7 +86,8 @@ public void can_set_aggregator_through_extension_methods_and_strategy() [Fact] public void can_use_custom_aggregator_with_inline_projection() - { + { + // SAMPLE: scenarios-immutableprojections-projectstream var quest = new QuestStarted {Name = "Destroy the Ring"}; var questId = Guid.NewGuid(); theSession.Events.StartStream(questId, quest); @@ -92,13 +95,14 @@ public void can_use_custom_aggregator_with_inline_projection() var projection = theSession.Load(questId); projection.Name.ShouldBe("Destroy the Ring"); - } + // ENDSAMPLE + } } - + // SAMPLE: scenarios-immutableprojections-projection public class AggregateWithPrivateEventApply { - public Guid Id { get; set; } + public Guid Id { get; private set; } private void Apply(QuestStarted started) { @@ -107,14 +111,15 @@ private void Apply(QuestStarted started) public string Name { get; private set; } } + // ENDSAMPLE + // SAMPLE: scenarios-immutableprojections-serializer internal class ResolvePrivateSetters : DefaultContractResolver { protected override JsonProperty CreateProperty( MemberInfo member, MemberSerialization memberSerialization) - { - //TODO: Maybe cache + { var prop = base.CreateProperty(member, memberSerialization); if (!prop.Writable) @@ -130,4 +135,5 @@ protected override JsonProperty CreateProperty( return prop; } } + // ENDSAMPLE } \ No newline at end of file