Last Updated: October 16, 2022
·
63.83K
· jamesbrooks

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

9 Responses
Add your response

Rails is black magic. That's insane.

over 1 year ago ·

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! :)

over 1 year ago ·

@aug-riedinger

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'
over 1 year ago ·

OK, everything is cool but... why don't you use Rails's STI & default_scopes 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
over 1 year ago ·

Here's a full rails test-like gist: https://gist.github.com/tzvetkoff/7287456

over 1 year ago ·

very magic!

over 1 year ago ·

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
over 1 year ago ·

tnx

over 1 year ago ·

cool

over 1 year ago ·