Skip to content

Commit

Permalink
Merge pull request rails#50872 from Shopify/delegate-anonymous-block
Browse files Browse the repository at this point in the history
Improve Active Support delegation
  • Loading branch information
byroot authored Jan 25, 2024
2 parents c7551d0 + 8ec5219 commit 95bb5cc
Show file tree
Hide file tree
Showing 10 changed files with 229 additions and 179 deletions.
27 changes: 17 additions & 10 deletions actioncable/lib/action_cable/channel/broadcasting.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,26 @@ def broadcasting_for(model)
serialize_broadcasting([ channel_name, model ])
end

def serialize_broadcasting(object) # :nodoc:
case
when object.is_a?(Array)
object.map { |m| serialize_broadcasting(m) }.join(":")
when object.respond_to?(:to_gid_param)
object.to_gid_param
else
object.to_param
private
def serialize_broadcasting(object) # :nodoc:
case
when object.is_a?(Array)
object.map { |m| serialize_broadcasting(m) }.join(":")
when object.respond_to?(:to_gid_param)
object.to_gid_param
else
object.to_param
end
end
end
end

delegate :broadcasting_for, :broadcast_to, to: :class, as: ClassMethods
def broadcasting_for(model)
self.class.broadcasting_for(model)
end

def broadcast_to(model, message)
self.class.broadcast_to(model, message)
end
end
end
end
4 changes: 3 additions & 1 deletion actioncable/lib/action_cable/channel/naming.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ def channel_name
end
end

delegate :channel_name, to: :class, as: ClassMethods
def channel_name
self.class.channel_name
end
end
end
end
5 changes: 4 additions & 1 deletion actionview/lib/action_view/template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -524,12 +524,15 @@ def handle_render_error(view, e)
end
end

RUBY_RESERVED_KEYWORDS = ::ActiveSupport::Delegation::RUBY_RESERVED_KEYWORDS
private_constant :RUBY_RESERVED_KEYWORDS

def locals_code
return "" if strict_locals?

# Only locals with valid variable names get set directly. Others will
# still be available in local_assigns.
locals = @locals - Module::RUBY_RESERVED_KEYWORDS
locals = @locals - RUBY_RESERVED_KEYWORDS

locals = locals.grep(/\A(?![A-Z0-9])(?:[[:alnum:]_]|[^\0-\177])+\z/)

Expand Down
2 changes: 1 addition & 1 deletion activerecord/lib/active_record/relation/delegation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def generate_method(method)
MUTEX.synchronize do
return if method_defined?(method)

