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 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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record_projection/event_handlers/update_subscribed_projections'
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordProjection
4
+ VERSION = '0.0.1'
5
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record_projection/mixin'
4
+ require 'active_record_projection/railtie'
5
+
6
+ module ActiveRecordProjection
7
+ def self.included(clazz)
8
+ clazz.include(Mixin)
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Generates a migration to add the projections table including indexes.
3
+
4
+ Examples:
5
+ rails generate active_record_projection:install
6
+
7
+ This will create:
8
+ db/migrate/[TIMESTAMP]_create_active_record_projection_projections.rb
@@ -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: []