voltage 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.
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