Daniel Viglione
Daniel Viglione

Reputation: 9507

Hashes as arguments and default argument values

I am perusing some of the Rails internals, particularly the has_many method. It shows the following examples:

  # Option examples:
  #   has_many :comments, -> { order "posted_on" }
  #   has_many :comments, -> { includes :author }
  #   has_many :people, -> { where(deleted: false).order("name") }, class_name: "Person"
  #   has_many :tracks, -> { order "position" }, dependent: :destroy
  #   has_many :comments, dependent: :nullify
  #   has_many :tags, as: :taggable
  #   has_many :reports, -> { readonly }
  #   has_many :subscribers, through: :subscriptions, source: :user
  def has_many(name, scope = nil, options = {}, &extension)
    reflection = Builder::HasMany.build(self, name, scope, options, &extension)
    Reflection.add_reflection self, name, reflection
  end

Look at this one in particular:

has_many :subscribers, through: :subscriptions, source: :user

The second argument is a hash. And I noticed it gets assigned to the options local variable. But why didn't it get assigned to the scope variable, since the scope variable is the second argument in the argument list? Why was the scope variable skipped during assignment? I no that it sets a default value of nil, if no second argument is passed in, but in fact a second argument was passed in.

Upvotes: 1

Views: 1799

Answers (2)

Simple Lime
Simple Lime

Reputation: 11070

The hash was assigned to the scope parameter, since it was passed in as the second parameter, thus leaving options to get its default value of {}. But, ActiveRecord swaps the parameters around if you pass a hash as the second value. This is handled in Builder::Association (which is used because Builder::HasMany inherits from Builder:: CollectionAssociation which in turn inherits from Builder::Association). The build class method calls create_builder which then passes the arguments onto initialize which checks

if scope.is_a?(Hash)
  options = scope
  scope   = nil
end

So no built-in ruby behind-the-scenes magic, ActiveRecord just chose to do a check to see if you passed a Hash as the second parameter and 'fix' it for you, instead of forcing you to pass nil.


CollectionAssociation does override the initialize method, but you can see they do call super at the start of the method, making sure that Association#initialize gets called first and this param swapping happens.

Upvotes: 1

Schwern
Schwern

Reputation: 165556

First, the definition of has_many changed between Rails 4 and Rails 5. Let's start with Rails 4.

Rails 4

You are correct that something fishy is going on here. Rails is cheating. I'm going to guess for backwards compatibilty with older versions of Ruby which didn't have as rich an argument syntax as now.

name, scope, and options are all positional arguments. We can define has_many to see what's going on.

def has_many(name, scope = nil, options = {}, &extension)
  puts "name: #{name}"
  puts "scope: #{scope}"
  puts "options: #{options}"
  puts "extension: #{extension}"
end

If we run has_many :subscribers, through: :subscriptions, source: :user...

name: subscribers
scope: {:through=>:subscriptions, :source=>:user}
options: {}
extension: 

Well that's not right. Let's look at its source...

def has_many(name, scope = nil, options = {}, &extension)
  reflection = Builder::HasMany.build(self, name, scope, options, &extension)
  Reflection.add_reflection self, name, reflection
end

The arguments are passed through to Builder::HasMany.build which calls create_builder model, name, scope, options, &block. That calls new(model, name, scope, options, &block) to create a new instance. In its initializer we find this...

def initialize(model, name, scope, options)
  # TODO: Move this to create_builder as soon we drop support to activerecord-deprecated_finders.
  if scope.is_a?(Hash)
    options = scope
    scope   = nil
  end
  ...

So it's just cheating. If scope is a hash it switches it to options. This is a pretty nasty hack, seeing as how the wrong arguments go through several layers of method calls before they're fixed.

Rails 5

Rails 5 changed the signature of has_many.

def has_many(name, scope = nil, **options, &extension)
  puts "name: #{name}"
  puts "scope: #{scope}"
  puts "options: #{options}"
  puts "extension: #{extension}"
end

has_many :subscribers, through: :subscriptions, source: :user

And now it works as it's supposed to.

name: subscribers
scope: 
options: {:through=>:subscriptions, :source=>:user}
extension: 

** converts keyword arguments to a hash and this makes it work.

When you call has_many :subscribers, through: :subscriptions, source: :user this is what happens.

  1. Ruby needs the first positional name so it takes :subscribers.
  2. Ruby needs the second positional scope, but it's run out of positional, the rest of the arguments are keywords, so it uses the default nil.
  3. Ruby slurps up the keywords and puts them in the options hash.
  4. extension is a block argument and it's fine if those are left off.

You can read more about how argument processing happens in Ruby in the Calling Methods document.

Upvotes: 2

Related Questions