active_event_store 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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