Reputation: 9507
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
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
Reputation: 165556
First, the definition of has_many
changed between Rails 4 and Rails 5. Let's start with 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 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.
name
is a required positional argument.scope = nil
is a positional argument that has a default, so it's optional.**options
is a keyword argument that will convert the keywords into a hash.&extension
is a block argument.When you call has_many :subscribers, through: :subscriptions, source: :user
this is what happens.
name
so it takes :subscribers
.scope
, but it's run out of positional, the rest of the arguments are keywords, so it uses the default nil
.options
hash.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