Where developers come to connect, share, build and be inspired.

25

Rails - filter using join model on has_many through

5001 views

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

Comments

  • 317471_10150285147655308_136807138_n
    jamesbrooks

    @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'
    

  • Blank-mugshot
    tzvetkoff

    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
    

  • Profile_picture_may_2012
    jalada

    Rails is black magic. That's insane.

  • Blank-mugshot
    aug-riedinger

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

  • Blank-mugshot
    tzvetkoff

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

  • E5b8b9b9d298829d2a8bf36033451b72_normal
    usa_lg

    very magic!

Add a comment