active_record_projection 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/README.md +94 -0
- data/lib/active_record_projection/event_handlers/update_subscribed_projections.rb +21 -0
- data/lib/active_record_projection/event_handlers.rb +3 -0
- data/lib/active_record_projection/jobs/update_projection.rb +13 -0
- data/lib/active_record_projection/mixin.rb +62 -0
- data/lib/active_record_projection/models/projection.rb +15 -0
- data/lib/active_record_projection/projected_event_registry.rb +19 -0
- data/lib/active_record_projection/railtie.rb +13 -0
- data/lib/active_record_projection/version.rb +5 -0
- data/lib/active_record_projection.rb +10 -0
- data/lib/generators/active_record_projection/install/USAGE +8 -0
- data/lib/generators/active_record_projection/install/install_generator.rb +21 -0
- data/lib/generators/active_record_projection/install/templates/migration.rb.erb +15 -0
- metadata +127 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6db014f62ef839ae834307bbc60593b93dce62c2d78eee1a4df8cd02fb848f5f
|
|
4
|
+
data.tar.gz: bc2f4ed0e4c6afd4814448b6e61a0059ea4b73a998620e5d6ed184578940d0ef
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b14e19b67a68e8662058c2022c7f6d6f21119646b5814cb700d3a4d13b956fef39a5a674ac387cdbf5d8cb681e19c870019be54b53b9f37139d2efc62f3abe7b
|
|
7
|
+
data.tar.gz: 5c56b026f4c90725189dbce6d09652243889eb2a8c7373f072a9c998321750aee184cde2c87306acc47d5a08acd4d687dfd8350a104cfaa82c88b4cf4452a7b6
|
data/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Active Record Projection
|
|
2
|
+
|
|
3
|
+
Persistent [Rails Event Store](https://railseventstore.org/) projections built on [Active Record](https://guides.rubyonrails.org/active_record_basics.html) models.
|
|
4
|
+
|
|
5
|
+
Built on the Rails Event Store [Aggregate Root](https://railseventstore.org/docs/core-concepts/event-sourcing).
|
|
6
|
+
|
|
7
|
+
How it works:
|
|
8
|
+
- a projection is built from a [stream](https://railseventstore.org/docs/core-concepts/link) of events
|
|
9
|
+
- a projection only has a single stream but a stream can be the source of multiple projections
|
|
10
|
+
- each projection has a [polymorphic](https://guides.rubyonrails.org/association_basics.html#polymorphic-associations) association to a record
|
|
11
|
+
- this record is your Active Record model that functions as an [aggregate root](https://www.baeldung.com/cs/aggregate-root-ddd#aggregates-and-aggregate-roots)
|
|
12
|
+
- it can update its own state and the state of its children by projecting events
|
|
13
|
+
- an async event handler is automatically [subscribed](https://railseventstore.org/docs/core-concepts/subscribe) to all events that you project using the `on` method
|
|
14
|
+
- when an event of each type occurs, the projections subscribed to all streams in which that event is linked are notified
|
|
15
|
+
- unseen events are then read, applied and the record is saved
|
|
16
|
+
- [optimistic locking](https://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html) is used to control concurrent updates to the projection/record
|
|
17
|
+
|
|
18
|
+
```mermaid
|
|
19
|
+
---
|
|
20
|
+
title: Records and Projections
|
|
21
|
+
---
|
|
22
|
+
classDiagram
|
|
23
|
+
namespace ActiveRecordProjection{
|
|
24
|
+
class Projection{
|
|
25
|
+
String: stream
|
|
26
|
+
Integer: lock_version
|
|
27
|
+
UUID: last_event_id
|
|
28
|
+
Integer: record_id
|
|
29
|
+
String: record_type
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class AggregateRoot{
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class Child{
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
namespace ActiveRecord{
|
|
40
|
+
class Base{
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
namespace RailsEventStore{
|
|
45
|
+
class Stream{
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
AggregateRoot --|> Base
|
|
50
|
+
Child --|> Base
|
|
51
|
+
AggregateRoot *-- Child
|
|
52
|
+
Projection o-- AggregateRoot : record
|
|
53
|
+
Projection o-- Stream : stream
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
Add to your `Gemfile` and run `bundle`.
|
|
58
|
+
```ruby
|
|
59
|
+
gem 'active_record_projection'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Create a `active_record_projection_projections` table in your database:
|
|
63
|
+
```bash
|
|
64
|
+
bundle exec rails generate active_record_projection:install
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Adding an Active Record Projection
|
|
68
|
+
To make your Active Record model a projection of an aggregate root:
|
|
69
|
+
- include `ActiveRecordProjection`
|
|
70
|
+
- define a `get_stream` method to allow a stream to be determined from the model data
|
|
71
|
+
- define how to project each event using the [Aggregate Root syntax](https://railseventstore.org/docs/core-concepts/event-sourcing#define-aggregate-logic) (define `on` methods)
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
class Order < ApplicationRecord
|
|
75
|
+
include ActiveRecordProjection
|
|
76
|
+
|
|
77
|
+
on OrderSubmitted.event_type do |event|
|
|
78
|
+
self.state = :submitted
|
|
79
|
+
self.delivery_date = event.data.fetch(:delivery_date)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
on OrderExpired.event_type do |_event|
|
|
83
|
+
self.state = :expired
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def get_stream
|
|
89
|
+
"orders$#{uuid}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
You need to add a `on` method for each event type in a stream, even if you don't need it in order to project.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# don't require this file, RailsEventStore::AsyncHandler requires rails to be configured first
|
|
4
|
+
|
|
5
|
+
require 'active_record_projection/jobs/update_projection'
|
|
6
|
+
require 'active_record_projection/models/projection'
|
|
7
|
+
|
|
8
|
+
module ActiveRecordProjection
|
|
9
|
+
module EventHandlers
|
|
10
|
+
class UpdateSubscribedProjections < ActiveJob::Base
|
|
11
|
+
prepend RailsEventStore::AsyncHandler
|
|
12
|
+
|
|
13
|
+
def perform(event)
|
|
14
|
+
streams = Rails.configuration.event_store.streams_of(event.event_id).map(&:name)
|
|
15
|
+
Models::Projection.where(stream: streams).pluck(:id).each do |id|
|
|
16
|
+
Jobs::UpdateProjection.perform_later(id:)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_record_projection/models/projection'
|
|
4
|
+
|
|
5
|
+
module ActiveRecordProjection
|
|
6
|
+
module Jobs
|
|
7
|
+
class UpdateProjection < ActiveJob::Base
|
|
8
|
+
def perform(id:)
|
|
9
|
+
Models::Projection.find(id).update_projection!
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_record_projection/models/projection'
|
|
4
|
+
require 'active_record_projection/projected_event_registry'
|
|
5
|
+
|
|
6
|
+
module ActiveRecordProjection
|
|
7
|
+
module Mixin
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
include AggregateRoot
|
|
12
|
+
|
|
13
|
+
has_one :projection, class_name: 'ActiveRecordProjection::Models::Projection', as: :record
|
|
14
|
+
|
|
15
|
+
delegate :last_event_id, :last_event_id=, :stream, to: :projection, allow_nil: true
|
|
16
|
+
after_initialize :add_projection, :initialize_transient
|
|
17
|
+
|
|
18
|
+
def self.on(*event_klasses, &)
|
|
19
|
+
ProjectedEventRegistry.register(event_klasses)
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# the update needs to happen from this end to ensure that
|
|
25
|
+
# the correct projection is saved - the one updated
|
|
26
|
+
# as unseen events are applied
|
|
27
|
+
def update_projection!
|
|
28
|
+
apply_unseen
|
|
29
|
+
|
|
30
|
+
ActiveRecord::Base.transaction do
|
|
31
|
+
save!
|
|
32
|
+
projection.save!
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def apply_unseen
|
|
39
|
+
query = Rails.configuration.event_store.read.stream(stream)
|
|
40
|
+
query = query.from(last_event_id) if last_event_id
|
|
41
|
+
query.reduce do |_, ev|
|
|
42
|
+
apply(ev)
|
|
43
|
+
self.last_event_id = ev.event_id
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def add_projection
|
|
48
|
+
self.projection ||= Models::Projection.new(
|
|
49
|
+
record: self,
|
|
50
|
+
stream: get_stream
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def initialize_transient
|
|
55
|
+
# this sets the variables at https://github.com/RailsEventStore/rails_event_store/blob/c2df01c4ac5d974e127b95b8d9eea21b7bebbad0/aggregate_root/lib/aggregate_root.rb#L35
|
|
56
|
+
# #new is not called when an AR model is loaded
|
|
57
|
+
# there must be a better way to use the module directly instead to avoid repeating
|
|
58
|
+
instance_variable_set(:@version, -1)
|
|
59
|
+
instance_variable_set(:@unpublished_events, [])
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_record'
|
|
4
|
+
|
|
5
|
+
module ActiveRecordProjection
|
|
6
|
+
module Models
|
|
7
|
+
class Projection < ActiveRecord::Base
|
|
8
|
+
delegate :update_projection!, to: :record
|
|
9
|
+
|
|
10
|
+
self.table_name = :active_record_projection_projections
|
|
11
|
+
|
|
12
|
+
belongs_to :record, polymorphic: true
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecordProjection
|
|
4
|
+
class ProjectedEventRegistry
|
|
5
|
+
class_attribute :event_types, default: Set.new
|
|
6
|
+
class_attribute :unsubscribe_handler
|
|
7
|
+
|
|
8
|
+
def self.register(event_types)
|
|
9
|
+
self.event_types.merge(event_types)
|
|
10
|
+
unsubscribe_handler&.call
|
|
11
|
+
self.unsubscribe_handler = Rails.configuration.event_store.subscribe(
|
|
12
|
+
EventHandlers::UpdateSubscribedProjections,
|
|
13
|
+
to: self.event_types
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private :event_types, :event_types=, :unsubscribe_handler, :unsubscribe_handler=
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/railtie'
|
|
4
|
+
|
|
5
|
+
module ActiveRecordProjection
|
|
6
|
+
class Railtie < ::Rails::Railtie
|
|
7
|
+
config.after_initialize do
|
|
8
|
+
# the prepend RailsEventStore::AsyncHandler in the handlers
|
|
9
|
+
# requires that rails event store has been configured first
|
|
10
|
+
require 'active_record_projection/event_handlers'
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators/active_record'
|
|
4
|
+
require 'rails/generators/active_record/migration'
|
|
5
|
+
|
|
6
|
+
module ActiveRecordProjection
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
include ::Rails::Generators::Migration
|
|
9
|
+
|
|
10
|
+
source_root File.expand_path('templates', __dir__)
|
|
11
|
+
argument :migration_name, type: :string, default: 'create_active_record_projection_projections'
|
|
12
|
+
|
|
13
|
+
def create_migration_file
|
|
14
|
+
migration_template 'migration.rb.erb', "db/migrate/#{migration_name}.rb"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.next_migration_number(dirname)
|
|
18
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class <%= migration_name.camelize %> < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :active_record_projection_projections, id: :uuid do |t|
|
|
4
|
+
t.string :stream, null: false
|
|
5
|
+
t.integer :lock_version, null: false, default: 0
|
|
6
|
+
t.uuid :last_event_id, null: true
|
|
7
|
+
t.references :record, polymorphic: true
|
|
8
|
+
|
|
9
|
+
t.timestamps
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :active_record_projection_projections, [:record_id, :record_type]
|
|
13
|
+
add_index :active_record_projection_projections, [:stream]
|
|
14
|
+
end
|
|
15
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: active_record_projection
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Brent Snook
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activesupport
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: aggregate_root
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rails
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rails_event_store
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
82
|
+
description: Builds Rails Event Store AggregateRoot
|
|
83
|
+
email: brent@fuglylogic.com
|
|
84
|
+
executables: []
|
|
85
|
+
extensions: []
|
|
86
|
+
extra_rdoc_files: []
|
|
87
|
+
files:
|
|
88
|
+
- README.md
|
|
89
|
+
- lib/active_record_projection.rb
|
|
90
|
+
- lib/active_record_projection/event_handlers.rb
|
|
91
|
+
- lib/active_record_projection/event_handlers/update_subscribed_projections.rb
|
|
92
|
+
- lib/active_record_projection/jobs/update_projection.rb
|
|
93
|
+
- lib/active_record_projection/mixin.rb
|
|
94
|
+
- lib/active_record_projection/models/projection.rb
|
|
95
|
+
- lib/active_record_projection/projected_event_registry.rb
|
|
96
|
+
- lib/active_record_projection/railtie.rb
|
|
97
|
+
- lib/active_record_projection/version.rb
|
|
98
|
+
- lib/generators/active_record_projection/install/USAGE
|
|
99
|
+
- lib/generators/active_record_projection/install/install_generator.rb
|
|
100
|
+
- lib/generators/active_record_projection/install/templates/migration.rb.erb
|
|
101
|
+
homepage: https://github.com/brentsnook/active_record_projection
|
|
102
|
+
licenses:
|
|
103
|
+
- MIT
|
|
104
|
+
metadata:
|
|
105
|
+
source_code_uri: https://github.com/brentsnook/active_record_projection
|
|
106
|
+
bug_tracker_uri: https://github.com/brentsnook/active_record_projection/issues
|
|
107
|
+
changelog_uri: https://github.com/brentsnook/active_record_projection/blob/main/CHANGELOG.md
|
|
108
|
+
rubygems_mfa_required: 'true'
|
|
109
|
+
rdoc_options: []
|
|
110
|
+
require_paths:
|
|
111
|
+
- lib
|
|
112
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - ">="
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '3.1'
|
|
117
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
118
|
+
requirements:
|
|
119
|
+
- - ">="
|
|
120
|
+
- !ruby/object:Gem::Version
|
|
121
|
+
version: '3.1'
|
|
122
|
+
requirements: []
|
|
123
|
+
rubygems_version: 3.6.9
|
|
124
|
+
specification_version: 4
|
|
125
|
+
summary: Persistent, automatically updated ActiveRecord projections for Rails Event
|
|
126
|
+
Store
|
|
127
|
+
test_files: []
|