if /\A[a-zA-Z_]\w*[!?]?\z/.match?(method) && !DELEGATION_RESERVED_METHOD_NAMES.include?(method.to_s)
if /\A[a-zA-Z_]\w*[!?]?\z/.match?(method) && !::ActiveSupport::Delegation::RESERVED_METHOD_NAMES.include?(method.to_s)
module_eval <<-RUBY, __FILE__, __LINE__ + 1
def #{method}(...)
scoping { klass.#{method}(...) }
Expand Down
1 change: 1 addition & 0 deletions activesupport/lib/active_support.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ module ActiveSupport
autoload :Callbacks
autoload :Configurable
autoload :Deprecation
autoload :Delegation
autoload :Digest
autoload :ExecutionContext
autoload :Gzip
Expand Down
183 changes: 21 additions & 162 deletions activesupport/lib/active_support/core_ext/module/delegation.rb
Original file line number Diff line number Diff line change
@@ -1,33 +1,16 @@
# frozen_string_literal: true

require "set"

class Module
# Error generated by +delegate+ when a method is called on +nil+ and +allow_nil+
# option is not used.
class DelegationError < NoMethodError
class << self
def nil_target(method_name, target) # :nodoc:
new("#{method_name} delegated to #{target}, but #{target} is nil")
end
end
end

RUBY_RESERVED_KEYWORDS = %w(__ENCODING__ __LINE__ __FILE__ alias and BEGIN begin break
case class def defined? do else elsif END end ensure false for if in module next nil
not or redo rescue retry return self super then true undef unless until when while yield)
DELEGATION_RESERVED_KEYWORDS = %w(_ arg args block)
DELEGATION_RESERVED_METHOD_NAMES = Set.new(
RUBY_RESERVED_KEYWORDS + DELEGATION_RESERVED_KEYWORDS
).freeze
require "active_support/delegation"
DelegationError = ActiveSupport::DelegationError # :nodoc:

# Provides a +delegate+ class method to easily expose contained objects'
# public methods as your own.
#
# ==== Options
# * <tt>:to</tt> - Specifies the target object name as a symbol or string
# * <tt>:prefix</tt> - Prefixes the new method with the target name or a custom prefix
# * <tt>:allow_nil</tt> - If set to true, prevents a +Module::DelegationError+
# * <tt>:allow_nil</tt> - If set to true, prevents a +ActiveSupport::DelegationError+
# from being raised
# * <tt>:private</tt> - If set to true, changes method visibility to private
#
Expand Down Expand Up @@ -138,7 +121,7 @@ def nil_target(method_name, target) # :nodoc:
# User.new.age # => 2
#
# If the target is +nil+ and does not respond to the delegated method a
# +Module::DelegationError+ is raised. If you wish to instead return +nil+,
# +ActiveSupport::DelegationError+ is raised. If you wish to instead return +nil+,
# use the <tt>:allow_nil</tt> option.
#
# class User < ActiveRecord::Base
Expand All @@ -147,7 +130,7 @@ def nil_target(method_name, target) # :nodoc:
# end
#
# User.new.age
# # => Module::DelegationError: User#age delegated to profile.age, but profile is nil
# # => ActiveSupport::DelegationError: User#age delegated to profile.age, but profile is nil
#
# But if not having a profile yet is fine and should not be an error
# condition:
Expand All @@ -174,113 +157,16 @@ def nil_target(method_name, target) # :nodoc:
# Foo.new("Bar").name # raises NoMethodError: undefined method `name'
#
# The target method must be public, otherwise it will raise +NoMethodError+.
def delegate(*methods, to: nil, prefix: nil, allow_nil: nil, private: nil, as: nil)
unless to
raise ArgumentError, "Delegation needs a target. Supply a keyword argument 'to' (e.g. delegate :hello, to: :greeter)."
end

if prefix == true && /^[^a-z_]/.match?(to)
raise ArgumentError, "Can only automatically set the delegation prefix when delegating to a method."
end

method_prefix = \
if prefix
"#{prefix == true ? to : prefix}_"
else
""
end

location = caller_locations(1, 1).first
file, line = location.path, location.lineno

receiver = to.to_s
receiver = "self.#{receiver}" if DELEGATION_RESERVED_METHOD_NAMES.include?(receiver)

explicit_receiver = false
receiver_class = if as
explicit_receiver = true
as
elsif to.is_a?(Module)
to.singleton_class
elsif receiver == "self.class"
singleton_class
end

method_def = []
method_names = []

method_def << "self.private" if private

methods.each do |method|
method_name = prefix ? "#{method_prefix}#{method}" : method
method_names << method_name.to_sym

# Attribute writer methods only accept one argument. Makes sure []=
# methods still accept two arguments.
definition = \
if /[^\]]=\z/.match?(method)
"arg"
else
method_object = if receiver_class
begin
receiver_class.public_instance_method(method)
rescue NameError
raise if explicit_receiver
# Do nothing. Fall back to `"..."`
end
end

if method_object
parameters = method_object.parameters

if parameters.map(&:first).intersect?([:opt, :rest, :keyreq, :key, :keyrest])
"..."
else
defn = parameters.filter_map { |type, arg| arg if type == :req }
defn << "&block"
defn.join(", ")
end
else
"..."
end
end

