voltage 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md ADDED
@@ -0,0 +1,299 @@
1
+ # Voltage
2
+
3
+ [![Tests](https://github.com/fnando/voltage/actions/workflows/ruby-tests.yml/badge.svg)](https://github.com/fnando/voltage/actions/workflows/ruby-tests.yml)
4
+ [![Gem](https://img.shields.io/gem/v/voltage.svg)](https://rubygems.org/gems/voltage)
5
+ [![Gem](https://img.shields.io/gem/dt/voltage.svg)](https://rubygems.org/gems/voltage)
6
+
7
+ A simple observer implementation on POROs (Plain Old Ruby Object) and
8
+ ActiveRecord objects.
9
+
10
+ ## Installation
11
+
12
+ Add this line to your application's Gemfile:
13
+
14
+ ```ruby
15
+ gem "voltage"
16
+ ```
17
+
18
+ And then execute:
19
+
20
+ $ bundle
21
+
22
+ Or install it yourself as:
23
+
24
+ $ gem install voltage
25
+
26
+ ## Usage
27
+
28
+ You can use Voltage with PORO (Plain Old Ruby Object) and ActiveRecord.
29
+
30
+ ### Plain Ruby
31
+
32
+ All you have to do is including the `Voltage` module. Then you can add listeners
33
+ and trigger events.
34
+
35
+ ```ruby
36
+ class Status
37
+ include Voltage
38
+
39
+ def ready!
40
+ emit(:ready)
41
+ end
42
+ end
43
+
44
+ status = Status.new
45
+ status.before(:ready) { puts "Before the ready event!" }
46
+ status.on(:ready) { puts "I'm ready!" }
47
+ status.after(:ready) { puts "After the ready event!" }
48
+ status.ready!
49
+ #=> Before the ready event!
50
+ #=> I'm ready!
51
+ #=> After the ready event!
52
+ ```
53
+
54
+ You can also pass objects that implement methods like `before_*`, `on_*` and
55
+ `after_*`.
56
+
57
+ ```ruby
58
+ class MyListener
59
+ def before_ready
60
+ puts "Before the ready event!"
61
+ end
62
+
63
+ def on_ready
64
+ puts "I'm ready!"
65
+ end
66
+
67
+ def after_ready
68
+ puts "After the ready event!"
69
+ end
70
+ end
71
+
72
+ Status.new
73
+ .add_listener(MyListener.new)
74
+ .ready!
75
+ #=> Before the ready event!
76
+ #=> I'm ready!
77
+ #=> After the ready event!
78
+ ```
79
+
80
+ Executed blocks don't switch context. You always have to emit the object you're
81
+ interested in. The follow example uses `emit(:output, self)` to send the
82
+ `Contact` instance to all listeners.
83
+
84
+ ```ruby
85
+ class Contact
86
+ include Voltage
87
+
88
+ attr_reader :name, :email
89
+
90
+ def initialize(name, email)
91
+ @name, @email = name, email
92
+ end
93
+
94
+ def output!
95
+ emit(:output, self)
96
+ end
97
+ end
98
+
99
+ contact = Contact.new('John Doe', 'john@example.org')
100
+ contact.on(:output) {|contact| puts contact.name, contact.email }
101
+ contact.output!
102
+ #=> John Doe
103
+ #=> john@example.org
104
+ ```
105
+
106
+ You can provide arguments while emitting a voltage:
107
+
108
+ ```ruby
109
+ class Arguments
110
+ include Voltage
111
+ end
112
+
113
+ class MyListener
114
+ def on_args(a, b)
115
+ puts a, b
116
+ end
117
+ end
118
+
119
+ Arguments.new
120
+ .on(:args) {|a, b| puts a, b }
121
+ .add_listener(MyListener.new)
122
+ .emit(:args, 1, 2)
123
+ ```
124
+
125
+ ### ActiveRecord
126
+
127
+ You can use Voltage with ActiveRecord, which will give you some default events
128
+ like `:create`, `:update`, `:remove` and `:validation`.
129
+
130
+ ```ruby
131
+ class Thing < ActiveRecord::Base
132
+ include Voltage.active_record
133
+
134
+ validates_presence_of :name
135
+ end
136
+
137
+ thing = Thing.new(:name => "Stuff")
138
+ thing.on(:create) {|thing| puts thing.updated_at, thing.name }
139
+ thing.on(:update) {|thing| puts thing.updated_at, thing.name }
140
+ thing.on(:remove) {|thing| puts thing.destroyed? }
141
+ thing.on(:validation) {|thing| p thing.errors.full_messages }
142
+
143
+ thing.save!
144
+ #=> 2013-01-26 10:32:39 -0200
145
+ #=> Stuff
146
+
147
+ thing.update_attributes(:name => "Updated stuff")
148
+ #=> 2013-01-26 10:33:11 -0200
149
+ #=> Updated stuff
150
+
151
+ thing.update_attributes(:name => nil)
152
+ #=> ["Name can't be blank"]
153
+
154
+ thing.destroy
155
+ #=> true
156
+ ```
157
+
158
+ These are the available events:
159
+
160
+ - `before(:create)`: triggered before creating the record (record is valid).
161
+ - `on(:create)`: triggered after `before(:create)` event.
162
+ - `after(:create)`: triggered after the `on(:create)` event.
163
+ - `before(:update)`: triggered before updating the record (record is valid).
164
+ - `on(:update)`: triggered when the `before(:update)` event.
165
+ - `after(:update)`: triggered after the `on(:update)` event.
166
+ - `before(:remove)`: triggered before removing the record.
167
+ - `on(:remove)`: triggered after the `before(:remove)`.
168
+ - `after(:remove)`: triggered after the `on(:remove)` event.
169
+ - `before(:validation)`: triggered before validating record.
170
+ - `on(:validation)`: triggered when record is invalid.
171
+ - `after(:validation)`: triggered after validating record.
172
+
173
+ ### Inside Rails
174
+
175
+ Although there's no special code for Rails, here's just an example of how you
176
+ can use it:
177
+
178
+ ```ruby
179
+ class UsersController < ApplicationController
180
+ def create
181
+ @user = User.new(user_params)
182
+
183
+ Signup.new(@user)
184
+ .on(:success) { redirect_to login_path, notice: 'Welcome to MyApp!' }
185
+ .on(:failure) { render :new }
186
+ .call
187
+ end
188
+ end
189
+ ```
190
+
191
+ If you're using plain ActiveRecord, just do something like the following:
192
+
193
+ ```ruby
194
+ class UsersController < ApplicationController
195
+ def create
196
+ @user = User.new(user_params)
197
+ @user
198
+ .on(:create) { redirect_to login_path, notice: 'Welcome to MyApp!' }
199
+ .on(:validation) { render :new }
200
+ .save
201
+ end
202
+ end
203
+ ```
204
+
205
+ ### Voltage::Call
206
+
207
+ You can include `Voltage.call` instead, so you can have a common interface for
208
+ your observable object. This will add the `.call()` method to the target class,
209
+ which will delegate attributes to the observable's `initialize` method and call
210
+ its `call` method.
211
+
212
+ ```ruby
213
+ class Contact
214
+ include Voltage.call
215
+
216
+ attr_reader :name, :email
217
+
218
+ def initialize(name, email)
219
+ @name, @email = name, email
220
+ end
221
+
222
+ def call
223
+ emit(:output, self)
224
+ end
225
+ end
226
+
227
+ Contact.call('John', 'john@example.com') do |o|
228
+ o.on(:output) {|contact| puts contact }
229
+ end
230
+ ```
231
+
232
+ Notice that you don't have to explicit call the instance's `call` method;
233
+ `Contact.call` will initialize the object with all the provided parameters and
234
+ call `Contact#call` after the block has been executed.
235
+
236
+ ### Testing
237
+
238
+ `Voltage::Mock` can be helpful for most test situations where you don't want to
239
+ bring other mock libraries.
240
+
241
+ ```ruby
242
+ require "voltage/mock"
243
+
244
+ class SomeTest < Minitest::Test
245
+ def test_some_test
246
+ mock = Voltage::Mock.new
247
+
248
+ # Using listener
249
+ sum = Sum.new
250
+ sum.add_listener(mock)
251
+
252
+ # Calling `mock.on(event_name)` is required because
253
+ # the handler doesn't receive the event name, just the
254
+ # arguments.
255
+ sum = Sum.new
256
+ sum.on(:result, &mock.on(:result))
257
+
258
+ # Using with Voltage.call
259
+ Sum.call(1, 2, &mock)
260
+
261
+ assert mock.received?(:result)
262
+ assert mock.received?(:result, times: 1)
263
+ assert mock.received?(:result, with: [3])
264
+ assert mock.received?(:result, with: ->(result) { result == 3 } )
265
+ end
266
+ end
267
+
268
+ ```
269
+
270
+ ## Contributing
271
+
272
+ 1. Fork it
273
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
274
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
275
+ 4. Push to the branch (`git push origin my-new-feature`)
276
+ 5. Create new Pull Request
277
+
278
+ ## License
279
+
280
+ Copyright (c) 2013 Nando Vieira
281
+
282
+ MIT License
283
+
284
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
285
+ this software and associated documentation files (the "Software"), to deal in
286
+ the Software without restriction, including without limitation the rights to
287
+ use, copy, modify, merge, publish, distribute, sub-license, and/or sell copies
288
+ of the Software, and to permit persons to whom the Software is furnished to do
289
+ so, subject to the following conditions:
290
+
291
+ The above copyright notice and this permission notice shall be included in all
292
+ copies or substantial portions of the Software.
293
+
294
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
295
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
296
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
297
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
298
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
299
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+ require "rubocop/rake_task"
6
+
7
+ Rake::TestTask.new(:test) do |t|
8
+ t.libs << "test"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ t.warning = false
11
+ end
12
+
13
+ RuboCop::RakeTask.new
14
+
15
+ task default: %i[test rubocop]
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+ require "voltage"
5
+ require "active_record"
6
+
7
+ ActiveRecord::Base.establish_connection(
8
+ adapter: "sqlite3",
9
+ database: ":memory:"
10
+ )
11
+
12
+ ActiveRecord::Schema.define(version: 0) do
13
+ create_table :things do |t|
14
+ t.string :name
15
+ t.timestamps null: false
16
+ end
17
+ end
18
+
19
+ class Thing < ActiveRecord::Base
20
+ include Voltage::ActiveRecord
21
+
22
+ validates_presence_of :name
23
+ end
24
+
25
+ thing = Thing.new(name: "Stuff")
26
+ thing.on(:create) {|model| puts model.updated_at, model.name }
27
+ thing.on(:update) {|model| puts model.updated_at, model.name }
28
+ thing.on(:remove) {|model| puts model.destroyed? }
29
+ thing.on(:validation) {|model| p model.errors.full_messages }
30
+
31
+ thing.save!
32
+ #=> 2013-01-26 10:32:39 -0200
33
+ #=> Stuff
34
+
35
+ thing.update_attributes(name: "Updated stuff")
36
+ #=> 2013-01-26 10:33:11 -0200
37
+ #=> Updated stuff
38
+
39
+ thing.update_attributes(name: nil)
40
+ #=> ["Name can"t be blank"]
41
+
42
+ thing.destroy
43
+ #=> true
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+ require "voltage"
5
+
6
+ class Arguments
7
+ include Voltage
8
+ end
9
+
10
+ class MyListener
11
+ def on_args(a, b)
12
+ puts a, b
13
+ end
14
+ end
15
+
16
+ args = Arguments.new
17
+ args.on(:args) {|a, b| puts a, b }
18
+ args.listeners << MyListener.new
19
+ args.emit(:args, 1, 2)
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+ require "voltage"
5
+
6
+ class Contact
7
+ include Voltage
8
+
9
+ attr_reader :name, :email
10
+
11
+ def initialize(name, email)
12
+ @name = name
13
+ @email = email
14
+ end
15
+
16
+ def output!
17
+ emit(:output, self)
18
+ end
19
+ end
20
+
21
+ contact = Contact.new("John Doe", "john@example.org")
22
+ contact.on(:output) {|c| puts c.name, c.email }
23
+ contact.output!
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+ require "voltage"
5
+
6
+ class Status
7
+ include Voltage
8
+
9
+ def ready!
10
+ emit(:ready)
11
+ end
12
+ end
13
+
14
+ status = Status.new
15
+ status.before(:ready) { puts "Before the ready event!" }
16
+ status.on(:ready) { puts "I'm ready!" }
17
+ status.after(:ready) { puts "After the ready event!" }
18
+ status.ready!
data/examples/call.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+ require "voltage"
5
+
6
+ class Contact
7
+ include Voltage.call
8
+
9
+ attr_reader :name, :email
10
+
11
+ def initialize(name, email)
12
+ @name = name
13
+ @email = email
14
+ end
15
+
16
+ def call
17
+ emit(:output, self)
18
+ end
19
+ end
20
+
21
+ Contact.call("John", "john@example.com") do |o|
22
+ o.on(:output) {|contact| puts contact.name }
23
+ end
data/examples/chain.rb ADDED
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+ require "voltage"
5
+ require "active_record"
6
+
7
+ ActiveRecord::Base.establish_connection(
8
+ adapter: "sqlite3",
9
+ database: ":memory:"
10
+ )
11
+
12
+ ActiveRecord::Schema.define(version: 0) do
13
+ create_table :things do |t|
14
+ t.string :name
15
+ t.timestamps null: false
16
+ end
17
+ end
18
+
19
+ class Thing < ActiveRecord::Base
20
+ include Voltage.active_record
21
+
22
+ validates_presence_of :name
23
+ end
24
+
25
+ class MyListener
26
+ %i[validation update create remove].each do |type|
27
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
28
+ def before_#{type}(thing); puts __method__; end
29
+ def on_#{type}(thing); puts __method__; end
30
+ def after_#{type}(thing); puts __method__; end
31
+ RUBY
32
+ end
33
+ end
34
+
35
+ puts "\n=== Creating valid record"
36
+ thing = Thing.new(name: "Stuff")
37
+ thing.listeners << MyListener.new
38
+ thing.save
39
+
40
+ puts "\n=== Creating invalid record"
41
+ thing = Thing.new(name: nil)
42
+ thing.listeners << MyListener.new
43
+ thing.save
44
+
45
+ puts "\n=== Updating valid record"
46
+ thing = Thing.create(name: "Stuff")
47
+ thing.listeners << MyListener.new
48
+ thing.update_attributes(name: "Updated stuff")
49
+
50
+ puts "\n=== Updating invalid record"
51
+ thing = Thing.create!(name: "Stuff")
52
+ thing.listeners << MyListener.new
53
+ thing.update_attributes(name: nil)
54
+
55
+ puts "\n=== Removing record"
56
+ thing = Thing.create(name: "Stuff")
57
+ thing.listeners << MyListener.new
58
+ thing.destroy
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+ require "voltage"
5
+
6
+ class Status
7
+ include Voltage
8
+
9
+ def ready!
10
+ emit(:ready)
11
+ end
12
+ end
13
+
14
+ class MyListener
15
+ def before_ready
16
+ puts "Before the ready event!"
17
+ end
18
+
19
+ def on_ready
20
+ puts "I'm ready!"
21
+ end
22
+
23
+ def after_ready
24
+ puts "After the ready event!"
25
+ end
26
+ end
27
+
28
+ status = Status.new
29
+ status.listeners << MyListener.new
30
+ status.ready!
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Voltage
4
+ def self.active_record
5
+ Extensions::ActiveRecord
6
+ end
7
+
8
+ module Extensions
9
+ module ActiveRecord
10
+ def self.included(base)
11
+ base.class_eval do
12
+ include Voltage
13
+
14
+ around_create :around_create_signal
15
+ around_save :around_save_signal
16
+ around_destroy :around_destroy_signal
17
+ before_validation :before_validation_signal
18
+ after_validation :after_validation_signal
19
+ end
20
+ end
21
+
22
+ private def around_create_signal
23
+ emit_signal(:before, :create, self)
24
+ yield
25
+ return unless persisted?
26
+
27
+ emit_signal(:on, :create, self)
28
+ emit_signal(:after, :create, self)
29
+ end
30
+
31
+ private def around_save_signal
32
+ if new_record?
33
+ yield
34
+ return
35
+ end
36
+
37
+ emit_signal(:before, :update, self)
38
+ yield
39
+ emit_signal(:on, :update, self)
40
+ emit_signal(:after, :update, self)
41
+ end
42
+
43
+ private def around_destroy_signal
44
+ emit_signal(:before, :remove, self)
45
+ yield
46
+ emit_signal(:on, :remove, self)
47
+ emit_signal(:after, :remove, self)
48
+ end
49
+
50
+ private def before_validation_signal
51
+ emit_signal(:before, :validation, self)
52
+ end
53
+
54
+ private def after_validation_signal
55
+ emit_signal(:on, :validation, self) if errors.any?
56
+ emit_signal(:after, :validation, self)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Voltage
4
+ def self.call
5
+ Extensions::Call
6
+ end
7
+
8
+ module Extensions
9
+ module Call
10
+ def self.included(target)
11
+ target.include(Voltage)
12
+ target.extend(ClassMethods)
13
+ end
14
+
15
+ module ClassMethods
16
+ def call(*args, **kwargs)
17
+ new(*args, **kwargs).tap do |instance|
18
+ yield(instance) if block_given?
19
+ instance.call
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Voltage
4
+ class Listener
5
+ def initialize(context, type, event, &block)
6
+ @context = context
7
+ @type = type
8
+ @event = event
9
+ @block = block
10
+ @event_method = :"#{@type}_#{@event}"
11
+ end
12
+
13
+ def method_missing(method_name, *args)
14
+ return super unless respond_to_missing?(method_name, false)
15
+
16
+ @block.call(*args)
17
+ end
18
+
19
+ def to_s
20
+ "<#{self.class} event: #{@event_method}>"
21
+ end
22
+
23
+ def respond_to_missing?(method_name, _include_private)
24
+ method_name == @event_method
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Voltage
4
+ class Mock
5
+ def calls
6
+ @calls ||= []
7
+ end
8
+
9
+ def method_missing(name, *args)
10
+ return super unless respond_to_missing?(name)
11
+
12
+ calls << {event: name.to_s.gsub(/^on_/, "").to_sym, args: args}
13
+ end
14
+
15
+ def respond_to_missing?(name, _include_all = false)
16
+ name =~ /^on_/
17
+ end
18
+
19
+ def received?(event, options = {})
20
+ received_event?(event, options[:times] || -1) &&
21
+ received_with?(event, options[:with])
22
+ end
23
+
24
+ def to_proc
25
+ proc {|action| action.add_listener(self) }
26
+ end
27
+
28
+ def on(event)
29
+ proc {|*args| calls << {event: event, args: args} }
30
+ end
31
+
32
+ private def received_event?(event, count)
33
+ received_calls = calls.count {|call| call[:event] == event }
34
+
35
+ return received_calls.nonzero? if count == -1
36
+
37
+ received_calls == count
38
+ end
39
+
40
+ private def received_with?(event, args)
41
+ return true unless args
42
+
43
+ calls.any? do |call|
44
+ next unless call[:event] == event
45
+ next args.call(call[:args]) if args.is_a?(Proc)
46
+
47
+ args == call[:args]
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Voltage
4
+ VERSION = "0.1.0"
5
+ end