Rails' named_scope: Picking Up Where Associations Leave Off

Posted on Jul 22

With the addition of named_scope to Edge Rails a while ago (merged from the has_finder plugin), many doors were opened and many codes were DRY'd up (presumably). Fast-forward to today when I was faced with an interesting situation… 

 In my scenario I have Account, Document, and DocumentFilter classes:
class Account < ActiveRecord::Base
  has_many :documents
  has_many :document_filters
end
class Document < ActiveRecord::Base
  belongs_to :account
end
class DocumentFilter < ActiveRecord::Base
  belongs_to :account 
  has_many :rules 
  def to_sql()
    ... produces a string for the WHERE clause based on its rules ...
  end
end 

The DocumentFilter can easily be changed to add/remove rules like "document type is Spreadsheet" (SQL: "type = 'Spreadsheet').  Given that, I needed to create a method called DocumentFilter#documents that would:

  a) return all its account's documents

  b) filter the documents based on its rules

One might consider creating a join table called DocumentFiltersDocuments, which would create a "has_many :documents, :through => :document_filters_documents" on the DocumentFilter class. But we really don't want to keep an extra table row for each match between a Document and a DocumentFilter. 

Both Document and DocumentFilter share no foreign_key relationship in the database besides that they might both belong to the same Account, so my first idea was that I could do a has_many :documents, :through => :account (based on the "DocumentFilter#belongs_to :account" relationship). This would not work though, since has_many :through doesn't work with a belongs_to in the middle (and was really meant to work with join tables, afaik). 

 There are traces of attempts to change this, but I didn't want to rely on a hack or try to rewrite a new AssociationCollection class for ActiveRecord. So here's my solution (which required just a few more lines of code than a has_many):

class Document < ActiveRecord::Base
  belongs_to :account
  named_scope :for_document_filter, lambda { {|df| :conditions => df.to_sql]} }
end 
class DocumentFilter < ActiveRecord::Base
  belongs_to :account
  def documents(force_reload=false)
    @documents = account.documents.for_document_filter(self) if @documents.nil? || force_reload
  end
end 

And if you're on the latest Edge Rails, you can also shorten it further with memoize():

def documents
  account.documents.for_document_filter(self)
end
memoize :documents 

 

Now you can almost treat DocumentFilter#documents as you would a has_many.

df = DocumentFilter.first # account_id is 1 and df.to_sql() returns "type = 'Spreadsheet'"
df.documents
#=> SELECT * FROM `documents` WHERE (`documents`.account_id = 1 AND (type = 'Spreadsheet') 
df.documents
#=> same results, but cached in @documents, so there is no SQL 
df.documents(true)
#=> SELECT * FROM `documents` WHERE (`documents`.account_id = 1 AND (type = 'Spreadsheet') 
df.documents.find(:all, :conditions => "true", :limit => 5)
#=> SELECT * FROM `documents` WHERE (`documents`.account_id = 1 AND (true)) AND (type = 'Spreadsheet') LIMIT 5 

 

 

Conclusion: named_scope is rule. 

Comments

  • MARTY FRAZIER
    MARTY FRAZIER posted on Aug 18 - 2008 12:52:51 AM

    IT OK BUT HAVE A LITTLE PROMBLE GET ON MY LIVE WIRE ,BUT OTHER THAN THAT U ARE REAL GOOD AKA MARTY