tiny_hooks 0.3.0 → 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: 0c9ab59bc23d29d510f328d7b6c0f19bfc9b561b37b6865174cccb1470e76513
4
- data.tar.gz: 58f946a1c561d3a52d4f3cb5b1943a0af75cd20e7c6fb1db3386406b55266f3c
3
+ metadata.gz: c5d6e7ef3a23f548af13f1f6497d5ac5eedcf833d8090380b085a9c85bddf37a
4
+ data.tar.gz: 6992a9ce9f85531803f35f009817b0b7c0b140088e64b9f38c67f4b0225e7d0d
5
5
  SHA512:
6
- metadata.gz: 2faa91f190024c927083e05a50861c4a367aa7f1a16dd2d88e2925081b96b91ea5e235eff0e7e7ec9d85a778dd5bfbcbc20a93e77050fa0aa8d4562dc3ebc13f
7
- data.tar.gz: db4bc764b7e46918680cd552cf7fdde2b968fe728c30e5e67a0da896a4b6a0a24d72e62b5b4e9100ce65f7cca28c31effe460aff82d2f949c6f9fcf18c929543
6
+ metadata.gz: 9dfd1ec6b94c48b2309bf4f713dccdd7d8ae572b6ea42615ebb891238306c84224b15657f9fe77d1f3f6ae8a7f45b26c6ac347183d7bde7750e0a6d6a0879a38
7
+ data.tar.gz: 109d45de44c9309ad233b8a819550306016e6dacb33a7f2a8980de4fddda7562839e6f7463c9c2b0a730544322941d21acfba1b77ea50bb6e7dbc18cdf62dffb
data/.rubocop.yml CHANGED
@@ -30,13 +30,18 @@ Layout/MultilineAssignmentLayout:
30
30
  Lint/ConstantResolution:
31
31
  Enabled: false
32
32
 
33
- Metrics/MethodLength:
34
- Max: 15
33
+ # I couldn't come up with code clean enough
34
+ # For now I gave up following Metrics cops
35
+ Metrics:
36
+ Enabled: false
35
37
 
36
38
  Security/Eval:
37
39
  Exclude:
38
40
  - 'test/**/*.rb'
39
41
 
42
+ Style/ClassVars:
43
+ Enabled: false
44
+
40
45
  Style/ConstantVisibility:
41
46
  Exclude:
42
47
  - 'lib/tiny_hooks/version.rb'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.0.0] - 2021-05-09
4
+
5
+ - Support halting
6
+ - [BREAKING CHANGE] Inferface changed from `extend` to `include`
7
+ - Support public only mode enabled by `public_only!`
8
+ - Support targeting with `target!`
9
+ - Support `if` option of `define_hook`
10
+
3
11
  ## [0.3.0] - 2021-04-10
4
12
 
5
13
  - Implement restoration of original method
data/Gemfile CHANGED
@@ -14,3 +14,7 @@ gem 'rubocop-minitest', '~> 0.11.0', require: false # For lint
14
14
  gem 'rubocop-performance', '~> 1.10.1', require: false # For lint
15
15
  gem 'rubocop-rake', '>= 0.5.1', require: false # For lint
16
16
  gem 'rubocop-sensible', '~> 0.3.0', require: false # For lint
17
+
18
+ # Benchmark
19
+ gem 'activesupport'
20
+ gem 'benchmark_driver'
data/README.md CHANGED
@@ -20,11 +20,11 @@ Or install it yourself as:
20
20
 
21
21
  ## Usage
22
22
 
23
- `extend TinyHooks` in your class/module and you're all set to use `define_hook`!
23
+ `include TinyHooks` in your class/module and you're all set to use `define_hook`!
24
24
 