# The following generated method calls the target exactly once, storing
# the returned value in a dummy variable.
#
# Reason is twofold: On one hand doing less calls is in general better.
# On the other hand it could be that the target has side-effects,
# whereas conceptually, from the user point of view, the delegator should
# be doing one call.
if allow_nil
method = method.to_s

method_def <<
"def #{method_name}(#{definition})" <<
" _ = #{receiver}" <<
" if !_.nil? || nil.respond_to?(:#{method})" <<
" _.#{method}(#{definition})" <<
" end" <<
"end"
else
method = method.to_s
method_name = method_name.to_s

method_def <<
"def #{method_name}(#{definition})" <<
" _ = #{receiver}" <<
" _.#{method}(#{definition})" <<
"rescue NoMethodError => e" <<
" if _.nil? && e.name == :#{method}" <<
" raise DelegationError.nil_target(:#{method_name}, :'#{receiver}')" <<
" else" <<
" raise" <<
" end" <<
"end"
end
end
module_eval(method_def.join(";"), file, line)
method_names
def delegate(*methods, to: nil, prefix: nil, allow_nil: nil, private: nil)
::ActiveSupport::Delegation.generate(
self,
methods,
location: caller_locations(1, 1).first,
to: to,
prefix: prefix,
allow_nil: allow_nil,
private: private,
)
end

# When building decorators, a common pattern may emerge:
Expand Down Expand Up @@ -322,45 +208,18 @@ def delegate(*methods, to: nil, prefix: nil, allow_nil: nil, private: nil, as: n
# variables, methods, constants, etc.
#
# The delegated method must be public on the target, otherwise it will
# raise +DelegationError+. If you wish to instead return +nil+,
# raise +ActiveSupport::DelegationError+. If you wish to instead return +nil+,
# use the <tt>:allow_nil</tt> option.
#
# The <tt>marshal_dump</tt> and <tt>_dump</tt> methods are exempt from
# delegation due to possible interference when calling
# <tt>Marshal.dump(object)</tt>, should the delegation target method
# of <tt>object</tt> add or remove instance variables.
def delegate_missing_to(target, allow_nil: nil)
target = target.to_s
target = "self.#{target}" if DELEGATION_RESERVED_METHOD_NAMES.include?(target)

module_eval <<-RUBY, __FILE__, __LINE__ + 1
def respond_to_missing?(name, include_private = false)
# It may look like an oversight, but we deliberately do not pass
# +include_private+, because they do not get delegated.
return false if name == :marshal_dump || name == :_dump
#{target}.respond_to?(name) || super
end
def method_missing(method, ...)
if #{target}.respond_to?(method)
#{target}.public_send(method, ...)
else
begin
super
rescue NoMethodError
if #{target}.nil?
if #{allow_nil == true}
nil
else
raise DelegationError.nil_target(method, :'#{target}')
end
else
raise
end
end
end
end
RUBY
::ActiveSupport::Delegation.generate_method_missing(
self,
target,
allow_nil: allow_nil,
)
end
end
5 changes: 3 additions & 2 deletions activesupport/lib/active_support/current_attributes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ def attribute(*names, default: nil)
end
end

singleton_class.delegate(*names.flat_map { |name| [name, "#{name}="] }, to: :instance, as: self)
Delegation.generate(singleton_class, names, to: :instance, nilable: false, signature: "")
Delegation.generate(singleton_class, names.map { |n| "#{n}=" }, to: :instance, nilable: false, signature: "value")

self.defaults = defaults.merge(names.index_with { default })
end
Expand Down Expand Up @@ -184,7 +185,7 @@ def method_added(name)
return if name == :initialize
return unless public_method_defined?(name)
return if respond_to?(name, true)
singleton_class.delegate(name, to: :instance, as: self)
Delegation.generate(singleton_class, [name], to: :instance, as: self, nilable: false)
end
end

Expand Down
Loading

0 comments on commit 95bb5cc

Please sign in to comment.