active_event_store 0.0.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a94c6723818483ba570ca3b781125da2461c53d7869213e5e7545606496c912
4
- data.tar.gz: a160dca5145e401308d15aede842cd4ad758400bb4f96cb07d11f199424c31ed
3
+ metadata.gz: 79a390bc7f50283a2ed74f34d62ae845dfa5208a19a2a174238a5ebf4a684203
4
+ data.tar.gz: cfd5584f0a8c56a645fdb58fb1dfe6b35252d1c23481898974a6c333233d59a1
5
5
  SHA512:
6
- metadata.gz: e0cd0a84d08b51d8851e6cefe51bc95e39bc47a69d3a209f9c254793a9e70b0400ea788320e16208b7b0a9b1b1221923062c6cd1f02fae751ff810a3d387aee5
7
- data.tar.gz: '08effd30fe98d80eba9d72c65a7d04154ce54e6dd13effe9e9598b8c22af09bf33028a67dd92abe714c78d01671018116717135e409da8beef0771799fdc48e6'
6
+ metadata.gz: 91d3ecb6b2635ff0e895176e687d3fa3820d519b3d50cbba6d3d4ec9a79c632c527fd17817317d9eddf15d3c6fdff6a581f729bdcc1df55231e9250bb7332c74
7
+ data.tar.gz: ce974d79cd1989cfdd86e1ecfdfbf408b1facae1b9ab0be50c72f644f9abb32a0ce0c8a024752b3f2450c921d1f3c0ec5b0a8bc31c311ff814aa0cbd8dba89df
@@ -2,4 +2,8 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ## 0.1.0 (2020-04-22)
6
+
7
+ - Open source Active Event Store. ([@palkan][])
8
+
5
9
  [@palkan]: https://github.com/palkan
data/README.md CHANGED
@@ -1,38 +1,189 @@
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.
7
6
 
8
- ## Installation
7
+ ## Motivation
9
8
 
10
- Adding to a gem:
9
+ Why creating a wrapper and not using Rails Event Store itself?
11
10
 
12
- ```ruby
13
- # my-cool-gem.gemspec
14
- Gem::Specification.new do |spec|
15
- # ...
16
- spec.add_dependency "active_event_store"
17
- # ...
18
- end
19
- ```
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`).
20
14
 
21
- Or adding to your project:
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>
17
+
18
+ ## Installation
19
+
20
+ Add gem to your project:
22
21
 
23
22
  ```ruby
24
23
  # Gemfile
25
24
  gem "active_event_store"
