active_event_store 0.0.1 → 1.0.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: 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