├── .codeclimate.yml ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── .rubocop.yml ├── .simplecov ├── .standard.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── casting.gemspec ├── examples ├── activation.rb ├── casting_forwardable.rb ├── dci.rb ├── delegation_dci.rb ├── json-generator.rb └── roles_over_objects.rb ├── lib ├── casting.rb └── casting │ ├── client.rb │ ├── context.rb │ ├── delegation.rb │ ├── enum.rb │ ├── method_consolidator.rb │ ├── missing_method_client.rb │ ├── missing_method_client_class.rb │ ├── null.rb │ ├── super_delegate.rb │ └── version.rb └── test ├── casting_enum_test.rb ├── casting_test.rb ├── class_refinement_test.rb ├── client_test.rb ├── context_test.rb ├── delegation_test.rb ├── frozen_client_test.rb ├── method_consolidator_test.rb ├── missing_method_client_test.rb ├── module_cleanup_test.rb ├── null_module_test.rb ├── super_test.rb └── test_helper.rb /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | duplication: 4 | enabled: true 5 | config: 6 | languages: 7 | - ruby 8 | reek: 9 | enabled: true 10 | checks: 11 | Attribute/Attribute: 12 | enabled: false 13 | BooleanParameter: 14 | enabled: false 15 | ControlCouple/BooleanParameter: 16 | enabled: false 17 | ControlCouple/ControlParameter: 18 | enabled: false 19 | 20 | rubocop: 21 | enabled: true 22 | ratings: 23 | paths: 24 | - "**.rb" 25 | - "lib/**/*.rb" 26 | exclude_paths: 27 | - examples/* 28 | - test/* 29 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "10:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: ['**'] 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | ruby: [ '2.7', '3.0', '3.1', '3.2' ] 16 | name: Ruby ${{ matrix.ruby }} 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Ruby 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: ${{ matrix.ruby }} 23 | bundler-cache: true 24 | - name: Install dependencies 25 | run: bundle install --jobs 4 --retry 3 26 | - uses: amancevice/setup-code-climate@v1 27 | with: 28 | cc_test_reporter_id: ${{ secrets.CC_TEST_REPORTER_ID }} 29 | - run: cc-test-reporter before-build 30 | - name: Run tests 31 | run: bundle exec rake 32 | - run: cc-test-reporter after-build 33 | if: ${{ github.event_name != 'pull_request' }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | coverage.data 19 | coverage -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: standard 2 | 3 | inherit_gem: 4 | standard: config/base.yml 5 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saturnflyer/casting/d9c896a6091c255d8960d83e83d42bc997ae2fa8/.simplecov -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saturnflyer/casting/d9c896a6091c255d8960d83e83d42bc997ae2fa8/.standard.yml -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Unreleased 2 | 3 | ## 1.0.2 4 | 5 | - Allow use of Casting::Enum.enum 6 | - Remove unused local variable in enum. 7 | - Added CODE_OF_CONDUCT.md 8 | 9 | ## 1.0.1 10 | 11 | - Fix to properly include Enum files 12 | - 1.0.0 actually dropped 2.6 and below. 13 | - Fix changelog which had 0.7.3 notes that are actually 1.0 notes 14 | 15 | ## 1.0.0 16 | 17 | - Drop Ruby 2.5 and below 18 | - Add Casting::Enum to return enumerators which apply a set of behaviors 19 | - Remove Casting::PreparedDelegation class and move all features into Casting::Delegation 20 | 21 | ## 0.7.2 2016-07-28 22 | 23 | - Return defined __delegates__ or empty array, allowing frozen client objects. 24 | Previous implementation raised an error when accessing uninitialized collection 25 | of __delegates__ 26 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | jim@saturnflyer.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "codeclimate-test-reporter", require: nil 6 | gem "minitest" 7 | gem "rake" 8 | gem "simplecov" 9 | gem "standard" 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2022 Jim Gay 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Casting 2 | 3 | [![Code Climate](https://codeclimate.com/github/saturnflyer/casting.png)](https://codeclimate.com/github/saturnflyer/casting) 4 | [![Test Coverage](https://codeclimate.com/github/saturnflyer/casting/badges/coverage.svg)](https://codeclimate.com/github/saturnflyer/casting/coverage) 5 | [![Gem Version](https://badge.fury.io/rb/casting.png)](http://badge.fury.io/rb/casting) 6 | 7 | ## Add behavior to your objects without using extend 8 | Do it for the life of the object or only for the life of a block of code. 9 | 10 | Casting gives you real delegation that flattens your object structure compared to libraries 11 | like Delegate or Forwardable. With casting, you can implement your own decorators that 12 | will be so much simpler than using wrappers. 13 | 14 | Here's a quick example that you might try in a Rails project: 15 | 16 | ```ruby 17 | # implement a module that contains information for the request response 18 | # and apply it to an object in your system. 19 | def show 20 | @user = user.cast_as(UserRepresenter) 21 | end 22 | ``` 23 | 24 | To use proper delegation, your approach should preserve `self` as a reference 25 | to the original object receiving a method. When the object receiving the forwarded 26 | message has its own and separate notion of `self`, you're working with a wrapper (also called 27 | consultation) and not using delegation. 28 | 29 | The Ruby standard library includes a library called "delegate", but it is 30 | a consultation approach. With that "delegate", all messages are forwarded to 31 | another object, but the attendant object maintains its own identity. 32 | 33 | With Casting, your defined methods may reference `self` and during 34 | execution it will refer to the original client object. 35 | 36 | Casting was created while exploring ideas for [cleaning up ruby programs](http://clean-ruby.com). 37 | 38 | ## Usage 39 | 40 | To use Casting, you must first extend an object as the delegation client: 41 | 42 | ```ruby 43 | actor = Object.new 44 | actor.extend(Casting::Client) 45 | ``` 46 | 47 | Or you may include the module in a particular class: 48 | 49 | ```ruby 50 | class Actor 51 | include Casting::Client 52 | end 53 | actor = Actor.new 54 | ``` 55 | 56 | Your objects will have a few additional methods: `delegation`, `cast`, and if you do not *already* have it defined (from another library, for example): `delegate`. The `delegate` method is aliased to `cast`. 57 | 58 | Then you may delegate a method to an attendant object: 59 | 60 | ```ruby 61 | actor.delegate(:hello_world, other_actor) 62 | ``` 63 | 64 | Or you may create an object to manage the delegation of methods to an attendant object: 65 | 66 | ```ruby 67 | actor.delegation(:hello_world).to(other_actor).call 68 | ``` 69 | 70 | You may also delegate methods without an explicit attendant instance, but provide 71 | a module containing the behavior you need to use: 72 | 73 | ```ruby 74 | module GreetingModule 75 | def hello_world 76 | "hello world" 77 | end 78 | end 79 | 80 | actor.delegate(:hello_world, GreetingModule) 81 | # or 82 | actor.delegation(:hello_world).to(GreetingModule).call 83 | ``` 84 | 85 | Pass arguments to your delegated method: 86 | 87 | ```ruby 88 | actor.delegate(:verbose_method, another_actor, arg1, arg2) 89 | 90 | actor.delegation(:verbose_method).to(another_actor).with(arg1, arg2).call 91 | 92 | actor.delegation(:verbose_method).to(another_actor).call(arg1, arg2) 93 | ``` 94 | 95 | _That's great, but why do I need to do these extra steps? I just want to run the method._ 96 | 97 | Casting gives you the option to do what you want. You can run just a single method once, or alter your object to always delegate. Even better, you can alter your object to delegate temporarily... 98 | 99 | ### Temporary Behavior 100 | 101 | Casting also provides an option to temporarily apply behaviors to an object. 102 | 103 | Once your class or object is a `Casting::Client` you may send the `delegate_missing_methods` message to it and your object will use `method_missing` to delegate methods to a stored attendant. 104 | 105 | ```ruby 106 | class Actor 107 | include Casting::Client 108 | delegate_missing_methods 109 | end 110 | actor = Actor.new 111 | 112 | actor.hello_world #=> NoMethodError 113 | 114 | Casting.delegating(actor => GreetingModule) do 115 | actor.hello_world #=> output the value / perform the method 116 | end 117 | 118 | actor.hello_world #=> NoMethodError 119 | ``` 120 | 121 | The use of `method_missing` is opt-in. If you don't want that mucking up your method calls, just don't tell it to `delegate_missing_methods`. 122 | 123 | Before the block is run in `Casting.delegating`, a collection of delegate objects is set in the current Thread for the provided attendant. Then the block yields, and an `ensure` block cleans up the stored attendant. 124 | 125 | This allows you to nest your `delegating` blocks as well: 126 | 127 | ```ruby 128 | actor.hello_world #=> NoMethodError 129 | 130 | Casting.delegating(actor => GreetingModule) do 131 | actor.hello_world #=> output the value / perform the method 132 | 133 | Casting.delegating(actor => OtherModule) do 134 | actor.hello_world #=> still works! 135 | actor.other_method # values/operations from the OtherModule 136 | end 137 | 138 | actor.other_method #=> NoMethodError 139 | actor.hello_world #=> still works! 140 | end 141 | 142 | actor.hello_world #=> NoMethodError 143 | ``` 144 | 145 | Currently, by using `delegate_missing_methods` you forever mark that object or class to use `method_missing`. This may change in the future. 146 | 147 | ### Manual Delegate Management 148 | 149 | If you'd rather not wrap things in the `delegating` block, you can control the delegation yourself. 150 | For example, you can `cast_as` and `uncast` an object with a given module: 151 | 152 | ```ruby 153 | actor.cast_as(GreetingModule) 154 | actor.hello_world # all subsequent calls to this method run from the module 155 | actor.uncast # manually cleanup the delegate 156 | actor.hello_world # => NoMethodError 157 | ``` 158 | 159 | These methods are only defined on your `Casting::Client` object when you tell it to `delegate_missing_methods`. Because these require `method_missing`, they do not exist until you opt-in. 160 | 161 | ### Duck-typing with NullObject-like behavior 162 | 163 | Casting has a few modules built in to help with treating your objects like null objects. 164 | Take a look at the following example: 165 | 166 | ```ruby 167 | module SpecialStuff 168 | def special_link 169 | # some link code 170 | end 171 | end 172 | 173 | special_user.cast_as(SpecialStuff) 174 | special_user.special_link # outputs your link 175 | ``` 176 | 177 | If your app, for example, generates a list of info for a collection of users, how do you manage the objects which don't have the expected behavior? 178 | 179 | ```ruby 180 | [normal_user, other_user, special_user].each do |user| 181 | user.special_link #=> blows up for normal_user or other_user 182 | end 183 | ``` 184 | 185 | You can cast the other objects with `Casting::Null` or `Casting::Blank`: 186 | 187 | ```ruby 188 | normal_user.cast_as(Casting::Null) 189 | other_user.cast_as(Casting::Blank) 190 | special_user.cast_as(SpecialStuff) 191 | 192 | [normal_user, other_user, special_user].each do |user| 193 | user.special_link #=> normal_user yields nil, other_user yields "", and special_user yields the special_link 194 | end 195 | ``` 196 | 197 | ## I have a Rails app, how does this help me? 198 | 199 | Well, a common use for this behavior would be in using decorators. 200 | 201 | When using a wrapper, your forms can behave unexpectedly 202 | 203 | ```ruby 204 | class UsersController 205 | def edit 206 | @user = UserDecorator.new(User.find(params[:id])) 207 | end 208 | end 209 | 210 | <%= form_for(@user) do |f| %> #=>
211 | ``` 212 | 213 | Ruby allows you to hack this by defining the `class` method: 214 | 215 | ```ruby 216 | class UserDecorator 217 | def class 218 | User 219 | end 220 | end 221 | ``` 222 | 223 | That would solve the problem, and it works! But having an object report that 224 | its class is something other than what it actually is can be confusing 225 | when you're debugging. 226 | 227 | Instead, you could cast the object as a module and your form will generate properly: 228 | 229 | ```ruby 230 | class UsersController 231 | def edit 232 | @user = User.find(params[:id]).cast_as(UserDecorator) # as a module 233 | end 234 | end 235 | 236 | <%= form_for(@user) do |f| %> #=> 237 | ``` 238 | 239 | This keeps your code focused on the object you care about. 240 | 241 | Check out [Characterize](http://github.com/saturnflyer/characterize) for hooking into Rails automatically. 242 | 243 | ## Oh, my! Could this be used to add behavior like refinements? 244 | 245 | You can apply methods from a delegate to all instances of a class. 246 | 247 | ```ruby 248 | person.hello_world #=> NoMethodError 249 | 250 | Casting.delegating(Person => GreetingModule) do 251 | person.hello_world #=> output the value / perform the method 252 | end 253 | 254 | person.hello_world #=> NoMethodError 255 | ``` 256 | 257 | By default, the `delegate_missing_methods` method will set delegates on instances so you'll need to opt-in for this. 258 | 259 | ```ruby 260 | class Person 261 | include Casting::Client 262 | delegate_missing_methods :class 263 | end 264 | ``` 265 | 266 | _But what happens when you have method clashes or want a specific instance to behave differently?_ 267 | 268 | You can have your objects look to their instance delegates, their class delegates, or in a particular order: 269 | 270 | ```ruby 271 | class Person 272 | include Casting::Client 273 | # default delegation to instances 274 | delegate_missing_methods 275 | 276 | # delegate methods to those defined on the class 277 | delegate_missing_methods :class 278 | 279 | # delegate methods to those defined on the class, then those defined on the instance 280 | delegate_missing_methods :class, :instance 281 | 282 | # delegate methods to those defined on the instance, then those defined on the class 283 | delegate_missing_methods :instance, :class 284 | end 285 | ``` 286 | 287 | ## What's happening when I use this? 288 | 289 | Ruby allows you to access methods as objects and pass them around just like any other object. 290 | 291 | For example, if you want a method from a class you may do this: 292 | 293 | ```ruby 294 | class Person 295 | def hello 296 | "hello" 297 | end 298 | end 299 | Person.new.method(:hello).unbind #=> # 300 | # or 301 | Person.instance_method(:hello) #=> # 302 | ``` 303 | 304 | But if you attempt to use that `UnboundMethod` on an object that is not a `Person` you'll get 305 | an error about a type mismatch. 306 | 307 | Casting will bind an UnboundMethod method to a client object and execute the method as though it is 308 | defined on the client object. Any reference to `self` from the method block will refer to the 309 | client object. 310 | 311 | Rather than define methods on classes, you may take any method from a module and apply it to any object regardless of its class. 312 | 313 | ```ruby 314 | GreetingModule.instance_method(:hello).bind(actor).call 315 | ``` 316 | 317 | Casting provides a convenience for doing this. 318 | 319 | ## What if my modules create instance variables on the object? Can I clean them up? 320 | 321 | Yup. 322 | 323 | If you need to set some variables so that your module can access them, it's as easy as defining `cast_object` and `uncast_object` on your module. Here's an example: 324 | 325 | ```ruby 326 | module Special 327 | def self.cast_object(obj) 328 | obj.instance_variable_set(:@special_value, 'this is special!') 329 | end 330 | 331 | def self.uncast_object(obj) 332 | obj.remove_instance_variable(:@special_value) 333 | end 334 | 335 | def special_behavior 336 | "#{self.name} thinks... #{@special_value}" 337 | end 338 | end 339 | 340 | object.cast_as(Special) 341 | object.special_method 342 | object.uncast 343 | # object no longer has the @special_value instance variable 344 | ``` 345 | 346 | You'll be able to leave your objects as if they were never touched by the module where you defined your behavior. 347 | 348 | ## It doesn't work! 349 | 350 | You might be trying to override existing methods. Casting can help you apply behavior to an object using `delegate_missing_methods` but that depends on the methods being missing. In other words, if you have an `as_json` method that you want to change with a module, you won't be able to just `cast_as(MyJsonModule)` and have the `as_json` method from it be picked up because that will never hit `method_missing`. 351 | 352 | If you want to override an existing method, you must do so explicitly. 353 | 354 | This will _not_ work: 355 | 356 | ```ruby 357 | module MyJsonModule 358 | def as_json 359 | super.merge({ extra: 'details' }) 360 | end 361 | end 362 | some_object.cast_as(MyJsonModule) 363 | some_object.as_json 364 | ``` 365 | 366 | Instead, you'll need to explicitly override existing methods: 367 | 368 | ```ruby 369 | some_object.cast(:as_json, MyJsonModule) 370 | ``` 371 | 372 | ## How can I speed it up? 373 | 374 | Are you looping over lots of objects and want see better performance? 375 | 376 | If you want to make things a bit faster, you can prepare the method delegation ahead of time and change the client object. 377 | 378 | ```ruby 379 | prepared_delegation = some_object.delegation(:some_delegated_method).to(MySpecialModule) 380 | # Some looping code 381 | big_list_of_objects.each do |object| 382 | prepared_delegation.client = object 383 | prepared_delegation.call 384 | end 385 | ``` 386 | 387 | Preparing the delegated method like this will probably speed things up for you but be sure to verify for yourself. 388 | 389 | ## Installation 390 | 391 | If you are using Bundler, add this line to your application's Gemfile: 392 | 393 | ```ruby 394 | gem 'casting' 395 | ``` 396 | 397 | And then execute: 398 | 399 | $ bundle 400 | 401 | Or install it yourself as: 402 | 403 | $ gem install casting 404 | 405 | ## Contributing 406 | 407 | 1. Fork it 408 | 2. Create your feature branch (`git checkout -b my-new-feature`) 409 | 3. Commit your changes (`git commit -am 'Add some feature'`) 410 | 4. Push to the branch (`git push origin my-new-feature`) 411 | 5. Create new Pull Request 412 | 413 | Built by Jim Gay at [Saturn Flyer](http://www.saturnflyer.com) 414 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require "bundler/gem_tasks" 3 | require "rake/testtask" 4 | 5 | Rake::TestTask.new do |t| 6 | t.libs << "test" 7 | t.test_files = FileList["test/*_test.rb"] 8 | t.ruby_opts = ["-w"] 9 | t.verbose = true 10 | end 11 | 12 | task default: :test 13 | -------------------------------------------------------------------------------- /casting.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path("../lib/casting/version", __FILE__) 2 | 3 | Gem::Specification.new do |gem| 4 | gem.authors = ["Jim Gay"] 5 | gem.email = ["jim@saturnflyer.com"] 6 | gem.description = "Casting assists in method delegation which preserves the binding of 'self' to the object receiving a message. 7 | 8 | This allows you to define behavior and apply it to an object temporarily without extending the object's super class structure." 9 | gem.summary = "Proper method delegation." 10 | gem.homepage = "http://github.com/saturnflyer/casting" 11 | 12 | gem.files = ["lib/casting.rb", 13 | "lib/casting/client.rb", 14 | "lib/casting/context.rb", 15 | "lib/casting/delegation.rb", 16 | "lib/casting/enum.rb", 17 | "lib/casting/method_consolidator.rb", 18 | "lib/casting/missing_method_client.rb", 19 | "lib/casting/missing_method_client_class.rb", 20 | "lib/casting/null.rb", 21 | "lib/casting/super_delegate.rb", 22 | "lib/casting/version.rb", 23 | "LICENSE", 24 | "Rakefile", 25 | "README.md"] 26 | gem.name = "casting" 27 | gem.version = Casting::VERSION 28 | gem.license = "MIT" 29 | gem.required_ruby_version = ">= 2.7" 30 | end 31 | -------------------------------------------------------------------------------- /examples/activation.rb: -------------------------------------------------------------------------------- 1 | require "casting" 2 | 3 | class Activation 4 | def initialize(user, time = Time.now) 5 | @user = user.cast_as(Activator) 6 | @time = time 7 | end 8 | attr_reader :user, :time 9 | 10 | def create 11 | user.create_activation(time) 12 | end 13 | 14 | def validate 15 | user.activated?(time) 16 | end 17 | 18 | module Activator 19 | def activated?(now = Time.now) 20 | activation = Activation.find_by_user_id(id) || NullActivation.instance 21 | activation.created_at < now 22 | end 23 | 24 | def create_activation(now = Time.now) 25 | Activation.create!(self, now) 26 | rescue Activation::Invalid 27 | go_to # ... 28 | end 29 | end 30 | end 31 | 32 | activation = Activation.new(user) 33 | activation.create 34 | activation = Activation.new(user, last_wednesday) 35 | activation.validate? 36 | -------------------------------------------------------------------------------- /examples/casting_forwardable.rb: -------------------------------------------------------------------------------- 1 | # Provide a feature like the Forwardable library, 2 | # but this leaves the methods always present. 3 | # It provides a class level "delegate" method to do 4 | # delegation, but adds a "forward" method to do non-delegation 5 | # method forwarding. 6 | # 7 | # class SomeClass 8 | # include Casting::Client 9 | # extend CastingForwardable 10 | # 11 | # delegate [:name, :id] => :collaborator, [:color, :type] => :partner 12 | # forward :description => :describer, [:settings, :url] => :config 13 | # end 14 | # 15 | # This will define methods on instances that delegate to the 16 | # result of another method. 17 | # 18 | # For example, the above :collaborator reference could return a module 19 | # 20 | # module SomeModule 21 | # def name 22 | # "<~#{self.object_id}~>" 23 | # end 24 | # end 25 | # 26 | # class SomeClass 27 | # def collaborator 28 | # SomeModule 29 | # end 30 | # end 31 | # 32 | # Or it could return an object 33 | # 34 | # class SomeClass 35 | # attr_accessor :collaborator 36 | # end 37 | # 38 | # thing = SomeClass.new 39 | # thing.collaborator = SomeModule # or some other object 40 | # thing.name 41 | # 42 | module CastingForwardable 43 | def delegate(options) 44 | options.each_pair do |key, value| 45 | Array(key).each do |prepared_method| 46 | define_method prepared_method do 47 | delegate(prepared_method, __send__(value)) 48 | end 49 | end 50 | end 51 | end 52 | 53 | def forward(options) 54 | options.each_pair do |key, value| 55 | Array(key).each do |prepared_method| 56 | define_method prepared_method do 57 | __send__(value).__send__(key) 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /examples/dci.rb: -------------------------------------------------------------------------------- 1 | # Execute this script with: 2 | # ruby -I lib examples/dci.rb 3 | 4 | require "casting" 5 | 6 | def log(message) 7 | puts("Transaction log: " + message) 8 | end 9 | 10 | # What it is 11 | Account = Struct.new(:name, :balance) 12 | class Account 13 | include Casting::Client 14 | delegate_missing_methods 15 | 16 | alias_method :to_s, :name 17 | end 18 | 19 | checking = Account.new(":checking:", 500) 20 | savings = Account.new("~savings~", 2) 21 | 22 | # What it does 23 | Transfer = Struct.new(:amount, :source, :destination) 24 | class Transfer 25 | def execute 26 | Casting.delegating(source => Source) do 27 | source.transfer(amount, destination) 28 | end 29 | end 30 | 31 | module Source 32 | def transfer(amount, destination) 33 | log("#{self} transferring #{amount} to #{destination}") 34 | 35 | Funding.new(self, -amount).enter && 36 | Funding.new(destination, amount).enter 37 | 38 | log("#{self} successfully transferred #{amount} to #{destination}") 39 | rescue Funding::InsufficientFunds 40 | log("#{self} unable to transfer #{amount} to #{destination}") 41 | end 42 | end 43 | end 44 | 45 | Funding = Struct.new(:account, :amount) 46 | class Funding 47 | def enter 48 | Casting.delegating(account => Sink) do 49 | account.add_funds(amount) 50 | end 51 | end 52 | 53 | class InsufficientFunds < StandardError; end 54 | 55 | module Sink 56 | def add_funds(amount) 57 | log_total 58 | log("Adding #{amount} to #{self}") 59 | 60 | self.balance += amount 61 | 62 | if self.balance > 0 63 | log("Funded #{self} with #{amount}") 64 | else 65 | self.balance -= amount 66 | log("#{self} has insufficient funds for #{amount}") 67 | raise Funding::InsufficientFunds.new 68 | end 69 | ensure 70 | log_total 71 | end 72 | 73 | def log_total 74 | log("#{self} ==total== is #{balance}") 75 | end 76 | end 77 | end 78 | 79 | puts "Transferring..." 80 | Transfer.new(30, checking, savings).execute 81 | Transfer.new(50, savings, checking).execute 82 | -------------------------------------------------------------------------------- /examples/delegation_dci.rb: -------------------------------------------------------------------------------- 1 | # Execute this script with: 2 | # ruby -I lib examples/delegation_dci.rb 3 | 4 | require "casting" 5 | require "casting/context" 6 | 7 | def log(message) 8 | puts("Transaction log: " + message.to_s) 9 | end 10 | 11 | # What it is 12 | Account = Data.define(:name, :amounts) 13 | class Account 14 | include Casting::Client 15 | 16 | def balance 17 | amounts.sum 18 | end 19 | 20 | alias_method :to_s, :name 21 | end 22 | 23 | checking = Account.new(":checking:", [500]) 24 | savings = Account.new("~savings~", [2]) 25 | 26 | # What it does 27 | class Transfer 28 | extend Casting::Context 29 | using Casting::Context 30 | 31 | initialize :amount, :source, :destination 32 | 33 | def execute 34 | log("#{source} has #{source.balance}") 35 | log("#{destination} has #{destination.balance}") 36 | result = catch(:result) do 37 | tell(:destination, :increase_balance) 38 | end 39 | log(result) 40 | end 41 | 42 | attr_writer :source, :destination 43 | 44 | module Destination 45 | def increase_balance 46 | tell(:source, :decrease_balance) 47 | log("#{self} accepting #{role(:amount)} from #{role(:source)}") 48 | context.destination = with(amounts: amounts << role(:amount)) 49 | end 50 | end 51 | 52 | module Source 53 | def decrease_balance 54 | log("#{self} releasing #{role(:amount)} to #{role(:destination)}") 55 | tell :source, :check_balance 56 | 57 | context.source = with(amounts: self.amounts << -role(:amount)).tap do |obj| 58 | log("#{self} released #{role(:amount)}. balance is now #{balance}") 59 | end 60 | end 61 | 62 | def check_balance 63 | if balance < role(:amount) 64 | throw(:result, "#{self} has insufficient funds for withdrawal of #{role(:amount)}. Current balance is #{balance}") 65 | end 66 | end 67 | end 68 | end 69 | 70 | puts "Transferring..." 71 | Transfer.new(amount: 30, source: checking, destination: savings).execute 72 | Transfer.new(amount: 50, source: savings, destination: checking).execute 73 | -------------------------------------------------------------------------------- /examples/json-generator.rb: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "casting" 3 | 4 | module Names 5 | def names 6 | "names here" 7 | end 8 | end 9 | 10 | module Secrets 11 | def secrets 12 | "secrets here" 13 | end 14 | end 15 | 16 | module Redacted 17 | def secrets 18 | "***********" 19 | end 20 | end 21 | 22 | class Jsoner 23 | include Casting::Client 24 | delegate_missing_methods 25 | 26 | def as_json(*attributes) 27 | {}.tap do |hash| 28 | attributes.each do |att| 29 | hash[att] = send(att) 30 | end 31 | end 32 | end 33 | end 34 | 35 | empty = Jsoner.new 36 | empty.cast_as(Casting::Null) 37 | puts empty.as_json(:names, :secrets) 38 | 39 | secret = Jsoner.new 40 | secret.cast_as(Casting::Null, Secrets) 41 | puts secret.as_json(:names, :secrets) 42 | 43 | everything = Jsoner.new 44 | everything.cast_as(Casting::Null, Secrets, Names) 45 | puts everything.as_json(:names, :secrets) 46 | 47 | names = Jsoner.new 48 | names.cast_as(Casting::Null, Names) 49 | puts names.as_json(:names, :secrets) 50 | 51 | redacted = Jsoner.new 52 | redacted.cast_as(Casting::Null, Secrets, Redacted) 53 | puts redacted.as_json(:names, :secrets) 54 | 55 | # {:names=>nil, :secrets=>nil} 56 | # {:names=>nil, :secrets=>"secrets here"} 57 | # {:names=>"names here", :secrets=>"secrets here"} 58 | # {:names=>"names here", :secrets=>nil} 59 | # {:names=>nil, :secrets=>"***********"} 60 | -------------------------------------------------------------------------------- /examples/roles_over_objects.rb: -------------------------------------------------------------------------------- 1 | # This code is related to subscription services. 2 | # Instead of making a Subscriber a separate object 3 | # with a reference to a User, make the User play a role. 4 | 5 | class User 6 | include Casting::Client 7 | delegate_missing_methods 8 | end 9 | 10 | module Subscriber 11 | def subscribe 12 | @subscription = Subscription.find_by_subscriber(self) || Subscription.create_with_subscriber(self) 13 | self 14 | end 15 | 16 | def subscription 17 | @subscription or raise NoSubscriptionError 18 | end 19 | 20 | class NoSubscriptionError < StandardError; end 21 | end 22 | 23 | user = User.new # 24 | user.subscription # NoMethodError 25 | user.subscribe # NoMethodError 26 | user.cast_as(Subscriber) 27 | user.subscription # NoSubscriptionError 28 | user.subscribe # > 29 | user.subscription # 30 | user.uncast 31 | user.subscribe # NoMethodError 32 | -------------------------------------------------------------------------------- /lib/casting.rb: -------------------------------------------------------------------------------- 1 | require "casting/version" 2 | require "casting/client" 3 | require "casting/enum" 4 | require "casting/super_delegate" 5 | require "casting/null" 6 | require "casting/context" 7 | 8 | module Casting 9 | class InvalidClientError < StandardError; end 10 | 11 | def self.delegating(assignments) 12 | assignments.each do |object, mod| 13 | cast_object(object, mod) 14 | end 15 | yield 16 | ensure 17 | assignments.each do |object, mod| 18 | uncast_object(object) 19 | end 20 | end 21 | 22 | def self.cast_object(object, mod) 23 | raise InvalidClientError.new unless object.respond_to?(:cast_as) 24 | 25 | object.cast_as(mod) 26 | end 27 | 28 | def self.uncast_object(object) 29 | return unless object.respond_to?(:uncast) 30 | 31 | object.uncast 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/casting/client.rb: -------------------------------------------------------------------------------- 1 | require "casting/delegation" 2 | require "casting/missing_method_client" 3 | require "casting/missing_method_client_class" 4 | 5 | module Casting 6 | module Client 7 | def self.included(base) 8 | def base.delegate_missing_methods(*which) 9 | Casting::Client.set_delegation_strategy(self, *which.reverse) 10 | end 11 | 12 | unless base.method_defined?(:delegate) 13 | add_delegate_method_to(base) 14 | end 15 | end 16 | 17 | def self.extended(base) 18 | unless base.respond_to?(:delegate) 19 | add_delegate_method_to(base.singleton_class) 20 | end 21 | end 22 | 23 | def delegation(delegated_method_name) 24 | Casting::Delegation.prepare(delegated_method_name, self) 25 | end 26 | 27 | def cast(delegated_method_name, attendant, ...) 28 | validate_attendant(attendant) 29 | delegation(delegated_method_name).to(attendant).call(...) 30 | end 31 | 32 | def delegate_missing_methods(*which) 33 | Casting::Client.set_delegation_strategy(singleton_class, *which.reverse) 34 | end 35 | 36 | private 37 | 38 | def validate_attendant(attendant) 39 | if attendant == self 40 | raise Casting::InvalidAttendant.new("client can not delegate to itself") 41 | end 42 | end 43 | 44 | def self.set_delegation_strategy(base, *which) 45 | which = [:instance] if which.empty? 46 | which.map! { |selection| 47 | selection == :instance && selection = method(:set_method_missing_client) 48 | selection == :class && selection = method(:set_method_missing_client_class) 49 | selection 50 | }.map { |meth| meth.call(base) } 51 | end 52 | 53 | def self.add_delegate_method_to(base) 54 | base.class_eval { alias_method :delegate, :cast } 55 | end 56 | 57 | def self.set_method_missing_client(base) 58 | base.send(:include, ::Casting::MissingMethodClient) 59 | end 60 | 61 | def self.set_method_missing_client_class(base) 62 | base.send(:extend, ::Casting::MissingMethodClientClass) 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/casting/context.rb: -------------------------------------------------------------------------------- 1 | # This is an experimental implementation of allowing contextual use of behaviors. 2 | # 3 | # This relies on versions of Ruby supporting refinements. 4 | # 5 | # You *must* include the following module and use it for refinements, and 6 | # you *must* set the current context for the thread: 7 | # 8 | # class SomeContext 9 | # using Casting::Context 10 | # extend Casting::Context 11 | # 12 | # initialize(:some, :thing) 13 | # # doing that defines your constructor but would cause it too look for 14 | # # modules named Some and Thing 15 | # module Some; end 16 | # module Thing; end 17 | # 18 | # # if you want different module names (why would you?) then you'd need 19 | # # to do all this: 20 | # def initialize(some, thing) 21 | # assign [some, SomeRole], [thing, OtherRole] 22 | # Thread.current[:context] = self 23 | # end 24 | # attr_reader :some, :thing, :assignments 25 | # 26 | # module SomeRole; end 27 | # module OtherRole; end 28 | # end 29 | # 30 | module Casting 31 | module Context 32 | def self.extended(base) 33 | base.send(:include, InstanceMethods) 34 | end 35 | 36 | def initialize(*setup_args, &block) 37 | attr_reader(*setup_args) 38 | private(*setup_args) 39 | 40 | define_method(:__custom_initialize, &(block || proc {})) 41 | 42 | mod = Module.new 43 | mod.class_eval <<~INIT, __FILE__, __LINE__ + 1 44 | def initialize(#{setup_args.map { |name| "#{name}:" }.join(",")}) 45 | @assignments = [] 46 | #{setup_args.map do |name| 47 | ["assign(", name, ", '", name, "')"].join 48 | end.join("\n")} 49 | __custom_initialize 50 | Thread.current[:context] = self 51 | end 52 | attr_reader :assignments 53 | INIT 54 | const_set(:Initializer, mod) 55 | include mod 56 | end 57 | 58 | module InstanceMethods 59 | def context 60 | self 61 | end 62 | 63 | def assignments 64 | @assignments ||= [] 65 | end 66 | 67 | # Keep track of objects and their behaviors 68 | def assign(object, role_name) 69 | instance_variable_set("@#{role_name}", object) 70 | assignments << [object, role_for(role_name)] 71 | end 72 | 73 | def contains?(obj) 74 | assignments.map(&:first).include?(obj) 75 | end 76 | 77 | # Execute the behavior from the role on the specifed object 78 | def dispatch(object, method_name, ...) 79 | if object.respond_to?(:cast) 80 | object.cast(method_name, context.role_implementing(object, method_name), ...) 81 | else 82 | Casting::Delegation.prepare(method_name, object).to(role_implementing(object, method_name)).with(...).call 83 | end 84 | end 85 | 86 | # Find the first assigned role which implements a response for the given method name 87 | def role_implementing(object, method_name) 88 | assigned_roles(object).find { |role| role.method_defined?(method_name) } || raise(NoMethodError, "unknown method '#{method_name}' expected for #{object}") 89 | end 90 | 91 | # Get the roles for the given object 92 | def assigned_roles(object) 93 | assignments.select { |pair| 94 | pair.first == object 95 | }.map(&:last) 96 | end 97 | 98 | # Get the behavior module for the named role. 99 | # This role constant for special_person is SpecialPerson. 100 | def role_for(name) 101 | role_name = name.to_s.gsub(/(?:^|_)([a-z])/) { $1.upcase } 102 | self.class.const_get(role_name) 103 | rescue NameError 104 | Module.new 105 | end 106 | end 107 | 108 | refine Object do 109 | def context 110 | Thread.current[:context] 111 | end 112 | 113 | def context=(obj) 114 | Thread.current[:context] = obj 115 | end 116 | 117 | # Get the object playing a particular role 118 | def role(role_name) 119 | context.send(role_name) 120 | end 121 | 122 | # Execute the named method on the object plaing the name role 123 | def tell(role_name, method_name, ...) 124 | if context == self || context.contains?(self) 125 | context.dispatch(role(role_name), method_name, ...) 126 | end 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/casting/delegation.rb: -------------------------------------------------------------------------------- 1 | module Casting 2 | class MissingAttendant < StandardError 3 | def message 4 | "You must set your attendant object using `to'." 5 | end 6 | end 7 | 8 | class InvalidAttendant < StandardError; end 9 | 10 | class Delegation 11 | def self.prepare(delegated_method_name, client, &block) 12 | new(delegated_method_name: delegated_method_name, client: client, &block) 13 | end 14 | 15 | attr_accessor :client, :delegated_method_name, :attendant, :arguments, :block 16 | private :block 17 | 18 | def initialize(**settings, &block) 19 | @delegated_method_name = settings[:delegated_method_name] 20 | @client = settings[:client] 21 | @attendant = settings[:attendant] 22 | @arguments = settings[:arguments] 23 | @keyword_arguments = settings[:keyword_arguments] 24 | @block = block 25 | end 26 | 27 | def to(object_or_module) 28 | @attendant = object_or_module 29 | begin 30 | bound_method 31 | rescue TypeError 32 | @attendant = method_module || raise 33 | end 34 | self 35 | end 36 | 37 | def with(*args, **kwargs, &block) 38 | @arguments = args 39 | @keyword_arguments = kwargs 40 | @block = block 41 | self 42 | end 43 | 44 | def call(*args, **kwargs, &block) 45 | raise MissingAttendant.new unless attendant 46 | 47 | call_args = positional_arguments(args) 48 | call_kwargs = keyword_arguments(kwargs) 49 | call_block = block_argument(&block) 50 | 51 | case 52 | when call_args && call_kwargs 53 | bound_method.call(*call_args, **call_kwargs, &call_block) 54 | when call_args 55 | bound_method.call(*call_args, &call_block) 56 | when call_kwargs 57 | bound_method.call(**call_kwargs, &call_block) 58 | else 59 | bound_method.call(&call_block) 60 | end 61 | end 62 | 63 | private 64 | 65 | def block_argument(&block) 66 | block || @block 67 | end 68 | 69 | def positional_arguments(options) 70 | return options unless options.empty? 71 | @arguments 72 | end 73 | 74 | def keyword_arguments(options) 75 | return options unless options.empty? 76 | @keyword_arguments 77 | end 78 | 79 | def bound_method 80 | delegated_method.bind(client) 81 | rescue TypeError 82 | raise TypeError.new("`to' argument must be a module or an object with #{delegated_method_name} defined in a module") 83 | end 84 | 85 | def method_module 86 | mod = delegated_method.owner 87 | unless mod.is_a?(Class) 88 | mod 89 | end 90 | end 91 | 92 | def delegated_method 93 | if Module === attendant 94 | attendant 95 | else 96 | attendant.method(delegated_method_name).owner 97 | end.instance_method(delegated_method_name) 98 | rescue NameError => e 99 | raise InvalidAttendant, e.message 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/casting/enum.rb: -------------------------------------------------------------------------------- 1 | module Casting 2 | module Enum 3 | extend self 4 | 5 | def enum(collection, *behaviors) 6 | Enumerator.new do |yielder| 7 | collection.each do |item| 8 | yielder.yield(item.cast_as(*behaviors)) 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/casting/method_consolidator.rb: -------------------------------------------------------------------------------- 1 | module Casting 2 | module MethodConsolidator 3 | def methods(all = true) 4 | (super + delegated_methods(all)).uniq 5 | end 6 | 7 | def public_methods(include_super = true) 8 | (super + delegated_public_methods(include_super)).uniq 9 | end 10 | 11 | def protected_methods(include_super = true) 12 | (super + delegated_protected_methods(include_super)).uniq 13 | end 14 | 15 | def private_methods(include_super = true) 16 | (super + delegated_private_methods(include_super)).uniq 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/casting/missing_method_client.rb: -------------------------------------------------------------------------------- 1 | require "casting/method_consolidator" 2 | 3 | module Casting 4 | module MissingMethodClient 5 | def cast_as(*attendants) 6 | attendants.each do |attendant| 7 | validate_attendant(attendant) 8 | attendant.cast_object(self) if attendant.respond_to?(:cast_object) 9 | __delegates__.unshift(attendant) 10 | end 11 | self 12 | end 13 | 14 | def uncast(count = 1) 15 | count.times do 16 | attendant = __delegates__.shift 17 | attendant.uncast_object(self) if attendant.respond_to?(:uncast_object) 18 | end 19 | self 20 | end 21 | 22 | def delegated_methods(all = true) 23 | __delegates__.flat_map { |attendant| 24 | attendant_methods(attendant, all) 25 | } 26 | end 27 | 28 | def delegated_public_methods(include_super = true) 29 | __delegates__.flat_map { |attendant| 30 | attendant_public_methods(attendant, include_super) 31 | } 32 | end 33 | 34 | def delegated_protected_methods(include_super = true) 35 | __delegates__.flat_map { |attendant| 36 | attendant_protected_methods(attendant, include_super) 37 | } 38 | end 39 | 40 | def delegated_private_methods(include_super = true) 41 | __delegates__.flat_map { |attendant| 42 | attendant_private_methods(attendant, include_super) 43 | } 44 | end 45 | 46 | private 47 | 48 | def __delegates__ 49 | Thread.current[:instance_delegates] ||= {} 50 | Thread.current[:instance_delegates][object_id] ||= [] 51 | Thread.current[:instance_delegates][object_id] 52 | end 53 | 54 | def method_missing(meth, ...) 55 | attendant = method_delegate(meth) 56 | if !!attendant 57 | cast(meth, attendant, ...) 58 | else 59 | super 60 | end 61 | end 62 | 63 | def respond_to_missing?(meth, *) 64 | !!method_delegate(meth) || super 65 | end 66 | 67 | def method_delegate(meth) 68 | __delegates__.find { |attendant| 69 | attendant.respond_to?(:method_defined?) && attendant.method_defined?(meth) || 70 | attendant_methods(attendant).include?(meth) 71 | } 72 | end 73 | 74 | def attendant_methods(attendant, all = true) 75 | collection = attendant_public_methods(attendant) + attendant_protected_methods(attendant) 76 | collection += attendant_private_methods(attendant) if all 77 | collection 78 | end 79 | 80 | def attendant_public_methods(attendant, include_super = true) 81 | if Module === attendant 82 | attendant.public_instance_methods(include_super) 83 | else 84 | attendant.public_methods(include_super) 85 | end 86 | end 87 | 88 | def attendant_protected_methods(attendant, include_super = true) 89 | if Module === attendant 90 | attendant.protected_instance_methods(include_super) 91 | else 92 | attendant.protected_methods(include_super) 93 | end 94 | end 95 | 96 | def attendant_private_methods(attendant, include_super = true) 97 | if Module === attendant 98 | attendant.private_instance_methods(include_super) 99 | else 100 | attendant.private_methods(include_super) 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/casting/missing_method_client_class.rb: -------------------------------------------------------------------------------- 1 | module Casting 2 | module MissingMethodClientClass 3 | def self.extended(base) 4 | base.send(:include, InstanceMethods) 5 | end 6 | 7 | module InstanceMethods 8 | private 9 | 10 | def __class_delegates__ 11 | self.class.__delegates__ 12 | end 13 | 14 | def method_missing(meth, *args, &block) 15 | attendant = method_class_delegate(meth) 16 | if !!attendant 17 | cast(meth, attendant, *args, &block) 18 | else 19 | super 20 | end 21 | end 22 | 23 | def respond_to_missing?(meth, *) 24 | !!method_class_delegate(meth) || super 25 | end 26 | 27 | def method_class_delegate(meth) 28 | __class_delegates__.find { |attendant| 29 | attendant.method_defined?(meth) 30 | } 31 | end 32 | end 33 | 34 | def cast_as(attendant) 35 | __delegates__.unshift(attendant) 36 | self 37 | end 38 | 39 | def uncast 40 | __delegates__.shift 41 | self 42 | end 43 | 44 | def __delegates__ 45 | Thread.current[:class_delegates] ||= {} 46 | Thread.current[:class_delegates][name] ||= [] 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/casting/null.rb: -------------------------------------------------------------------------------- 1 | module Casting 2 | module Null 3 | def self.instance_method(*_) 4 | Empty.instance_method(:null) 5 | end 6 | 7 | def self.method_defined?(*_) 8 | true 9 | end 10 | end 11 | 12 | module Blank 13 | def self.instance_method(*_) 14 | Empty.instance_method(:blank) 15 | end 16 | 17 | def self.method_defined?(*_) 18 | true 19 | end 20 | end 21 | 22 | module Empty 23 | def null(*, &_block) 24 | end 25 | 26 | def blank(*, &_block) 27 | "" 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/casting/super_delegate.rb: -------------------------------------------------------------------------------- 1 | module Casting 2 | module SuperDelegate 3 | # Call the method of the same name defined in the next delegate stored in your object 4 | # 5 | # Because Casting creates an alternative method lookup path using a collection of delegates, 6 | # you may use `super_delegate` to work like `super`. 7 | # 8 | # If you use this feature, be sure that you have created a delegate collection which does 9 | # have the method you need or you'll see a NoMethodError. 10 | # 11 | # Example: 12 | # 13 | # module Greeter 14 | # def greet 15 | # "Hello" 16 | # end 17 | # end 18 | # 19 | # module FormalGreeter 20 | # include Casting::Super 21 | # 22 | # def greet 23 | # "#{super_delegate}, how do you do?" 24 | # end 25 | # end 26 | # 27 | # some_object.cast_as(Greeter, FormalGreeter) 28 | # some_object.greet #=> 'Hello, how do you do?' 29 | # 30 | def super_delegate(mod = :none, *args, **kwargs, &block) 31 | method_name = name_of_calling_method(caller_locations) 32 | owner = (mod unless mod == :none) || method_delegate(method_name) 33 | 34 | super_delegate_method = unbound_method_from_next_delegate(method_name, owner) 35 | super_delegate_method.bind_call(self, *args, **kwargs, &block) 36 | rescue NameError 37 | raise NoMethodError.new("super_delegate: no delegate method `#{method_name}' for #{inspect} from #{owner}") 38 | end 39 | 40 | def unbound_method_from_next_delegate(method_name, *skipped) 41 | method_delegate_skipping(method_name, *skipped).instance_method(method_name) 42 | end 43 | 44 | def method_delegate_skipping(meth, skipped) 45 | skipped_index = __delegates__.index(skipped) 46 | __delegates__[(skipped_index + 1)..__delegates__.length].find { |attendant| 47 | attendant_methods(attendant).include?(meth) 48 | } 49 | end 50 | 51 | def calling_location(call_stack) 52 | call_stack.reject { |line| 53 | line.to_s.match? Regexp.union(casting_library_matcher, gem_home_matcher, debugging_matcher) 54 | }.first 55 | end 56 | 57 | def name_of_calling_method(call_stack) 58 | calling_location(call_stack).label.to_sym 59 | end 60 | 61 | def casting_library_matcher 62 | Regexp.new(Dir.pwd.to_s + "/lib") 63 | end 64 | 65 | def gem_home_matcher 66 | Regexp.new(ENV["GEM_HOME"]) 67 | end 68 | 69 | def debugging_matcher 70 | Regexp.new("internal:trace_point") 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/casting/version.rb: -------------------------------------------------------------------------------- 1 | module Casting 2 | VERSION = "1.0.3" 3 | end 4 | -------------------------------------------------------------------------------- /test/casting_enum_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Person 4 | include Casting::Client 5 | delegate_missing_methods 6 | 7 | def initialize(name) 8 | @name = name 9 | end 10 | attr_reader :name 11 | end 12 | 13 | class PersonCollection 14 | include Casting::Enum 15 | 16 | def initialize(array) 17 | @array = array 18 | end 19 | attr_reader :array 20 | 21 | def each(*behaviors, &block) 22 | enum(array, *behaviors).each(&block) 23 | end 24 | end 25 | 26 | module Hello 27 | def hello 28 | "Hello, I'm #{name}" 29 | end 30 | end 31 | 32 | describe Casting::Enum, "#enum" do 33 | let(:people) { 34 | [Person.new("Jim"), Person.new("TJ"), Person.new("Sandi")] 35 | } 36 | it "iterates and applies behaviors to each item" do 37 | client = PersonCollection.new(people) 38 | assert_equal ["Hello, I'm Jim", "Hello, I'm TJ", "Hello, I'm Sandi"], client.each(Hello).map(&:hello) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/casting_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | describe Casting, ".delegating" do 4 | it "delegates missing methods to object delegates" do 5 | client = test_person 6 | client.extend(Casting::Client) 7 | client.delegate_missing_methods 8 | 9 | attendant = test_person 10 | attendant.extend(TestPerson::Greeter) 11 | 12 | assert_raises(NoMethodError) { 13 | client.greet 14 | } 15 | Casting.delegating(client => attendant) do 16 | assert_equal "hello", client.greet 17 | end 18 | assert_raises(NoMethodError) { 19 | client.greet 20 | } 21 | end 22 | 23 | it "delegates missing methods for the objects inside the block" do 24 | client = BlockTestPerson.new("Jim") 25 | verbose_client = BlockTestPerson.new("Amy") 26 | 27 | assert_raises(NoMethodError) { 28 | client.greet 29 | } 30 | Casting.delegating(client => TestPerson::Greeter, verbose_client => TestPerson::Verbose) do 31 | assert_equal "hello", client.greet 32 | assert_equal "this,that", verbose_client.verbose("this", "that") 33 | end 34 | assert_raises(NoMethodError) { 35 | client.greet 36 | } 37 | end 38 | 39 | it "delegates missing methods on altered objects inside the block" do 40 | client = test_person.extend(Casting::Client) 41 | client.delegate_missing_methods 42 | 43 | assert_raises(NoMethodError) { 44 | client.greet 45 | } 46 | Casting.delegating(client => TestPerson::Greeter) do 47 | assert_equal "hello", client.greet 48 | end 49 | assert_raises(NoMethodError) { 50 | client.greet 51 | } 52 | end 53 | 54 | it "responds to added methods inside the block" do 55 | client = test_person.extend(Casting::Client) 56 | client.delegate_missing_methods 57 | 58 | assert !client.respond_to?(:greet) 59 | 60 | Casting.delegating(client => TestPerson::Greeter) do 61 | assert client.respond_to?(:greet) 62 | end 63 | 64 | assert !client.respond_to?(:greet) 65 | end 66 | 67 | it "raises an error if the given object is not an object that delegates missing methods" do 68 | client = test_person.extend(Casting::Client) 69 | 70 | assert_raises(Casting::InvalidClientError) { 71 | Casting.delegating(client => TestPerson::Greeter) {} 72 | } 73 | end 74 | 75 | it "allows for nested delegating" do 76 | client = test_person.extend(Casting::Client) 77 | client.delegate_missing_methods 78 | 79 | Casting.delegating(client => TestPerson::Greeter) do 80 | assert client.respond_to?(:greet) 81 | Casting.delegating(client => TestPerson::Verbose) do 82 | assert client.respond_to?(:greet) 83 | assert client.respond_to?(:verbose) 84 | end 85 | assert !client.respond_to?(:verbose) 86 | end 87 | assert !client.respond_to?(:greet) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/class_refinement_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | ClassDelegatingPerson = Struct.new(:name) 4 | ClassDelegatingPerson.send(:include, Casting::Client) 5 | ClassDelegatingPerson.delegate_missing_methods :class 6 | 7 | InstanceDelegatingPerson = Struct.new(:name) 8 | InstanceDelegatingPerson.send(:include, Casting::Client) 9 | InstanceDelegatingPerson.delegate_missing_methods :instance 10 | 11 | ClassThenInstanceDelegatingPerson = Struct.new(:name) 12 | ClassThenInstanceDelegatingPerson.send(:include, Casting::Client) 13 | ClassThenInstanceDelegatingPerson.delegate_missing_methods :class, :instance 14 | 15 | InstanceThenClassDelegatingPerson = Struct.new(:name) 16 | InstanceThenClassDelegatingPerson.send(:include, Casting::Client) 17 | InstanceThenClassDelegatingPerson.delegate_missing_methods :instance, :class 18 | 19 | module ClassGreeter 20 | def greet 21 | "hello from the class delegate" 22 | end 23 | 24 | def class_greeting 25 | "Hi!" 26 | end 27 | end 28 | 29 | module InstanceGreeter 30 | def greet 31 | "hello from the instance delegate" 32 | end 33 | 34 | def instance_greeting 35 | "hi!" 36 | end 37 | end 38 | 39 | describe Casting, ".delegating" do 40 | it "delegates methods for all instances to a class delegate inside a block" do 41 | jim = ClassDelegatingPerson.new("Jim") 42 | amy = ClassDelegatingPerson.new("Amy") 43 | 44 | assert_raises(NoMethodError) { 45 | jim.greet 46 | } 47 | Casting.delegating(ClassDelegatingPerson => TestPerson::Greeter) do 48 | assert_equal "hello", jim.greet 49 | assert_equal "hello", amy.greet 50 | end 51 | assert_raises(NoMethodError) { 52 | jim.greet 53 | } 54 | end 55 | 56 | it "delegates methods for given instances to an instance delegate inside a block" do 57 | jim = InstanceDelegatingPerson.new("Jim") 58 | amy = InstanceDelegatingPerson.new("Amy") 59 | 60 | assert_raises(NoMethodError) { 61 | jim.greet 62 | } 63 | Casting.delegating(jim => TestPerson::Greeter) do 64 | assert_equal "hello", jim.greet 65 | assert_raises(NoMethodError) { amy.greet } 66 | end 67 | assert_raises(NoMethodError) { 68 | jim.greet 69 | } 70 | end 71 | 72 | it "delegates first to class delegates, then to instance delegates inside a block" do 73 | jim = ClassThenInstanceDelegatingPerson.new("Jim") 74 | amy = ClassThenInstanceDelegatingPerson.new("Amy") 75 | 76 | assert_raises(NoMethodError) { 77 | jim.greet 78 | } 79 | Casting.delegating(ClassThenInstanceDelegatingPerson => ClassGreeter, jim => InstanceGreeter) do 80 | assert_equal "hello from the class delegate", jim.greet 81 | assert_equal "hi!", jim.instance_greeting 82 | assert_equal "hello from the class delegate", amy.greet 83 | assert(NoMethodError) { amy.instance_greeting } 84 | end 85 | assert_raises(NoMethodError) { 86 | jim.greet 87 | } 88 | end 89 | 90 | it "delegates first to instance delegates, then to class delegates inside a block" do 91 | jim = InstanceThenClassDelegatingPerson.new("Jim") 92 | amy = InstanceThenClassDelegatingPerson.new("Amy") 93 | 94 | assert_raises(NoMethodError) { 95 | jim.greet 96 | } 97 | Casting.delegating(InstanceThenClassDelegatingPerson => ClassGreeter, jim => InstanceGreeter) do 98 | assert_equal "hello from the instance delegate", jim.greet 99 | assert_equal "hi!", jim.instance_greeting 100 | assert_equal "hello from the class delegate", amy.greet 101 | assert(NoMethodError) { amy.instance_greeting } 102 | end 103 | assert_raises(NoMethodError) { 104 | jim.greet 105 | } 106 | end 107 | 108 | it "sets instances to respond_to? class delegate methods" do 109 | jim = ClassDelegatingPerson.new("Jim") 110 | 111 | refute jim.respond_to?(:greet) 112 | 113 | Casting.delegating(ClassDelegatingPerson => ClassGreeter) do 114 | assert jim.respond_to?(:greet) 115 | end 116 | 117 | refute jim.respond_to?(:greet) 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /test/client_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | describe Casting::Client do 4 | it "will not override an existing `delegate` method" do 5 | client = TestPerson.new 6 | def client.delegate 7 | "existing delegate method" 8 | end 9 | client.extend(Casting::Client) 10 | 11 | attendant = TestPerson::Greeter 12 | 13 | assert_equal "existing delegate method", client.delegate 14 | 15 | assert_equal "hello", client.cast("greet", attendant) 16 | end 17 | 18 | it "adds a delegate method to call a method on an attendant" do 19 | client = TestPerson.new 20 | client.extend(Casting::Client) 21 | attendant = TestPerson::Greeter 22 | 23 | assert_equal "hello", client.delegate("greet", attendant) 24 | end 25 | 26 | it "passes additional parameters to the attendant" do 27 | client = TestPerson.new 28 | client.extend(Casting::Client) 29 | attendant = TestPerson::Verbose 30 | 31 | assert_equal "hello,goodbye", client.delegate("verbose", attendant, "hello", "goodbye") 32 | end 33 | 34 | it "executes delegated methods with a block" do 35 | client = TestPerson.new 36 | client.extend(Casting::Client) 37 | mod = Module.new 38 | mod.module_eval do 39 | def blocky(arg, &block) 40 | block.call(arg, self) 41 | end 42 | end 43 | 44 | output = client.delegate("blocky", mod, "argument") do |arg, me| 45 | %(#{arg} from #{me.name}) 46 | end 47 | 48 | assert_equal "argument from name from TestPerson", output 49 | end 50 | 51 | it "passes the object as the client for delegation" do 52 | client = Object.new 53 | client.extend(Casting::Client) 54 | 55 | delegation = client.delegation("id") 56 | 57 | assert_equal client, delegation.client 58 | end 59 | 60 | it "refuses to delegate to itself" do 61 | client = TestPerson.new 62 | client.extend(Casting::Client) 63 | 64 | assert_raises(Casting::InvalidAttendant) { 65 | client.delegate("to_s", client) 66 | } 67 | end 68 | 69 | it "does not delegate singleton methods" do 70 | client = test_person.extend(Casting::Client) 71 | client.delegate_missing_methods 72 | attendant = test_person 73 | 74 | def attendant.hello 75 | "hello" 76 | end 77 | assert_raises(TypeError) { 78 | client.delegate("hello", attendant) 79 | } 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/context_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class TestContext 4 | using Casting::Context 5 | extend Casting::Context 6 | 7 | initialize :admin, :user 8 | 9 | def approve 10 | tell :admin, :say, "I approve" 11 | end 12 | 13 | def approve_with_keyword 14 | tell :admin, :keyword_say, what: "I approve" 15 | end 16 | 17 | def user_approve 18 | tell :user, :approve 19 | end 20 | 21 | module Admin 22 | def say(what) 23 | what 24 | end 25 | 26 | def keyword_say(what:) 27 | what 28 | end 29 | end 30 | 31 | module User 32 | def approve 33 | "Yay!" 34 | end 35 | end 36 | end 37 | 38 | class MissingModuleContext 39 | using Casting::Context 40 | extend Casting::Context 41 | 42 | initialize :admin, :user 43 | 44 | def run 45 | tell :admin, :go 46 | end 47 | end 48 | 49 | class BlockContext 50 | using Casting::Context 51 | extend Casting::Context 52 | 53 | initialize :admin do 54 | @blocked = true 55 | end 56 | attr :blocked 57 | end 58 | 59 | describe Casting::Context do 60 | it "accepts an initialize block" do 61 | admin = casting_person 62 | context = BlockContext.new(admin: admin) 63 | 64 | expect(context).must_be :blocked 65 | end 66 | 67 | it "applies module methods to Casting::Client objects" do 68 | admin = casting_person 69 | user = casting_person 70 | 71 | context = TestContext.new admin: admin, user: user 72 | 73 | expect(context.approve).must_equal("I approve") 74 | expect(context.approve_with_keyword).must_equal("I approve") 75 | expect(context.user_approve).must_equal("Yay!") 76 | end 77 | 78 | it "applies module methods to any object" do 79 | admin = Object.new 80 | user = 1 81 | 82 | context = TestContext.new admin: admin, user: user 83 | 84 | expect(context.approve).must_equal("I approve") 85 | expect(context.user_approve).must_equal("Yay!") 86 | end 87 | 88 | it "handles missing modules and raises missing method error" do 89 | admin = TestPerson.new 90 | user = TestPerson.new 91 | 92 | context = MissingModuleContext.new admin: admin, user: user 93 | 94 | err = expect { context.run }.must_raise(NoMethodError) 95 | expect(err.message).must_match(/unknown method 'go'/) 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /test/delegation_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | describe Casting::Delegation do 4 | it "initializes with method name and object" do 5 | assert Casting::Delegation.prepare("some_method", Object.new) 6 | end 7 | 8 | it "raises an error when calling without an attendant object" do 9 | delegation = Casting::Delegation.prepare("some_method", Object.new) 10 | begin 11 | delegation.call 12 | rescue => e 13 | end 14 | assert_kind_of Casting::MissingAttendant, e 15 | assert_equal "You must set your attendant object using `to'.", e.message 16 | end 17 | 18 | it "raises an error when setting an invalid attendant type" do 19 | delegation = Casting::Delegation.prepare("some_method", TestPerson.new) 20 | assert_raises(Casting::InvalidAttendant) { 21 | delegation.to(Unrelated.new) 22 | } 23 | end 24 | 25 | it "raises an error when setting a class as the attendant" do 26 | delegation = Casting::Delegation.prepare("some_method", TestPerson) 27 | assert_raises(Casting::InvalidAttendant) { 28 | delegation.to(Unrelated.new) 29 | } 30 | end 31 | 32 | it "sets an attendant to an object of an ancestor class of the object class" do 33 | attendant = test_person 34 | client = SubTestPerson.new 35 | 36 | delegation = Casting::Delegation.prepare("name", client) 37 | assert delegation.to(attendant) 38 | end 39 | 40 | it "delegates when given a module" do 41 | client = test_person 42 | delegation = Casting::Delegation.prepare("greet", client).to(TestPerson::Greeter) 43 | assert_equal "hello", delegation.call 44 | end 45 | 46 | it "does not delegate when given a class" do 47 | client = test_person 48 | err = expect { 49 | Casting::Delegation.prepare("class_defined", client).to(Unrelated) 50 | }.must_raise(TypeError) 51 | expect(err.message).must_match(/ argument must be a module or an object with/) 52 | end 53 | 54 | it "finds the module defining a method and uses it to delegate" do 55 | client = test_person 56 | attendant = Unrelated.new 57 | delegation = Casting::Delegation.prepare("unrelated", client).to(attendant) 58 | assert_equal attendant.unrelated, delegation.call 59 | end 60 | 61 | it "does not delegate to methods defined in classes" do 62 | client = test_person 63 | attendant = Unrelated.new 64 | assert_raises(TypeError) { 65 | Casting::Delegation.prepare("class_defined", client).to(attendant) 66 | } 67 | end 68 | 69 | it "assigns arguments to the delegated method using with" do 70 | client = test_person 71 | attendant = TestPerson::Verbose 72 | 73 | delegation = Casting::Delegation.prepare("verbose", client).to(attendant) 74 | 75 | assert_equal "hello,goodbye", delegation.with("hello", "goodbye").call 76 | end 77 | 78 | it "assigns keyword arguments to the delegated method using with" do 79 | client = test_person 80 | attendant = TestPerson::Verbose 81 | 82 | delegation = Casting::Delegation.prepare("verbose_keywords", client).to(attendant) 83 | 84 | assert_equal "hello,goodbye", delegation.with(key: "hello", word: "goodbye").call 85 | end 86 | 87 | it "assigns regular and keyword arguments to the delegated method using with" do 88 | client = test_person 89 | attendant = TestPerson::Verbose 90 | 91 | delegation = Casting::Delegation.prepare("verbose_multi_args", client).to(attendant) 92 | 93 | assert_equal("hello,goodbye,keys,words,block!", delegation.with("hello", "goodbye", key: "keys", word: "words") do 94 | "block!" 95 | end.call) 96 | end 97 | 98 | it "handles flexible arguments to the delegated method using with" do 99 | client = test_person 100 | attendant = TestPerson::Verbose 101 | 102 | delegation = Casting::Delegation.prepare("verbose_flex", client).to(attendant) 103 | 104 | assert_equal("hello,key:keys,word:words,block!", delegation.with("hello", key: "keys", word: "words") do 105 | "block!" 106 | end.call) 107 | end 108 | 109 | it "prefers `call` arguments over `with`" do 110 | client = test_person 111 | attendant = TestPerson::Verbose 112 | 113 | delegation = Casting::Delegation.prepare("verbose", client).to(attendant) 114 | 115 | assert_equal "call,args", delegation.with("hello", "goodbye").call("call", "args") 116 | end 117 | 118 | it 'prefers "call" keyword arguments over "with"' do 119 | client = test_person 120 | attendant = TestPerson::Verbose 121 | 122 | delegation = Casting::Delegation.prepare("verbose_keywords", client).to(attendant) 123 | 124 | assert_equal "call,args", delegation.with(key: "hello", word: "goodbye").call(key: "call", word: "args") 125 | end 126 | 127 | it 'prefers "call" regular and keyword arguments over "with"' do 128 | client = test_person 129 | attendant = TestPerson::Verbose 130 | 131 | delegation = Casting::Delegation.prepare("verbose_multi_args", client).to(attendant) 132 | 133 | assert_equal "hello,goodbye,call,args", delegation.with("this", "that", key: "something", word: "else").call("hello", "goodbye", key: "call", word: "args") 134 | end 135 | 136 | it 'prefers "call" block arguments over "with"' do 137 | client = test_person 138 | attendant = TestPerson::Verbose 139 | 140 | delegation = Casting::Delegation.prepare("verbose_multi_args", client).to(attendant) 141 | 142 | prepared = delegation.with("this", "that", key: "something", word: "else") { "prepared block!" } 143 | 144 | assert_equal("this,that,something,else,call block!", prepared.call { "call block!" }) 145 | end 146 | 147 | it 'prefers "call" keyword arguments and block over "with"' do 148 | client = test_person 149 | attendant = TestPerson::Verbose 150 | 151 | delegation = Casting::Delegation.prepare("verbose_flex", client).to(attendant) 152 | 153 | prepared = delegation.with(key: "something", word: "else") { "prepared block!" } 154 | 155 | assert_equal("key:call_key,word:call_word,call block!", prepared.call(key: "call_key", word: "call_word") { "call block!" }) 156 | end 157 | 158 | it 'prefers "call" and block over "with"' do 159 | client = test_person 160 | attendant = TestPerson::Verbose 161 | 162 | delegation = Casting::Delegation.prepare("verbose_flex", client).to(attendant) 163 | 164 | prepared = delegation.with { "prepared block!" } 165 | 166 | assert_equal("call block!", prepared.call { "call block!" }) 167 | end 168 | 169 | it "calls a method defined on another object of the same type" do 170 | client = test_person 171 | attendant = test_person 172 | attendant.extend(TestPerson::Greeter) 173 | delegation = Casting::Delegation.prepare("greet", client).to(attendant) 174 | assert_equal "hello", delegation.call 175 | end 176 | 177 | it "passes arguments to a delegated method" do 178 | client = test_person 179 | attendant = test_person 180 | attendant.extend(TestPerson::Verbose) 181 | delegation = Casting::Delegation.prepare("verbose", client).to(attendant).with("arg1", "arg2") 182 | assert_equal "arg1,arg2", delegation.call 183 | end 184 | 185 | it "delegates when given a module" do 186 | client = test_person 187 | delegation = Casting::Delegation.prepare("greet", client).to(TestPerson::Greeter) 188 | assert_equal "hello", delegation.call 189 | end 190 | 191 | it "does not delegate when given a class" do 192 | client = test_person 193 | assert_raises(TypeError) { 194 | Casting::Delegation.prepare("class_defined", client).to(Unrelated) 195 | } 196 | end 197 | end 198 | -------------------------------------------------------------------------------- /test/frozen_client_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | describe Casting::Client do 4 | it "will not attempt to alter a frozen client" do 5 | client = TestPerson.new 6 | client.extend(Casting::Client) 7 | client.delegate_missing_methods 8 | 9 | client.freeze 10 | 11 | err = expect { client.greet }.must_raise(NoMethodError) 12 | expect(err.message).must_match(/undefined method `greet'/) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/method_consolidator_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | describe Casting::MethodConsolidator, "#methods" do 4 | let(:client) { 5 | object = test_person.extend(Casting::Client, Casting::MissingMethodClient, Casting::MethodConsolidator) 6 | object.cast_as(TestPerson::Greeter) 7 | object 8 | } 9 | 10 | it "returns all instance methods including private from the object and it's delegates" do 11 | assert_includes(client.methods(true), :psst) 12 | end 13 | 14 | it "returns all public instance methods from the object and it's delegates" do 15 | refute_includes(client.methods(false), :psst) 16 | end 17 | 18 | it "returns all protected instance methods from the object and it's delegates" do 19 | assert_includes(client.methods(true), :hey) 20 | end 21 | end 22 | 23 | describe Casting::MethodConsolidator, "#public_methods" do 24 | let(:client) { 25 | object = test_person.extend(Casting::Client, Casting::MissingMethodClient, Casting::MethodConsolidator) 26 | object.cast_as(TestPerson::Greeter) 27 | object 28 | } 29 | 30 | it "returns all public_methods and those from it's delegates" do 31 | assert_includes(client.public_methods, :greet) 32 | end 33 | 34 | it "excludes all protected_methods and those from it's delegates" do 35 | refute_includes(client.public_methods, :hey) 36 | end 37 | 38 | it "excludes all private_methods from the object and it's delegates" do 39 | refute_includes(client.public_methods, :psst) 40 | end 41 | end 42 | 43 | describe Casting::MethodConsolidator, "#protected_methods" do 44 | let(:client) { 45 | object = test_person.extend(Casting::Client, Casting::MissingMethodClient, Casting::MethodConsolidator) 46 | object.cast_as(TestPerson::Greeter) 47 | object 48 | } 49 | 50 | it "excludes all public_methods and those from it's delegates" do 51 | refute_includes(client.protected_methods, :greet) 52 | end 53 | 54 | it "returns all protected_methods and those from it's delegates" do 55 | assert_includes(client.protected_methods, :hey) 56 | end 57 | 58 | it "excludes all private_methods from the object and it's delegates" do 59 | refute_includes(client.protected_methods, :psst) 60 | end 61 | end 62 | 63 | describe Casting::MethodConsolidator, "#private_methods" do 64 | let(:client) { 65 | object = test_person.extend(Casting::Client, Casting::MissingMethodClient, Casting::MethodConsolidator) 66 | object.cast_as(TestPerson::Greeter) 67 | object 68 | } 69 | 70 | it "excludes all public_methods and those from it's delegates" do 71 | refute_includes(client.private_methods, :greet) 72 | end 73 | 74 | it "excludes all protected_methods and those from it's delegates" do 75 | refute_includes(client.private_methods, :hey) 76 | end 77 | 78 | it "excludes all private_methods from the object and it's delegates" do 79 | assert_includes(client.private_methods, :psst) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/missing_method_client_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module One 4 | def similar 5 | "from One" 6 | end 7 | end 8 | 9 | module Two 10 | def similar 11 | "from Two" 12 | end 13 | end 14 | 15 | describe Casting::MissingMethodClient, "#cast_as" do 16 | let(:client) { 17 | test_person.extend(Casting::Client, Casting::MissingMethodClient) 18 | } 19 | 20 | it "sets the object's delegate for missing methods" do 21 | client.cast_as(TestPerson::Greeter) 22 | assert_equal "hello", client.greet 23 | end 24 | 25 | it "delegates to objects of the same type" do 26 | # avoid using another client 27 | client = test_person 28 | client.extend(TestPerson::Greeter) 29 | attendant = client.clone 30 | client.extend(Casting::Client, Casting::MissingMethodClient) 31 | 32 | client.singleton_class.send(:undef_method, :greet) 33 | client.cast_as(attendant) 34 | assert_equal "hello", client.greet 35 | end 36 | 37 | it "raises an error when given the client object" do 38 | assert_raises(Casting::InvalidAttendant) { 39 | client.cast_as(client) 40 | } 41 | end 42 | 43 | it "returns the object for further operation" do 44 | jim = test_person.extend(Casting::Client, Casting::MissingMethodClient) 45 | 46 | assert_equal "hello", jim.cast_as(TestPerson::Greeter).greet 47 | end 48 | 49 | it "delegates methods to the last module added containing the method" do 50 | jim = test_person.extend(Casting::Client, Casting::MissingMethodClient) 51 | 52 | assert_equal "from Two", jim.cast_as(One, Two).similar 53 | end 54 | end 55 | 56 | describe Casting::MissingMethodClient, "#uncast" do 57 | let(:client) { 58 | test_person.extend(Casting::Client, Casting::MissingMethodClient) 59 | } 60 | 61 | it "removes the last added delegate" do 62 | client.cast_as(TestPerson::Greeter) 63 | assert_equal "hello", client.greet 64 | client.uncast 65 | assert_raises(NoMethodError) { client.greet } 66 | end 67 | 68 | it "maintains any previously added delegates" do 69 | client.cast_as(TestPerson::Verbose) 70 | assert_equal "one,two", client.verbose("one", "two") 71 | client.uncast 72 | assert_raises(NoMethodError) { client.verbose("one", "two") } 73 | end 74 | 75 | it "returns the object for further operation" do 76 | jim = test_person.extend(Casting::Client, Casting::MissingMethodClient) 77 | 78 | assert_equal "name from TestPerson", jim.uncast.name 79 | end 80 | 81 | it "removes the specified number of delegates" do 82 | jim = test_person.extend(Casting::Client, Casting::MissingMethodClient) 83 | jim.cast_as(TestPerson::Greeter, TestPerson::Verbose) 84 | 85 | assert_includes(jim.delegated_methods(true), :psst) 86 | assert_includes(jim.delegated_methods(true), :verbose) 87 | 88 | jim.uncast(2) 89 | 90 | refute_includes(jim.delegated_methods(true), :psst) 91 | refute_includes(jim.delegated_methods(true), :verbose) 92 | end 93 | end 94 | 95 | describe Casting::MissingMethodClient, "#delegated_methods" do 96 | let(:client) { 97 | object = test_person.extend(Casting::Client, Casting::MissingMethodClient) 98 | object.cast_as(TestPerson::Greeter) 99 | object 100 | } 101 | 102 | it "returns all instance methods including private from the object's delegates" do 103 | assert_includes(client.delegated_methods(true), :psst) 104 | end 105 | 106 | it "returns all public instance methods from the object and it's delegates" do 107 | refute_includes(client.delegated_methods(false), :psst) 108 | end 109 | 110 | it "returns all protected instance methods from the object and it's delegates" do 111 | assert_includes(client.delegated_methods(true), :hey) 112 | end 113 | end 114 | 115 | describe Casting::MissingMethodClient, "#delegated_public_methods" do 116 | let(:client) { 117 | object = test_person.extend(Casting::Client, Casting::MissingMethodClient) 118 | object.cast_as(TestPerson::Greeter) 119 | object 120 | } 121 | 122 | it "returns all public methods from the object's delegates" do 123 | assert_includes(client.delegated_public_methods, :greet) 124 | end 125 | 126 | it "excludes all private methods from the object's delegates" do 127 | refute_includes(client.delegated_public_methods, :psst) 128 | end 129 | 130 | it "excludes all protected methods from the object's delegates" do 131 | refute_includes(client.delegated_public_methods, :hey) 132 | end 133 | 134 | it "includes methods from superclasses" do 135 | client.cast_as(Nested) 136 | assert_includes(client.delegated_public_methods(true), :nested_deep) 137 | end 138 | 139 | it "excludes methods from superclasses" do 140 | client.cast_as(Nested) 141 | refute_includes(client.delegated_public_methods(false), :nested_deep) 142 | end 143 | end 144 | 145 | describe Casting::MissingMethodClient, "#delegated_protected_methods" do 146 | let(:client) { 147 | object = test_person.extend(Casting::Client, Casting::MissingMethodClient) 148 | object.cast_as(TestPerson::Greeter) 149 | object 150 | } 151 | 152 | it "excludes all public methods from the object's delegates" do 153 | refute_includes(client.delegated_protected_methods, :greet) 154 | end 155 | 156 | it "excludes all private methods from the object's delegates" do 157 | refute_includes(client.delegated_protected_methods, :psst) 158 | end 159 | 160 | it "includes all protected methods from the object's delegates" do 161 | assert_includes(client.delegated_protected_methods, :hey) 162 | end 163 | 164 | it "includes methods from superclasses" do 165 | client.cast_as(Nested) 166 | assert_includes(client.delegated_protected_methods(true), :protected_nested_deep) 167 | end 168 | 169 | it "excludes methods from superclasses" do 170 | client.cast_as(Nested) 171 | refute_includes(client.delegated_protected_methods(false), :protected_nested_deep) 172 | end 173 | end 174 | 175 | describe Casting::MissingMethodClient, "#delegated_private_methods" do 176 | let(:client) { 177 | object = test_person.extend(Casting::Client, Casting::MissingMethodClient) 178 | object.cast_as(TestPerson::Greeter) 179 | object 180 | } 181 | 182 | it "excludes all public methods from the object's delegates" do 183 | refute_includes(client.delegated_private_methods, :greet) 184 | end 185 | 186 | it "includes all private methods from the object's delegates" do 187 | assert_includes(client.delegated_private_methods, :psst) 188 | end 189 | 190 | it "excludes all protected methods from the object's delegates" do 191 | refute_includes(client.delegated_private_methods, :hey) 192 | end 193 | 194 | it "includes methods from superclasses" do 195 | client.cast_as(Nested) 196 | assert_includes(client.delegated_private_methods(true), :private_nested_deep) 197 | end 198 | 199 | it "excludes methods from superclasses" do 200 | client.cast_as(Nested) 201 | refute_includes(client.delegated_private_methods(false), :private_nested_deep) 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /test/module_cleanup_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Cleaner 4 | def self.uncast_object(object) 5 | object.send(:remove_instance_variable, :@cleaner_message) 6 | end 7 | 8 | def self.cast_object(object) 9 | object.instance_variable_set(:@cleaner_message, "#{object.name} will be cleaned up") 10 | end 11 | 12 | def cleaner_message 13 | @cleaner_message 14 | end 15 | end 16 | 17 | class CleanupPerson 18 | include Casting::Client 19 | delegate_missing_methods 20 | attr_accessor :name 21 | end 22 | 23 | describe "modules with setup tasks" do 24 | it "allows modules to setup an object when cast_as" do 25 | jim = CleanupPerson.new 26 | jim.name = "Jim" 27 | jim.cast_as(Cleaner) 28 | assert_equal "Jim will be cleaned up", jim.cleaner_message 29 | assert_equal "Jim will be cleaned up", jim.instance_variable_get(:@cleaner_message) 30 | end 31 | end 32 | 33 | describe "modules with cleanup tasks" do 34 | it "allows modules to cleanup their required attributes when uncast" do 35 | jim = CleanupPerson.new 36 | jim.name = "Jim" 37 | jim.cast_as(Cleaner) 38 | assert_equal "Jim will be cleaned up", jim.cleaner_message 39 | assert_equal "Jim will be cleaned up", jim.instance_variable_get(:@cleaner_message) 40 | jim.uncast 41 | refute jim.instance_variable_defined?(:@cleaner_message) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/null_module_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | describe Casting::Null do 4 | it "will answer to any method with nil" do 5 | client = TestPerson.new 6 | client.extend(Casting::Client) 7 | attendant = Casting::Null 8 | 9 | assert_nil client.delegate("greet", attendant) 10 | end 11 | end 12 | 13 | describe Casting::Blank do 14 | it "will answer to any method with an empty string" do 15 | client = TestPerson.new 16 | client.extend(Casting::Client) 17 | attendant = Casting::Blank 18 | 19 | assert_empty client.delegate("greet", attendant) 20 | end 21 | end 22 | 23 | describe "making null objects" do 24 | it "answers to missing methods" do 25 | client = TestPerson.new 26 | client.extend(Casting::Client) 27 | client.delegate_missing_methods 28 | attendant = Casting::Null 29 | 30 | assert_respond_to client.cast_as(attendant), "xyz" 31 | end 32 | end 33 | 34 | describe "making blank objects" do 35 | it "answers to missing methods" do 36 | client = TestPerson.new 37 | client.extend(Casting::Client) 38 | client.delegate_missing_methods 39 | attendant = Casting::Blank 40 | 41 | assert_respond_to client.cast_as(attendant), "xyz" 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/super_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module AnyWay 4 | def which_way 5 | "any way" 6 | end 7 | 8 | def way_with_args(one, two, &block) 9 | [one, two, block&.call].compact.inspect 10 | end 11 | 12 | def way_with_keyword_args(one:, two:, &block) 13 | [one, two, block&.call].compact.inspect 14 | end 15 | end 16 | 17 | module ThisWay 18 | include Casting::SuperDelegate 19 | def which_way 20 | "this way or #{super_delegate}" 21 | end 22 | 23 | def way_with_args(one, two, &block) 24 | [one, two, block&.call].compact.inspect 25 | end 26 | 27 | def way_with_keyword_args(one:, two:, &block) 28 | [one, two, block&.call].compact.inspect 29 | end 30 | 31 | def no_super 32 | super_delegate 33 | end 34 | end 35 | 36 | module ThatWay 37 | include Casting::SuperDelegate 38 | def which_way 39 | "#{super_delegate(ThatWay)} and that way!" 40 | end 41 | 42 | def way_with_args(one, two, &block) 43 | super_delegate(one, two, block&.call).compact 44 | end 45 | 46 | def way_with_keyword_args(one:, two:, &block) 47 | [one, two, block&.call].compact.inspect 48 | end 49 | end 50 | 51 | describe Casting, "modules using delegate_super" do 52 | it "call the method from the next delegate with the same arguments" do 53 | client = TestPerson.new.extend(Casting::Client) 54 | client.delegate_missing_methods 55 | client.cast_as(AnyWay, ThatWay, ThisWay) 56 | 57 | assert_equal "this way or any way and that way!", client.which_way 58 | end 59 | 60 | it "passes arguments" do 61 | client = TestPerson.new.extend(Casting::Client) 62 | client.delegate_missing_methods 63 | client.cast_as(ThatWay, ThisWay) 64 | 65 | assert_equal %(["first", "second", "block"]), client.way_with_args("first", "second") { "block" } 66 | end 67 | 68 | it "passes keyword arguments" do 69 | client = TestPerson.new.extend(Casting::Client) 70 | client.delegate_missing_methods 71 | client.cast_as(ThatWay, ThisWay) 72 | 73 | assert_equal %(["first", "second", "block"]), client.way_with_keyword_args(one: "first", two: "second") { "block" } 74 | end 75 | 76 | it "raises an error when method is not defined" do 77 | client = TestPerson.new.extend(Casting::Client) 78 | client.delegate_missing_methods 79 | client.cast_as(ThisWay) 80 | 81 | err = expect { 82 | client.no_super 83 | }.must_raise(NoMethodError) 84 | 85 | expect(err.message).must_match(/super_delegate: no delegate method `no_super' for \# from ThisWay/) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "simplecov" 2 | SimpleCov.start do 3 | add_filter "test" 4 | end 5 | require "minitest/autorun" 6 | require "casting" 7 | 8 | BlockTestPerson = Struct.new(:name) 9 | BlockTestPerson.send(:include, Casting::Client) 10 | BlockTestPerson.delegate_missing_methods 11 | 12 | class TestPerson 13 | def name 14 | "name from TestPerson" 15 | end 16 | 17 | module Greeter 18 | def greet 19 | "hello" 20 | end 21 | 22 | protected 23 | 24 | def hey 25 | end 26 | 27 | private 28 | 29 | def psst 30 | end 31 | end 32 | 33 | module Verbose 34 | def verbose(arg1, arg2) 35 | [arg1, arg2].join(",") 36 | end 37 | 38 | def verbose_keywords(key:, word:) 39 | [key, word].join(",") 40 | end 41 | 42 | def verbose_multi_args(arg1, arg2, key:, word:, &block) 43 | [arg1, arg2, key, word, block&.call].compact.join(",") 44 | end 45 | 46 | def verbose_flex(*args, **kwargs, &block) 47 | [args, kwargs.map { |k, v| "#{k}:#{v}" }, block&.call].flatten.compact.join(",") 48 | end 49 | end 50 | end 51 | 52 | class TestGreeter 53 | include TestPerson::Greeter 54 | end 55 | 56 | class SubTestPerson < TestPerson 57 | def sub_method 58 | "sub" 59 | end 60 | end 61 | 62 | class Unrelated 63 | module More 64 | def unrelated 65 | "unrelated" 66 | end 67 | end 68 | include More 69 | 70 | def class_defined 71 | "oops!" 72 | end 73 | end 74 | 75 | module Deep 76 | def nested_deep 77 | end 78 | 79 | protected 80 | 81 | def protected_nested_deep 82 | end 83 | 84 | private 85 | 86 | def private_nested_deep 87 | end 88 | end 89 | 90 | module Nested 91 | include Deep 92 | 93 | def nested 94 | end 95 | 96 | protected 97 | 98 | def protected_nested 99 | end 100 | 101 | private 102 | 103 | def private_nested 104 | end 105 | end 106 | 107 | def test_person 108 | TestPerson.new 109 | end 110 | 111 | def casting_person 112 | test_person.extend(Casting::Client) 113 | end 114 | --------------------------------------------------------------------------------