26
25
  ```
27
26
 
28
- ### Supported Ruby versions
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
29
35
 
30
36
  - Ruby (MRI) >= 2.5.0
31
- - JRuby >= 9.2.9
37
+ - Rails >= 5.0
32
38
 
33
39
  ## Usage
34
40
 
35
- TBD
41
+ ### Describe events
42
+
43
+ Events are represented by _event classes_, which describe events payloads and identifiers:
44
+
45
+ ```ruby
46
+ class ProfileCompleted < ActiveEventStore::Event
47
+ # (optional) event identifier is used for transmitting events
48
+ # to subscribers.
49
+ #
50
+ # By default, identifier is equal to `name.underscore.gsub('/', '.')`.
51
+ #
52
+ # You don't need to specify identifier manually, only for backward compatibility when
53
+ # class name is changed.
54
+ self.identifier = "profile_completed"
55
+
56
+ # Add attributes accessors
57
+ attributes :user_id
58
+
59
+ # Sync attributes only available for sync subscribers
60
+ # (so you can add some optional non-JSON serializable data here)
61
+ # For example, we can also add `user` record to the event to avoid
62
+ # reloading in sync subscribers
63
+ sync_attributes :user
64
+ end
65
+ ```
66
+
67
+ **NOTE:** we use JSON to [serialize events](https://railseventstore.org/docs/mapping_serialization/), thus only the simple field types (numbers, strings, booleans) are supported.
68
+
69
+ Each event has predefined (_reserved_) fields:
70
+
71
+ - `event_id` – unique event id
72
+ - `type` – event type (=identifier)
73
+ - `metadata`
74
+
75
+ 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.).
76
+
77
+ We recommend to keep event definitions in the `app/events` folder.
78
+
79
+ ### Events registration
80
+
81
+ 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.
82
+
83
+ In most cases, we register events automatically when they're published or when a subscription is created.
84
+
85
+ You can also register events manually:
86
+
87
+ ```ruby
88
+ # by passing an event class
89
+ ActiveEventStore.mapper.register_event MyEventClass
90
+
91
+ # or more precisely (in that case `event.type` must be equal to "my_event")
92
+ ActiveEventStore.mapper.register "my_event", MyEventClass
93
+ ```
94
+
95
+ ### Publish events
96
+
97
+ To publish an event you must first create an instance of the event class and call `ActiveEventStore.publish` method:
98
+
99
+ ```ruby
100
+ event = ProfileCompleted.new(user_id: user.id)
101
+
102
+ # or with metadata
103
+ event = ProfileCompleted.new(user_id: user.id, metadata: {ip: request.remote_ip})
104
+
105
+ # then publish the event
106
+ ActiveEventStore.publish(event)
107
+ ```
108
+
109
+ That's it! Your event has been stored and propagated to the subscribers.
110
+
111
+ ### Subscribe to events
112
+
113
+ To subscribe a handler to an event you must use `ActiveEventStore.subscribe` method.
114
+
115
+ You can do this in your app or engine initializer:
116
+
117
+ ```ruby
118
+ # some/engine.rb
119
+
120
+ # To make sure event store has been initialized use the load hook
121
+ # `store` == `ActiveEventStore`
122
+ ActiveSupport.on_load :active_event_store do |store|
123
+ # async subscriber – invoked from background job, enqueued after the current transaction commits
124
+ # NOTE: all subscribers are asynchronous by default
125
+ store.subscribe MyEventHandler, to: ProfileCreated
126
+
127
+ # sync subscriber – invoked right "within" `publish` method
128
+ store.subscribe MyEventHandler, to: ProfileCreated, sync: true
129
+
130
+ # anonymous handler (could only be synchronous)
131
+ store.subscribe(to: ProfileCreated, sync: true) do |event|
132
+ # do something
133
+ end
134
+
135
+ # you can omit event if your subscriber follows the convention
136
+ # for example, the following subscriber would subscribe to
137
+ # ProfileCreated event
138
+ store.subscribe OnProfileCreated::DoThat
139
+ end
140
+ ```
141
+
142
+ Subscribers could be any callable Ruby objects that accept a single argument (event) as its input.
143
+
144
+ 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`.
145
+
146
+ ### Testing
147
+
148
+ You can test subscribers as normal Ruby objects.
149
+
150
+ **NOTE:** Currently, we provide additional matchers only for RSpec. PRs with Minitest support are welcomed!
151
+
152
+ To test that a given subscriber exists, you can use the `have_enqueued_async_subscriber_for` matcher:
153
+
154
+ ```ruby
155
+ # for asynchronous subscriptions
156
+ it "is subscribed to some event" do
157
+ event = MyEvent.new(some: "data")
158
+ expect { ActiveEventStore.publish event }
159
+ .to have_enqueued_async_subscriber_for(MySubscriberService)
160
+ .with(event)
161
+ end
162
+ ```
163
+
164
+ For synchronous subscribers using `have_received` is enough:
165
+
166
+ ```ruby
167
+ it "is subscribed to some event" do
168
+ allow(MySubscriberService).to receive(:call)
169
+
170
+ event = MyEvent.new(some: "data")
171
+
172
+ ActiveEventStore.publish event
173
+
174
+ expect(MySubscriberService).to have_received(:call).with(event)
175
+ end
176
+ ```
177
+
178
+ To test event publishing, use `have_published_event` matcher:
179
+
180
+ ```ruby
181
+ expect { subject }.to have_published_event(ProfileCreated).with(user_id: user.id)
182
+ ```
183
+
184
+ **NOTE:** `have_published_event` only supports block expectations.
185
+
186
+ **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
187
 
37
188
  ## Contributing
38
189
 
