views:

63

answers:

2

I've got two models: Item and Tag. Both have a name attribute. I want to find items tagged with several tags.

class Item < ActiveRecord::Base
  has_many :tags
  validates_presence_of :name
end

class Tag < ActiveRecord::Base
  belongs_to :item
  validates_presence_of :name
end

Given a list of tag ids, I can easily enough get the list of items tagged with one tag or the other:

# Find the items tagged with one or more of the tags on tag_ids
Item.all(:conditions => ['tags.id in (?)', tag_ids], :joins => :tags)

If tag_ids is {1,4}, then I get all pictures tagged with 1, or 4, or both.

I want to know now how to get the pictures that are tagged with both - 1 AND 4.

I can't even imagine the SQL that is needed here.

+5  A: 

You can solve this by grouping the results and checking the count:

Item.all(
  :conditions => ['tags.id IN (?)', tag_ids], 
  :joins      => :tags, 
  :group      => 'items.id', 
  :having     => ['COUNT(*) >= ?', tag_ids.length]
)
elektronaut
This is simple and elegant. It helped me a lot. Thank you!
egarcia
A: 

I've got one thing to add to elektronaut's otherwise wonderful answer: it will not work on PostgreSQL.

On my real example the Item.all call includes other tables; so the select looks like this:

SELECT items.id AS t0_f0, items.name as t0_f1 ..., table2.field1 as t1_f0 .. etc

PostgreSQL's GROUP BY requires that all fields used on a select to be included there. So I had to include all the fields used on the previous select on the GROUP BY clause.

And still it didn't work; I'm not sure why.

I ended up doing a simpler, uglier thing. It requires two db requests. One of them is used to return ids, which are used as a condition.

class Item < ActiveRecord::Base

  # returns the ids of the items tagged with all tags
  # usage: Item.tagged_all(1,2,3)
  named_scope :tagged_all, lambda { |*args|
    { :select => "items.id",
      :joins => :tags,
      :group => "items.id",
      :having => ['COUNT(items.id) >= ?', args.length],
      :conditions => ["tags.id IN (?)", args]
    }
  }

Then I can do this:

  Item.all(
    :conditions => [
      'items.id IN (?) AND ... (other conditions) ...',
      Items.tagged_all(*tag_ids).collect(&:id),
      ... (other values for conditions) ...
    ],
    :includes => [:model2, :model3] #tags isn't needed here any more
  )

Hacky, but it works, and the hackyness is localized.

egarcia