active_event_store 0.0.1 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a94c6723818483ba570ca3b781125da2461c53d7869213e5e7545606496c912
4
- data.tar.gz: a160dca5145e401308d15aede842cd4ad758400bb4f96cb07d11f199424c31ed
3
+ metadata.gz: 8791927b35712c61094dcb6cc915e2b27912129e675c84230cd6f5271585902e
4
+ data.tar.gz: fe58f1151444dc521caa4666adf06dd97d00bc37882843b1252d776683a98dcf
5
5
  SHA512:
6
- metadata.gz: e0cd0a84d08b51d8851e6cefe51bc95e39bc47a69d3a209f9c254793a9e70b0400ea788320e16208b7b0a9b1b1221923062c6cd1f02fae751ff810a3d387aee5
7
- data.tar.gz: '08effd30fe98d80eba9d72c65a7d04154ce54e6dd13effe9e9598b8c22af09bf33028a67dd92abe714c78d01671018116717135e409da8beef0771799fdc48e6'
6
+ metadata.gz: 95f22443b58343d34b93b0400b53c63bf5190ff5f0c8e56c3c96c2e9ebf3d8137bee3136035f7f12d17f8c4488f8bdebef3a4e971eb67dfd17dbafdb48b857ba
7
+ data.tar.gz: c947302a3db94490b5a781f3fb74bacc0b45d437240599766dd33138147b8807119604105b55034482861254463f4a49919dba2689d52eb16599459c2fe15b7a
data/CHANGELOG.md CHANGED
@@ -2,4 +2,21 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ## 1.0.0 (2021-01-14)
6
+
7
+ - Ruby 2.6+, Rails 6+ and RailsEventStore 2.1+ is required.
8
+ ## 0.2.1 (2020-09-30)
9
+
10
+ - Fix Active Support load hook name. ([@palkan][])
11
+
12
+ Now `ActiveSupport.on_load(:active_event_store) { ... }` works.
13
+
14
+ ## 0.2.0 (2020-05-11)
15
+
16
+ - Update Event API to support both RES 1.0 and 0.42+. ([@palkan][])
17
+
18
+ ## 0.1.0 (2020-04-22)
19
+
20
+ - Open source Active Event Store. ([@palkan][])
21
+
5
22
  [@palkan]: https://github.com/palkan
data/LICENSE.txt CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2020 Vladimir Dementyev
1
+ Copyright (c) 2020-2021 Vladimir Dementyev
2
2
 
3
3
  MIT License
4
4
 
