rails-event-sourcing 0.1.0
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/.rspec +3 -0
- data/.rubocop.yml +28 -0
- data/Gemfile +18 -0
- data/LICENSE.txt +21 -0
- data/README.md +126 -0
- data/bin/console +15 -0
- data/bin/pry +29 -0
- data/bin/rails +15 -0
- data/bin/rspec +29 -0
- data/bin/rubocop +29 -0
- data/bin/setup +8 -0
- data/lib/rails-event-sourcing/base_event.rb +142 -0
- data/lib/rails-event-sourcing/command.rb +110 -0
- data/lib/rails-event-sourcing/dispatcher.rb +83 -0
- data/lib/rails-event-sourcing/reactor_job.rb +14 -0
- data/lib/rails-event-sourcing/version.rb +5 -0
- data/lib/rails-event-sourcing.rb +11 -0
- metadata +89 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 6515057a49928dc1c71c0b4767bea5c0a825f2be5f411a8a35457e53d17a9380
|
4
|
+
data.tar.gz: bfe0bc40897dca758f5be3b40118ea2381c400da92f0b4b77597a75351a8f5b7
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 5b025b485eb1d0e456c1818b9d0698a5ab8336a13f21d391230e0ad09315bceaa685b418a046ded7134771e2f4217650236ae6f2df16f276061db54e61bce5e3
|
7
|
+
data.tar.gz: bba96d5db5376ed99fb9695faa2318645633feb6bb12439597932490b3cbb521724a7579d184fa2bcf141d661177c0f261b9f2f8dd95fbf4ade60c7e532cd879
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
---
|
2
|
+
inherit_from:
|
3
|
+
- https://relaxed.ruby.style/rubocop.yml
|
4
|
+
|
5
|
+
require:
|
6
|
+
- rubocop-rspec
|
7
|
+
|
8
|
+
AllCops:
|
9
|
+
Exclude:
|
10
|
+
- _misc/*
|
11
|
+
- bin/*
|
12
|
+
- spec/dummy/**/*
|
13
|
+
NewCops: enable
|
14
|
+
SuggestExtensions: false
|
15
|
+
TargetRubyVersion: 2.6
|
16
|
+
|
17
|
+
Naming/FileName:
|
18
|
+
Exclude:
|
19
|
+
- lib/rails-event-sourcing.rb
|
20
|
+
|
21
|
+
RSpec/ExampleLength:
|
22
|
+
Max: 8
|
23
|
+
|
24
|
+
RSpec/MultipleMemoizedHelpers:
|
25
|
+
Max: 8
|
26
|
+
|
27
|
+
Style/GuardClause:
|
28
|
+
Enabled: false
|
data/Gemfile
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
source 'https://rubygems.org'
|
4
|
+
|
5
|
+
# Specify your gem's dependencies in rails-event-sourcing.gemspec
|
6
|
+
gemspec
|
7
|
+
|
8
|
+
gem 'rspec-rails'
|
9
|
+
gem 'simplecov'
|
10
|
+
gem 'sqlite3'
|
11
|
+
|
12
|
+
# Linters
|
13
|
+
gem 'rubocop', '~> 1.21'
|
14
|
+
gem 'rubocop-rspec'
|
15
|
+
|
16
|
+
# Tools
|
17
|
+
gem 'awesome_print'
|
18
|
+
gem 'pry-rails'
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2021 Mattia Roccoberton
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,126 @@
|
|
1
|
+
# Rails Event Sourcing
|
2
|
+
|
3
|
+
This gem provides features to setup an event sourcing application using ActiveRecord.
|
4
|
+
ActiveJob is necessary only to use async callbacks.
|
5
|
+
|
6
|
+
> DISCLAIMER: this project is in alpha stage
|
7
|
+
|
8
|
+
The main components are:
|
9
|
+
- **event**: model used to track state changes for an entity;
|
10
|
+
- **command**: wrap events creation;
|
11
|
+
- **dispatcher**: events' callbacks (sync and async).
|
12
|
+
|
13
|
+
This gem adds a layer to handle events for the underlying application models. In short:
|
14
|
+
- an event model is created for each "event-ed" application model;
|
15
|
+
- every change to an application model (named _aggregate_ in the event perspective) is stored in an event record;
|
16
|
+
- querying application models is the same as usual;
|
17
|
+
- writing changes to application entities is applied creating events.
|
18
|
+
|
19
|
+
A sample workflow can be:
|
20
|
+
|
21
|
+
```rb
|
22
|
+
# I have a plain Post model:
|
23
|
+
post = Post.find(1)
|
24
|
+
# When I need to update that post:
|
25
|
+
Posts::ChangedDescription.create!(post: post, description: 'My beautiful post content')
|
26
|
+
# When I need to create a new post:
|
27
|
+
Posts::Created.create!(title: 'New post!', description: 'Another beautiful post')
|
28
|
+
# I can query the events for an aggregated entity:
|
29
|
+
events = Posts::Event.events_for(post) # Posts::Event is usually a base class for all events for an aggregate (using STI)
|
30
|
+
# I can rollback to a specific version of the aggregated entity:
|
31
|
+
events[2].rollback! # the aggregated entity is restored to the specific state, the events above that point are removed
|
32
|
+
```
|
33
|
+
|
34
|
+
The project is based on a [demo app](https://github.com/pcreux/event-sourcing-rails-todo-app-demo) proposed by [Philippe Creux](https://github.com/pcreux) and his video presentation for Rails Conf 2019:
|
35
|
+
|
36
|
+
[](https://www.youtube.com/watch?v=ulF6lEFvrKo "Event Sourcing made Simple by Philippe Creux")
|
37
|
+
|
38
|
+
Please :star: if you like it.
|
39
|
+
|
40
|
+
## Usage
|
41
|
+
|
42
|
+
- Add to your Gemfile: `gem 'rails-event-sourcing'` (and execute `bundle`)
|
43
|
+
- Create a migration per model to store the related events, example for User:
|
44
|
+
`bin/rails generate migration CreateUserEvents type:string user:reference data:text metadata:text`
|
45
|
+
- Create the events, example for `Users::Created`:
|
46
|
+
```rb
|
47
|
+
module Users
|
48
|
+
class Created < RailsEventSourcing::BaseEvent
|
49
|
+
self.table_name = 'user_events' # usually this fits better in a base class using STI
|
50
|
+
|
51
|
+
belongs_to :user, autosave: false
|
52
|
+
|
53
|
+
data_attributes :name
|
54
|
+
|
55
|
+
def apply(user)
|
56
|
+
# this method will be applied when the event is created
|
57
|
+
user.name = name
|
58
|
+
user
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
```
|
63
|
+
- Invoke an event with: `Users::Created.create!(name: 'Some user')`
|
64
|
+
- Optionally create a Command, example:
|
65
|
+
```rb
|
66
|
+
module Users
|
67
|
+
class CreateCommand
|
68
|
+
include RailsEventSourcing::Command
|
69
|
+
|
70
|
+
attributes :user, :name
|
71
|
+
|
72
|
+
def build_event
|
73
|
+
# this method will be applied when the command is executed
|
74
|
+
Users::Created.new(user_id: user.id, name: name)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
```
|
79
|
+
- Invoke a command with: `Users::CreateCommand.call(name: 'Some name')`
|
80
|
+
|
81
|
+
## Examples
|
82
|
+
|
83
|
+
Please take a look at the [dummy app](spec/dummy/app) for a detailed example.
|
84
|
+
|
85
|
+
Events:
|
86
|
+
```rb
|
87
|
+
TodoLists::Created.create!(name: 'My TODO 1')
|
88
|
+
TodoLists::NameUpdated.create!(name: 'My TODO!', todo_list: TodoList.first)
|
89
|
+
TodoItems::Created.create!(todo_list_id: TodoList.first.id, name: 'First item')
|
90
|
+
TodoItems::Completed.create!(todo_item: TodoItem.last)
|
91
|
+
```
|
92
|
+
|
93
|
+
Commands:
|
94
|
+
```rb
|
95
|
+
TodoLists::Create.call(name: 'My todo')
|
96
|
+
TodoItems::Create.call(todo_list: TodoList.first, name: 'Some task')
|
97
|
+
```
|
98
|
+
|
99
|
+
Dispatchers:
|
100
|
+
```rb
|
101
|
+
class TodoItemsDispatcher < RailsEventSourcing::EventDispatcher
|
102
|
+
on TodoItems::Created, trigger: ->(todo_item) { puts ">>> TodoItems::Created [##{todo_item.id}]" }
|
103
|
+
on TodoItems::Completed, async: Notifications::TodoItems::Completed
|
104
|
+
end
|
105
|
+
# Now when the event TodoItems::Created is created the trigger callback is executed
|
106
|
+
```
|
107
|
+
|
108
|
+
## To do
|
109
|
+
|
110
|
+
- [ ] Generators for events, commands and dispatchers
|
111
|
+
- [ ] Database specific optimizations
|
112
|
+
- [ ] Add more usage examples
|
113
|
+
|
114
|
+
## Do you like it? Star it!
|
115
|
+
|
116
|
+
If you use this component just star it. A developer is more motivated to improve a project when there is some interest.
|
117
|
+
|
118
|
+
Or consider offering me a coffee, it's a small thing but it is greatly appreciated: [about me](https://www.blocknot.es/about-me).
|
119
|
+
|
120
|
+
## Contributors
|
121
|
+
|
122
|
+
- [Mattia Roccoberton](https://www.blocknot.es): author
|
123
|
+
|
124
|
+
## License
|
125
|
+
|
126
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "rails-event-sourcing"
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
# require "pry"
|
12
|
+
# Pry.start
|
13
|
+
|
14
|
+
require "irb"
|
15
|
+
IRB.start(__FILE__)
|
data/bin/pry
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'pry' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
require "pathname"
|
12
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
|
13
|
+
Pathname.new(__FILE__).realpath)
|
14
|
+
|
15
|
+
bundle_binstub = File.expand_path("../bundle", __FILE__)
|
16
|
+
|
17
|
+
if File.file?(bundle_binstub)
|
18
|
+
if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
|
19
|
+
load(bundle_binstub)
|
20
|
+
else
|
21
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
22
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
require "rubygems"
|
27
|
+
require "bundler/setup"
|
28
|
+
|
29
|
+
load Gem.bin_path("pry", "pry")
|
data/bin/rails
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# This command will automatically be run when you run "rails" with Rails gems
|
3
|
+
# installed from the root of your application.
|
4
|
+
|
5
|
+
ENV['RAILS_ENV'] ||= 'test'
|
6
|
+
|
7
|
+
ENGINE_ROOT = File.expand_path('..', __dir__)
|
8
|
+
APP_PATH = File.expand_path('../spec/dummy/config/application', __dir__)
|
9
|
+
|
10
|
+
# Set up gems listed in the Gemfile.
|
11
|
+
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
|
12
|
+
require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"])
|
13
|
+
|
14
|
+
require "rails/all"
|
15
|
+
require "rails/engine/commands"
|
data/bin/rspec
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'rspec' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
require "pathname"
|
12
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
|
13
|
+
Pathname.new(__FILE__).realpath)
|
14
|
+
|
15
|
+
bundle_binstub = File.expand_path("../bundle", __FILE__)
|
16
|
+
|
17
|
+
if File.file?(bundle_binstub)
|
18
|
+
if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
|
19
|
+
load(bundle_binstub)
|
20
|
+
else
|
21
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
22
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
require "rubygems"
|
27
|
+
require "bundler/setup"
|
28
|
+
|
29
|
+
load Gem.bin_path("rspec-core", "rspec")
|
data/bin/rubocop
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'rubocop' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
require "pathname"
|
12
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
|
13
|
+
Pathname.new(__FILE__).realpath)
|
14
|
+
|
15
|
+
bundle_binstub = File.expand_path("../bundle", __FILE__)
|
16
|
+
|
17
|
+
if File.file?(bundle_binstub)
|
18
|
+
if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
|
19
|
+
load(bundle_binstub)
|
20
|
+
else
|
21
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
22
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
require "rubygems"
|
27
|
+
require "bundler/setup"
|
28
|
+
|
29
|
+
load Gem.bin_path("rubocop", "rubocop")
|
data/bin/setup
ADDED
@@ -0,0 +1,142 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsEventSourcing
|
4
|
+
# This is the BaseEvent class that all Events inherit from.
|
5
|
+
# It takes care of serializing `data` and `metadata` via json
|
6
|
+
# It defines setters and accessors for the defined `data_attributes`
|
7
|
+
# After create, it calls `apply` to apply changes.
|
8
|
+
class BaseEvent < ::ActiveRecord::Base
|
9
|
+
self.abstract_class = true
|
10
|
+
|
11
|
+
serialize :data, JSON
|
12
|
+
serialize :metadata, JSON
|
13
|
+
|
14
|
+
before_validation :preset_aggregate
|
15
|
+
before_create :apply_and_persist
|
16
|
+
after_create :dispatch
|
17
|
+
|
18
|
+
after_initialize do
|
19
|
+
self.data ||= {}
|
20
|
+
self.metadata ||= {}
|
21
|
+
end
|
22
|
+
|
23
|
+
scope :recent_first, -> { reorder('id DESC') }
|
24
|
+
|
25
|
+
def aggregate
|
26
|
+
public_send aggregate_name
|
27
|
+
end
|
28
|
+
|
29
|
+
def aggregate=(model)
|
30
|
+
public_send "#{aggregate_name}=", model
|
31
|
+
end
|
32
|
+
|
33
|
+
def aggregate_id=(id)
|
34
|
+
public_send "#{aggregate_name}_id=", id
|
35
|
+
end
|
36
|
+
|
37
|
+
# Apply the event to the aggregate passed in.
|
38
|
+
# Must return the aggregate.
|
39
|
+
def apply(aggregate)
|
40
|
+
raise NotImplementedError
|
41
|
+
end
|
42
|
+
|
43
|
+
def aggregate_id
|
44
|
+
public_send "#{aggregate_name}_id"
|
45
|
+
end
|
46
|
+
|
47
|
+
def build_aggregate
|
48
|
+
public_send "build_#{aggregate_name}"
|
49
|
+
end
|
50
|
+
|
51
|
+
# Rollback an aggregate entity to a specific version
|
52
|
+
#
|
53
|
+
# Update the aggregate with the changes up to the current event and
|
54
|
+
# destroys the events after
|
55
|
+
def rollback!
|
56
|
+
base_class = self.class.superclass == RailsEventSourcing::BaseEvent ? self.class : self.class.superclass
|
57
|
+
new_attributes = aggregate.class.new.attributes
|
58
|
+
preserve_columns = new_attributes.keys - base_class.reserved_column_names
|
59
|
+
new_attributes.slice!(*preserve_columns)
|
60
|
+
aggregate.assign_attributes(new_attributes)
|
61
|
+
aggregate.transaction do
|
62
|
+
base_class.events_for(aggregate).where('id > ?', id).destroy_all
|
63
|
+
base_class.events_for(aggregate).reorder('id ASC').each do |event|
|
64
|
+
event.apply(aggregate)
|
65
|
+
end
|
66
|
+
aggregate.save!
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
delegate :aggregate_name, to: :class
|
71
|
+
|
72
|
+
class << self
|
73
|
+
def aggregate_name
|
74
|
+
inferred_aggregate = reflect_on_all_associations(:belongs_to).first
|
75
|
+
raise "Events must belong to an aggregate" if inferred_aggregate.nil?
|
76
|
+
|
77
|
+
inferred_aggregate.name
|
78
|
+
end
|
79
|
+
|
80
|
+
# Define attributes to be serialize in the `data` column.
|
81
|
+
# It generates setters and getters for those.
|
82
|
+
#
|
83
|
+
# Example:
|
84
|
+
#
|
85
|
+
# class MyEvent < RailsEventSourcing::BaseEvent
|
86
|
+
# data_attributes :title, :description, :drop_id
|
87
|
+
# end
|
88
|
+
def data_attributes(*attrs)
|
89
|
+
@data_attributes ||= []
|
90
|
+
|
91
|
+
attrs.map(&:to_s).each do |attr|
|
92
|
+
@data_attributes << attr unless @data_attributes.include?(attr)
|
93
|
+
|
94
|
+
define_method attr do
|
95
|
+
self.data ||= {}
|
96
|
+
self.data[attr]
|
97
|
+
end
|
98
|
+
|
99
|
+
define_method "#{attr}=" do |arg|
|
100
|
+
self.data ||= {}
|
101
|
+
self.data[attr] = arg
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
@data_attributes
|
106
|
+
end
|
107
|
+
|
108
|
+
# Underscored class name by default. ex: "post/updated"
|
109
|
+
# Used when sending events to the data pipeline
|
110
|
+
def event_name
|
111
|
+
name.underscore
|
112
|
+
end
|
113
|
+
|
114
|
+
def events_for(aggregate)
|
115
|
+
where(aggregate_name => aggregate)
|
116
|
+
end
|
117
|
+
|
118
|
+
def reserved_column_names
|
119
|
+
%w[id created_at updated_at]
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
private
|
124
|
+
|
125
|
+
# Build aggregate when the event is creating an aggregate
|
126
|
+
def preset_aggregate
|
127
|
+
self.aggregate ||= build_aggregate
|
128
|
+
end
|
129
|
+
|
130
|
+
# Apply the transformation to the aggregate and save it
|
131
|
+
def apply_and_persist
|
132
|
+
aggregate.lock! if aggregate.persisted?
|
133
|
+
self.aggregate = apply(aggregate)
|
134
|
+
aggregate.save!
|
135
|
+
self.aggregate_id = aggregate.id if aggregate_id.nil?
|
136
|
+
end
|
137
|
+
|
138
|
+
def dispatch
|
139
|
+
Dispatcher.dispatch(self)
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsEventSourcing
|
4
|
+
# The Base command mixin that commands include.
|
5
|
+
#
|
6
|
+
# A Command has the following public api.
|
7
|
+
#
|
8
|
+
# ```
|
9
|
+
# MyCommand.call(user: ..., post: ...) # shorthand to initialize, validate and execute the command
|
10
|
+
# command = MyCommand.new(user: ..., post: ...)
|
11
|
+
# command.valid? # true or false
|
12
|
+
# command.errors # +> <ActiveModel::Errors ... >
|
13
|
+
# command.call # validate and execute the command
|
14
|
+
# ```
|
15
|
+
#
|
16
|
+
# `call` will raise an `ActiveRecord::RecordInvalid` error if it fails validations.
|
17
|
+
#
|
18
|
+
# Commands including the `RailsEventSourcing::Command` mixin must:
|
19
|
+
# * list the attributes the command takes
|
20
|
+
# * implement `build_event` which returns a non-persisted event or nil for noop.
|
21
|
+
#
|
22
|
+
# Ex:
|
23
|
+
#
|
24
|
+
# ```
|
25
|
+
# class MyCommand
|
26
|
+
# include RailsEventSourcing::Command
|
27
|
+
#
|
28
|
+
# attributes :user, :post
|
29
|
+
#
|
30
|
+
# def build_event
|
31
|
+
# Event.new(...)
|
32
|
+
# end
|
33
|
+
# end
|
34
|
+
# ```
|
35
|
+
module Command
|
36
|
+
extend ActiveSupport::Concern
|
37
|
+
|
38
|
+
included do
|
39
|
+
include ActiveModel::Validations
|
40
|
+
end
|
41
|
+
|
42
|
+
class_methods do
|
43
|
+
# Run validations and persist the event.
|
44
|
+
#
|
45
|
+
# On success: returns the event
|
46
|
+
# On noop: returns nil
|
47
|
+
# On failure: raise an ActiveRecord::RecordInvalid error
|
48
|
+
def call(*args)
|
49
|
+
new(*args).call
|
50
|
+
end
|
51
|
+
|
52
|
+
# Define the attributes.
|
53
|
+
# They are set when initializing the command as keyword arguments and
|
54
|
+
# are all accessible as getter methods.
|
55
|
+
#
|
56
|
+
# ex: `attributes :post, :user, :ability`
|
57
|
+
def attributes(*args)
|
58
|
+
attr_reader(*args)
|
59
|
+
|
60
|
+
initialize_method_arguments = args.map { |arg| "#{arg}:" }.join(', ')
|
61
|
+
initialize_method_body = args.map { |arg| "@#{arg} = #{arg}" }.join(";")
|
62
|
+
initialize_definition = <<~CODE
|
63
|
+
def initialize(#{initialize_method_arguments})
|
64
|
+
#{initialize_method_body}
|
65
|
+
after_initialize
|
66
|
+
end
|
67
|
+
CODE
|
68
|
+
|
69
|
+
class_eval(initialize_definition)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def call
|
74
|
+
return nil if event.nil?
|
75
|
+
raise "The event should not be persisted at this stage!" if event.persisted?
|
76
|
+
|
77
|
+
validate!
|
78
|
+
execute!
|
79
|
+
|
80
|
+
event
|
81
|
+
end
|
82
|
+
|
83
|
+
# A new record or nil if noop
|
84
|
+
def event
|
85
|
+
@event ||= (noop? ? nil : build_event)
|
86
|
+
end
|
87
|
+
|
88
|
+
def noop?
|
89
|
+
false
|
90
|
+
end
|
91
|
+
|
92
|
+
private
|
93
|
+
|
94
|
+
# Save the event. Should not be overwritten by the command as side effects
|
95
|
+
# should be implemented via Reactors triggering other Events.
|
96
|
+
def execute!
|
97
|
+
event.save!
|
98
|
+
end
|
99
|
+
|
100
|
+
# Returns a new event record or nil if noop
|
101
|
+
def build_event
|
102
|
+
raise NotImplementedError
|
103
|
+
end
|
104
|
+
|
105
|
+
# Hook to set default values
|
106
|
+
def after_initialize
|
107
|
+
# noop
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsEventSourcing
|
4
|
+
# Dispatcher implementation used by Events on after save.
|
5
|
+
class Dispatcher
|
6
|
+
class << self
|
7
|
+
# Register Reactors to Events.
|
8
|
+
# * Reactors registered with `trigger` will be triggered synchronously
|
9
|
+
# * Reactors registered with `async` will be triggered asynchronously via a ActiveJob
|
10
|
+
#
|
11
|
+
# Example:
|
12
|
+
#
|
13
|
+
# on SomeEvent, trigger: ->(item) { puts "Callable block on #{item.id}" }
|
14
|
+
# on BaseEvent, trigger: LogEvent, async: TrackEvent
|
15
|
+
# on PledgeCancelled, PaymentFailed, async: [NotifyAdmin, CreateTask]
|
16
|
+
# on [PledgeCancelled, PaymentFailed], async: [NotifyAdmin, CreateTask]
|
17
|
+
#
|
18
|
+
def on(*events, trigger: [], async: [])
|
19
|
+
rules.register(events: events.flatten, sync: Array(trigger), async: Array(async))
|
20
|
+
end
|
21
|
+
|
22
|
+
# Dispatches events to matching Reactors once.
|
23
|
+
# Called by all events after they are created.
|
24
|
+
def dispatch(event)
|
25
|
+
reactors = rules.for(event)
|
26
|
+
reactors.sync.each { |reactor| reactor.call(event) }
|
27
|
+
reactors.async.each { |reactor| RailsEventSourcing::ReactorJob.perform_later(event, reactor.to_s) }
|
28
|
+
end
|
29
|
+
|
30
|
+
def rules
|
31
|
+
@@rules ||= RuleSet.new # rubocop:disable Style/ClassVars
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
class RuleSet
|
36
|
+
def initialize
|
37
|
+
@rules = Hash.new { |h, k| h[k] = ReactorSet.new }
|
38
|
+
end
|
39
|
+
|
40
|
+
def register(events:, sync:, async:)
|
41
|
+
events.each do |event|
|
42
|
+
@rules[event].add_sync(sync)
|
43
|
+
@rules[event].add_async(async)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# Return a ReactorSet containing all Reactors matching an Event
|
48
|
+
def for(event)
|
49
|
+
reactors = ReactorSet.new
|
50
|
+
|
51
|
+
@rules.each do |event_class, rule|
|
52
|
+
# Match event by class including ancestors. e.g. All events match a role for BaseEvent.
|
53
|
+
if event.is_a?(event_class)
|
54
|
+
reactors.add_sync(rule.sync)
|
55
|
+
reactors.add_async(rule.async)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
reactors
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# Contains sync and async reactors. Used to:
|
64
|
+
# * store reactors via Rules#register
|
65
|
+
# * return a set of matching reactors with Rules#for
|
66
|
+
class ReactorSet
|
67
|
+
attr_reader :sync, :async
|
68
|
+
|
69
|
+
def initialize
|
70
|
+
@sync = Set.new
|
71
|
+
@async = Set.new
|
72
|
+
end
|
73
|
+
|
74
|
+
def add_sync(reactors)
|
75
|
+
@sync += reactors
|
76
|
+
end
|
77
|
+
|
78
|
+
def add_async(reactors)
|
79
|
+
@async += reactors
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
if defined? ActiveJob
|
4
|
+
module RailsEventSourcing
|
5
|
+
class ReactorJob < ActiveJob::Base
|
6
|
+
def perform(event, reactor_class)
|
7
|
+
reactor = reactor_class.constantize
|
8
|
+
if reactor.ancestors.include? RailsEventSourcing::BaseEvent
|
9
|
+
reactor.create!(aggregate: event.aggregate)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'rails-event-sourcing/version'
|
4
|
+
|
5
|
+
require_relative 'rails-event-sourcing/base_event'
|
6
|
+
require_relative 'rails-event-sourcing/command'
|
7
|
+
require_relative 'rails-event-sourcing/dispatcher'
|
8
|
+
require_relative 'rails-event-sourcing/reactor_job'
|
9
|
+
|
10
|
+
module RailsEventSourcing
|
11
|
+
end
|
metadata
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: rails-event-sourcing
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Mattia Roccoberton
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-11-19 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '6.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '6.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rails
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '6.0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '6.0'
|
41
|
+
description: Setup event sourcing in a Rails application easily
|
42
|
+
email:
|
43
|
+
- mat@blocknot.es
|
44
|
+
executables: []
|
45
|
+
extensions: []
|
46
|
+
extra_rdoc_files: []
|
47
|
+
files:
|
48
|
+
- ".rspec"
|
49
|
+
- ".rubocop.yml"
|
50
|
+
- Gemfile
|
51
|
+
- LICENSE.txt
|
52
|
+
- README.md
|
53
|
+
- bin/console
|
54
|
+
- bin/pry
|
55
|
+
- bin/rails
|
56
|
+
- bin/rspec
|
57
|
+
- bin/rubocop
|
58
|
+
- bin/setup
|
59
|
+
- lib/rails-event-sourcing.rb
|
60
|
+
- lib/rails-event-sourcing/base_event.rb
|
61
|
+
- lib/rails-event-sourcing/command.rb
|
62
|
+
- lib/rails-event-sourcing/dispatcher.rb
|
63
|
+
- lib/rails-event-sourcing/reactor_job.rb
|
64
|
+
- lib/rails-event-sourcing/version.rb
|
65
|
+
homepage: https://github.com/blocknotes/rails-event-sourcing
|
66
|
+
licenses:
|
67
|
+
- MIT
|
68
|
+
metadata:
|
69
|
+
homepage_uri: https://github.com/blocknotes/rails-event-sourcing
|
70
|
+
post_install_message:
|
71
|
+
rdoc_options: []
|
72
|
+
require_paths:
|
73
|
+
- lib
|
74
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
75
|
+
requirements:
|
76
|
+
- - ">="
|
77
|
+
- !ruby/object:Gem::Version
|
78
|
+
version: 2.6.0
|
79
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
80
|
+
requirements:
|
81
|
+
- - ">="
|
82
|
+
- !ruby/object:Gem::Version
|
83
|
+
version: '0'
|
84
|
+
requirements: []
|
85
|
+
rubygems_version: 3.0.3.1
|
86
|
+
signing_key:
|
87
|
+
specification_version: 4
|
88
|
+
summary: Rails Event Sourcing library
|
89
|
+
test_files: []
|