Rails - filter using join model on has_many through
So you want to filter using a scope through an association (not just for use on has_many through, but I feel this example really shows how #merge shines!).
Given the following models:
class Person
has_many :accounts
has_many :computers, through: :accounts
end
class Account
belongs_to :person
belongs_to :computer
scope :administrators, -> { where(role: 'administrator') }
end
class Computer
has_many :accounts
has_many :people, through: :accounts
end
Filtering using a join model on a has_many through can be naively accomplished with the following. Here assume we have a person instance and want to find all computers where they are an administrator.
person.accounts.administrators.map(&:computer) # ewww
We can do this better using ActiveRecord::SpawnMethods#merge!
person.computers.merge(Account.administrators) # very nice!
Cleaner syntax, claner query. Win win
Written by James Brooks
Related protips
9 Responses
Rails is black magic. That's insane.
Are you sure this is much efficient? Doen't Rails need to make two queries + run a merging algorithm?
Actually, I was looking for a clean solution, and I'll go for the first one! :)
It is as #merge combines AR::Relations into a new AR::Relation. When this is ran only a single query is ran opposed to multiple queries (as well as multiple AR instance instantiations being performed that aren't required).
The first approach (using #map) runs 1+O(n) queries (which can be reduced to 2 by using includes(:computer) before the #map call).
The second approach (using #merge) only runs 1 query, as well as having the advantage as returning a AR::Relation (good for chaining on additional scopes if required, as well as lazy evaluation).
I benchmarked the results of the three different calls (including using #include also), my results were (1000 iterations each):
user system total real
#map 1.460000 0.060000 1.520000 ( 1.813301)
#map + #include 1.110000 0.040000 1.150000 ( 1.381373)
#merge 0.190000 0.000000 0.190000 ( 0.188655)
Seems to be ~10x speedup over #map, ~7x speedup over #map + #include.
footnote: In this example the query generated (looking at #to_sql) using #merge is:
SELECT `computers`.* FROM `computers` INNER JOIN `accounts` ON `computers`.`id` = `accounts`.`computer_id` WHERE `accounts`.`person_id` = 1 AND `accounts`.`role` = 'administrator'
OK, everything is cool but... why don't you use Rails's STI
& default_scope
s for that?
Besides the really nice query interface, you'll also get the write association methods working for free, as ActiveRecord can populate default values from the current scope.
# The person, a real object
class Person < ActiveRecord::Base
# Every computer that person has access to
has_many :accounts
has_many :computers, :through => :accounts
# Only comps that this account can administrate
has_many :administrator_accounts, :class_name => 'Account::Administrator'
has_many :administrated_computers, :through => :administrator_accounts, :source => :computer
end
# The account - a relationship (<3) between a person and a computer
class Account < ActiveRecord::Base
belongs_to :person
belongs_to :computer
# The administrator subclass - here we're using a `default_scope` rather than just normal STI with a `type` field (which I prefer)
class Administrator < Account
default_scope lambda { where(:role => 'administrator') }
end
end
# The computer being used
class Computer < ActiveRecord::Base
# [ALL] Users
has_many :accounts
has_many :people, :through => :accounts
# Administrators through abnormal accounts
has_many :administrator_accounts, :class_name => 'Account::Administrator'
has_many :administrators, :through => :administrator_accounts, :source => :person
end
person1 = Person.create
person2 = Person.create
comp1 = Computer.create
comp2 = Computer.create
person1.computers << comp1
person1.administrated_computers << comp2
person2.computers << comp2
person2.administrated_computers << comp1
ap person1.computers.to_a
ap person1. administrated_computers.to_a
ap person2.computers.to_a
ap person2. administrated_computers.to_a
Here's a full rails test-like gist: https://gist.github.com/tzvetkoff/7287456
very magic!
Why not simply do
class Person < ActiveRecord::Base
# Every computer that person has access to
has_many :accounts
has_many :computers, :through => :accounts
# Only comps that this account can administrate
has_many :administrator_accounts, -> { administrators }, class_name: 'Account'
has_many :administrated_computers, :through => :administrator_accounts, :source => :computer
end
tnx
cool