@@ -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)
28
+ subscriber ||= Proc.new
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"
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveEventStore
4
+ class Config
5
+ attr_writer :repository, :job_queue_name, :store_options
6
+
7
+ def repository
8
+ @repository ||= RailsEventStoreActiveRecord::EventRepository.new
9
+ end
10
+
11
+ def job_queue_name
12
+ @job_queue_name ||= :events_subscribers
13
+ end
14
+
15
+ def store_options
16
+ @store_options ||= {}
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,32 @@
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(scheduler: RailsEventStore::ActiveJobScheduler.new),
22
+ RubyEventStore::Dispatcher.new
23
+ ),
24
+ repository: ActiveEventStore.config.repository,
25
+ mapper: ActiveEventStore::Mapper.new(mapping: ActiveEventStore.mapping),
26
+ **ActiveEventStore.config.store_options
27
+ )
28
+
29
+ ActiveSupport.run_load_hooks("active_event_store", ActiveEventStore)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,98 @@
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
+ def inspect
76
+ "#{self.class.name}<#{type}##{message_id}>, data: #{data}, metadata: #{metadata}"
77
+ end
78
+
79
+ protected
80
+
81
+ attr_writer :event_id
82
+
83
+ def validate_attributes!(params)
84
+ unknown_fields = params.keys.map(&:to_sym) - self.class.defined_attributes - self.class.defined_sync_attributes
85
+ unless unknown_fields.empty?
86
+ raise ArgumentError, "Unknown event attributes: #{unknown_fields.join(", ")}"
87
+ end
88
+ end
89
+
90
+ def extract_sync_attributes!(params)
91
+ params.keys.each do |key|
92
+ next unless self.class.defined_sync_attributes.include?(key.to_sym)
93
+
94
+ instance_variable_set(:"@#{key}", params.delete(key))
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ActiveEventStore
6
+ using(Module.new do
7
+ refine Hash do
8
+ def symbolize_keys
9
+ RubyEventStore::TransformKeys.symbolize(self)
10
+ end
11
+ end
12
+ end)
13
+
14
+ # Custom mapper for RES events.
15
+ #
16
+ # See https://github.com/RailsEventStore/rails_event_store/blob/v0.35.0/ruby_event_store/lib/ruby_event_store/mappers/default.rb
17
+ class Mapper
18
+ def initialize(mapping:, serializer: JSON)
19
+ @serializer = serializer
20
+ @mapping = mapping
21
+ end
22
+
23
+ def event_to_serialized_record(domain_event)
24
+ # lazily add type to mapping
25
+ # NOTE: use class name instead of a class to handle code reload
26
+ # in development (to avoid accessing orphaned classes)
27
+ mapping.register(domain_event.type, domain_event.class.name) unless mapping.exist?(domain_event.type)
28
+
29
+ RubyEventStore::SerializedRecord.new(
30
+ event_id: domain_event.event_id,
31
+ metadata: serializer.dump(domain_event.metadata.to_h),
32
+ data: serializer.dump(domain_event.data),
33
+ event_type: domain_event.type
34
+ )
35
+ end
36
+
37
+ def serialized_record_to_event(record)
38
+ event_class = mapping.fetch(record.event_type) do
39
+ raise "Don't know how to deserialize event: \"#{record.event_type}\". " \
40
+ "Add explicit mapping: ActiveEventStore.mapper.register \"#{record.event_type}\", \"<Class Name>\""
41
+ end
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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_event_store/rspec/have_published_event"
@@ -0,0 +1,70 @@
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(actual_serialized)
25
+
26
+ actual.type == event.type && data_matches?(actual.data)
27
+ end
28
+
29
+ def description
30
+ "be #{event.inspect}"
31
+ end
32
+
33
+ private
34
+
35
+ def data_matches?(actual)
36
+ ::RSpec::Matchers::BuiltIn::Match.new(event.data).matches?(actual)
37
+ end
38
+ end
39
+
40
+ def initialize(subscriber_class)
41
+ subscriber_job = ActiveEventStore::SubscriberJob.for(subscriber_class)
42
+ if subscriber_job.nil?
43
+ raise(
44
+ RSpec::Expectations::ExpectationNotMetError,
45
+ "No such async subscriber: #{subscriber_class.name}"
46
+ )
47
+ end
48
+ super(subscriber_job)
49
+ on_queue("events_subscribers")
50
+ end
51
+
52
+ def with(event)
53
+ super(EventMatcher.new(event))
54
+ end
55
+
56
+ def matches?(block)
57
+ raise ArgumentError, "have_enqueued_async_subscriber_for only supports block expectations" unless block.is_a?(Proc)
58
+ # Make sure that there is a transaction
59
+ super(proc { ActiveRecord::Base.transaction(&block) })
60
+ end
61
+ end
62
+ end
63
+
64
+ RSpec.configure do |config|
65
+ config.include(Module.new do
66
+ def have_enqueued_async_subscriber_for(*args)
67
+ ActiveEventStore::HaveEnqueuedAsyncSubscriberFor.new(*args)
68
+ end
69
+ end)
70
+ 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.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,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)
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 = "0.1.0"
5
5
  end
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: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vladimir Dementyev
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-04-09 00:00:00.000000000 Z
11
+ date: 2020-04-22 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: 0.42.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 0.42.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:
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveEventStore # :nodoc:
4
- class Railtie < ::Rails::Railtie # :nodoc:
5
- end
6
- end