25
25
  ```ruby
26
26
  class MyClass
27
- extend TinyHooks
27
+ include TinyHooks
28
28
 
29
29
  def my_method
30
30
  puts 'my method'
@@ -41,15 +41,170 @@ MyClass.new.my_method
41
41
 
42
42
  TinyHooks shines when the class/module is the base class/module of your library and your users will inherit/include it. In these cases, end users can define hooks to the methods you provide. The only thing you have to do is to provide the list of methods.
43
43
 
44
- ## Difference between TinyHooks and ActiveSupport::Callbacks
44
+ ### Halting
45
+
46
+ You can halt hook and method body execution by `throw`ing `:abort`.
47
+
48
+ ```ruby
49
+ class MyClass
50
+ include TinyHooks
51
+
52
+ def my_method
53
+ puts 'my method'
54
+ end
55
+
56
+ define_hook :before, :my_method do
57
+ throw :abort
58
+ puts 'my before hook'
59
+ end
60
+ end
61
+
62
+ MyClass.new.my_method
63
+ # => ""
64
+ ```
65
+
66
+ You can change how to halt from two options: throwing `:abort` and returning `false`. This can be done via `terminator` option.
67
+
68
+ ```ruby
69
+ class MyClass
70
+ include TinyHooks
71
+
72
+ def my_method
73
+ puts 'my method'
74
+ end
75
+
76
+ define_hook :before, :my_method, terminator: :return_false do
77
+ false
78
+ end
79
+ end
80
+
81
+ MyClass.new.my_method
82
+ # => ""
83
+ ```
84
+
85
+ ### Targeting for hooks
86
+
87
+ You can limit the targets for hooks in two ways. You can enable hooks for public methods only by using `public_only!` method and include/exclude targets with Regexp pattern by using `targets!` method.
88
+
89
+ ```ruby
90
+ class MyClass
91
+ include TinyHooks
92
+
93
+ def my_method
94
+ puts 'my method'
95
+ end
96
+
97
+ private
98
+
99
+ def my_private_method
100
+ puts 'my private method'
101
+ end
102
+ end
103
+
104
+ class MyClassWithPublicOnly < MyClass
105
+ public_only!
106
+
107
+ define_hook :before, :my_private_method do
108
+ puts 'my_private_method'
109
+ end
110
+ # => This causes PrivateError
111
+ end
112
+
113
+ class MyClassWithExclude < MyClass
114
+ target! exclude_pattern: /my_method/
115
+
116
+ define_hook :before, :my_method do
117
+ puts 'my_method'
118
+ end
119
+ # => This causes TargetError
120
+ end
121
+ ```
122
+
123
+ You can call `include_private!` method to disable the effect of `public_only!`.
124
+
125
+ ### Conditional hooks
126
+
127
+ You can add `if` option to `define_hook` call. `if` option must be a Proc and is evaluated in context of an instance.
128
+
129
+ ```ruby
130
+ class MyClass
131
+ include TinyHooks
132
+
133
+ def initialize(hook_enabled = true)
134
+ @hook_enabled = hook_enabled
135
+ end
136
+
137
+ def my_method
138
+ puts 'my method'
139
+ end
140
+
141
+ def hook_enabled?
142
+ @hook_enabled
143
+ end
144
+
145
+ define_hook :before, :my_method, if: -> { hook_enabled? } do
146
+ puts 'my before hook'
147
+ end
148
+ end
149
+
150
+ MyClass.new(true).my_method
151
+ # => "my before hook\nmy method\n"
152
+
153
+ MyClass.new(false).my_method
154
+ # => "my method\n"
155
+ ```
156
+
157
+ ## Differences between TinyHooks and ActiveSupport::Callbacks
45
158
 
46
159
  While `TinyHooks` and `ActiveSupport::Callbacks` share the same purpose, there are a few major differences.
47
160
 
48
- * `TinyHooks` doesn’t support halting, but will support in the future.
161
+ ### Differences in usage
162
+
49
163
  * While `ActiveSupport::Callbacks` has a set of methods for callbacks to work, `TinyHooks` has only one method.
50
164
  * You can apply callbacks/hooks into any existing methods without any changes with `TinyHooks`, while you need to change methods to call `run_callbacks` method within them to apply callbacks with `ActiveSupport::Callbacks`.
51
165
 
52
- In short, `TinyHooks` is simpler while `ActiveSupport::Callbacks` allows more control over callbacks.
166
+ ### Differences in performance
167
+
168
+ According to the [benchmark](https://github.com/okuramasafumi/tiny_hooks/blob/main/benchmark/compare_to_as_callbacks.rb), `TinyHooks` is 1.6 times as fast as `ActiveSupport::Callbacks` when before and after callbacks are applied, and twice as fast when no callbacks are applied.
169
+
170
+ The result on my machine:
171
+
172
+ ```
173
+ Warming up --------------------------------------
174
+ ActiveSupport 246.181k i/s - 256.956k times in 1.043769s (4.06μs/i)
175
+ TinyHooks 282.834k i/s - 293.502k times in 1.037719s (3.54μs/i)
176
+ Calculating -------------------------------------
177
+ ActiveSupport 230.196k i/s - 738.542k times in 3.208320s (4.34μs/i)
178
+ TinyHooks 373.057k i/s - 848.501k times in 2.274453s (2.68μs/i)
179
+
180
+ Comparison:
181
+ TinyHooks: 373057.2 i/s
182
+ ActiveSupport: 230195.9 i/s - 1.62x slower
183
+
184
+ Warming up --------------------------------------
185
+ ActiveSupport no callback set 1.992M i/s - 2.096M times in 1.052258s (501.99ns/i)
186
+ TinyHooks no callback set 3.754M i/s - 3.791M times in 1.009753s (266.39ns/i)
187
+ Plain 3.852M i/s - 3.955M times in 1.026654s (259.57ns/i)
188
+ Calculating -------------------------------------
189
+ ActiveSupport no callback set 2.005M i/s - 5.976M times in 2.980861s (498.79ns/i)
190
+ TinyHooks no callback set 4.025M i/s - 11.262M times in 2.798054s (248.46ns/i)
191
+ Plain 3.765M i/s - 11.557M times in 3.069944s (265.63ns/i)
192
+
193
+ Comparison:
194
+ TinyHooks no callback set: 4024854.4 i/s
195
+ Plain: 3764695.4 i/s - 1.07x slower
196
+ ActiveSupport no callback set: 2004848.9 i/s - 2.01x slower
197
+ ```
198
+
199
+ ### Differences in functionality
200
+
201
+ There are few things TinyHooks doesn't cover. For example, TinyHooks doesn't support `unless` option in `define_hook` method or Symbol as a callback body since they are just syntax sugar.
202
+
203
+ One of the features TinyHooks doesn't have is `reset_callbacks` which resets all callbacks with given condition. In order to do this, you must call `restore_original` method in iteration.
204
+
205
+ ### Conclusion
206
+
207
+ In short, in most cases, TinyHooks is simpler, easier and faster solution.
53
208
 
54
209
  ## Development
55
210
 
@@ -0,0 +1,101 @@
1
+ require 'benchmark_driver'
2
+
3
+ Benchmark.driver do |x|
4
+ x.prelude <<~RUBY
5
+ require 'active_support/callbacks'
6
+ require 'active_support/core_ext/object/blank'
7
+ class Record
8
+ include ActiveSupport::Callbacks
9
+ define_callbacks :save
10
+
11
+ def save
12
+ run_callbacks :save do
13
+ puts "- save"
14
+ end
15
+ end
16
+ end
17
+
18
+ class PersonRecord < Record
19
+ set_callback :save, :before, :saving_message
20
+ def saving_message
21
+ puts "saving..."
22
+ end
23
+
24
+ set_callback :save, :after do |object|
25
+ puts "saved"
26
+ end
27
+ end
28
+
29
+ require 'tiny_hooks'
30
+
31
+ class TinyRecord
32
+ include TinyHooks
33
+
34
+ def save
35
+ puts '- save'
36
+ end
37
+ end
38
+
39
+ class TinyPersonRecord < TinyRecord
40
+ def saving_message
41
+ puts 'saving...'
42
+ end
43
+
44
+ define_hook :before, :save do
45
+ saving_message
46
+ end
47
+
48
+ define_hook :after, :save do
49
+ puts 'saved'
50
+ end
51
+ end
52
+
53
+ person = PersonRecord.new
54
+ tiny_person = TinyPersonRecord.new
55
+ RUBY
56
+
57
+ x.report 'ActiveSupport', %( person.save )
58
+ x.report 'TinyHooks', %( tiny_person.save )
59
+ end
60
+
61
+ Benchmark.driver do |x|
62
+ x.prelude <<~RUBY
63
+ require 'active_support/callbacks'
64
+ require 'active_support/core_ext/object/blank'
65
+
66
+ class ASNoCallbackSet
67
+ include ActiveSupport::Callbacks
68
+ define_callbacks :save
69
+
70
+ def save
71
+ run_callbacks :save do
72
+ puts "- save"
73
+ end
74
+ end
75
+ end
76
+
77
+ require 'tiny_hooks'
78
+
79
+ class TinyNoCallbackSet
80
+ include TinyHooks
81
+
82
+ def save
83
+ puts '- save'
84
+ end
85
+ end
86
+
87
+ class Plain
88
+ def save
89
+ puts '- save'
90
+ end
91
+ end
92
+
93
+ as_no_callback_set = ASNoCallbackSet.new
94
+ tiny_no_callback_set = TinyNoCallbackSet.new
95
+ plain = Plain.new
96
+ RUBY
97
+
98
+ x.report 'ActiveSupport no callback set', %( as_no_callback_set.save )
99
+ x.report 'TinyHooks no callback set', %( tiny_no_callback_set.save )
100
+ x.report 'Plain', %( plain.save )
101
+ end
data/lib/tiny_hooks.rb CHANGED
@@ -8,92 +8,181 @@ require_relative 'tiny_hooks/version'
8
8
  module TinyHooks
9
9
  class Error < StandardError; end
10
10
 
11
+ class PrivateError < Error; end
12
+
13
+ class TargetError < Error; end
14
+
15
+ HALTING = Object.new.freeze
16
+ private_constant :HALTING
17
+ UNDEFINED_TARGETS = [].freeze
18
+ private_constant :UNDEFINED_TARGETS
19
+
11
20
  # @api private
12
- def self.extended(mod)
13
- mod.class_eval { @@_originals ||= {} }
14
- # mod.instance_variable_set(:@_originals, {}) unless mod.instance_variable_defined?(:@_originals)
15
- # mod.define_singleton_method(:_originals) do
16
- # mod.instance_variable_get(:@_originals)
17
- # end
21
+ def self.included(base)
22
+ base.class_eval do
23
+ @_originals = {}
24
+ @_targets = UNDEFINED_TARGETS
25
+ @_public_only = false
26
+ end
27
+ base.extend ClassMethods
18
28
  end
19
29
 
20
- # Define hook with kind and target method
21
- #
22
- # @param [Symbol, String] kind the kind of the hook, possible values are: :before, :after and :around
23
- # @param [Symbol, String] target the name of the targeted method
24
- def define_hook(kind, target, &block)
25
- raise ArgumentError, 'You must provide a block' unless block
26
-
27
- original_method = instance_method(target)
28
- @@_originals[target.to_sym] = original_method unless @@_originals[target.to_sym]
29
-
30
- body = case kind.to_sym
31
- when :before
32
- _before(original_method, &block)
33
- when :after
34
- _after(original_method, &block)
35
- when :around
36
- _around(original_method, &block)
37
- else
38
- raise Error, "#{kind} is not supported."
39
- end
40
- undef_method(target)
41
- define_method(target, &body)
42
- end
30
+ # @api private
31
+ def self.with_halting(terminator, *args, **kwargs, &block)
32
+ hook_result = nil
33
+ abort_result = catch :abort do
34
+ hook_result = instance_exec(*args, **kwargs, &block)
35
+ true
36
+ end
37
+ return HALTING if abort_result.nil? && terminator == :abort
38
+ return HALTING if hook_result == false && terminator == :return_false
43
39
 
44
- module_function :define_hook
40
+ hook_result
41
+ end
45
42
 
46
- # Restore original method
47
- #
48
- # @param [Symbol, String] target
49
- def restore_original(target)
50
- original_method = @@_originals[target.to_sym] || instance_method(target)
43
+ # Class methods
44
+ module ClassMethods
45
+ # Define hook with kind and target method
46
+ #
47
+ # @param [Symbol, String] kind the kind of the hook, possible values are: :before, :after and :around
48
+ # @param [Symbol, String] target the name of the targeted method
49
+ # @param [Symbol] terminator choice for terminating execution, default is throwing abort symbol
50
+ # @param [Symbol] if condition to determine if it should define callback. Block is evaluated in context of self
51
+ def define_hook(kind, target, terminator: :abort, if: nil, &block) # rubocop:disable Naming/MethodParameterName
52
+ raise ArgumentError, 'You must provide a block' unless block
53
+ raise ArgumentError, 'terminator must be one of the following: :abort or :return_false' unless %i[abort return_false].include? terminator.to_sym
54
+ raise TinyHooks::TargetError, "Hook for #{target} is not allowed" if @_targets != UNDEFINED_TARGETS && !@_targets.include?(target)
51
55
 
52
- undef_method(target)
53
- define_method(target, original_method)
54
- end
56
+ is_private = private_instance_methods.include?(target.to_sym)
55
57
 
56
- private
58
+ begin
59
+ original_method = @_public_only ? public_instance_method(target) : instance_method(target)
60
+ rescue NameError => e
61
+ raise unless e.message.include?('private')
57
62
 
58
- def _before(original_method, &block)
59
- if RUBY_VERSION >= '2.7'
60
- proc do |*args, **kwargs, &blk|
61
- instance_exec(*args, **kwargs, &block)
62
- original_method.bind_call(self, *args, **kwargs, &blk)
63
- end
64
- else
65
- proc do |*args, &blk|
66
- instance_exec(*args, &block)
67
- original_method.bind(self).call(*args, &blk)
63
+ raise TinyHooks::PrivateError, "Public only mode is on and hooks for private methods (#{target} for this time) are not available."
68
64
  end
65
+ @_originals[target.to_sym] = original_method unless @_originals[target.to_sym]
66
+
67
+ undef_method(target)
68
+ define_method(target, &method_body(kind, original_method, terminator, binding.local_variable_get(:if), &block))
69
+ private target if is_private
69
70
  end
70
- end
71
71
 
72
- def _after(original_method, &block)
73
- if RUBY_VERSION >= '2.7'
74
- proc do |*args, **kwargs, &blk|
75
- original_method.bind_call(self, *args, **kwargs, &blk)
76
- instance_exec(*args, **kwargs, &block)
72
+ # Restore original method
73
+ #
74
+ # @param [Symbol, String] target
75
+ def restore_original(target)
76
+ original_method = @_originals[target.to_sym] || instance_method(target)
77
+
78
+ undef_method(target)
79
+ define_method(target, original_method)
80
+ end
81
+
82
+ # Defines target for hooks
83
+ # @param include_pattern [Regexp]
84
+ # @param exclude_pattern [Regexp]
85
+ def target!(include_pattern: nil, exclude_pattern: nil)
86
+ raise ArgumentError if include_pattern.nil? && exclude_pattern.nil?
87
+
88
+ candidates = @_public_only ? instance_methods : instance_methods + private_instance_methods
89
+ @_targets = if include_pattern && exclude_pattern
90
+ targets = candidates.grep(include_pattern)
91
+ targets.grep_v(exclude_pattern)
92
+ elsif include_pattern
93
+ candidates.grep(include_pattern)
94
+ else
95
+ candidates.grep_v(exclude_pattern)
96
+ end
97
+ end
98
+
99
+ # Enable public only mode
100
+ def public_only!
101
+ @_public_only = true
102
+ end
103
+
104
+ # Disable public only mode
105
+ def include_private!
106
+ @_public_only = false
107
+ end
108
+
109
+ private
110
+
111
+ def method_body(kind, original_method, terminator, if_proc, &block)
112
+ case kind.to_sym
113
+ when :before then _before(original_method, terminator: terminator, if_proc: if_proc, &block)
114
+ when :after then _after(original_method, if_proc: if_proc, &block)
115
+ when :around then _around(original_method, if_proc: if_proc, &block)
116
+ else
117
+ raise Error, "#{kind} is not supported."
77
118
  end
78
- else
79
- proc do |*args, &blk|
80
- original_method.bind(self).call(*args, &blk)
81
- instance_exec(*args, &block)
119
+ end
120
+
121
+ def _before(original_method, terminator:, if_proc:, &block)
122
+ if RUBY_VERSION >= '2.7'
123
+ proc do |*args, **kwargs, &blk|
124
+ if if_proc.nil? || instance_exec(&if_proc) != false
125
+ hook_result = nil
126
+ abort_result = catch :abort do
127
+ hook_result = instance_exec(*args, **kwargs, &block)
128
+ true
129
+ end
130
+ return if abort_result.nil? && terminator == :abort
131
+ return if hook_result == false && terminator == :return_false
132
+ end
133
+
134
+ original_method.bind_call(self, *args, **kwargs, &blk)
135
+ end
136
+ else
137
+ proc do |*args, &blk|
138
+ if if_proc.nil? || instance_exec(&if_proc) != false
139
+ hook_result = nil
140
+ abort_result = catch :abort do
141
+ hook_result = instance_exec(*args, &block)
142
+ true
143
+ end
144
+ return if abort_result.nil? && terminator == :abort
145
+ return if hook_result == false && terminator == :return_false
146
+ end
147
+
148
+ original_method.bind(self).call(*args, &blk)
149
+ end
82
150
  end
83
151
  end
84
- end
85
152
 
86
- def _around(original_method, &block)
87
- if RUBY_VERSION >= '2.7'
88
- proc do |*args, **kwargs, &blk|
89
- wrapper = -> { original_method.bind_call(self, *args, **kwargs, &blk) }
90
- instance_exec(wrapper, *args, **kwargs, &block)
153
+ def _after(original_method, if_proc:, &block)
154
+ if RUBY_VERSION >= '2.7'
155
+ proc do |*args, **kwargs, &blk|
156
+ original_method.bind_call(self, *args, **kwargs, &blk)
157
+ instance_exec(*args, **kwargs, &block) if if_proc.nil? || instance_exec(&if_proc) != false
158
+ end
159
+ else
160
+ proc do |*args, &blk|
161
+ original_method.bind(self).call(*args, &blk)
162
+ instance_exec(*args, &block) if if_proc.nil? || instance_exec(&if_proc) != false
163
+ end
91
164
  end
92
- else
93
- proc do |*args, &blk|
94
- wrapper = -> { original_method.bind(self).call(*args, &blk) }
95
- instance_exec(wrapper, *args, &block)
165
+ end
166
+
167
+ def _around(original_method, if_proc:, &block)
168
+ if RUBY_VERSION >= '2.7'
169
+ proc do |*args, **kwargs, &blk|
170
+ wrapper = -> { original_method.bind_call(self, *args, **kwargs, &blk) }
171
+ instance_exec(wrapper, *args, **kwargs, &block) if if_proc.nil? || instance_exec(&if_proc) != false
172
+ end
173
+ else
174
+ proc do |*args, &blk|
175
+ wrapper = -> { original_method.bind(self).call(*args, &blk) }
176
+ instance_exec(wrapper, *args, &block) if if_proc.nil? || instance_exec(&if_proc) != false
177
+ end
96
178
  end
97
179
  end
180
+
181
+ def inherited(subclass)
182
+ super
183
+ subclass.instance_variable_set(:@_originals, instance_variable_get(:@_originals).clone)
184
+ subclass.instance_variable_set(:@_targets, instance_variable_get(:@_targets).clone)
185
+ subclass.instance_variable_set(:@_public_only, instance_variable_get(:@_public_only).clone)
186
+ end
98
187
  end
99
188
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TinyHooks
4
- VERSION = '0.3.0'
4
+ VERSION = '1.0.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tiny_hooks
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - OKURA Masafumi
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-04-25 00:00:00.000000000 Z
11
+ date: 2021-05-09 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Simple, tiny and general hooks control.
14
14
  email:
@@ -26,6 +26,7 @@ files:
26
26
  - LICENSE.txt
27
27
  - README.md
28
28
  - Rakefile
29
+ - benchmark/compare_to_as_callbacks.rb
29
30
  - bin/console
30
31
  - bin/setup
31
32
  - lib/tiny_hooks.rb