├── .github └── workflows │ └── main.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── adornable.gemspec ├── bin ├── asdf_switch ├── console └── setup ├── lib ├── adornable.rb └── adornable │ ├── context.rb │ ├── decorators.rb │ ├── error.rb │ ├── machinery.rb │ ├── utils.rb │ └── version.rb └── spec ├── adornable_spec.rb ├── singleton_class_decorators_spec.rb └── spec_helper.rb /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test and lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | # Allows you to run this workflow manually from the Actions tab 13 | workflow_dispatch: 14 | 15 | jobs: 16 | build: 17 | name: Run RSpec tests and RuboCop lints 18 | 19 | runs-on: ubuntu-latest 20 | 21 | strategy: 22 | matrix: 23 | ruby-version: 24 | - 3.2 25 | - 3.1 26 | - 3.0 27 | - 2.7 28 | - 2.6 29 | - 2.5 30 | 31 | steps: 32 | - name: Checkout the repo 33 | uses: actions/checkout@v2 34 | 35 | - name: Set up Ruby v${{ matrix.ruby-version }} 36 | uses: ruby/setup-ruby@v1 37 | with: 38 | ruby-version: ${{ matrix.ruby-version }} 39 | bundler-cache: true 40 | 41 | - name: Install dependencies 42 | run: bundle install 43 | 44 | - name: Run RSpec tests 45 | run: bundle exec rake spec 46 | 47 | - name: Run RuboCop lints 48 | run: bundle exec rubocop 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # generic stuff 2 | .env 3 | *.gem 4 | *.rbc 5 | log/*.log 6 | /.config 7 | /InstalledFiles 8 | /pkg/ 9 | /tmp/ 10 | 11 | # testy stuff 12 | .rspec 13 | .rspec_status 14 | *.orig 15 | /coverage/ 16 | /coverage/ 17 | /db/*.sqlite3 18 | /db/*.sqlite3-[0-9]* 19 | /db/*.sqlite3-journal 20 | /public/system 21 | /spec/examples.txt 22 | /spec/reports/ 23 | /spec/tmp 24 | /test/tmp/ 25 | /test/version_tmp/ 26 | capybara-*.html 27 | pickle-email-*.html 28 | rerun.txt 29 | test/dummy/db/*.sqlite3 30 | test/dummy/db/*.sqlite3-journal 31 | test/dummy/log/*.log 32 | test/dummy/node_modules/ 33 | test/dummy/storage/ 34 | test/dummy/tmp/ 35 | test/dummy/yarn-error.log 36 | 37 | # debuggy stuff 38 | .byebug_history 39 | 40 | ## doccy stuff 41 | /.yardoc/ 42 | /_yardoc/ 43 | /doc/ 44 | /rdoc/ 45 | 46 | ## bundly stuff 47 | /.bundle/ 48 | /vendor/bundle 49 | /lib/bundler/man/ 50 | 51 | # "for a library or gem, you might want to ignore these files since the code is 52 | # intended to run in multiple environments" 53 | Gemfile.lock 54 | .ruby-version 55 | .ruby-gemset 56 | 57 | # rvmmy stuff 58 | .rvmrc 59 | 60 | # editory stuff 61 | .idea 62 | .vscode 63 | *.rdb 64 | 65 | # systemy stuff 66 | *.swm 67 | *.swn 68 | *.swo 69 | *.swp 70 | *.DS_Store 71 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-performance 3 | - rubocop-rspec 4 | - rubocop-rake 5 | 6 | # Globals 7 | 8 | AllCops: 9 | NewCops: enable 10 | TargetRubyVersion: 2.5 11 | 12 | # Gemspec 13 | 14 | Gemspec/RequiredRubyVersion: 15 | Enabled: false 16 | 17 | # Layout 18 | 19 | Layout/LineLength: 20 | Max: 120 21 | Exclude: 22 | - 'spec/**/*_spec.rb' 23 | 24 | Layout/EndAlignment: 25 | EnforcedStyleAlignWith: variable 26 | 27 | Layout/FirstArrayElementIndentation: 28 | EnforcedStyle: consistent 29 | 30 | # Metrics 31 | 32 | Metrics/AbcSize: 33 | Max: 20 34 | CountRepeatedAttributes: false 35 | Exclude: 36 | - 'spec/**/*_spec.rb' 37 | 38 | Metrics/BlockLength: 39 | Exclude: 40 | - 'spec/**/*_spec.rb' 41 | 42 | Metrics/ClassLength: 43 | Max: 150 44 | CountComments: false 45 | CountAsOne: 46 | - array 47 | - hash 48 | - heredoc 49 | Exclude: 50 | - 'spec/**/*_spec.rb' 51 | 52 | Metrics/MethodLength: 53 | Max: 20 54 | CountComments: false 55 | CountAsOne: 56 | - array 57 | - hash 58 | - heredoc 59 | 60 | Metrics/ModuleLength: 61 | Max: 150 62 | CountComments: false 63 | CountAsOne: 64 | - array 65 | - hash 66 | - heredoc 67 | Exclude: 68 | - 'spec/**/*_spec.rb' 69 | 70 | Metrics/ParameterLists: 71 | CountKeywordArgs: false 72 | 73 | # Rspec 74 | 75 | RSpec/ExampleLength: 76 | Max: 40 77 | 78 | RSpec/MessageSpies: 79 | Enabled: false 80 | 81 | RSpec/MultipleExpectations: 82 | Enabled: false 83 | 84 | RSpec/NestedGroups: 85 | Max: 10 86 | 87 | # Style 88 | 89 | Style/DoubleNegation: 90 | Enabled: false 91 | 92 | Style/ExpandPathArguments: 93 | Exclude: 94 | - 'adornable.gemspec' 95 | 96 | Style/StringLiterals: 97 | Enabled: false 98 | 99 | Style/TrailingCommaInArguments: 100 | EnforcedStyleForMultiline: consistent_comma 101 | 102 | Style/TrailingCommaInArrayLiteral: 103 | EnforcedStyleForMultiline: consistent_comma 104 | 105 | Style/TrailingCommaInHashLiteral: 106 | EnforcedStyleForMultiline: consistent_comma 107 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | sudo: false 3 | language: ruby 4 | cache: bundler 5 | rvm: 6 | - 2.4.7 7 | before_install: gem install bundler -v 1.17.3 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | # Specify your gem's dependencies in adornable.gemspec 8 | gemspec 9 | 10 | group :development, :test do 11 | gem "bundler", "~> 2.2" 12 | gem "pry" 13 | gem "rake", "~> 13.0" 14 | gem "rspec", "~> 3.0" 15 | gem "rubocop", "~> 1.10" 16 | gem "rubocop-performance", "~> 1.9" 17 | gem "rubocop-rake", "~> 0.5" 18 | gem "rubocop-rspec", "~> 2.2" 19 | gem "solargraph" 20 | end 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Keegan Leitz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Adornable 2 | 3 | Adornable provides the ability to cleanly decorate methods in Ruby. You can make and use your own decorators, and you can also use some of the built-in ones that the gem provides. _Decorating_ methods is as simple as slapping a `decorate :some_decorator` above your method definition. _Defining_ decorators can be as simple as defining a method that yields to a block, or as complex as manipulating the decorated method's receiver and arguments, and/or changing the functionality of the decorator based on custom options supplied to it when initially applying the decorator. 4 | 5 | ## Installation 6 | 7 | **NOTE:** This library is tested with Ruby versions 2.5.x through 3.2.x. 8 | 9 | ### Locally (to your application) 10 | 11 | Add the gem to your application's `Gemfile`: 12 | 13 | ```ruby 14 | gem 'adornable' 15 | ``` 16 | 17 | ...and then run: 18 | 19 | ```bash 20 | bundle install 21 | ``` 22 | 23 | ### Globally (to your system) 24 | 25 | Alternatively, install it globally: 26 | 27 | ```bash 28 | gem install adornable 29 | ``` 30 | 31 | ## Usage 32 | 33 | ### The basics 34 | 35 | Think of a decorator as if it's just a wrapper function. You want something to happen before, around, or after a method is called, in a reusable (but dynamic) way? Maybe you want to print to a log whenever a certain method is called, or memoize its result so that additional calls don't have to re-execute the body of the method. You've tried this: 36 | 37 | ```rb 38 | class RandomValueGenerator 39 | def value 40 | # logging the method call 41 | puts "Calling method `RandomValueGenerator#value` with no arguments" 42 | # memoizing the result 43 | @value ||= rand 44 | end 45 | 46 | def values(max) 47 | # logging the method call 48 | puts "Calling method `RandomValueGenerator#values` with arguments `[#{max}]`" 49 | # memoizing the result 50 | @values ||= {} 51 | @values[max] ||= (1..max).map { rand } 52 | end 53 | end 54 | 55 | random_value_generator = RandomValueGenerator.new 56 | 57 | values1 = random_value_generator.values(1000) 58 | # Calling method `RandomValueGenerator#values` with arguments `[1000]` 59 | #=> [0.7044444114998132, 0.401953296596267, 0.3023797513191562, ...] 60 | 61 | values1 = random_value_generator.values(1000) 62 | # Calling method `RandomValueGenerator#values` with arguments `[1000]` 63 | #=> [0.7044444114998132, 0.401953296596267, 0.3023797513191562, ...] 64 | 65 | values3 = random_value_generator.values(5000) 66 | # Calling method `RandomValueGenerator#values` with arguments `[5000]` 67 | #=> [0.9916088057511011, 0.04466750434972333, 0.6073659341272127] 68 | 69 | value1 = random_value_generator.value 70 | # Calling method `RandomValueGenerator#value` with no arguments 71 | #=> 0.4196007135344746 72 | 73 | value2 = random_value_generator.value 74 | # Calling method `RandomValueGenerator#value` with no arguments 75 | #=> 0.4196007135344746 76 | ``` 77 | 78 | However, you have a million more methods to write, and if you refactor, you'll have to screw around with a slew of method definitions across your app. 79 | 80 | What if you could do this, instead, to achieve the same result? 81 | 82 | ```rb 83 | class RandomValueGenerator 84 | extend Adornable 85 | 86 | decorate :log 87 | decorate :memoize 88 | def value 89 | rand 90 | end 91 | 92 | decorate :log 93 | decorate :memoize 94 | def values(max) 95 | (1..max).map { rand } 96 | end 97 | end 98 | ``` 99 | 100 | Nice, right? 101 | 102 | > **Note:** in the case of multiple decorators decorating a method, each is executed from top to bottom. 103 | 104 | ### Adding decorator functionality 105 | 106 | Add the `::decorate` macro to your classes by `extend`-ing `Adornable`: 107 | 108 | ```rb 109 | class Foo 110 | extend Adornable 111 | 112 | # ... 113 | end 114 | ``` 115 | 116 | ### Decorating methods 117 | 118 | Use the `decorate` macro to decorate methods. 119 | 120 | #### Using built-in decorators 121 | 122 | There are a couple of built-in decorators for common use-cases (these can be overridden if you so choose): 123 | 124 | ```rb 125 | class Foo 126 | extend Adornable 127 | 128 | decorate :log 129 | def some_method 130 | # the method name (Foo#some_method) and arguments will be logged 131 | end 132 | 133 | decorate :memoize 134 | def some_other_method 135 | # the return value will be cached 136 | end 137 | 138 | decorate :memoize 139 | def yet_another_method(some_arg, some_other_arg = true, key_word_arg:, key_word_arg_with_default: 123) 140 | # the return value will be cached based on the arguments the method receives 141 | end 142 | 143 | decorate :log 144 | decorate :memoize, for_any_arguments: true 145 | def oh_boy_another_method(some_arg, some_other_arg = true, key_word_arg:, key_word_arg_with_default: 123) 146 | # the method name (Foo#oh_boy_another_method) and arguments will be logged 147 | # the return value will be cached regardless of the arguments received 148 | end 149 | 150 | decorate :log 151 | def self.yeah_it_works_on_class_methods_too 152 | # the method name (Foo::yeah_it_works_on_class_methods_too) and arguments 153 | # will be logged 154 | end 155 | end 156 | ``` 157 | 158 | - `decorate :log` logs the method name and any passed arguments to the console 159 | - `decorate :memoize` caches the result of the first call and returns that initial result (and does not execute the method again) for any additional calls. By default, it namespaces the cache by the arguments passed to the method, so it will re-compute only if the arguments change; if the arguments are the same as any previous time the method was called, it will return the cached result instead. 160 | - pass the `for_any_arguments: true` option (e.g., `decorate :memoize, for_any_arguments: true`) to ignore the arguments in the caching process and simply memoize the result no matter what 161 | - a `nil` value returned from a memoized method will still be cached like any other value 162 | 163 | > **Note:** in the case of multiple decorators decorating a method, each is executed from top to bottom. 164 | 165 | #### Writing custom decorators and using them _explicitly_ 166 | 167 | You can reference any decorator method you write, like so: 168 | 169 | ```rb 170 | class FooDecorators 171 | # Note: this is defined as a CLASS method, but it can be applied to both class 172 | # and instance methods. The only difference is in how you source the 173 | # decorator when doing the decoration; see below for more info. 174 | def self.blast_it(context) 175 | puts "Blasting it!" 176 | value = yield 177 | "#{value}!" 178 | end 179 | 180 | # Note: this is defined as an INSTANCE method, but it can be applied to both 181 | # class and instance methods. The only difference is in how you source 182 | # the decorator when doing the decoration; see below for more info. 183 | def wait_for_it(context, dot_count: 3) 184 | ellipsis = dot_count.times.map { '.' }.join 185 | puts "Waiting for it#{ellipsis}" 186 | value = yield 187 | "#{value}#{ellipsis}" 188 | end 189 | end 190 | 191 | class Foo 192 | extend Adornable 193 | 194 | # Note: `from: FooDecorators` references a class (and will look for the 195 | # `::blast_it` method on that class) 196 | decorate :blast_it, from: FooDecorators 197 | def some_method 198 | "haha I'm a method" 199 | end 200 | 201 | # Note: `from: FooDecorators.new` references an instance (and will look for 202 | # the `#wait_for_it` method on that instance) 203 | decorate :wait_for_it, from: FooDecorators.new 204 | def other_method 205 | "haha I'm another method" 206 | end 207 | 208 | decorate :log 209 | def yet_another_method(foo, bar:) 210 | "haha I'm yet another method" 211 | end 212 | end 213 | 214 | foo = Foo.new 215 | 216 | foo.some_method 217 | #=> "haha I'm a method!" # Note the exclamation mark 218 | 219 | foo.other_method 220 | #=> "haha I'm another method..." # Note the ellipsis 221 | 222 | foo.yet_another_method(123, bloop: "bleep") 223 | # Calling method `Foo#yet_another_method` with arguments `[123, {:bloop=>"bleep"}]` 224 | #=> "haha I'm yet another method" 225 | ``` 226 | 227 | Use the `from:` option to specify what should receive the decorator method. Keep in mind that the decorator method will be called on the thing specified by `from:`... so, if you provide a class, it better be a class method on that thing, and if you supply an instance, it better be an instance method on that thing. 228 | 229 | Every custom decorator method that you define must take one required argument (`context`) and any number of keyword arguments. It should also `yield` (or take a block argument and invoke it) at some point in the body of the method. The point at which you `yield` will be the point at which the decorated method will execute (or, if there are multiple decorators on the method, each following decorator will be invoked until the decorators have been exhausted and the decorated method is finally executed). 230 | 231 | ##### The required argument (`context`) 232 | 233 | The **required argument** is an instance of `Adornable::Context`, which has some useful information about the decorated method being called 234 | 235 | - `Adornable::Context#method_name`: the name of the decorated method being called (a symbol; e.g., `:some_method` or `:other_method`) 236 | - `Adornable::Context#method_receiver`: the actual object that the decorated method (the `#method_name`) belongs to/is being called on (an object/class; e.g., the class `Foo` if it's a decorated class method, or an instance of `Foo` if it's a decorated instance method) 237 | - `Adornable::Context#method_arguments`: an array of arguments passed to the decorated method, including keyword arguments as a final hash (e.g., if `:yet_another_method` was called like `Foo.new.yet_another_method(123, bar: true, baz: 456)` then `method_arguments` would be `[123, {:bar=>true,:baz=>456}]`) 238 | - `Adornable::Context#method_positional_args`: an array of just the positional arguments passed to the decorated method, excluding keyword arguments (e.g., if `:yet_another_method` was called like `Foo.new.yet_another_method(123, bar: true, baz: 456)` then `method_positional_args` would be `[123]`) 239 | - `Adornable::Context#method_kwargs`: a hash of just the keyword arguments passed to the decorated method (e.g., if `:yet_another_method` was called like `Foo.new.yet_another_method(123, { bam: "hi" }, bar: true, baz: 456)` then `method_kwargs` would be `{:bar=>true,:baz=>456}`) 240 | 241 | ##### Custom keyword arguments (optional) 242 | 243 | The **optional keyword arguments** are any parameters you want to be able to pass to the decorator method when decorating a method with `::decorate`: 244 | 245 | - If you define a decorator like `def self.some_decorator(context)` then it takes no options when it is used: `decorate :some_decorator`. 246 | - If you define a decorator like `def self.some_decorator(context, some_option:)` then it takes one _required_ keyword argument when it is used: `decorate :some_decorator, some_option: 123` (so that `::some_decorator` will receive `123` as the `some_option` parameter every time the decorated method is called). You can customize functionality of the decorator this way. 247 | - Similarly, if you define a decorator like `def self.some_decorator(context, some_option: 456)`, then it takes one _optional_ keyword argument when it is used: `decorate :some_decorator` is valid (and implies `some_option: 456` since it has a default), and `decorate :some_decorator, some_option: 789` is valid as well. 248 | 249 | ##### Yielding to the next decorator/decorated method 250 | 251 | Every decorator method **should also probably `yield`** at some point in the method body. I say _"should"_ because, technically, you don't have to, but if you don't then the original method will never be called. That's a valid use-case, but 99% of the time you're gonna want to `yield`. 252 | 253 | > **Note:** the return value of your decorator **will replace the return value of the decorated method,** so _also_ you should probably return whatever value `yield` returned. Again, it is a valid use case to return something _else,_ but 99% of the time you probably want to return the value returned by the wrapped method. 254 | > 255 | > A contrived example of when you might want to muck around with the return value: 256 | > 257 | > ```rb 258 | > class FooDecorators 259 | > def self.coerce_to_int(context) 260 | > value = yield 261 | > new_value = value.strip.to_i 262 | > puts "New value: #{value.inspect} (class: #{value.class})" 263 | > new_value 264 | > end 265 | > end 266 | > 267 | > class Foo 268 | > extend Adornable 269 | > 270 | > decorate :coerce_to_int, from: FooDecorators 271 | > def get_number_from_user 272 | > print "Enter a number: " 273 | > value = gets 274 | > puts "Value: #{value.inspect} (class: #{value.class})" 275 | > value 276 | > end 277 | > end 278 | > 279 | > foo = Foo.new 280 | > 281 | > foo.get_number_from_user 282 | > # Enter a number 283 | > # > 123 284 | > # Value: "123" (class: String) 285 | > # New value: 123 (class: Integer) 286 | > #=> 123 287 | > ``` 288 | 289 | #### Writing custom decorators and using them _implicitly_ 290 | 291 | You can also register decorator receivers so that you don't have to reference them with the `from:` option: 292 | 293 | ```rb 294 | class FooDecorators 295 | def self.blast_it(context) 296 | puts "Blasting it!" 297 | value = yield 298 | "#{value}!" 299 | end 300 | end 301 | 302 | class MoreFooDecorators 303 | def wait_for_it(context, dot_count: 3) 304 | ellipsis = dot_count.times.map { '.' }.join 305 | puts "Waiting for it#{ellipsis}" 306 | value = yield 307 | "#{value}#{ellipsis}" 308 | end 309 | end 310 | 311 | class Foo 312 | extend Adornable 313 | 314 | add_decorators_from FooDecorators 315 | add_decorators_from MoreFooDecorators.new 316 | 317 | decorate :blast_it 318 | decorate :wait_for_it, dot_count: 9 319 | def some_method 320 | "haha I'm a method" 321 | end 322 | end 323 | 324 | foo = Foo.new 325 | 326 | foo.some_method 327 | # Blasting it! 328 | # Waiting for it......... 329 | #=> "haha I'm a method!........." 330 | ``` 331 | 332 | > **Note:** All the rest of the stuff from the previous section (using decorators explicitly) also applies here (using decorators implicitly). 333 | 334 | > **Note:** In the case of duplicate decorator methods, later receivers registered with `::add_decorators_from` will override any decorators by the same name from earlier registered receivers. 335 | 336 | > **Note:** in the case of multiple decorators decorating a method, each is executed from top to bottom; i.e., the top wraps the next, which wraps the next, and so on, until the method itself is wrapped. 337 | 338 | ## Development 339 | 340 | ### Install dependencies 341 | 342 | ```bash 343 | bin/setup 344 | ``` 345 | 346 | ### Run the tests 347 | 348 | ```bash 349 | rake spec 350 | ``` 351 | 352 | ### Run the linter 353 | 354 | ```bash 355 | rubocop 356 | ``` 357 | 358 | ## Contributing 359 | 360 | Bug reports and pull requests for this project are welcome at its [GitHub page](https://github.com/kjleitz/adornable). If you choose to contribute, please be nice so I don't have to run out of bubblegum, etc. 361 | 362 | ## License 363 | 364 | This project is open source, under the terms of the [MIT license.](https://github.com/kjleitz/adornable/blob/master/LICENSE) 365 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /adornable.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("../lib", __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "adornable/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "adornable" 9 | spec.version = Adornable::VERSION 10 | spec.authors = ["Keegan Leitz"] 11 | spec.email = ["kjleitz@gmail.com"] 12 | 13 | spec.summary = "Method decorators for Ruby" 14 | spec.description = "Adornable provides the ability to cleanly decorate methods in Ruby. You can make and use your own decorators, and you can also use some of the built-in ones that the gem provides. _Decorating_ methods is as simple as slapping a `decorate :some_decorator` above your method definition. _Defining_ decorators can be as simple as defining a method that yields to a block, or as complex as manipulating the decorated method's receiver and arguments, and/or changing the functionality of the decorator based on custom options supplied to it when initially applying the decorator." # rubocop:disable Layout/LineLength 15 | spec.homepage = "https://github.com/kjleitz/adornable" 16 | spec.license = "MIT" 17 | spec.required_ruby_version = ">= 2.5.0" 18 | 19 | # Specify which files should be added to the gem when it is released. 20 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 21 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 22 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 23 | end 24 | spec.bindir = "exe" 25 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 26 | spec.require_paths = ["lib"] 27 | spec.metadata['rubygems_mfa_required'] = 'true' 28 | end 29 | -------------------------------------------------------------------------------- /bin/asdf_switch: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # First argument should be desired ruby version. If a partial version is given 4 | # (e.g., 2.6) and multiple versions are found to be installed (e.g., 2.6.9 and 5 | # 2.6.10), then the highest version (in semver version order) will be selected. 6 | SELECTED_RUBY_VERSION=$(asdf list ruby | sort -V | sed -e 's/^[[:space:]]*//' | grep "^$1" | tail -n 1) 7 | 8 | if [ -z "$SELECTED_RUBY_VERSION" ]; then 9 | echo "Ruby version $1 is not installed. Try running:" 10 | echo " asdf install ruby $1" 11 | exit 1 12 | fi 13 | 14 | echo "Using Ruby version $SELECTED_RUBY_VERSION" 15 | 16 | echo "(setting global Ruby)" 17 | asdf global ruby $SELECTED_RUBY_VERSION 18 | 19 | echo "(reshimming for this version)" 20 | asdf reshim ruby $SELECTED_RUBY_VERSION 21 | 22 | echo "(reinstalling bundled gems)" 23 | bundle install --redownload 24 | 25 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "adornable" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/adornable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "adornable/version" 4 | require "adornable/utils" 5 | require "adornable/error" 6 | require "adornable/decorators" 7 | require "adornable/machinery" 8 | 9 | # Extend the `Adornable` module in your class in order to have access to the 10 | # `decorate` and `add_decorators_from` macros. 11 | module Adornable 12 | def adornable_machinery 13 | @adornable_machinery ||= Adornable::Machinery.new 14 | end 15 | 16 | def decorate(decorator_name, from: nil, defer_validation: false, **decorator_options) 17 | if Adornable::Utils.blank?(decorator_name) 18 | raise Adornable::Error::InvalidDecoratorArguments, "Decorator name must be provided." 19 | end 20 | 21 | adornable_machinery.accumulate_decorator!( 22 | name: decorator_name, 23 | receiver: from, 24 | defer_validation: !!defer_validation, 25 | decorator_options: decorator_options, 26 | ) 27 | end 28 | 29 | def add_decorators_from(receiver) 30 | adornable_machinery.register_decorator_receiver!(receiver) 31 | end 32 | 33 | def method_added(method_name) 34 | machinery = adornable_machinery # for local variable 35 | return unless machinery.accumulated_decorators? 36 | 37 | machinery.apply_accumulated_decorators_to_instance_method!(method_name) 38 | original_method = instance_method(method_name) 39 | 40 | # NB: If you only supply `*args` to the block, you get kwargs as a trailing 41 | # Hash member in the `args` array. If you supply both `*args, **kwargs` to 42 | # the block, kwargs are excluded from the `args` array and only appear in 43 | # the `kwargs` argument as a Hash. 44 | define_method(method_name) do |*args, **kwargs| 45 | bound_method = original_method.bind(self) 46 | machinery.run_decorated_instance_method(bound_method, *args, **kwargs) 47 | end 48 | 49 | super 50 | end 51 | 52 | def singleton_method_added(method_name) 53 | machinery = adornable_machinery # for local variable 54 | return unless machinery.accumulated_decorators? 55 | 56 | machinery.apply_accumulated_decorators_to_class_method!(method_name) 57 | original_method = method(method_name) 58 | 59 | # NB: If you only supply `*args` to the block, you get kwargs as a trailing 60 | # Hash member in the `args` array. If you supply both `*args, **kwargs` to 61 | # the block, kwargs are excluded from the `args` array and only appear in 62 | # the `kwargs` argument as a Hash. 63 | define_singleton_method(method_name) do |*args, **kwargs| 64 | machinery.run_decorated_class_method(original_method, *args, **kwargs) 65 | end 66 | 67 | super 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/adornable/context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Adornable 4 | # A context object is passed to the decorator method, and contains information 5 | # about the decorated method being called. 6 | class Context 7 | attr_reader(*%i[ 8 | method_receiver 9 | method_name 10 | method_arguments 11 | method_positional_args 12 | method_kwargs 13 | decorator_name 14 | decorator_options 15 | ]) 16 | 17 | def initialize( 18 | method_receiver:, 19 | method_name:, 20 | method_arguments:, 21 | method_positional_args:, 22 | method_kwargs:, 23 | decorator_name:, 24 | decorator_options: 25 | ) 26 | @method_receiver = method_receiver 27 | @method_name = method_name 28 | @method_arguments = method_arguments 29 | @method_positional_args = method_positional_args 30 | @method_kwargs = method_kwargs 31 | @decorator_name = decorator_name 32 | @decorator_options = decorator_options 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/adornable/decorators.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'adornable/utils' 4 | 5 | module Adornable 6 | # `Adornable::Decorators` is used as the default namespace for decorator 7 | # methods when a decorator method that is neither explicitly sourced (via the 8 | # `decorate from: ` option) nor implicitly sourced (via the 9 | # `add_decorators_from ` macro). 10 | class Decorators 11 | def self.log(context) 12 | method_receiver = context.method_receiver 13 | method_name = context.method_name 14 | method_args = context.method_arguments 15 | full_name = Adornable::Utils.formal_method_name(method_receiver, method_name) 16 | arguments_desc = method_args.empty? ? "no arguments" : "arguments `#{method_args.inspect}`" 17 | puts "Calling method #{full_name} with #{arguments_desc}" 18 | yield 19 | end 20 | 21 | def self.memoize(context, for_any_arguments: false, &block) 22 | return memoize_for_arguments(context, &block) unless for_any_arguments 23 | 24 | method_receiver = context.method_receiver 25 | method_name = context.method_name 26 | memo_var_name = :"@adornable_memoized_#{method_receiver.object_id}_#{method_name}" 27 | 28 | if instance_variable_defined?(memo_var_name) 29 | instance_variable_get(memo_var_name) 30 | else 31 | instance_variable_set(memo_var_name, yield) 32 | end 33 | end 34 | 35 | def self.memoize_for_arguments(context) 36 | method_receiver = context.method_receiver 37 | method_name = context.method_name 38 | method_args = context.method_arguments 39 | memo_var_name = :"@adornable_memoized_for_arguments_#{method_receiver.object_id}_#{method_name}" 40 | memo = instance_variable_get(memo_var_name) || {} 41 | instance_variable_set(memo_var_name, memo) 42 | args_key = method_args.inspect 43 | memo[args_key] = yield unless memo.key?(args_key) 44 | memo[args_key] 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/adornable/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Adornable 4 | module Error 5 | class Base < ::StandardError 6 | end 7 | 8 | class InvalidDecoratorArguments < Adornable::Error::Base 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/adornable/machinery.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'adornable/utils' 4 | require 'adornable/error' 5 | require 'adornable/context' 6 | 7 | module Adornable 8 | class Machinery # :nodoc: 9 | def register_decorator_receiver!(receiver) 10 | registered_decorator_receivers.unshift(receiver) 11 | end 12 | 13 | def accumulate_decorator!(name:, receiver:, defer_validation:, decorator_options:) 14 | name = name.to_sym 15 | receiver ||= find_suitable_receiver_for(name) 16 | validate_decorator!(name, receiver) unless defer_validation 17 | 18 | decorator = { 19 | name: name, 20 | receiver: receiver, 21 | options: decorator_options || {}, 22 | } 23 | 24 | accumulated_decorators << decorator 25 | end 26 | 27 | def accumulated_decorators? 28 | Adornable::Utils.present?(accumulated_decorators) 29 | end 30 | 31 | def apply_accumulated_decorators_to_instance_method!(method_name) 32 | set_instance_method_decorators!(method_name, accumulated_decorators) 33 | clear_accumulated_decorators! 34 | end 35 | 36 | def apply_accumulated_decorators_to_class_method!(method_name) 37 | set_class_method_decorators!(method_name, accumulated_decorators) 38 | clear_accumulated_decorators! 39 | end 40 | 41 | def run_decorated_instance_method(bound_method, *args, **kwargs) 42 | decorators = get_instance_method_decorators(bound_method.name) 43 | run_decorators(decorators, bound_method, *args, **kwargs) 44 | end 45 | 46 | def run_decorated_class_method(bound_method, *args, **kwargs) 47 | decorators = get_class_method_decorators(bound_method.name) 48 | run_decorators(decorators, bound_method, *args, **kwargs) 49 | end 50 | 51 | private 52 | 53 | def registered_decorator_receivers 54 | @registered_decorator_receivers ||= [Adornable::Decorators] 55 | end 56 | 57 | def accumulated_decorators 58 | @accumulated_decorators ||= [] 59 | end 60 | 61 | def clear_accumulated_decorators! 62 | @accumulated_decorators = [] 63 | end 64 | 65 | def get_instance_method_decorators(method_name) 66 | name = method_name.to_sym 67 | @instance_method_decorators ||= {} 68 | @instance_method_decorators[name] ||= [] 69 | @instance_method_decorators[name] 70 | end 71 | 72 | def set_instance_method_decorators!(method_name, decorators) 73 | name = method_name.to_sym 74 | @instance_method_decorators ||= {} 75 | @instance_method_decorators[name] = decorators || [] 76 | end 77 | 78 | def get_class_method_decorators(method_name) 79 | name = method_name.to_sym 80 | @class_method_decorators ||= {} 81 | @class_method_decorators[name] ||= [] 82 | @class_method_decorators[name] 83 | end 84 | 85 | def set_class_method_decorators!(method_name, decorators) 86 | name = method_name.to_sym 87 | @class_method_decorators ||= {} 88 | @class_method_decorators[name] = decorators || [] 89 | end 90 | 91 | def run_decorators(decorators, bound_method, *method_positional_args, **method_kwargs) 92 | if Adornable::Utils.blank?(decorators) 93 | return Adornable::Utils.empty_aware_send(bound_method, :call, method_positional_args, method_kwargs) 94 | end 95 | 96 | decorator, *remaining_decorators = decorators 97 | decorator_name = decorator[:name] 98 | decorator_receiver = decorator[:receiver] 99 | decorator_options = decorator[:options] 100 | validate_decorator!(decorator_name, decorator_receiver, bound_method) 101 | 102 | # This is for backwards-compatibility between Ruby 2.x and Ruby 3.x; in v3 103 | # keyword arguments are treated differently than in v2 with respect to 104 | # hash parameter equivalency. Previously, it was easy to just assume 105 | # `method_arguments` could be an array with a hash at the end representing 106 | # any given keyword arguments. However, in Ruby 3.x, we have to be able to 107 | # distinguish between kwargs and trailing positional args of type `Hash`, 108 | # so we'll shim `Adornable::Context#method_arguments` to look like it used 109 | # to and then provide two new properties, `#method_positional_args` and 110 | # `#method_kwargs`, to `Adornable::Context` for explicitness. 111 | method_arguments = method_positional_args.dup 112 | method_arguments << method_kwargs if Adornable::Utils.present?(method_kwargs) 113 | 114 | context = Adornable::Context.new( 115 | method_receiver: bound_method.receiver, 116 | method_name: bound_method.name, 117 | method_arguments: method_arguments, 118 | method_positional_args: method_positional_args, 119 | method_kwargs: method_kwargs, 120 | decorator_name: decorator_name, 121 | decorator_options: decorator_options, 122 | ) 123 | 124 | Adornable::Utils.empty_aware_send(decorator_receiver, decorator_name, [context], decorator_options) do 125 | run_decorators(remaining_decorators, bound_method, *method_positional_args, **method_kwargs) 126 | end 127 | end 128 | 129 | def find_suitable_receiver_for(decorator_name) 130 | registered_decorator_receivers.detect do |receiver| 131 | receiver.respond_to?(decorator_name) 132 | end 133 | end 134 | 135 | # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Layout/LineLength 136 | def validate_decorator!(decorator_name, decorator_receiver, bound_method = nil) 137 | return if decorator_receiver.respond_to?(decorator_name) 138 | 139 | location_hint = if bound_method 140 | method_receiver = bound_method.receiver 141 | method_full_name = method_receiver.is_a?(Class) ? "#{method_receiver}::#{method.name}" : "#{method_receiver.class}##{method.name}" 142 | method_location = bound_method.source_location 143 | "Cannot decorate `#{method_full_name}` (defined at `#{method_location.first}:#{method_location.second})." 144 | end 145 | 146 | base_message = "Decorator method `#{decorator_name.inspect}` cannot be found on `#{decorator_receiver.inspect}`." 147 | 148 | definition_hint = if decorator_receiver.is_a?(Class) && decorator_receiver.instance_methods.include?(decorator_name) 149 | class_name = decorator_receiver.inspect 150 | "It is, however, an instance method of the class. To use this decorator method, either A) supply an instance of the `#{class_name}` class to the `found_on:` option (instead of the class itself), B) convert the instance method `#{class_name}##{decorator_name}` to a class method, or C) create a new class method on `#{class_name}` of the same decorator_name." 151 | elsif !decorator_receiver.is_a?(Class) && decorator_receiver.class.methods.include?(decorator_name) 152 | class_name = decorator_receiver.class.inspect 153 | "It is, however, a method of this instance's class. To use this decorator method, either A) supply the `#{class_name}` class itself to the `found_on:` option (instead of an instance of that class), B) convert the class method `#{class_name}::#{decorator_name}` to an instance method, or C) create a new instance method on `#{class_name}` of the same name." 154 | end 155 | 156 | message = [location_hint, base_message, definition_hint].compact.join(" ") 157 | raise Adornable::Error::InvalidDecoratorArguments, message 158 | end 159 | # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Layout/LineLength 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /lib/adornable/utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Adornable 4 | class Utils # :nodoc: 5 | class << self 6 | def blank?(value) 7 | value.nil? || (value.respond_to?(:empty?) && value.empty?) 8 | end 9 | 10 | def present?(value) 11 | !blank?(value) 12 | end 13 | 14 | def presence(value) 15 | value if present?(value) 16 | end 17 | 18 | def formal_method_name(method_receiver, method_name) 19 | receiver_name, name_delimiter = if method_receiver.is_a?(Class) 20 | [method_receiver.to_s, '::'] 21 | else 22 | [method_receiver.class.to_s, '#'] 23 | end 24 | "`#{receiver_name}#{name_delimiter}#{method_name}`" 25 | end 26 | 27 | # This craziness is here because Ruby 2.6 and below don't like when you 28 | # pass even _empty_ arguments to `#call` or `#send` or any other method 29 | # with a splat, for callables that take no arguments. For example, this 30 | # takes the place of: 31 | # 32 | # receiver.send(method_name, *splat_args, **splat_kwargs) 33 | # 34 | # ...or: 35 | # 36 | # receiver.some_method(*splat_args, **splat_kwargs) 37 | # 38 | # ...which is not cool <= 2.6.x apparently, if `#some_method` takes zero 39 | # arguments even if both `splat_args` and `splat_kwargs` are empty (thus 40 | # passing it zero arguments in actuality). Oh well. 41 | # 42 | def empty_aware_send(receiver, method_name, splat_args, splat_kwargs, &block) 43 | return receiver.send(method_name, &block) if splat_args.empty? && splat_kwargs.empty? 44 | return receiver.send(method_name, *splat_args, &block) if splat_kwargs.empty? 45 | return receiver.send(method_name, **splat_kwargs, &block) if splat_args.empty? 46 | 47 | receiver.send(method_name, *splat_args, **splat_kwargs, &block) 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/adornable/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Adornable 4 | VERSION = "1.3.0" 5 | end 6 | -------------------------------------------------------------------------------- /spec/adornable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rubocop:disable Lint/UnusedMethodArgument 4 | class FoobarExplicitDecorators 5 | def self.blast_it(context) 6 | value = yield 7 | "#{value}!" 8 | end 9 | end 10 | # rubocop:enable Lint/UnusedMethodArgument 11 | 12 | # rubocop:disable Lint/UnusedMethodArgument 13 | class FoobarImplicitDecorators 14 | def self.wait_for_it(context) 15 | value = yield 16 | "#{value}..." 17 | end 18 | 19 | def self.wait_for_it_excitedly(context) 20 | value = yield 21 | "#{value}...!" 22 | end 23 | end 24 | # rubocop:enable Lint/UnusedMethodArgument 25 | 26 | # rubocop:disable Lint/UnusedMethodArgument 27 | class FoobarImplicitDecorators2 28 | def self.wait_for_it_excitedly(context) 29 | value = yield 30 | "#{value}...WOO!" 31 | end 32 | end 33 | # rubocop:enable Lint/UnusedMethodArgument 34 | 35 | # rubocop:disable Lint/UnusedMethodArgument 36 | class Foobar 37 | extend Adornable 38 | 39 | add_decorators_from FoobarImplicitDecorators 40 | add_decorators_from FoobarImplicitDecorators2 41 | add_decorators_from self 42 | 43 | ### 44 | 45 | def self.whoa_its_a_local_decorator(context) 46 | value = yield 47 | "#{value} - now that's what I call a class method!" 48 | end 49 | 50 | ### 51 | 52 | def some_instance_method_undecorated(foo, bar:) 53 | "we are in some_instance_method_undecorated" 54 | end 55 | 56 | decorate :log 57 | def some_instance_method_decorated(foo, bam, bar:, baz:) 58 | "we are in some_instance_method_decorated" 59 | end 60 | 61 | decorate :log 62 | decorate :memoize 63 | def some_instance_method_multi_decorated(foo, bar:) 64 | "we are in some_instance_method_multi_decorated" 65 | end 66 | 67 | def self.some_class_method_undecorated(foo, bar:) 68 | "we are in self.some_class_method_undecorated" 69 | end 70 | 71 | decorate :log 72 | def self.some_class_method_decorated(foo, bar:) 73 | "we are in self.some_class_method_decorated" 74 | end 75 | 76 | decorate :log 77 | decorate :memoize 78 | def self.some_class_method_multi_decorated(foo, bar:) 79 | "we are in self.some_class_method_multi_decorated" 80 | end 81 | 82 | ### 83 | 84 | # rubocop:disable Lint/DuplicateMethods 85 | decorate :log 86 | def shadowed_instance_method_both_have_decorator(foo, bar:) 87 | "we are in shadowed_instance_method_both_have_decorator" 88 | end 89 | 90 | decorate :log 91 | def shadowed_instance_method_both_have_decorator(foo, bar:) 92 | "we are in shadowed_instance_method_both_have_decorator" 93 | end 94 | # rubocop:enable Lint/DuplicateMethods 95 | 96 | ### 97 | 98 | # rubocop:disable Lint/DuplicateMethods 99 | decorate :log 100 | def shadowed_instance_method_decorator_removed(foo, bar:) 101 | "we are in shadowed_instance_method_decorator_removed" 102 | end 103 | 104 | def shadowed_instance_method_decorator_removed(foo, bar:) 105 | "we are in shadowed_instance_method_decorator_removed" 106 | end 107 | # rubocop:enable Lint/DuplicateMethods 108 | 109 | ### 110 | 111 | # rubocop:disable Lint/DuplicateMethods 112 | def shadowed_instance_method_decorator_added(foo, bar:) 113 | "we are in shadowed_instance_method_decorator_added" 114 | end 115 | 116 | decorate :log 117 | def shadowed_instance_method_decorator_added(foo, bar:) 118 | "we are in shadowed_instance_method_decorator_added" 119 | end 120 | # rubocop:enable Lint/DuplicateMethods 121 | 122 | ### 123 | 124 | # rubocop:disable Lint/DuplicateMethods 125 | decorate :log 126 | def self.shadowed_class_method_both_have_decorator(foo, bar:) 127 | "we are in self.shadowed_class_method_both_have_decorator" 128 | end 129 | 130 | decorate :log 131 | def self.shadowed_class_method_both_have_decorator(foo, bar:) 132 | "we are in self.shadowed_class_method_both_have_decorator" 133 | end 134 | # rubocop:enable Lint/DuplicateMethods 135 | 136 | ### 137 | 138 | # rubocop:disable Lint/DuplicateMethods 139 | decorate :log 140 | def self.shadowed_class_method_decorator_removed(foo, bar:) 141 | "we are in self.shadowed_class_method_decorator_removed" 142 | end 143 | 144 | def self.shadowed_class_method_decorator_removed(foo, bar:) 145 | "we are in self.shadowed_class_method_decorator_removed" 146 | end 147 | # rubocop:enable Lint/DuplicateMethods 148 | 149 | ### 150 | 151 | # rubocop:disable Lint/DuplicateMethods 152 | def self.shadowed_class_method_decorator_added(foo, bar:) 153 | "we are in self.shadowed_class_method_decorator_added" 154 | end 155 | 156 | decorate :log 157 | def self.shadowed_class_method_decorator_added(foo, bar:) 158 | "we are in self.shadowed_class_method_decorator_added" 159 | end 160 | # rubocop:enable Lint/DuplicateMethods 161 | 162 | ### 163 | 164 | decorate :blast_it, from: FoobarExplicitDecorators 165 | def custom_explicit_decorated_instance_method(foo, bar:) 166 | "we are in custom_explicit_decorated_instance_method" 167 | end 168 | 169 | decorate :blast_it, from: FoobarExplicitDecorators 170 | def self.custom_explicit_decorated_class_method(foo, bar:) 171 | "we are in self.custom_explicit_decorated_class_method" 172 | end 173 | 174 | ### 175 | 176 | decorate :wait_for_it 177 | def custom_implicit_decorated_instance_method(foo, bar:) 178 | "we are in custom_implicit_decorated_instance_method" 179 | end 180 | 181 | decorate :wait_for_it 182 | def self.custom_implicit_decorated_class_method(foo, bar:) 183 | "we are in self.custom_implicit_decorated_class_method" 184 | end 185 | 186 | ### 187 | 188 | decorate :wait_for_it_excitedly 189 | def custom_implicit_overridden_decorated_instance_method(foo, bar:) 190 | "we are in custom_implicit_overridden_decorated_instance_method" 191 | end 192 | 193 | decorate :wait_for_it_excitedly 194 | def self.custom_implicit_overridden_decorated_class_method(foo, bar:) 195 | "we are in self.custom_implicit_overridden_decorated_class_method" 196 | end 197 | 198 | ### 199 | 200 | decorate :whoa_its_a_local_decorator 201 | def custom_implicit_local_decorated_instance_method(foo, bar:) 202 | "we are in custom_implicit_local_decorated_instance_method" 203 | end 204 | 205 | decorate :whoa_its_a_local_decorator 206 | def self.custom_implicit_local_decorated_class_method(foo, bar:) 207 | "we are in self.custom_implicit_local_decorated_class_method" 208 | end 209 | 210 | ### 211 | 212 | decorate :log 213 | def logged_instance_method(foo, bar:) 214 | rand 215 | end 216 | 217 | decorate :log 218 | def logged_instance_method_no_args 219 | rand 220 | end 221 | 222 | decorate :log 223 | def self.logged_class_method(foo, bar:) 224 | rand 225 | end 226 | 227 | decorate :log 228 | def self.logged_class_method_no_args 229 | rand 230 | end 231 | 232 | ### 233 | 234 | decorate :memoize, for_any_arguments: true 235 | def memoized_instance_method_for_any_args_as_option(foo, bar:) 236 | rand 237 | end 238 | 239 | decorate :memoize, for_any_arguments: true 240 | def self.memoized_class_method_for_any_args_as_option(foo, bar:) 241 | rand 242 | end 243 | 244 | ### 245 | 246 | decorate :memoize 247 | def memoized_instance_method_for_args_as_default_option(foo, bar:) 248 | rand 249 | end 250 | 251 | decorate :memoize 252 | def memoized_instance_method_for_args_with_nil_return(counter) 253 | counter.value += 1 254 | nil 255 | end 256 | 257 | decorate :memoize, for_any_arguments: true 258 | def memoized_instance_method_for_any_args_with_nil_return(counter) 259 | counter.value += 1 260 | nil 261 | end 262 | 263 | decorate :memoize 264 | def self.memoized_class_method_for_args_as_default_option(foo, bar:) 265 | rand 266 | end 267 | 268 | ### 269 | 270 | decorate :memoize_for_arguments 271 | def memoized_instance_method_for_args(foo, bar:) 272 | rand 273 | end 274 | 275 | decorate :memoize_for_arguments 276 | def self.memoized_class_method_for_args(foo, bar:) 277 | rand 278 | end 279 | end 280 | # rubocop:enable Lint/UnusedMethodArgument 281 | 282 | RSpec.describe Adornable do 283 | it "has a version number" do 284 | expect(Adornable::VERSION).not_to be_nil 285 | end 286 | 287 | context "when decorating instance methods" do 288 | it "does not decorate undecorated instance methods" do 289 | foobar = Foobar.new 290 | 291 | expect(Adornable::Decorators).not_to receive(:log) 292 | 293 | returned = foobar.some_instance_method_undecorated("foo", bar: "bar") 294 | expect(returned).to eq("we are in some_instance_method_undecorated") 295 | end 296 | 297 | it "decorates decorated instance methods" do 298 | foobar = Foobar.new 299 | 300 | decorator_called = false 301 | 302 | allow(Adornable::Decorators).to receive(:log) do |*args, &block| 303 | decorator_called = true 304 | context = args.first 305 | expect(context).to be_a(Adornable::Context) 306 | expect(context.method_receiver).to eq(foobar) 307 | expect(context.method_name).to eq(:some_instance_method_decorated) 308 | expect(context.method_arguments).to eq(["foo", { bam: "hi" }, { bar: "bar", baz: 123 }]) 309 | expect(context.method_positional_args).to eq(["foo", { bam: "hi" }]) 310 | expect(context.method_kwargs).to eq({ bar: "bar", baz: 123 }) 311 | expect(context.decorator_name).to eq(:log) 312 | expect(context.decorator_options).to be_empty 313 | block.call 314 | end 315 | 316 | returned = foobar.some_instance_method_decorated("foo", { bam: "hi" }, bar: "bar", baz: 123) 317 | expect(returned).to eq("we are in some_instance_method_decorated") 318 | expect(decorator_called).to be true 319 | end 320 | 321 | it "decorates multi-decorated instance methods" do 322 | foobar = Foobar.new 323 | 324 | log_decorator_called = false 325 | 326 | allow(Adornable::Decorators).to receive(:log) do |*args, &block| 327 | log_decorator_called = true 328 | context = args.first 329 | expect(context).to be_a(Adornable::Context) 330 | expect(context.method_receiver).to eq(foobar) 331 | expect(context.method_name).to eq(:some_instance_method_multi_decorated) 332 | expect(context.method_arguments).to eq(["foo", { bar: "bar" }]) 333 | expect(context.method_positional_args).to eq(["foo"]) 334 | expect(context.method_kwargs).to eq({ bar: "bar" }) 335 | expect(context.decorator_name).to eq(:log) 336 | expect(context.decorator_options).to be_empty 337 | block.call 338 | end 339 | 340 | memo_decorator_called = false 341 | 342 | allow(Adornable::Decorators).to receive(:memoize) do |*args, &block| 343 | memo_decorator_called = true 344 | context = args.first 345 | expect(context).to be_a(Adornable::Context) 346 | expect(context.method_receiver).to eq(foobar) 347 | expect(context.method_name).to eq(:some_instance_method_multi_decorated) 348 | expect(context.method_arguments).to eq(["foo", { bar: "bar" }]) 349 | expect(context.method_positional_args).to eq(["foo"]) 350 | expect(context.method_kwargs).to eq({ bar: "bar" }) 351 | expect(context.decorator_name).to eq(:memoize) 352 | expect(context.decorator_options).to be_empty 353 | block.call 354 | end 355 | 356 | returned = foobar.some_instance_method_multi_decorated("foo", bar: "bar") 357 | expect(returned).to eq("we are in some_instance_method_multi_decorated") 358 | expect(log_decorator_called).to be true 359 | expect(memo_decorator_called).to be true 360 | end 361 | end 362 | 363 | context "when decorating class methods" do 364 | it "does not decorate undecorated class methods" do 365 | expect(Adornable::Decorators).not_to receive(:log) 366 | 367 | returned = Foobar.some_class_method_undecorated("foo", bar: "bar") 368 | expect(returned).to eq("we are in self.some_class_method_undecorated") 369 | end 370 | 371 | it "decorates decorated class methods" do 372 | decorator_called = false 373 | 374 | allow(Adornable::Decorators).to receive(:log) do |*args, &block| 375 | decorator_called = true 376 | context = args.first 377 | expect(context).to be_a(Adornable::Context) 378 | expect(context.method_receiver).to eq(Foobar) 379 | expect(context.method_name).to eq(:some_class_method_decorated) 380 | expect(context.method_arguments).to eq(["foo", { bar: "bar" }]) 381 | expect(context.method_positional_args).to eq(["foo"]) 382 | expect(context.method_kwargs).to eq({ bar: "bar" }) 383 | expect(context.decorator_name).to eq(:log) 384 | expect(context.decorator_options).to be_empty 385 | block.call 386 | end 387 | 388 | returned = Foobar.some_class_method_decorated("foo", bar: "bar") 389 | expect(returned).to eq("we are in self.some_class_method_decorated") 390 | expect(decorator_called).to be true 391 | end 392 | 393 | it "decorates multi-decorated class methods" do 394 | log_decorator_called = false 395 | 396 | allow(Adornable::Decorators).to receive(:log) do |*args, &block| 397 | log_decorator_called = true 398 | context = args.first 399 | expect(context).to be_a(Adornable::Context) 400 | expect(context.method_receiver).to eq(Foobar) 401 | expect(context.method_name).to eq(:some_class_method_multi_decorated) 402 | expect(context.method_arguments).to eq(["foo", { bar: "bar" }]) 403 | expect(context.method_positional_args).to eq(["foo"]) 404 | expect(context.method_kwargs).to eq({ bar: "bar" }) 405 | expect(context.decorator_name).to eq(:log) 406 | expect(context.decorator_options).to be_empty 407 | block.call 408 | end 409 | 410 | memo_decorator_called = false 411 | 412 | allow(Adornable::Decorators).to receive(:memoize) do |*args, &block| 413 | memo_decorator_called = true 414 | context = args.first 415 | expect(context).to be_a(Adornable::Context) 416 | expect(context.method_receiver).to eq(Foobar) 417 | expect(context.method_name).to eq(:some_class_method_multi_decorated) 418 | expect(context.method_arguments).to eq(["foo", { bar: "bar" }]) 419 | expect(context.method_positional_args).to eq(["foo"]) 420 | expect(context.method_kwargs).to eq({ bar: "bar" }) 421 | expect(context.decorator_name).to eq(:memoize) 422 | expect(context.decorator_options).to be_empty 423 | block.call 424 | end 425 | 426 | returned = Foobar.some_class_method_multi_decorated("foo", bar: "bar") 427 | expect(returned).to eq("we are in self.some_class_method_multi_decorated") 428 | expect(log_decorator_called).to be true 429 | expect(memo_decorator_called).to be true 430 | end 431 | end 432 | 433 | context "when decorating shadowed instance methods" do 434 | it "only decorates once if both have decorators" do 435 | foobar = Foobar.new 436 | 437 | decorator_called = false 438 | 439 | allow(Adornable::Decorators).to receive(:log) do |*args, &block| 440 | decorator_called = true 441 | context = args.first 442 | expect(context).to be_a(Adornable::Context) 443 | expect(context.method_receiver).to eq(foobar) 444 | expect(context.method_name).to eq(:shadowed_instance_method_both_have_decorator) 445 | expect(context.method_arguments).to eq(["foo", { bar: "bar" }]) 446 | expect(context.method_positional_args).to eq(["foo"]) 447 | expect(context.method_kwargs).to eq({ bar: "bar" }) 448 | expect(context.decorator_name).to eq(:log) 449 | expect(context.decorator_options).to be_empty 450 | block.call 451 | end 452 | 453 | returned = foobar.shadowed_instance_method_both_have_decorator("foo", bar: "bar") 454 | expect(returned).to eq("we are in shadowed_instance_method_both_have_decorator") 455 | expect(decorator_called).to be true 456 | end 457 | 458 | it "does not decorate if the shadow does not have decorators" do 459 | foobar = Foobar.new 460 | 461 | expect(Adornable::Decorators).not_to receive(:log) 462 | 463 | returned = foobar.shadowed_instance_method_decorator_removed("foo", bar: "bar") 464 | expect(returned).to eq("we are in shadowed_instance_method_decorator_removed") 465 | end 466 | 467 | it "decorates if the shadow has a decorator even if the original does not" do 468 | foobar = Foobar.new 469 | 470 | decorator_called = false 471 | 472 | allow(Adornable::Decorators).to receive(:log) do |*args, &block| 473 | decorator_called = true 474 | context = args.first 475 | expect(context).to be_a(Adornable::Context) 476 | expect(context.method_receiver).to eq(foobar) 477 | expect(context.method_name).to eq(:shadowed_instance_method_decorator_added) 478 | expect(context.method_arguments).to eq(["foo", { bar: "bar" }]) 479 | expect(context.method_positional_args).to eq(["foo"]) 480 | expect(context.method_kwargs).to eq({ bar: "bar" }) 481 | expect(context.decorator_name).to eq(:log) 482 | expect(context.decorator_options).to be_empty 483 | block.call 484 | end 485 | 486 | returned = foobar.shadowed_instance_method_decorator_added("foo", bar: "bar") 487 | expect(returned).to eq("we are in shadowed_instance_method_decorator_added") 488 | expect(decorator_called).to be true 489 | end 490 | end 491 | 492 | context "when decorating shadowed class methods" do 493 | it "only decorates once if both have decorators" do 494 | decorator_called = false 495 | 496 | allow(Adornable::Decorators).to receive(:log) do |*args, &block| 497 | decorator_called = true 498 | context = args.first 499 | expect(context).to be_a(Adornable::Context) 500 | expect(context.method_receiver).to eq(Foobar) 501 | expect(context.method_name).to eq(:shadowed_class_method_both_have_decorator) 502 | expect(context.method_arguments).to eq(["foo", { bar: "bar" }]) 503 | expect(context.method_positional_args).to eq(["foo"]) 504 | expect(context.method_kwargs).to eq({ bar: "bar" }) 505 | expect(context.decorator_name).to eq(:log) 506 | expect(context.decorator_options).to be_empty 507 | block.call 508 | end 509 | 510 | returned = Foobar.shadowed_class_method_both_have_decorator("foo", bar: "bar") 511 | expect(returned).to eq("we are in self.shadowed_class_method_both_have_decorator") 512 | expect(decorator_called).to be true 513 | end 514 | 515 | it "does not decorate if the shadow does not have decorators" do 516 | expect(Adornable::Decorators).not_to receive(:log) 517 | 518 | returned = Foobar.shadowed_class_method_decorator_removed("foo", bar: "bar") 519 | expect(returned).to eq("we are in self.shadowed_class_method_decorator_removed") 520 | end 521 | 522 | it "decorates if the shadow has a decorator even if the original does not" do 523 | decorator_called = false 524 | 525 | allow(Adornable::Decorators).to receive(:log) do |*args, &block| 526 | decorator_called = true 527 | context = args.first 528 | expect(context).to be_a(Adornable::Context) 529 | expect(context.method_receiver).to eq(Foobar) 530 | expect(context.method_name).to eq(:shadowed_class_method_decorator_added) 531 | expect(context.method_arguments).to eq(["foo", { bar: "bar" }]) 532 | expect(context.method_positional_args).to eq(["foo"]) 533 | expect(context.method_kwargs).to eq({ bar: "bar" }) 534 | expect(context.decorator_name).to eq(:log) 535 | expect(context.decorator_options).to be_empty 536 | block.call 537 | end 538 | 539 | returned = Foobar.shadowed_class_method_decorator_added("foo", bar: "bar") 540 | expect(returned).to eq("we are in self.shadowed_class_method_decorator_added") 541 | expect(decorator_called).to be true 542 | end 543 | end 544 | 545 | context "when using custom decorator methods explicitly" do 546 | it "decorates the instance method with a method found on the specified receiver" do 547 | foobar = Foobar.new 548 | returned = foobar.custom_explicit_decorated_instance_method("foo", bar: "bar") 549 | expect(returned).to eq("we are in custom_explicit_decorated_instance_method!") 550 | end 551 | 552 | it "decorates the class method with a method found on the specified receiver" do 553 | returned = Foobar.custom_explicit_decorated_class_method("foo", bar: "bar") 554 | expect(returned).to eq("we are in self.custom_explicit_decorated_class_method!") 555 | end 556 | end 557 | 558 | context "when using custom decorator methods implicitly" do 559 | it "decorates the instance method with a method found on the specified receiver" do 560 | foobar = Foobar.new 561 | returned = foobar.custom_implicit_decorated_instance_method("foo", bar: "bar") 562 | expect(returned).to eq("we are in custom_implicit_decorated_instance_method...") 563 | end 564 | 565 | it "decorates the class method with a method found on the specified receiver" do 566 | returned = Foobar.custom_implicit_decorated_class_method("foo", bar: "bar") 567 | expect(returned).to eq("we are in self.custom_implicit_decorated_class_method...") 568 | end 569 | 570 | it "chooses the last registered receiver in the case of duplicates for decorated instance methods" do 571 | foobar = Foobar.new 572 | returned = foobar.custom_implicit_overridden_decorated_instance_method("foo", bar: "bar") 573 | expect(returned).to eq("we are in custom_implicit_overridden_decorated_instance_method...WOO!") 574 | end 575 | 576 | it "decorates the class method with a method found on the specified receiver for decorated class methods" do 577 | returned = Foobar.custom_implicit_overridden_decorated_class_method("foo", bar: "bar") 578 | expect(returned).to eq("we are in self.custom_implicit_overridden_decorated_class_method...WOO!") 579 | end 580 | 581 | it "can decorate instance methods with local class methods" do 582 | foobar = Foobar.new 583 | returned = foobar.custom_implicit_local_decorated_instance_method("foo", bar: "bar") 584 | expect(returned).to eq("we are in custom_implicit_local_decorated_instance_method - now that's what I call a class method!") 585 | end 586 | 587 | it "can decorate class methods with local class methods" do 588 | returned = Foobar.custom_implicit_local_decorated_class_method("foo", bar: "bar") 589 | expect(returned).to eq("we are in self.custom_implicit_local_decorated_class_method - now that's what I call a class method!") 590 | end 591 | end 592 | 593 | context "when using built-in decorators" do 594 | describe "decorate :log" do 595 | context "when decorating instance methods" do 596 | it "logs the method with arguments to STDOUT" do 597 | foobar = Foobar.new 598 | normal_args = [123] 599 | keyword_args = { bar: { baz: [:hi, "there"] } } 600 | all_args = [*normal_args, keyword_args] 601 | expected_log = "Calling method `Foobar#logged_instance_method` with arguments `#{all_args.inspect}`\n" 602 | expect do 603 | foobar.logged_instance_method(*normal_args, **keyword_args) 604 | end.to output(expected_log).to_stdout 605 | end 606 | 607 | it "logs the method with no arguments to STDOUT" do 608 | foobar = Foobar.new 609 | expected_log = "Calling method `Foobar#logged_instance_method_no_args` with no arguments\n" 610 | expect { foobar.logged_instance_method_no_args }.to output(expected_log).to_stdout 611 | end 612 | end 613 | 614 | context "when decorating class methods" do 615 | it "logs the method with arguments to STDOUT" do 616 | normal_args = [123] 617 | keyword_args = { bar: { baz: [:hi, "there"] } } 618 | all_args = [*normal_args, keyword_args] 619 | expected_log = "Calling method `Foobar::logged_class_method` with arguments `#{all_args.inspect}`\n" 620 | expect do 621 | Foobar.logged_class_method(*normal_args, **keyword_args) 622 | end.to output(expected_log).to_stdout 623 | end 624 | 625 | it "logs the method with no arguments to STDOUT" do 626 | expected_log = "Calling method `Foobar::logged_class_method_no_args` with no arguments\n" 627 | expect { Foobar.logged_class_method_no_args }.to output(expected_log).to_stdout 628 | end 629 | end 630 | end 631 | 632 | describe "decorate :memoize, for_any_arguments: true" do 633 | context "when decorating instance methods" do 634 | it "returns the cached value after being called" do 635 | foobar = Foobar.new 636 | value1 = foobar.memoized_instance_method_for_any_args_as_option(123, bar: 456) 637 | value2 = foobar.memoized_instance_method_for_any_args_as_option(123, bar: 456) 638 | value3 = foobar.memoized_instance_method_for_any_args_as_option("whoa", bar: [1, 2, 3]) 639 | expect(value1).to eq(value2) 640 | expect(value2).to eq(value3) 641 | end 642 | end 643 | 644 | context "when decorating class methods" do 645 | it "returns the cached value after being called" do 646 | value1 = Foobar.memoized_class_method_for_any_args_as_option(123, bar: 456) 647 | value2 = Foobar.memoized_class_method_for_any_args_as_option(123, bar: 456) 648 | value3 = Foobar.memoized_class_method_for_any_args_as_option("whoa", bar: [1, 2, 3]) 649 | expect(value1).to eq(value2) 650 | expect(value2).to eq(value3) 651 | end 652 | end 653 | end 654 | 655 | describe "decorate :memoize, for_any_arguments: false (default)" do 656 | context "when decorating instance methods" do 657 | it "returns the cached value when given the same simple arguments" do 658 | foobar = Foobar.new 659 | value1 = foobar.memoized_instance_method_for_args_as_default_option(123, bar: 456) 660 | value2 = foobar.memoized_instance_method_for_args_as_default_option(123, bar: 456) 661 | expect(value1).to eq(value2) 662 | end 663 | 664 | it "returns the cached value when given the same complex arguments" do 665 | foobar = Foobar.new 666 | value1 = foobar.memoized_instance_method_for_args_as_default_option([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 667 | value2 = foobar.memoized_instance_method_for_args_as_default_option([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 668 | expect(value1).to eq(value2) 669 | end 670 | 671 | it "returns a new value when given different simple arguments" do 672 | foobar = Foobar.new 673 | 674 | value1 = foobar.memoized_instance_method_for_args_as_default_option(123, bar: 456) 675 | value2 = foobar.memoized_instance_method_for_args_as_default_option(123, bar: 456) 676 | expect(value1).to eq(value2) 677 | 678 | value1 = foobar.memoized_instance_method_for_args_as_default_option(123, bar: 456) 679 | value2 = foobar.memoized_instance_method_for_args_as_default_option(456, bar: 456) 680 | expect(value1).not_to eq(value2) 681 | 682 | value1 = foobar.memoized_instance_method_for_args_as_default_option(123, bar: 456) 683 | value2 = foobar.memoized_instance_method_for_args_as_default_option(123, bar: 123) 684 | expect(value1).not_to eq(value2) 685 | end 686 | 687 | it "returns a new value when given different complex arguments" do 688 | foobar = Foobar.new 689 | 690 | value1 = foobar.memoized_instance_method_for_args_as_default_option([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 691 | value2 = foobar.memoized_instance_method_for_args_as_default_option([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 692 | expect(value1).to eq(value2) 693 | 694 | value1 = foobar.memoized_instance_method_for_args_as_default_option([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 695 | value2 = foobar.memoized_instance_method_for_args_as_default_option("[1, 2, 3]", bar: { baz: true, bam: [:hi, "there"] }) 696 | expect(value1).not_to eq(value2) 697 | 698 | value1 = foobar.memoized_instance_method_for_args_as_default_option([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 699 | value2 = foobar.memoized_instance_method_for_args_as_default_option([1, 2, 3], bar: { baz: true, bam: %w[hi there] }) 700 | expect(value1).not_to eq(value2) 701 | end 702 | 703 | it "computes the value only once for args if the return value is nil" do 704 | foobar = Foobar.new 705 | counter = Struct.new(:value, :inspect).new(0, "struct") # rubocop:disable Lint/StructNewOverride 706 | 707 | foobar.memoized_instance_method_for_args_with_nil_return(counter) 708 | foobar.memoized_instance_method_for_args_with_nil_return(counter) 709 | expect(counter.value).to eq 1 710 | end 711 | 712 | it "computes the value only once for any args if the return value is nil" do 713 | foobar = Foobar.new 714 | counter = Struct.new(:value).new(0) 715 | 716 | foobar.memoized_instance_method_for_any_args_with_nil_return(counter) 717 | foobar.memoized_instance_method_for_any_args_with_nil_return(counter) 718 | expect(counter.value).to eq 1 719 | end 720 | end 721 | 722 | context "when decorating class methods" do 723 | it "returns the cached value when given the same simple arguments" do 724 | value1 = Foobar.memoized_class_method_for_args_as_default_option(123, bar: 456) 725 | value2 = Foobar.memoized_class_method_for_args_as_default_option(123, bar: 456) 726 | expect(value1).to eq(value2) 727 | end 728 | 729 | it "returns the cached value when given the same complex arguments" do 730 | value1 = Foobar.memoized_class_method_for_args_as_default_option([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 731 | value2 = Foobar.memoized_class_method_for_args_as_default_option([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 732 | expect(value1).to eq(value2) 733 | end 734 | 735 | it "returns a new value when given different simple arguments" do 736 | value1 = Foobar.memoized_class_method_for_args_as_default_option(123, bar: 456) 737 | value2 = Foobar.memoized_class_method_for_args_as_default_option(123, bar: 456) 738 | expect(value1).to eq(value2) 739 | 740 | value1 = Foobar.memoized_class_method_for_args_as_default_option(123, bar: 456) 741 | value2 = Foobar.memoized_class_method_for_args_as_default_option(456, bar: 456) 742 | expect(value1).not_to eq(value2) 743 | 744 | value1 = Foobar.memoized_class_method_for_args_as_default_option(123, bar: 456) 745 | value2 = Foobar.memoized_class_method_for_args_as_default_option(123, bar: 123) 746 | expect(value1).not_to eq(value2) 747 | end 748 | 749 | it "returns a new value when given different complex arguments" do 750 | value1 = Foobar.memoized_class_method_for_args_as_default_option([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 751 | value2 = Foobar.memoized_class_method_for_args_as_default_option([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 752 | expect(value1).to eq(value2) 753 | 754 | value1 = Foobar.memoized_class_method_for_args_as_default_option([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 755 | value2 = Foobar.memoized_class_method_for_args_as_default_option("[1, 2, 3]", bar: { baz: true, bam: [:hi, "there"] }) 756 | expect(value1).not_to eq(value2) 757 | 758 | value1 = Foobar.memoized_class_method_for_args_as_default_option([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 759 | value2 = Foobar.memoized_class_method_for_args_as_default_option([1, 2, 3], bar: { baz: true, bam: %w[hi there] }) 760 | expect(value1).not_to eq(value2) 761 | end 762 | end 763 | end 764 | 765 | describe "decorate :memoize_for_arguments" do 766 | context "when decorating instance methods" do 767 | it "returns the cached value when given the same simple arguments" do 768 | foobar = Foobar.new 769 | value1 = foobar.memoized_instance_method_for_args(123, bar: 456) 770 | value2 = foobar.memoized_instance_method_for_args(123, bar: 456) 771 | expect(value1).to eq(value2) 772 | end 773 | 774 | it "returns the cached value when given the same complex arguments" do 775 | foobar = Foobar.new 776 | value1 = foobar.memoized_instance_method_for_args([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 777 | value2 = foobar.memoized_instance_method_for_args([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 778 | expect(value1).to eq(value2) 779 | end 780 | 781 | it "returns a new value when given different simple arguments" do 782 | foobar = Foobar.new 783 | 784 | value1 = foobar.memoized_instance_method_for_args(123, bar: 456) 785 | value2 = foobar.memoized_instance_method_for_args(123, bar: 456) 786 | expect(value1).to eq(value2) 787 | 788 | value1 = foobar.memoized_instance_method_for_args(123, bar: 456) 789 | value2 = foobar.memoized_instance_method_for_args(456, bar: 456) 790 | expect(value1).not_to eq(value2) 791 | 792 | value1 = foobar.memoized_instance_method_for_args(123, bar: 456) 793 | value2 = foobar.memoized_instance_method_for_args(123, bar: 123) 794 | expect(value1).not_to eq(value2) 795 | end 796 | 797 | it "returns a new value when given different complex arguments" do 798 | foobar = Foobar.new 799 | 800 | value1 = foobar.memoized_instance_method_for_args([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 801 | value2 = foobar.memoized_instance_method_for_args([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 802 | expect(value1).to eq(value2) 803 | 804 | value1 = foobar.memoized_instance_method_for_args([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 805 | value2 = foobar.memoized_instance_method_for_args("[1, 2, 3]", bar: { baz: true, bam: [:hi, "there"] }) 806 | expect(value1).not_to eq(value2) 807 | 808 | value1 = foobar.memoized_instance_method_for_args([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 809 | value2 = foobar.memoized_instance_method_for_args([1, 2, 3], bar: { baz: true, bam: %w[hi there] }) 810 | expect(value1).not_to eq(value2) 811 | end 812 | end 813 | 814 | context "when decorating class methods" do 815 | it "returns the cached value when given the same simple arguments" do 816 | value1 = Foobar.memoized_class_method_for_args(123, bar: 456) 817 | value2 = Foobar.memoized_class_method_for_args(123, bar: 456) 818 | expect(value1).to eq(value2) 819 | end 820 | 821 | it "returns the cached value when given the same complex arguments" do 822 | value1 = Foobar.memoized_class_method_for_args([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 823 | value2 = Foobar.memoized_class_method_for_args([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 824 | expect(value1).to eq(value2) 825 | end 826 | 827 | it "returns a new value when given different simple arguments" do 828 | value1 = Foobar.memoized_class_method_for_args(123, bar: 456) 829 | value2 = Foobar.memoized_class_method_for_args(123, bar: 456) 830 | expect(value1).to eq(value2) 831 | 832 | value1 = Foobar.memoized_class_method_for_args(123, bar: 456) 833 | value2 = Foobar.memoized_class_method_for_args(456, bar: 456) 834 | expect(value1).not_to eq(value2) 835 | 836 | value1 = Foobar.memoized_class_method_for_args(123, bar: 456) 837 | value2 = Foobar.memoized_class_method_for_args(123, bar: 123) 838 | expect(value1).not_to eq(value2) 839 | end 840 | 841 | it "returns a new value when given different complex arguments" do 842 | value1 = Foobar.memoized_class_method_for_args([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 843 | value2 = Foobar.memoized_class_method_for_args([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 844 | expect(value1).to eq(value2) 845 | 846 | value1 = Foobar.memoized_class_method_for_args([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 847 | value2 = Foobar.memoized_class_method_for_args("[1, 2, 3]", bar: { baz: true, bam: [:hi, "there"] }) 848 | expect(value1).not_to eq(value2) 849 | 850 | value1 = Foobar.memoized_class_method_for_args([1, 2, 3], bar: { baz: true, bam: [:hi, "there"] }) 851 | value2 = Foobar.memoized_class_method_for_args([1, 2, 3], bar: { baz: true, bam: %w[hi there] }) 852 | expect(value1).not_to eq(value2) 853 | end 854 | end 855 | end 856 | end 857 | end 858 | -------------------------------------------------------------------------------- /spec/singleton_class_decorators_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Example from https://github.com/kjleitz/adornable/issues/9 4 | 5 | class SingletonClassDecorators 6 | def self.time(_context, stat:, &_block) 7 | start_time = Time.now.utc 8 | begin 9 | yield 10 | ensure 11 | end_time = Time.now.utc 12 | duration = (end_time - start_time) * 1000.0 13 | puts "#{stat} #{duration}ms" 14 | end 15 | end 16 | end 17 | 18 | class Dog 19 | class << self 20 | extend Adornable 21 | 22 | decorate :time, from: SingletonClassDecorators, stat: 'bark' 23 | def bark 24 | sleep 1.3 25 | end 26 | end 27 | end 28 | 29 | RSpec.describe SingletonClassDecorators do 30 | it "does not error when decorating singleton classes" do 31 | expect { Dog.bark }.not_to raise_error 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "adornable" 5 | require "pry" 6 | 7 | RSpec.configure do |config| 8 | # Enable flags like --only-failures and --next-failure 9 | config.example_status_persistence_file_path = ".rspec_status" 10 | 11 | # Disable RSpec exposing methods globally on `Module` and `main` 12 | config.disable_monkey_patching! 13 | 14 | config.expect_with :rspec do |c| 15 | c.syntax = :expect 16 | end 17 | end 18 | --------------------------------------------------------------------------------