data/README.md CHANGED
@@ -1,38 +1,194 @@
1
1
  [![Gem Version](https://badge.fury.io/rb/active_event_store.svg)](https://rubygems.org/gems/active_event_store) [![Build](https://github.com/palkan/active_event_store/workflows/Build/badge.svg)](https://github.com/palkan/active_event_store/actions)
2
- [![JRuby Build](https://github.com/palkan/active_event_store/workflows/JRuby%20Build/badge.svg)](https://github.com/palkan/active_event_store/actions)
3
2
 
4
3
  # Active Event Store
5
4
 
6
- TBD
5
+ Active Event Store is a wrapper over [Rails Event Store](https://railseventstore.org/) which adds conventions and transparent Rails integration.
6
+
7
+ ## Motivation
8
+
9
+ Why creating a wrapper and not using Rails Event Store itself?
10
+
11
+ RES is an awesome project but, in our opinion, it lacks Rails simplicity and elegance (=conventions and less boilerplate). It's an advanced tool for advanced developers. We've been using it in multiple projects in a similar way, and decided to extract our approach into this gem (originally private).
12
+
13
+ Secondly, we wanted to have a store implementation independent API that would allow us to adapterize the actual event store in the future (something like `ActiveEventStore.store_engine = :rails_event_store` or `ActiveEventStore.store_engine = :hanami_events`).
14
+
15
+ <a href="https://evilmartians.com/?utm_source=active_event_store">
16
+ <img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54"></a>
7
17
 
8
18
  ## Installation
9
19
 
10
- Adding to a gem:
20
+ Add the gem to your project:
21
+
22
+ ```ruby
23
+ # Gemfile
24
+ gem "active_event_store", "~> 1.0"
25
+ ```
26
+
27
+ Setup database according to the [Rails Event Store docs](https://railseventstore.org/docs/install/#setup-data-model):
28
+
29
+ ```sh
30
+ rails generate rails_event_store_active_record:migration
31
+ rails db:migrate
32
+ ```
33
+
34
+ ### Requirements
35
+
36
+ - Ruby (MRI) >= 2.6
37
+ - Rails >= 6.0
38
+ - RailsEventStore >= 2.1
39
+
40
+ ## Usage
41
+
42
+ ### Describe events
43
+
44
+ Events are represented by _event classes_, which describe events payloads and identifiers:
11
45
 
12
46
  ```ruby
13
- # my-cool-gem.gemspec
14
- Gem::Specification.new do |spec|
15
- # ...
16
- spec.add_dependency "active_event_store"
17
- # ...
47
+ class ProfileCompleted < ActiveEventStore::Event
48
+ # (optional) event identifier is used for transmitting events
49
+ # to subscribers.
50
+ #
51
+ # By default, identifier is equal to `name.underscore.gsub('/', '.')`.
52
+ #
53
+ # You don't need to specify identifier manually, only for backward compatibility when
54
+ # class name is changed.
55
+ self.identifier = "profile_completed"
56
+
57
+ # Add attributes accessors
58
+ attributes :user_id
59
+
60
+ # Sync attributes only available for sync subscribers
61
+ # (so you can add some optional non-JSON serializable data here)
62
+ # For example, we can also add `user` record to the event to avoid
63
+ # reloading in sync subscribers
64
+ sync_attributes :user
18
65
  end
19
66
  ```
20
67
 
21
- Or adding to your project:
68
+ **NOTE:** we use JSON to [serialize events](https://railseventstore.org/docs/mapping_serialization/), thus only the simple field types (numbers, strings, booleans) are supported.
69
+
70
+ Each event has predefined (_reserved_) fields:
71
+
72
+ - `event_id` – unique event id
73
+ - `type` – event type (=identifier)
74
+ - `metadata`
75
+
76
+ We suggest to use a naming convention for event classes, for example, using the past tense and describe what happened (e.g. "ProfileCreated", "EventPublished", etc.).
77
+
78
+ We recommend to keep event definitions in the `app/events` folder.
79
+
80
+ ### Events registration
81
+
82
+ Since we use _abstract_ identifiers instead of class names, we need a way to tell our _mapper_ how to infer an event class from its type.
83
+
84
+ In most cases, we register events automatically when they're published or when a subscription is created.
85
+
86
+ You can also register events manually:
22
87
 
23
88
  ```ruby
24
- # Gemfile
25
- gem "active_event_store"
89
+ # by passing an event class
90
+ ActiveEventStore.mapper.register_event MyEventClass
91
+
92
+ # or more precisely (in that case `event.type` must be equal to "my_event")
93
+ ActiveEventStore.mapper.register "my_event", MyEventClass
26
94
  ```
27
95
 
28
- ### Supported Ruby versions
96
+ ### Publish events
29
97
 
30
- - Ruby (MRI) >= 2.5.0
31
- - JRuby >= 9.2.9
98
+ To publish an event you must first create an instance of the event class and call `ActiveEventStore.publish` method:
32
99
 
33
- ## Usage
100
+ ```ruby
101
+ event = ProfileCompleted.new(user_id: user.id)
102
+
103
+ # or with metadata
104
+ event = ProfileCompleted.new(user_id: user.id, metadata: {ip: request.remote_ip})
105
+
106
+ # then publish the event
107
+ ActiveEventStore.publish(event)
108
+ ```
109
+
110
+ That's it! Your event has been stored and propagated to the subscribers.
111
+
112
+ ### Subscribe to events
113
+
114
+ To subscribe a handler to an event you must use `ActiveEventStore.subscribe` method.
115
+
116
+ You can do this in your app or engine initializer:
117
+
118
+ ```ruby
119
+ # some/engine.rb
120
+
121
+ # To make sure event store has been initialized use the load hook
122
+ # `store` == `ActiveEventStore`
123
+ ActiveSupport.on_load :active_event_store do |store|
124
+ # async subscriber – invoked from background job, enqueued after the current transaction commits
125
+ # NOTE: all subscribers are asynchronous by default
126
+ store.subscribe MyEventHandler, to: ProfileCreated
127
+
128
+ # sync subscriber – invoked right "within" `publish` method
129
+ store.subscribe MyEventHandler, to: ProfileCreated, sync: true
130
+
131
+ # anonymous handler (could only be synchronous)
132
+ store.subscribe(to: ProfileCreated, sync: true) do |event|
133
+ # do something
134
+ end
135
+
136
+ # you can omit event if your subscriber follows the convention
137
+ # for example, the following subscriber would subscribe to
138
+ # ProfileCreated event
139
+ store.subscribe OnProfileCreated::DoThat
140
+ end
141
+ ```
142
+
143
+ Subscribers could be any callable Ruby objects that accept a single argument (event) as its input.
144
+
145
+ We suggest putting subscribers to the `app/subscribers` folder using the following convention: `app/subscribers/on_<event_type>/<subscriber.rb>`, e.g. `app/subscribers/on_profile_created/create_chat_user.rb`.
146
+
147
+ **NOTE:** Active Job must be loaded to use async subscribers (i.e., `require "active_job/railtie"` or `require "rails/all"` in your `config/application.rb`).
148
+
149
+ ### Testing
150
+
151
+ You can test subscribers as normal Ruby objects.
152
+
153
+ **NOTE:** Currently, we provide additional matchers only for RSpec. PRs with Minitest support are welcomed!
154
+
155
+ To test that a given subscriber exists, you can use the `have_enqueued_async_subscriber_for` matcher:
156
+
157
+ ```ruby
158
+ # for asynchronous subscriptions
159
+ it "is subscribed to some event" do
160
+ event = MyEvent.new(some: "data")
161
+ expect { ActiveEventStore.publish event }
162
+ .to have_enqueued_async_subscriber_for(MySubscriberService)
163
+ .with(event)
164
+ end
165
+ ```
166
+
167
+ **NOTE:** You must have `rspec-rails` gem in your bundle to use `have_enqueued_async_subscriber_for` matcher.
168
+
169
+ For synchronous subscribers using `have_received` is enough:
170
+
171
+ ```ruby
172
+ it "is subscribed to some event" do
173
+ allow(MySubscriberService).to receive(:call)
174
+
175
+ event = MyEvent.new(some: "data")
176
+
177
+ ActiveEventStore.publish event
178
+
179
+ expect(MySubscriberService).to have_received(:call).with(event)
180
+ end
181
+ ```
182
+
183
+ To test event publishing, use `have_published_event` matcher:
184
+
185
+ ```ruby
186
+ expect { subject }.to have_published_event(ProfileCreated).with(user_id: user.id)
187
+ ```
188
+
189
+ **NOTE:** `have_published_event` only supports block expectations.
34
190
 
35
- TBD
191
+ **NOTE 2** `with` modifier works like `have_attributes` matcher (not `contain_exactly`); you can only specify serializable attributes in `with` (i.e. sync attributes are not supported, 'cause they are not persistent).
36
192
 
37
193
  ## Contributing
38
194
 
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ActiveEventStore
6
+ class Config
7
+ attr_writer :repository, :serializer, :job_queue_name, :store_options
8
+
9
+ def repository
10
+ @repository ||= RailsEventStoreActiveRecord::EventRepository.new(serializer: serializer)
11
+ end
12
+
13
+ def serializer
14
+ @serializer ||= JSON
15
+ end
16
+
17
+ def job_queue_name
18
+ @job_queue_name ||= :events_subscribers
19
+ end
20
+
21
+ def store_options
22
+ @store_options ||= {}
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module ActiveEventStore
6
+ class Engine < ::Rails::Engine
7
+ config.active_event_store = ActiveEventStore.config
8
+
9
+ # Use before configuration hook to check for ActiveJob presence
10
+ ActiveSupport.on_load(:before_configuration) do
11
+ next warn "Active Job is not loaded. Active Event Store asynchrounous subscriptions won't worke" unless defined?(::ActiveJob)
12
+
13
+ require "active_event_store/subscriber_job"
14
+ require "active_event_store/rspec/have_enqueued_async_subscriber_for" if defined?(::RSpec::Matchers)
15
+ end
16
+
17
+ config.to_prepare do
18
+ # See https://railseventstore.org/docs/subscribe/#scheduling-async-handlers-after-commit
19
+ ActiveEventStore.event_store = RailsEventStore::Client.new(
20
+ dispatcher: RubyEventStore::ComposedDispatcher.new(
21
+ RailsEventStore::AfterCommitAsyncDispatcher.new(
22
+ scheduler: RailsEventStore::ActiveJobScheduler.new(
23
+ serializer: ActiveEventStore.config.serializer
24
+ )
25
+ ),
26
+ RubyEventStore::Dispatcher.new
27
+ ),
28
+ repository: ActiveEventStore.config.repository,
29
+ mapper: ActiveEventStore::Mapper.new(mapping: ActiveEventStore.mapping),
30
+ **ActiveEventStore.config.store_options
31
+ )
32
+
33
+ ActiveSupport.run_load_hooks(:active_event_store, ActiveEventStore)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveEventStore
4
+ # RES event wrapper
5
+ class Event < RubyEventStore::Event
6
+ RESERVED_ATTRIBUTES = %i[event_id type metadata].freeze
7
+
8
+ class << self
9
+ attr_writer :identifier
10
+
11
+ def identifier
12
+ return @identifier if instance_variable_defined?(:@identifier)
13
+
14
+ @identifier = name.underscore.tr("/", ".")
15
+ end
16
+
17
+ # define store readers
18
+ def attributes(*fields)
19
+ fields.each do |field|
20
+ raise ArgumentError, "#{field} is reserved" if RESERVED_ATTRIBUTES.include?(field)
21
+
22
+ defined_attributes << field
23
+
24
+ class_eval <<~CODE, __FILE__, __LINE__ + 1
25
+ def #{field}
26
+ data[:#{field}]
27
+ end
28
+ CODE
29
+ end
30
+ end
31
+
32
+ def sync_attributes(*fields)
33
+ fields.each do |field|
34
+ raise ArgumentError, "#{field} is reserved" if RESERVED_ATTRIBUTES.include?(field)
35
+
36
+ defined_sync_attributes << field
37
+
38
+ attr_reader field
39
+ end
40
+ end
41
+
42
+ def defined_attributes
43
+ return @defined_attributes if instance_variable_defined?(:@defined_attributes)
44
+
45
+ @defined_attributes =
46
+ if superclass.respond_to?(:defined_attributes)
47
+ superclass.defined_attributes.dup
48
+ else
49
+ []
50
+ end
51
+ end
52
+
53
+ def defined_sync_attributes
54
+ return @defined_sync_attributes if instance_variable_defined?(:@defined_sync_attributes)
55
+
56
+ @defined_sync_attributes =
57
+ if superclass.respond_to?(:defined_sync_attributes)
58
+ superclass.defined_sync_attributes.dup
59
+ else
60
+ []
61
+ end
62
+ end
63
+ end
64
+
65
+ def initialize(metadata: {}, event_id: nil, **params)
66
+ validate_attributes!(params)
67
+ extract_sync_attributes!(params)
68
+ super(**{event_id: event_id, metadata: metadata, data: params}.compact)
69
+ end
70
+
71
+ def type
72
+ self.class.identifier
73
+ end
74
+
75
+ alias_method :event_type, :type
76
+
77
+ def inspect
78
+ "#{self.class.name}<#{event_type}##{message_id}>, data: #{data}, metadata: #{metadata}"
79
+ end
80
+
81
+ # Has been removed from RES: https://github.com/RailsEventStore/rails_event_store/pull/726
82
+ def to_h
83
+ {
84
+ event_id: event_id,
85
+ metadata: metadata.to_h,
86
+ data: data,
87
+ type: event_type
88
+ }
89
+ end
90
+
91
+ protected
92
+
93
+ attr_writer :event_id
94
+
95
+ def validate_attributes!(params)
96
+ unknown_fields = params.keys.map(&:to_sym) - self.class.defined_attributes - self.class.defined_sync_attributes
97
+ unless unknown_fields.empty?
98
+ raise ArgumentError, "Unknown event attributes: #{unknown_fields.join(", ")}"
99
+ end
100
+ end
101
+
102
+ def extract_sync_attributes!(params)
103
+ params.keys.each do |key|
104
+ next unless self.class.defined_sync_attributes.include?(key.to_sym)
105
+
106
+ instance_variable_set(:"@#{key}", params.delete(key))
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveEventStore
4
+ using(Module.new {
5
+ refine Hash do
6
+ def symbolize_keys
7
+ RubyEventStore::TransformKeys.symbolize(self)
8
+ end
9
+ end
10
+ })
11
+
12
+ # Custom mapper for RES events.
13
+ #
14
+ # See https://github.com/RailsEventStore/rails_event_store/blob/v0.35.0/ruby_event_store/lib/ruby_event_store/mappers/default.rb
15
+ class Mapper
16
+ def initialize(mapping:, serializer: ActiveEventStore.config.serializer)
17
+ @serializer = serializer
18
+ @mapping = mapping
19
+ end
20
+
21
+ def event_to_record(domain_event)
22
+ # lazily add type to mapping
23
+ # NOTE: use class name instead of a class to handle code reload
24
+ # in development (to avoid accessing orphaned classes)
25
+ mapping.register(domain_event.event_type, domain_event.class.name) unless mapping.exist?(domain_event.event_type)
26
+
27
+ RubyEventStore::Record.new(
28
+ event_id: domain_event.event_id,
29
+ metadata: serializer.dump(domain_event.metadata.to_h),
30
+ data: serializer.dump(domain_event.data),
31
+ event_type: domain_event.event_type,
32
+ timestamp: domain_event.timestamp,
33
+ valid_at: domain_event.valid_at
34
+ )
35
+ end
36
+
37
+ def record_to_event(record)
38
+ event_class = mapping.fetch(record.event_type) {
39
+ raise "Don't know how to deserialize event: \"#{record.event_type}\". " \
40
+ "Add explicit mapping: ActiveEventStore.mapping.register \"#{record.event_type}\", \"<Class Name>\""
41
+ }
42
+
43
+ Object.const_get(event_class).new(
44
+ **serializer.load(record.data).symbolize_keys,
45
+ metadata: serializer.load(record.metadata).symbolize_keys,
46
+ event_id: record.event_id
47
+ )
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :serializer, :mapping
53
+ end
54
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveEventStore
4
+ class Mapping
5
+ delegate :fetch, to: :data
6
+
7
+ def initialize
8
+ @data = {}
9
+ end
10
+
11
+ def register(type, class_name)
12
+ data[type] = class_name
13
+ end
14
+
15
+ def register_event(event_class)
16
+ register event_class.identifier, event_class.name
17
+ end
18
+
19
+ def exist?(type)
20
+ data.key?(type)
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :data
26
+ end
27
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Do not fail if rspec-rails is not available
4
+ begin
5
+ require "rspec/rails"
6
+ require "rspec/rails/matchers/active_job"
7
+ rescue LoadError
8
+ warn "You must add `rspec-rails` to your project to use `have_enqueued_async_subscriber_for` matcher"
9
+ return
10
+ end
11
+
12
+ module ActiveEventStore
13
+ class HaveEnqueuedAsyncSubscriberFor < RSpec::Rails::Matchers::ActiveJob::HaveEnqueuedJob
14
+ class EventMatcher
15
+ include ::RSpec::Matchers::Composable
16
+
17
+ attr_reader :event
18
+
19
+ def initialize(event)
20
+ @event = event
21
+ end
22
+
23
+ def matches?(actual_serialized)
24
+ actual = ActiveEventStore.event_store.deserialize(
25
+ **actual_serialized,
26
+ serializer: ActiveEventStore.config.serializer
27
+ )
28
+
29
+ actual.event_type == event.event_type && data_matches?(actual.data)
30
+ end
31
+
32
+ def description
33
+ "be #{event.inspect}"
34
+ end
35
+
36
+ private
37
+
38
+ def data_matches?(actual)
39
+ ::RSpec::Matchers::BuiltIn::Match.new(event.data).matches?(actual)
40
+ end
41
+ end
42
+
43
+ def initialize(subscriber_class)
44
+ subscriber_job = ActiveEventStore::SubscriberJob.for(subscriber_class)
45
+ if subscriber_job.nil?
46
+ raise(
47
+ RSpec::Expectations::ExpectationNotMetError,
48
+ "No such async subscriber: #{subscriber_class.name}"
49
+ )
50
+ end
51
+ super(subscriber_job)
52
+ on_queue("events_subscribers")
53
+ end
54
+
55
+ def with(event)
56
+ super(EventMatcher.new(event))
57
+ end
58
+
59
+ def matches?(block)
60
+ raise ArgumentError, "have_enqueued_async_subscriber_for only supports block expectations" unless block.is_a?(Proc)
61
+ # Make sure that there is a transaction
62
+ super(proc { ActiveRecord::Base.transaction(&block) })
63
+ end
64
+ end
65
+ end
66
+
67
+ RSpec.configure do |config|
68
+ config.include(Module.new {
69
+ def have_enqueued_async_subscriber_for(*args)
70
+ ActiveEventStore::HaveEnqueuedAsyncSubscriberFor.new(*args)
71
+ end
72
+ })
73
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveEventStore
4
+ class HavePublishedEvent < RSpec::Matchers::BuiltIn::BaseMatcher
5
+ attr_reader :event_class, :event_store, :attributes
6
+
7
+ def initialize(event_class)
8
+ @event_class = event_class
9
+ @event_store = ActiveEventStore.event_store
10
+ set_expected_number(:exactly, 1)
11
+ end
12
+
13
+ def with_store(store)
14
+ @event_store = store
15
+ self
16
+ end
17
+
18
+ def with(attributes)
19
+ @attributes = attributes
20
+ self
21
+ end
22
+
23
+ def exactly(count)
24
+ set_expected_number(:exactly, count)
25
+ self
26
+ end
27
+
28
+ def at_least(count)
29
+ set_expected_number(:at_least, count)
30
+ self
31
+ end
32
+
33
+ def at_most(count)
34
+ set_expected_number(:at_most, count)
35
+ self
36
+ end
37
+
38
+ def times
39
+ self
40
+ end
41
+
42
+ def once
43
+ exactly(:once)
44
+ end
45
+
46
+ def twice
47
+ exactly(:twice)
48
+ end
49
+
50
+ def thrice
51
+ exactly(:thrice)
52
+ end
53
+
54
+ def supports_block_expectations?
55
+ true
56
+ end
57
+
58
+ def matches?(block)
59
+ raise ArgumentError, "have_published_event only supports block expectations" unless block.is_a?(Proc)
60
+
61
+ original_count = event_store.read.count
62
+ block.call
63
+ new_count = event_store.read.count - original_count
64
+ in_block_events = new_count.positive? ? event_store.read.backward.limit(new_count).to_a :
65
+ []
66
+
67
+ @matching_events, @unmatching_events =
68
+ in_block_events.partition do |actual_event|
69
+ (event_class.identifier == actual_event.event_type) &&
70
+ (attributes.nil? || attributes_match?(actual_event))
71
+ end
72
+
73
+ @matching_count = @matching_events.size
74
+
75
+ case @expectation_type
76
+ when :exactly then @expected_number == @matching_count
77
+ when :at_most then @expected_number >= @matching_count
78
+ when :at_least then @expected_number <= @matching_count
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def attributes_match?(event)
85
+ RSpec::Matchers::BuiltIn::HaveAttributes.new(attributes).matches?(event)
86
+ end
87
+
88
+ def set_expected_number(relativity, count)
89
+ @expectation_type = relativity
90
+ @expected_number =
91
+ case count
92
+ when :once then 1
93
+ when :twice then 2
94
+ when :thrice then 3
95
+ else Integer(count)
96
+ end
97
+ end
98
+
99
+ def failure_message
100
+ (+"expected to publish #{event_class.identifier} event").tap do |msg|
101
+ msg << " #{message_expectation_modifier}, but"
102
+
103
+ if @unmatching_events.any?
104
+ msg << " published the following events:"
105
+ @unmatching_events.each do |unmatching_event|
106
+ msg << "\n #{unmatching_event.inspect}"
107
+ end
108
+ else
109
+ msg << " haven't published anything"
110
+ end
111
+ end
112
+ end
113
+
114
+ def failure_message_when_negated
115
+ "expected not to publish #{event_class.identifier} event"
116
+ end
117
+
118
+ def message_expectation_modifier
119
+ number_modifier = @expected_number == 1 ? "once" : "#{@expected_number} times"
120
+ case @expectation_type
121
+ when :exactly then "exactly #{number_modifier}"
122
+ when :at_most then "at most #{number_modifier}"
123
+ when :at_least then "at least #{number_modifier}"
124
+ end
125
+ end
126
+ end
127
+ end
128
+
129
+ RSpec.configure do |config|
130
+ config.include(Module.new do
131
+ def have_published_event(*args)
132
+ ActiveEventStore::HavePublishedEvent.new(*args)
133
+ end
134
+ end)
135
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_event_store/rspec/have_published_event"
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveEventStore
4
+ # Base job for async subscribers
5
+ class SubscriberJob < ActiveJob::Base
6
+ class << self
7
+ attr_accessor :subscriber
8
+
9
+ def from(callable)
10
+ if callable.is_a?(Proc) || callable.name.nil?
11
+ raise ArgumentError, "Anonymous subscribers (blocks/procs/lambdas or anonymous modules) " \
12
+ "could not be asynchronous (use sync: true)"
13
+ end
14
+
15
+ raise ArgumentError, "Async subscriber must be a module/class, not instance" unless callable.is_a?(Module)
16
+
17
+ if callable.const_defined?("SubscriberJob", false)
18
+ callable.const_get("SubscriberJob", false)
19
+ else
20
+ callable.const_set(
21
+ "SubscriberJob",
22
+ Class.new(self).tap do |job|
23
+ queue_as ActiveEventStore.config.job_queue_name
24
+
25
+ job.subscriber = callable
26
+ end
27
+ )
28
+ end
29
+ end
30
+
31
+ def for(callable)
32
+ raise ArgumentError, "Async subscriber must be a module/class" unless callable.is_a?(Module)
33
+
34
+ callable.const_defined?("SubscriberJob", false) ?
35
+ callable.const_get("SubscriberJob", false) :
36
+ nil
37
+ end
38
+ end
39
+
40
+ def perform(payload)
41
+ event = event_store.deserialize(**payload, serializer: ActiveEventStore.config.serializer)
42
+
43
+ event_store.with_metadata(**event.metadata.to_h) do
44
+ subscriber.call(event)
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def subscriber
51
+ self.class.subscriber
52
+ end
53
+
54
+ def event_store
55
+ ActiveEventStore.event_store
56
+ end
57
+ end
58
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveEventStore # :nodoc:
4
- VERSION = "0.0.1"
4
+ VERSION = "1.0.0"
5
5
  end
@@ -1,4 +1,75 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rails_event_store"
4
+
3
5
  require "active_event_store/version"
4
- require "active_event_store/railtie" if defined?(Rails::Railtie)
6
+
7
+ require "active_event_store/config"
8
+ require "active_event_store/event"
9
+ require "active_event_store/mapping"
10
+ require "active_event_store/mapper"
11
+
12
+ require "active_event_store/rspec" if defined?(RSpec::Core)
13
+
14
+ module ActiveEventStore
15
+ class << self
16
+ # Underlying RailsEventStore
17
+ attr_accessor :event_store
18
+
19
+ def mapping
20
+ @mapping ||= Mapping.new
21
+ end
22
+
23
+ def config
24
+ @config ||= Config.new
25
+ end
26
+
27
+ def subscribe(subscriber = nil, to: nil, sync: false, &block)
28
+ subscriber ||= block
29
+
30
+ to ||= infer_event_from_subscriber(subscriber) if subscriber.is_a?(Module)
31
+
32
+ if to.nil?
33
+ raise ArgumentError, "Couldn't infer event from subscriber. " \
34
+ "Please, specify event using `to:` option"
35
+ end
36
+
37
+ identifier =
38
+ if to.is_a?(Class) && ActiveEventStore::Event >= to
39
+ # register event
40
+ mapping.register_event to
41
+
42
+ to.identifier
43
+ else
44
+ to
45
+ end
46
+
47
+ subscriber = SubscriberJob.from(subscriber) unless sync
48
+
49
+ event_store.subscribe subscriber, to: [identifier]
50
+ end
51
+
52
+ def publish(event, **options)
53
+ event_store.publish event, **options
54
+ end
55
+
56
+ private
57
+
58
+ def infer_event_from_subscriber(subscriber)
59
+ event_class_name = subscriber.name.split("::").yield_self do |parts|
60
+ # handle explicti top-level name, e.g. ::Some::Event
61
+ parts.shift if parts.first.empty?
62
+ # drop last part – it's a unique subscriber name
63
+ parts.pop
64
+
65
+ parts.last.sub!(/^On/, "")
66
+
67
+ parts.join("::")
68
+ end
69
+
70
+ event_class_name.safe_constantize
71
+ end
72
+ end
73
+ end
74
+
75
+ require "active_event_store/engine"
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_event_store
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vladimir Dementyev
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-04-09 00:00:00.000000000 Z
11
+ date: 2021-09-14 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails_event_store
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 2.1.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 2.1.0
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: bundler
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -53,17 +67,17 @@ dependencies:
53
67
  - !ruby/object:Gem::Version
54
68
  version: '13.0'
55
69
  - !ruby/object:Gem::Dependency
56
- name: rspec
70
+ name: rspec-rails
57
71
  requirement: !ruby/object:Gem::Requirement
58
72
  requirements:
59
- - - "~>"
73
+ - - ">="
60
74
  - !ruby/object:Gem::Version
61
75
  version: '3.8'
62
76
  type: :development
63
77
  prerelease: false
64
78
  version_requirements: !ruby/object:Gem::Requirement
65
79
  requirements:
66
- - - "~>"
80
+ - - ">="
67
81
  - !ruby/object:Gem::Version
68
82
  version: '3.8'
69
83
  description: Wrapper over Rails Event Store with conventions and transparent Rails
@@ -78,7 +92,15 @@ files:
78
92
  - LICENSE.txt
79
93
  - README.md
80
94
  - lib/active_event_store.rb
81
- - lib/active_event_store/railtie.rb
95
+ - lib/active_event_store/config.rb
96
+ - lib/active_event_store/engine.rb
97
+ - lib/active_event_store/event.rb
98
+ - lib/active_event_store/mapper.rb
99
+ - lib/active_event_store/mapping.rb
100
+ - lib/active_event_store/rspec.rb
101
+ - lib/active_event_store/rspec/have_enqueued_async_subscriber_for.rb
102
+ - lib/active_event_store/rspec/have_published_event.rb
103
+ - lib/active_event_store/subscriber_job.rb
82
104
  - lib/active_event_store/version.rb
83
105
  homepage: http://github.com/palkan/active_event_store
84
106
  licenses:
@@ -89,7 +111,7 @@ metadata:
89
111
  documentation_uri: http://github.com/palkan/active_event_store
90
112
  homepage_uri: http://github.com/palkan/active_event_store
91
113
  source_code_uri: http://github.com/palkan/active_event_store
92
- post_install_message:
114
+ post_install_message:
93
115
  rdoc_options: []
94
116
  require_paths:
95
117
  - lib
@@ -97,15 +119,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
97
119
  requirements:
98
120
  - - ">="
99
121
  - !ruby/object:Gem::Version
100
- version: '2.5'
122
+ version: '2.6'
101
123
  required_rubygems_version: !ruby/object:Gem::Requirement
102
124
  requirements:
103
125
  - - ">="
104
126
  - !ruby/object:Gem::Version
105
127
  version: '0'
106
128
  requirements: []
107
- rubygems_version: 3.0.6
108
- signing_key:
129
+ rubygems_version: 3.2.15
130
+ signing_key:
109
131
  specification_version: 4
110
132
  summary: Rails Event Store in a more Rails way
111
133
  test_files: []
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveEventStore # :nodoc:
4
- class Railtie < ::Rails::Railtie # :nodoc:
5
- end
6
- end