event_sorcerer 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +19 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +16 -0
- data/README.md +21 -0
- data/Rakefile +1 -0
- data/event_sorcerer.gemspec +28 -0
- data/lib/event_sorcerer/aggregate.rb +91 -0
- data/lib/event_sorcerer/aggregate_creator.rb +35 -0
- data/lib/event_sorcerer/aggregate_loader.rb +67 -0
- data/lib/event_sorcerer/aggregate_proxy.rb +122 -0
- data/lib/event_sorcerer/argument_hashifier.rb +30 -0
- data/lib/event_sorcerer/event.rb +5 -0
- data/lib/event_sorcerer/event_applicator.rb +22 -0
- data/lib/event_sorcerer/event_store.rb +55 -0
- data/lib/event_sorcerer/message_bus.rb +17 -0
- data/lib/event_sorcerer/no_unit_of_work.rb +35 -0
- data/lib/event_sorcerer/unit_of_work.rb +70 -0
- data/lib/event_sorcerer/version.rb +4 -0
- data/lib/event_sorcerer.rb +77 -0
- data/spec/lib/event_sorcerer_spec.rb +11 -0
- metadata +163 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 8f1f0fac682006f02615146ad4d6997e9ceeddb5
|
4
|
+
data.tar.gz: ee90c449e367c4d6685f6fd7712b95a6236a939c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 1903c49e3cae9c1b11225c04c2fc6e8469e9475c8050945f62b8d4237548770d7306638bb176790815984da437b882f411ccf1d15c2dc765511fc07900f35430
|
7
|
+
data.tar.gz: 6f2030bc699886797c125f21d3c304293723a9e79fc5eebd600ce5f93bacafbb08000627ea2c5ea71d71630e00f5f8a95a19d3187107d297160c626c167eba77
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
Copyright 2014 Sebastian Edwards
|
2
|
+
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
you may not use this file except in compliance with the License.
|
5
|
+
You may obtain a copy of the License at
|
6
|
+
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
See the License for the specific language governing permissions and
|
13
|
+
limitations under the License.
|
14
|
+
|
15
|
+
All of the files in this project are under the project-wide license
|
16
|
+
unless they are otherwise marked.
|
data/README.md
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
# EventSorcerer
|
2
|
+
|
3
|
+
Generic event-sourcing scaffold.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
gem 'event_sorcerer'
|
10
|
+
|
11
|
+
And then execute:
|
12
|
+
|
13
|
+
$ bundle
|
14
|
+
|
15
|
+
## Contributing
|
16
|
+
|
17
|
+
1. Fork it ( http://github.com/sebastianedwards/event_sorcerer/fork )
|
18
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
19
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
20
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
21
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'bundler/gem_tasks'
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'event_sorcerer/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'event_sorcerer'
|
8
|
+
spec.version = EventSorcerer::VERSION
|
9
|
+
spec.authors = ['Sebastian Edwards']
|
10
|
+
spec.email = ['me@sebastianedwards.co.nz']
|
11
|
+
spec.homepage = 'https://github.com/SebastianEdwards/event_sorcerer'
|
12
|
+
spec.summary = %w(Generic event-sourcing scaffold)
|
13
|
+
spec.description = spec.summary
|
14
|
+
|
15
|
+
spec.files = `git ls-files -z`.split("\x0")
|
16
|
+
spec.test_files = spec.files.grep(/^(test|spec|features)\//)
|
17
|
+
spec.executables = spec.files.grep(/^bin\//) { |f| File.basename(f) }
|
18
|
+
spec.require_paths = ['lib']
|
19
|
+
|
20
|
+
spec.add_development_dependency 'bundler', '~> 1.5'
|
21
|
+
spec.add_development_dependency 'inch'
|
22
|
+
spec.add_development_dependency 'rspec', '~> 3.0.0'
|
23
|
+
spec.add_development_dependency 'rubocop'
|
24
|
+
spec.add_development_dependency 'rake'
|
25
|
+
|
26
|
+
spec.add_runtime_dependency 'invokr', '0.0.5'
|
27
|
+
spec.add_runtime_dependency 'timecop', '0.7.1'
|
28
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
module EventSorcerer
|
2
|
+
# Public: Mixin to turn a plain class into an event-store backed aggreagte.
|
3
|
+
module Aggregate
|
4
|
+
# Public: Returns the ID for the aggregate.
|
5
|
+
attr_reader :id
|
6
|
+
|
7
|
+
# Public: Returns the version number of the aggregate in memory.
|
8
|
+
attr_reader :local_version
|
9
|
+
|
10
|
+
# Public: Returns the version number of the aggregate in the database.
|
11
|
+
attr_reader :persisted_version
|
12
|
+
|
13
|
+
def self.included(base)
|
14
|
+
base.class.send :alias_method, :build, :new
|
15
|
+
base.extend(ClassMethods)
|
16
|
+
base.class.extend(Forwardable)
|
17
|
+
base.class.send :def_delegators, :EventSorcerer, :unit_of_work
|
18
|
+
end
|
19
|
+
|
20
|
+
# Public: Class methods to be extended onto the including class.
|
21
|
+
module ClassMethods
|
22
|
+
# Public: An array of symbols representing the names of the methods which
|
23
|
+
# are events.
|
24
|
+
def event_methods
|
25
|
+
@event_methods ||= []
|
26
|
+
end
|
27
|
+
|
28
|
+
# Public: Methods defined within this block will have their method symbol
|
29
|
+
# added to the event_methods array.
|
30
|
+
#
|
31
|
+
# block - block containing the event method definitions.
|
32
|
+
#
|
33
|
+
# Returns self.
|
34
|
+
def events(&block)
|
35
|
+
test_class ||= Class.new(BasicObject)
|
36
|
+
starting_methods = test_class.instance_methods
|
37
|
+
test_class.class_eval(&block)
|
38
|
+
|
39
|
+
new_events = test_class.instance_methods.select do |method|
|
40
|
+
!starting_methods.include? method
|
41
|
+
end
|
42
|
+
|
43
|
+
event_methods.concat new_events
|
44
|
+
class_eval(&block)
|
45
|
+
|
46
|
+
self
|
47
|
+
end
|
48
|
+
|
49
|
+
# Public: Load an aggregate out of the event store.
|
50
|
+
#
|
51
|
+
# id - the ID of the aggregate to load.
|
52
|
+
#
|
53
|
+
# Returns an AggregateProxy object.
|
54
|
+
def find(id)
|
55
|
+
if unit_of_work.fetch_aggregate(id)
|
56
|
+
return unit_of_work.fetch_aggregate(id)
|
57
|
+
end
|
58
|
+
|
59
|
+
AggregateLoader.new(self, id).load.tap do |aggregate|
|
60
|
+
unit_of_work.store_aggregate(aggregate)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Public: Load an aggregate out of the event store or create new.
|
65
|
+
#
|
66
|
+
# id - the ID of the aggregate to load.
|
67
|
+
#
|
68
|
+
# Returns an AggregateProxy object.
|
69
|
+
def find_or_new(id)
|
70
|
+
if unit_of_work.fetch_aggregate(id)
|
71
|
+
return unit_of_work.fetch_aggregate(id)
|
72
|
+
end
|
73
|
+
|
74
|
+
AggregateLoader.new(self, id, false).load.tap do |aggregate|
|
75
|
+
unit_of_work.store_aggregate(aggregate)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Public: Creates a new aggregate.
|
80
|
+
#
|
81
|
+
# id - the ID to set on the new aggregate.
|
82
|
+
#
|
83
|
+
# Returns an AggregateProxy object.
|
84
|
+
def new(id = EventSorcerer.generate_id)
|
85
|
+
AggregateCreator.new(self, id).create.tap do |aggregate|
|
86
|
+
unit_of_work.store_aggregate(aggregate)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module EventSorcerer
|
2
|
+
# Public: Service class for building new aggregates.
|
3
|
+
class AggregateCreator
|
4
|
+
# Public: Creates a new AggregateCreator instance.
|
5
|
+
#
|
6
|
+
# klass - class for the aggregate to be created.
|
7
|
+
# id - desired id for the aggregate to be created.
|
8
|
+
def initialize(klass, id)
|
9
|
+
@id = id
|
10
|
+
@klass = klass
|
11
|
+
end
|
12
|
+
|
13
|
+
# Public: Wraps and returns aggregate in a new AggregateProxy.
|
14
|
+
def create
|
15
|
+
AggregateProxy.new(aggregate)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
# Private: Returns the desired id for the aggregate to be created.
|
21
|
+
attr_reader :id
|
22
|
+
|
23
|
+
# Private: Returns the class for the aggregate to be created.
|
24
|
+
attr_reader :klass
|
25
|
+
|
26
|
+
# Private: Memorizes and returns a new instance of an aggregate.
|
27
|
+
def aggregate
|
28
|
+
@aggregate ||= klass.build.tap do |aggregate|
|
29
|
+
aggregate.instance_variable_set(:@id, id)
|
30
|
+
aggregate.instance_variable_set(:@local_version, 0)
|
31
|
+
aggregate.instance_variable_set(:@persisted_version, 0)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
module EventSorcerer
|
2
|
+
# Public: Service class for loading aggregates from the event store.
|
3
|
+
class AggregateLoader
|
4
|
+
extend Forwardable
|
5
|
+
|
6
|
+
# Public: Shortcut to access the global event_store.
|
7
|
+
def_delegators :EventSorcerer, :event_store
|
8
|
+
|
9
|
+
# Public: Creates a new AggregateLoader instance.
|
10
|
+
#
|
11
|
+
# klass - class for the aggregate to be loaded.
|
12
|
+
# id - id for the aggregate to be loaded.
|
13
|
+
# prohibit_new - whether or not to raise an error if aggregate not existing.
|
14
|
+
def initialize(klass, id, prohibit_new = true)
|
15
|
+
@id = id
|
16
|
+
@klass = klass
|
17
|
+
@prohibit_new = prohibit_new
|
18
|
+
end
|
19
|
+
|
20
|
+
# Public: Wraps and returns aggregate in a proxy.
|
21
|
+
#
|
22
|
+
# Returns an AggregateProxy.
|
23
|
+
# Raises AggregateNotFound if aggregate not found and prohibit_new is true.
|
24
|
+
def load
|
25
|
+
fail AggregateNotFound if prohibit_new && new_aggregate?
|
26
|
+
|
27
|
+
AggregateProxy.new(aggregate)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
# Private: Returns the id for the aggregate to be loaded.
|
33
|
+
attr_reader :id
|
34
|
+
|
35
|
+
# Private: Returns the class for the aggregate to be loaded.3
|
36
|
+
attr_reader :klass
|
37
|
+
|
38
|
+
# Private: Returns whether new aggregates will be allowed.
|
39
|
+
attr_reader :prohibit_new
|
40
|
+
|
41
|
+
# Private: Memorizes and returns a new instance of an aggregate. Applies
|
42
|
+
# existing events from the event store.
|
43
|
+
def aggregate
|
44
|
+
@aggregate ||= klass.build.tap do |aggregate|
|
45
|
+
aggregate.instance_variable_set(:@id, id)
|
46
|
+
aggregate.instance_variable_set(:@local_version, version)
|
47
|
+
aggregate.instance_variable_set(:@persisted_version, version)
|
48
|
+
events.each { |event| EventApplicator.apply_event!(aggregate, event) }
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# Private: Memorizes and returns the existing events from the event store.
|
53
|
+
def events
|
54
|
+
@events ||= event_store.read_events(id)
|
55
|
+
end
|
56
|
+
|
57
|
+
def new_aggregate?
|
58
|
+
!version || version == 0
|
59
|
+
end
|
60
|
+
|
61
|
+
# Private: Memorizes and returns the current version number from the event
|
62
|
+
# store.
|
63
|
+
def version
|
64
|
+
@version ||= event_store.get_current_version(id)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
module EventSorcerer
|
2
|
+
class EventArgumentError < RuntimeError; end
|
3
|
+
|
4
|
+
# Public: Transparent wrapped around Aggregate objects which tracks dirty
|
5
|
+
# events and handles persisting these.
|
6
|
+
class AggregateProxy
|
7
|
+
# Public: Value object which is returned from a successful save.
|
8
|
+
class SaveReciept < Struct.new(:id, :klass, :events, :meta); end
|
9
|
+
|
10
|
+
extend Forwardable
|
11
|
+
|
12
|
+
instance_methods.each do |m|
|
13
|
+
undef_method(m) unless m =~ /(^__|^nil\?$|^send$|^object_id$|^tap$)/
|
14
|
+
end
|
15
|
+
|
16
|
+
# Public: Shortcuts to access the global event_store and unit_of_work.
|
17
|
+
def_delegators :EventSorcerer, :event_store, :unit_of_work
|
18
|
+
|
19
|
+
# Public: Creates a new AggregateProxy instance.
|
20
|
+
#
|
21
|
+
# aggregate - aggregate to wrap.
|
22
|
+
def initialize(aggregate)
|
23
|
+
@_aggregate = aggregate
|
24
|
+
@_dirty_events = []
|
25
|
+
end
|
26
|
+
|
27
|
+
# Public: Forwards messages to the aggregate. Saves the details of event
|
28
|
+
# messages to the _dirty_events array.
|
29
|
+
def method_missing(method_sym, *arguments, &block)
|
30
|
+
if event_method?(method_sym)
|
31
|
+
send_event_to_aggregate(method_sym, *arguments, &block)
|
32
|
+
else
|
33
|
+
@_aggregate.send method_sym, *arguments, &block
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Public: Saves the current dirty events via the current unit_of_work.
|
38
|
+
#
|
39
|
+
# meta - Hash of extra data to publish on the message bus with the events
|
40
|
+
# after save.
|
41
|
+
#
|
42
|
+
# Returns self.
|
43
|
+
def save(meta = {})
|
44
|
+
dirty_events = @_dirty_events
|
45
|
+
version = persisted_version
|
46
|
+
|
47
|
+
unit_of_work.handle_save(proc do
|
48
|
+
event_store.append_events(id, self.class.name, dirty_events, version)
|
49
|
+
SaveReciept.new(id, self.class, dirty_events, meta)
|
50
|
+
end)
|
51
|
+
|
52
|
+
@_dirty_events = []
|
53
|
+
@_aggregate.instance_variable_set(:@persisted_version, local_version)
|
54
|
+
|
55
|
+
self
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
# Private: Handles the serialization of event arguments and pushes the
|
61
|
+
# Event object onto the _dirty_events array. Increments the local
|
62
|
+
# version number for the aggregate.
|
63
|
+
def add_dirty_event!(method_sym, *arguments)
|
64
|
+
increment_version!
|
65
|
+
|
66
|
+
method = @_aggregate.method(method_sym)
|
67
|
+
method.parameters.each.with_index.select { |(type, _), _| type == :req }
|
68
|
+
|
69
|
+
details = ArgumentHashifier.hashify(method.parameters, arguments.dup)
|
70
|
+
@_dirty_events << Event.new(method_sym, Time.now, details)
|
71
|
+
|
72
|
+
self
|
73
|
+
end
|
74
|
+
|
75
|
+
# Private: Decrements the wrapped aggregates local version by one.
|
76
|
+
def decrement_version!
|
77
|
+
@_aggregate.instance_variable_set(:@local_version, local_version - 1)
|
78
|
+
|
79
|
+
self
|
80
|
+
end
|
81
|
+
|
82
|
+
# Private: Checks whether the aggregate has an event method defined with
|
83
|
+
# a given name.
|
84
|
+
#
|
85
|
+
# method_sym - A symbol of the method name.
|
86
|
+
#
|
87
|
+
# Returns true if event method exists.
|
88
|
+
# Returns false if event method does not exist.
|
89
|
+
def event_method?(method_sym)
|
90
|
+
@_aggregate.class.event_methods.include? method_sym
|
91
|
+
end
|
92
|
+
|
93
|
+
# Private: Increments the wrapped aggregates local version by one.
|
94
|
+
def increment_version!
|
95
|
+
@_aggregate.instance_variable_set(:@local_version, local_version + 1)
|
96
|
+
|
97
|
+
self
|
98
|
+
end
|
99
|
+
|
100
|
+
# Private: Forwards an event message to the aggregate while adding the
|
101
|
+
# Event to the _dirty_events and incrementing the local version.
|
102
|
+
# Rolls everything back if a StandardError is caught.
|
103
|
+
def send_event_to_aggregate(method_sym, *arguments, &block)
|
104
|
+
fail EventArgumentError if block
|
105
|
+
|
106
|
+
add_dirty_event!(method_sym, *arguments)
|
107
|
+
|
108
|
+
@_aggregate.send method_sym, *arguments
|
109
|
+
rescue StandardError => e
|
110
|
+
undo_dirty_event!
|
111
|
+
raise e
|
112
|
+
end
|
113
|
+
|
114
|
+
# Private: Undoes the side-effects of the last event message.
|
115
|
+
def undo_dirty_event!
|
116
|
+
decrement_version!
|
117
|
+
@_dirty_events.pop
|
118
|
+
|
119
|
+
self
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module EventSorcerer
|
2
|
+
# Public: Helper to hashify a method call.
|
3
|
+
module ArgumentHashifier
|
4
|
+
# Public: Creates a Hash representing a particular method call.
|
5
|
+
#
|
6
|
+
# parameters - an Array of the parameters for a method.
|
7
|
+
# arguments - an Array of the arguments for a particular method call.
|
8
|
+
#
|
9
|
+
# Returns a Hash where arguments are keyed by the corrosponding parameter
|
10
|
+
# name.
|
11
|
+
def self.hashify(parameters, arguments)
|
12
|
+
{}.tap do |hash|
|
13
|
+
required_positionals(parameters).each do |param|
|
14
|
+
hash[param] = arguments.shift
|
15
|
+
end
|
16
|
+
|
17
|
+
keyword_arguments = arguments.last.is_a?(Hash) ? arguments.pop : {}
|
18
|
+
hash.merge! keyword_arguments
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
# Private: Returns an Array of the required positional parameter names for
|
25
|
+
# a given set of parameters.
|
26
|
+
def self.required_positionals(parameters)
|
27
|
+
parameters.select { |type, _| type == :req }.map(&:last)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'invokr'
|
2
|
+
require 'timecop'
|
3
|
+
|
4
|
+
module EventSorcerer
|
5
|
+
# Public: Helper for applying events to an aggregate.
|
6
|
+
module EventApplicator
|
7
|
+
# Public: Sets the clock to the event time and calls the event method on
|
8
|
+
# the aggregate.
|
9
|
+
#
|
10
|
+
# aggregate - The Aggregate to apply the Event to.
|
11
|
+
# event - The Event to be applied.
|
12
|
+
#
|
13
|
+
# Returns self.
|
14
|
+
def self.apply_event!(aggregate, event)
|
15
|
+
Timecop.freeze(event.created_at) do
|
16
|
+
Invokr.invoke method: event.name, on: aggregate, with: event.details
|
17
|
+
end
|
18
|
+
|
19
|
+
self
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
module EventSorcerer
|
2
|
+
# Public: Abstract class for event store errors.
|
3
|
+
class EventStoreError < StandardError; end
|
4
|
+
|
5
|
+
# Public: Error raised when given uuid is in invalid format.
|
6
|
+
class InvalidUUID < EventStoreError; end
|
7
|
+
|
8
|
+
# Public: Error raised when expected version doesn't match current stored
|
9
|
+
# version.
|
10
|
+
class UnexpectedVersionNumber < EventStoreError; end
|
11
|
+
|
12
|
+
# Public: Abstract class for an event store implementation.
|
13
|
+
class EventStore
|
14
|
+
# Public: Append events to a specified aggregate. Should be defined in a
|
15
|
+
# subclass.
|
16
|
+
#
|
17
|
+
# _aggregate_id - UUID of the aggregate as a String.
|
18
|
+
# _klass - Text representation of aggregate class.
|
19
|
+
# _events - Array of JSON-serialized events.
|
20
|
+
# _expected_version - The current version of the aggregate.
|
21
|
+
#
|
22
|
+
# Raises a NotImplementedError
|
23
|
+
def append_events(_aggregate_id, _klass, _events, _expected_version)
|
24
|
+
fail NotImplementedError
|
25
|
+
end
|
26
|
+
|
27
|
+
# Public: Retrieve the current version for a specified aggregate. Should
|
28
|
+
# be defined in a subclass.
|
29
|
+
#
|
30
|
+
# _aggregate_id - UUID of the aggregate as a String.
|
31
|
+
#
|
32
|
+
# Raises a NotImplementedError
|
33
|
+
def get_current_version(_aggregate_id)
|
34
|
+
fail NotImplementedError
|
35
|
+
end
|
36
|
+
|
37
|
+
# Public: Retrieve the events for a specified aggregate. Should be
|
38
|
+
# defined in a subclass.
|
39
|
+
#
|
40
|
+
# _aggregate_id - UUID of the aggregate as a String.
|
41
|
+
#
|
42
|
+
# Raises a NotImplementedError
|
43
|
+
def read_events(_aggregate_id)
|
44
|
+
fail NotImplementedError
|
45
|
+
end
|
46
|
+
|
47
|
+
# Public: Ensures events appended within the given block are done so
|
48
|
+
# atomically. Should be defined in a subclass.
|
49
|
+
#
|
50
|
+
# Raises a NotImplementedError
|
51
|
+
def with_transaction
|
52
|
+
fail NotImplementedError
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module EventSorcerer
|
2
|
+
# Public: Abstract class for an message bus implementation.
|
3
|
+
class MessageBus
|
4
|
+
# Public: Publish events for a specified aggregate. Should be defined in a
|
5
|
+
# subclass.
|
6
|
+
#
|
7
|
+
# _aggregate_id - UUID of the aggregate as a String.
|
8
|
+
# _klass - Text representation of aggregate class.
|
9
|
+
# _events - Array of Event objects.
|
10
|
+
# _meta - Hash of extra data to publish on the bus.
|
11
|
+
#
|
12
|
+
# Raises a NotImplementedError
|
13
|
+
def publish_events(_aggregate_id, _klass, _events, _meta)
|
14
|
+
fail NotImplementedError
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module EventSorcerer
|
2
|
+
# Public: Handles the API of a UnitOfWork but does nothing.
|
3
|
+
module NoUnitOfWork
|
4
|
+
class << self
|
5
|
+
extend Forwardable
|
6
|
+
|
7
|
+
# Public: Shortcuts to access the global message_bus.
|
8
|
+
def_delegators :EventSorcerer, :message_bus
|
9
|
+
|
10
|
+
# Public: Returns nil.
|
11
|
+
def fetch_aggregate(_id)
|
12
|
+
nil
|
13
|
+
end
|
14
|
+
|
15
|
+
# Public: Immediately calls the save proc and publishes the reciepts via
|
16
|
+
# the message bus.
|
17
|
+
#
|
18
|
+
# save - the Proc object representing the save process.
|
19
|
+
#
|
20
|
+
# Returns self.
|
21
|
+
def handle_save(save)
|
22
|
+
reciept = save.call
|
23
|
+
message_bus.publish_events(reciept.id, reciept.klass, reciept.events,
|
24
|
+
reciept.meta)
|
25
|
+
|
26
|
+
self
|
27
|
+
end
|
28
|
+
|
29
|
+
# Public: Returns self.
|
30
|
+
def store_aggregate(_aggregate)
|
31
|
+
self
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
module EventSorcerer
|
2
|
+
# Public: Provides transactional integrity across multiple aggregate saves.
|
3
|
+
# Also provides an indentity map.
|
4
|
+
class UnitOfWork
|
5
|
+
extend Forwardable
|
6
|
+
|
7
|
+
# Public: Shortcuts to access the global event_store and message_bus.
|
8
|
+
def_delegators :EventSorcerer, :event_store, :message_bus
|
9
|
+
|
10
|
+
# Public: Creates a new UnitOfWork instance.
|
11
|
+
def initialize
|
12
|
+
@identity_map = {}
|
13
|
+
@pending_saves = []
|
14
|
+
end
|
15
|
+
|
16
|
+
# Public: Executes all pending saves within a transaction, clears the
|
17
|
+
# pending saves, and publishes the reciepts via the message bus.
|
18
|
+
#
|
19
|
+
# Returns self.
|
20
|
+
def execute_work!
|
21
|
+
save_receipts = event_store.with_transaction do
|
22
|
+
pending_saves.map(&:call)
|
23
|
+
end
|
24
|
+
|
25
|
+
@pending_saves = []
|
26
|
+
|
27
|
+
save_receipts.each do |reciept|
|
28
|
+
message_bus.publish_events(reciept.id, reciept.klass, reciept.events,
|
29
|
+
reciept.meta)
|
30
|
+
end
|
31
|
+
|
32
|
+
self
|
33
|
+
end
|
34
|
+
|
35
|
+
# Public: Fetches an aggregate via it's ID from the identity map.
|
36
|
+
#
|
37
|
+
# id - the ID for the aggregate.
|
38
|
+
#
|
39
|
+
# Returns nil if not found.
|
40
|
+
# Returns Aggregate if found.
|
41
|
+
def fetch_aggregate(id)
|
42
|
+
identity_map[id]
|
43
|
+
end
|
44
|
+
|
45
|
+
def handle_save(save)
|
46
|
+
pending_saves << save
|
47
|
+
end
|
48
|
+
|
49
|
+
# Public: Stores an aggregate via it's ID into the identity map.
|
50
|
+
#
|
51
|
+
# aggregate - the aggregate to store.
|
52
|
+
#
|
53
|
+
# Returns self.
|
54
|
+
def store_aggregate(aggregate)
|
55
|
+
return self if fetch_aggregate(aggregate.id)
|
56
|
+
|
57
|
+
identity_map[aggregate.id] = aggregate
|
58
|
+
|
59
|
+
self
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
# Private: Returns the identity map Hash.
|
65
|
+
attr_reader :identity_map
|
66
|
+
|
67
|
+
# Private: Returns the pending saves Array.
|
68
|
+
attr_reader :pending_saves
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require 'event_sorcerer/version'
|
2
|
+
|
3
|
+
require 'event_sorcerer/aggregate'
|
4
|
+
require 'event_sorcerer/aggregate_creator'
|
5
|
+
require 'event_sorcerer/aggregate_loader'
|
6
|
+
require 'event_sorcerer/aggregate_proxy'
|
7
|
+
require 'event_sorcerer/argument_hashifier'
|
8
|
+
require 'event_sorcerer/event'
|
9
|
+
require 'event_sorcerer/event_applicator'
|
10
|
+
require 'event_sorcerer/event_store'
|
11
|
+
require 'event_sorcerer/message_bus'
|
12
|
+
require 'event_sorcerer/no_unit_of_work'
|
13
|
+
require 'event_sorcerer/unit_of_work'
|
14
|
+
|
15
|
+
# Public: Top-level namespace.
|
16
|
+
module EventSorcerer
|
17
|
+
class AggregateNotFound < RuntimeError; end
|
18
|
+
class UnsetEventStore < RuntimeError; end
|
19
|
+
class UnsetMessageBus < RuntimeError; end
|
20
|
+
|
21
|
+
class << self
|
22
|
+
# Public: Writer method to set new event_store.
|
23
|
+
attr_writer :event_store
|
24
|
+
|
25
|
+
# Public: Writer method to set a new id_generation function.
|
26
|
+
attr_writer :id_generator
|
27
|
+
|
28
|
+
# Public: Writer method to set new message_bus.
|
29
|
+
attr_writer :message_bus
|
30
|
+
|
31
|
+
# Public: Returns the current event store. Raises UnsetEventStore if not
|
32
|
+
# set.
|
33
|
+
def event_store
|
34
|
+
@event_store || fail(UnsetEventStore)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Public: Generates a new ID using the current id_generator.
|
38
|
+
def generate_id
|
39
|
+
id_generator.call
|
40
|
+
end
|
41
|
+
|
42
|
+
# Public: Returns the current id_generator. Defaults to a SecureRandom.uuid
|
43
|
+
# based generator.
|
44
|
+
def id_generator
|
45
|
+
@id_generator ||= proc { SecureRandom.uuid }
|
46
|
+
end
|
47
|
+
|
48
|
+
# Public: Returns the current message bus. Raises UnsetMessageBus if not
|
49
|
+
# set.
|
50
|
+
def message_bus
|
51
|
+
@message_bus || fail(UnsetMessageBus)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Public: Returns the unit_of_work for the current thread. Defaults to
|
55
|
+
# NoUnitOfWork.
|
56
|
+
def unit_of_work
|
57
|
+
Thread.current[:unit_of_work] || NoUnitOfWork
|
58
|
+
end
|
59
|
+
|
60
|
+
# Public: Creates a new UnitOfWork and sets it for the current thread
|
61
|
+
# within the block. Executes the work after block completion.
|
62
|
+
#
|
63
|
+
# Returns value of block.
|
64
|
+
def with_unit_of_work(unit_of_work = UnitOfWork.new, autosave = true)
|
65
|
+
old_unit_of_work = Thread.current[:unit_of_work]
|
66
|
+
new_unit_of_work = Thread.current[:unit_of_work] = unit_of_work
|
67
|
+
begin
|
68
|
+
result = yield
|
69
|
+
new_unit_of_work.execute_work! if autosave
|
70
|
+
ensure
|
71
|
+
Thread.current[:unit_of_work] = old_unit_of_work
|
72
|
+
end
|
73
|
+
|
74
|
+
result
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
require 'event_sorcerer'
|
2
|
+
|
3
|
+
describe EventSorcerer do
|
4
|
+
describe '.with_unit_of_work' do
|
5
|
+
it 'puts a new unit of work in the thread variables' do
|
6
|
+
EventSorcerer.with_unit_of_work do
|
7
|
+
expect(Thread.current[:unit_of_work]).to be_truthy
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
metadata
ADDED
@@ -0,0 +1,163 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: event_sorcerer
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Sebastian Edwards
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-07-29 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.5'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.5'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: inch
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 3.0.0
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 3.0.0
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rubocop
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rake
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: invokr
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - '='
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: 0.0.5
|
90
|
+
type: :runtime
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - '='
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: 0.0.5
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: timecop
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - '='
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: 0.7.1
|
104
|
+
type: :runtime
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - '='
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: 0.7.1
|
111
|
+
description: "[\"Generic\", \"event-sourcing\", \"scaffold\"]"
|
112
|
+
email:
|
113
|
+
- me@sebastianedwards.co.nz
|
114
|
+
executables: []
|
115
|
+
extensions: []
|
116
|
+
extra_rdoc_files: []
|
117
|
+
files:
|
118
|
+
- ".gitignore"
|
119
|
+
- Gemfile
|
120
|
+
- LICENSE.txt
|
121
|
+
- README.md
|
122
|
+
- Rakefile
|
123
|
+
- event_sorcerer.gemspec
|
124
|
+
- lib/event_sorcerer.rb
|
125
|
+
- lib/event_sorcerer/aggregate.rb
|
126
|
+
- lib/event_sorcerer/aggregate_creator.rb
|
127
|
+
- lib/event_sorcerer/aggregate_loader.rb
|
128
|
+
- lib/event_sorcerer/aggregate_proxy.rb
|
129
|
+
- lib/event_sorcerer/argument_hashifier.rb
|
130
|
+
- lib/event_sorcerer/event.rb
|
131
|
+
- lib/event_sorcerer/event_applicator.rb
|
132
|
+
- lib/event_sorcerer/event_store.rb
|
133
|
+
- lib/event_sorcerer/message_bus.rb
|
134
|
+
- lib/event_sorcerer/no_unit_of_work.rb
|
135
|
+
- lib/event_sorcerer/unit_of_work.rb
|
136
|
+
- lib/event_sorcerer/version.rb
|
137
|
+
- spec/lib/event_sorcerer_spec.rb
|
138
|
+
homepage: https://github.com/SebastianEdwards/event_sorcerer
|
139
|
+
licenses: []
|
140
|
+
metadata: {}
|
141
|
+
post_install_message:
|
142
|
+
rdoc_options: []
|
143
|
+
require_paths:
|
144
|
+
- lib
|
145
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
146
|
+
requirements:
|
147
|
+
- - ">="
|
148
|
+
- !ruby/object:Gem::Version
|
149
|
+
version: '0'
|
150
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
151
|
+
requirements:
|
152
|
+
- - ">="
|
153
|
+
- !ruby/object:Gem::Version
|
154
|
+
version: '0'
|
155
|
+
requirements: []
|
156
|
+
rubyforge_project:
|
157
|
+
rubygems_version: 2.2.0
|
158
|
+
signing_key:
|
159
|
+
specification_version: 4
|
160
|
+
summary: "[\"Generic\", \"event-sourcing\", \"scaffold\"]"
|
161
|
+
test_files:
|
162
|
+
- spec/lib/event_sorcerer_spec.rb
|
163
|
+
has_rdoc:
|