├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rubocop.yml ├── .yardopts ├── Gemfile ├── README.md ├── Rakefile ├── delegates.gemspec ├── lib └── delegates.rb └── spec ├── .rubocop.yml └── delegates_spec.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | ruby: [ 2.4, 2.5, 2.6, 2.7 ] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Set up Ruby 21 | uses: actions/setup-ruby@v1 22 | with: 23 | ruby-version: ${{ matrix.ruby }} 24 | - name: install dependencies 25 | run: bundle install --jobs 3 --retry 3 26 | - name: spec 27 | run: bundle exec rake spec 28 | - name: rubocop 29 | run: bundle exec rake rubocop 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .yardoc 2 | tmp 3 | doc 4 | Gemfile.lock 5 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: rubocop-rspec 2 | 3 | AllCops: 4 | Include: 5 | - 'lib/**/*.rb' 6 | - 'spec/**/*.rb' 7 | DisplayCopNames: true 8 | TargetRubyVersion: 2.4 9 | 10 | Layout/LineLength: 11 | Max: 100 12 | IgnoredPatterns: ['\# .*'] # ignore long comments 13 | 14 | Style/FormatString: 15 | EnforcedStyle: percent 16 | 17 | Style/ParallelAssignment: 18 | Enabled: false 19 | 20 | Style/FormatStringToken: 21 | Enabled: false 22 | 23 | Metrics: 24 | Enabled: false 25 | 26 | # Mandatory to either enable or disable 27 | 28 | Layout/EmptyLinesAroundAttributeAccessor: 29 | Enabled: true 30 | 31 | Layout/SpaceAroundMethodCallOperator: 32 | Enabled: true 33 | 34 | Lint/DeprecatedOpenSSLConstant: 35 | Enabled: true 36 | 37 | Lint/MixedRegexpCaptureTypes: 38 | Enabled: true 39 | 40 | Lint/RaiseException: 41 | Enabled: true 42 | 43 | Lint/StructNewOverride: 44 | Enabled: true 45 | 46 | Style/ExponentialNotation: 47 | Enabled: true 48 | 49 | Style/HashEachMethods: 50 | Enabled: true 51 | 52 | Style/HashTransformKeys: 53 | Enabled: true 54 | 55 | Style/HashTransformValues: 56 | Enabled: true 57 | 58 | Style/RedundantRegexpCharacterClass: 59 | Enabled: true 60 | 61 | Style/RedundantRegexpEscape: 62 | Enabled: true 63 | 64 | Style/SlicingWithRange: 65 | Enabled: true 66 | 67 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup=markdown 2 | --no-private 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Delegates 2 | 3 | [![Gem Version](https://badge.fury.io/rb/delegates.svg)](http://badge.fury.io/rb/delegates) 4 | ![build](https://github.com/zverok/delegates/workflows/CI/badge.svg) 5 | 6 | This gem is just an extraction of the handy `delegate :method1, :method2, method3, to: :receiver` from ActiveSupport. It seems to be seriously superior to stdlib's [Forwardable](https://ruby-doc.org/stdlib-2.7.1/libdoc/forwardable/rdoc/Forwardable.html), and sometimes I want it in contexts when ActiveSupport and monkey-patching is undesireable. 7 | 8 | Usage: 9 | 10 | ``` 11 | gem install delegates 12 | ``` 13 | (or add `gem 'delegates'` to your `Gemfile`). 14 | 15 | Then: 16 | 17 | ```ruby 18 | class Employee < Struct.new(:name, :department, :address) 19 | # ... 20 | extend Delegates 21 | delegate :city, :street, to: :address 22 | # ... 23 | end 24 | 25 | employee = Employee.new(name, department, address) 26 | 27 | employee.city # will call employee.address.city 28 | ``` 29 | 30 | `to:` can be anything evaluatable from inside the class: `:`, `:@`, `'chain.of.calls'` etc.; special names requiring `self` (like `class` method) handled gracefully with just `delegate ..., to: :class`. New methods are defined with `eval`-ing strings, so they are as fast as if manually written. 31 | 32 | Supported options examples (all that ActiveSupport's `Module#delegate` supports): 33 | 34 | ```ruby 35 | delegate :city, to: :address, prefix: true # defined method would be address_city 36 | delegate :city, to: :address, prefix: :adrs # defined method would be adrs_city 37 | 38 | delegate :city, to: :address, private: true # defined method would be private 39 | 40 | delegate :city, to: :address, allow_nil: true 41 | ``` 42 | The latter option will handle the `employee.city` call when `employee.address` is `nil` by returning `nil`; otherwise (by default) informative `DelegationError` is raised. 43 | 44 | ## Credits 45 | 46 | 99.99% of credits should go to Rails users and contributors, who found and and handled miriads of realistic edge cases. I just copied the code (and groomed it a bit for closer to my own style). 47 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | $LOAD_PATH.unshift 'lib' 3 | require 'pathname' 4 | require 'rubygems/tasks' 5 | Gem::Tasks.new 6 | 7 | require 'rspec/core/rake_task' 8 | RSpec::Core::RakeTask.new 9 | 10 | require 'rubocop/rake_task' 11 | RuboCop::RakeTask.new 12 | 13 | task default: %w[spec rubocop] 14 | -------------------------------------------------------------------------------- /delegates.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'delegates' 3 | s.version = '0.0.1' 4 | s.authors = ['Victor Shepelev'] 5 | s.email = 'zverok.offline@gmail.com' 6 | s.homepage = 'https://github.com/zverok/delegates' 7 | s.metadata = { 8 | 'bug_tracker_uri' => 'https://github.com/zverok/delegates/issues', 9 | 'documentation_uri' => 'https://www.rubydoc.info/gems/delegates/', 10 | 'homepage_uri' => 'https://github.com/zverok/delegates', 11 | 'source_code_uri' => 'https://github.com/zverok/delegates' 12 | } 13 | 14 | s.summary = 'delegate :methods, to: :target, extracted from ActiveSupport' 15 | s.description = <<-EOF 16 | ActiveSupport's delegation syntax is much more convenient than Ruby's stdlib Forwardable. 17 | This gem just extracts it as an independent functionality (available on-demand without 18 | monkey-patching Module). 19 | EOF 20 | s.licenses = ['MIT'] 21 | 22 | s.required_ruby_version = '>= 2.4.0' 23 | 24 | s.files = `git ls-files lib LICENSE.txt *.md`.split($RS) 25 | s.require_paths = ["lib"] 26 | 27 | s.add_development_dependency 'rubocop', '~> 0.85.0' 28 | s.add_development_dependency 'rubocop-rspec', '~> 1.38.0' 29 | 30 | s.add_development_dependency 'rspec', '>= 3.8' 31 | s.add_development_dependency 'rspec-its', '~> 1' 32 | s.add_development_dependency 'saharspec', '>= 0.0.7' 33 | s.add_development_dependency 'simplecov', '~> 0.9' 34 | 35 | s.add_development_dependency 'rake' 36 | s.add_development_dependency 'rubygems-tasks' 37 | 38 | s.add_development_dependency 'yard' 39 | end 40 | -------------------------------------------------------------------------------- /lib/delegates.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'set' 4 | 5 | # Provides a {#delegate} class method to define methods whose calls are delegated to nested objects 6 | # 7 | # **Examples:** 8 | # 9 | # ```ruby 10 | # class Greeter 11 | # def hello 12 | # 'hello' 13 | # end 14 | # 15 | # def goodbye 16 | # 'goodbye' 17 | # end 18 | # end 19 | # 20 | # class Foo 21 | # def greeter 22 | # Greeter.new 23 | # end 24 | # 25 | # extend Delegates 26 | # delegate :hello, to: :greeter 27 | # end 28 | # 29 | # Foo.new.hello # => "hello" 30 | # Foo.new.goodbye # => NoMethodError: undefined method `goodbye' for # 31 | # ``` 32 | # 33 | # Methods can be delegated to instance variables, class variables, or constants 34 | # by providing them as a symbols: 35 | # 36 | # ```ruby 37 | # class Foo 38 | # CONSTANT_ARRAY = [0,1,2,3] 39 | # @@class_array = [4,5,6,7] 40 | # 41 | # def initialize 42 | # @instance_array = [8,9,10,11] 43 | # end 44 | # delegate :sum, to: :CONSTANT_ARRAY 45 | # delegate :min, to: :@@class_array 46 | # delegate :max, to: :@instance_array 47 | # end 48 | # 49 | # Foo.new.sum # => 6 50 | # Foo.new.min # => 4 51 | # Foo.new.max # => 11 52 | # ``` 53 | # 54 | # See {#delegate} docs for available params. 55 | # 56 | # The target method must be public, otherwise it will raise `NoMethodError`. 57 | # 58 | module Delegates 59 | # Initial source: https://github.com/rails/rails/blob/master/activesupport/lib/active_support/core_ext/module/delegation.rb 60 | 61 | # Error generated by +delegate+ when a method is called on +nil+ and +allow_nil+ 62 | # option is not used. 63 | class DelegationError < NoMethodError; end 64 | 65 | # @private 66 | RUBY_RESERVED_KEYWORDS = 67 | %w[alias and BEGIN begin break case class def defined? do 68 | else elsif END end ensure false for if in module next nil not or redo rescue retry 69 | return self super then true undef unless until when while yield].freeze 70 | 71 | # @private 72 | DELEGATION_RESERVED_KEYWORDS = %w[_ arg args block].freeze 73 | 74 | # @private 75 | DELEGATION_RESERVED_METHOD_NAMES = Set.new( 76 | RUBY_RESERVED_KEYWORDS + DELEGATION_RESERVED_KEYWORDS 77 | ).freeze 78 | 79 | # Note that methods call to exactly once (in case it has side-effects) 80 | 81 | # @private 82 | ALLOW_NIL_PATTERN = <<~RUBY 83 | def %{method_name}(%{definition}) 84 | _ = %{to} 85 | if !_.nil? || nil.respond_to?(:%{method}) 86 | _.%{method}(%{definition}) 87 | end 88 | end 89 | RUBY 90 | 91 | # @private 92 | NO_NIL_PATTERN = <<~RUBY 93 | def %{method_name}(%{definition}) 94 | _ = %{to} 95 | _.%{method}(%{definition}) 96 | rescue NoMethodError => e 97 | if _.nil? && e.name == :%{method} 98 | raise DelegationError, "%{module}#%{method_name} delegated to %{to}.%{method}, but %{to} is nil: \#{self.inspect}" 99 | else 100 | raise 101 | end 102 | end 103 | RUBY 104 | 105 | # Defines methods specified in `methods` to delegate their calls to `to`. 106 | # 107 | # @param methods [Array] List of method names to delegate 108 | # @param to [String, Symbol] Specifies the target object name, could be anything callable from inside 109 | # the class, e.g. `:some_attribute`, `:@any_instance_variable`, `'chain.of.calls` 110 | # @param prefix [true, String, Symbol] If `true`, prefixes new method with target method, e.g. 111 | # `delegate :name, to: :customer, prefix: true` produces method named `customer_name`; if 112 | # set to a string/symbol, uses it as a custom prefix, e.g. `delegate :name, to: :customer, prefix: 'cs'` 113 | # produces method named `cs_name` 114 | # @param allow_nil [true, false] If set to `true`, then returns `nil` when delegated object 115 | # is `nil`; otherwise (default) raises `Delegates::DelegationError` 116 | # @param private [true, false] If set to `true`, changes method visibility to private 117 | # 118 | # @return [Array] Array of method names defined 119 | def delegate(*methods, to:, prefix: nil, allow_nil: false, private: false) 120 | if prefix == true && /^[^a-z_]/.match?(to) 121 | raise ArgumentError, 122 | 'Can only automatically set the delegation prefix when delegating to a method.' 123 | end 124 | 125 | method_prefix = if prefix 126 | "#{prefix == true ? to : prefix}_" 127 | else 128 | '' 129 | end 130 | 131 | location = caller_locations(1, 1).first 132 | file, line = location.path, location.lineno 133 | 134 | to = to.to_s 135 | to = "self.#{to}" if DELEGATION_RESERVED_METHOD_NAMES.include?(to) 136 | 137 | method_defs = [] 138 | method_names = [] 139 | 140 | methods.map(&:to_s).map do |method| 141 | method_name = "#{method_prefix}#{method}" 142 | method_names << method_name.to_sym 143 | 144 | # Attribute writer methods only accept one argument. Makes sure []= 145 | # methods still accept two arguments. 146 | definition = if method.match?(/[^\]]=$/) 147 | 'arg' 148 | elsif RUBY_VERSION >= '2.7' 149 | '...' 150 | else 151 | '*args, &block' 152 | end 153 | 154 | method_defs << 155 | if allow_nil 156 | ALLOW_NIL_PATTERN % { 157 | method_name: method_name, 158 | definition: definition, 159 | method: method, 160 | to: to 161 | } 162 | else 163 | NO_NIL_PATTERN % { 164 | module: self, 165 | method_name: method_name, 166 | definition: definition, 167 | method: method, 168 | to: to 169 | } 170 | end 171 | end 172 | module_eval(method_defs.join(';').gsub(/ *\n */m, ';'), file, line) 173 | private(*method_names) if private 174 | method_names 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /spec/.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: ../.rubocop.yml 2 | 3 | RSpec/ImplicitBlockExpectation: 4 | Enabled: false 5 | 6 | RSpec/ImplicitSubject: 7 | Enabled: false 8 | 9 | Style/BlockDelimiters: 10 | Enabled: false 11 | 12 | Layout/LineLength: 13 | Max: 120 14 | -------------------------------------------------------------------------------- /spec/delegates_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rspec/its' 4 | require 'saharspec' 5 | require 'delegates' 6 | 7 | RSpec.describe Delegates do 8 | RSpec::Matchers.define :provide do |**attrs| 9 | match do |block| 10 | result = block.call 11 | expect(result).to have_attributes(**attrs) 12 | end 13 | 14 | supports_block_expectations 15 | end 16 | 17 | RSpec::Matchers.define :provide_failing do |**errs| 18 | match do |block| 19 | result = block.call 20 | errs.each do |method, error| 21 | expect { result.send(method) }.to raise_error(*error) 22 | end 23 | end 24 | 25 | supports_block_expectations 26 | end 27 | 28 | subject(:delegator) { 29 | proc { |*methods, **params| 30 | Struct.new(:name, :place, :age, :case) { 31 | extend Delegates 32 | 33 | def self.table_name 34 | 'some_table' 35 | end 36 | 37 | def initialize(*) 38 | super 39 | @ivar = 111 40 | end 41 | 42 | # method with side-effects, should be called only once per delegation 43 | def item 44 | @items ||= [1] 45 | @items.shift # rubocop:disable RSpec/InstanceVariable 46 | end 47 | 48 | delegate(*methods, **params) 49 | }.new(*args) 50 | } 51 | } 52 | 53 | let(:place) { Struct.new(:street, :city) } 54 | let(:args) { ['Victor', place.new('Oleksiivska', 'Kharkiv'), nil, '#123'] } 55 | 56 | its_call(:street, :city, to: :place) { 57 | is_expected.to provide(city: 'Kharkiv', street: 'Oleksiivska') 58 | } 59 | 60 | its_call(:street, to: :place, prefix: true) { 61 | is_expected.to provide(place_street: 'Oleksiivska') 62 | } 63 | 64 | its_call(:street, to: :place, prefix: 'city') { 65 | is_expected.to provide(city_street: 'Oleksiivska') 66 | } 67 | 68 | its_call(:upcase, to: :name, prefix: true, allow_nil: true) { 69 | is_expected.to provide(name_upcase: 'VICTOR') 70 | } 71 | 72 | its_call(:floor, to: :age, prefix: true, allow_nil: true) { 73 | is_expected.to provide(age_floor: nil) 74 | } 75 | 76 | its_call(:to_f, to: :age, prefix: true, allow_nil: true) { 77 | is_expected.to provide(age_to_f: 0.0) 78 | } 79 | 80 | its_call(:floor, to: :age, prefix: true) { 81 | is_expected.to provide_failing(age_floor: [Delegates::DelegationError, /delegated to age\.floor, but age is nil/]) 82 | } 83 | 84 | its_call(:length, to: :case, prefix: true) { 85 | is_expected.to provide(case_length: 4) 86 | } 87 | 88 | its_call(:table_name, to: :class) { 89 | is_expected.to provide(table_name: 'some_table') 90 | } 91 | 92 | its_call(:table_name, to: :class, prefix: true) { 93 | is_expected.to provide(class_table_name: 'some_table') 94 | } 95 | 96 | its_call(:upcase, to: 'place.city') { 97 | is_expected.to provide(upcase: 'KHARKIV') 98 | } 99 | 100 | its_call(:foo, to: :place) { 101 | is_expected.to provide_failing(foo: NoMethodError) 102 | } 103 | 104 | its_call(:to_f, to: :item) { 105 | is_expected.to provide(to_f: 1.0) 106 | } 107 | 108 | its_call(:to_f, to: :@ivar) { 109 | is_expected.to provide(to_f: 111.0) 110 | } 111 | 112 | its_call(:to_f, to: :@ivar, prefix: true) { 113 | is_expected.to raise_error ArgumentError, /when delegating to a method/ 114 | } 115 | 116 | context 'when methods have arguments' do 117 | subject(:object) { delegator.call(:street=, to: :place) } 118 | 119 | it { 120 | expect { object.street = 'Peremohy' }.to change { object.place.street }.to 'Peremohy' 121 | } 122 | end 123 | end 124 | --------------------------------------------------------------------------------