views:

273

answers:

3

Is there an easy or at least elegant way to prevent duplicate entries in polymorphic has_many through associations?

I've got two models, stories and links that can be tagged. I'm making a conscious decision to not use a plugin here. I want to actually understand everything that's going on and not be dependent on someone else's code that I don't fully grasp.

To see what my question is getting at, if I run the following in the console (assuming the story and tag objects exist in the database already)

s = Story.find_by_id(1)

t = Tag.find_by_id(1)

s.tags << t

s.tags << t

My taggings join table will have two entries added to it, each with the same exact data (tag_id = 1, taggable_id = 1, taggable_type = "Story"). That just doesn't seem very proper to me. So in an attempt to prevent this from happening I added the following to my Tagging model:

before_validation :validate_uniqueness

def validate_uniqueness
    taggings = Tagging.find(:all, :conditions => { :tag_id => self.tag_id, :taggable_id => self.taggable_id, :taggable_type => self.taggable_type })

    if !taggings.empty?
        return false
    end

    return true
end

And it works almost as intended, but if I attempt to add a duplicate tag to a story or link I get an ActiveRecord::RecordInvalid: Validation failed exception. It seems that when you add an association to a list it calls the save! (rather than save sans !) method which raises exceptions if something goes wrong rather than just returning false. That isn't quite what I want to happen. I suppose I can surround any attempts to add new tags with a try/catch but that goes against the idea that you shouldn't expect your exceptions and this is something I fully expect to happen.

Is there a better way of doing this that won't raise exceptions when all I want to do is just silently not save the object to the database because a duplicate exists?

A: 

You can set the uniq option when defining has_many relation. Rails API docs says:

:uniq

If true, duplicates will be omitted from the collection. Useful in conjunction with :through.

(taken from: http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#M001833 under "Supported options" subheading)

Slobodan Kovacevic
The uniq option doesn't actually prevent anything from being put into the database. In fact, based on my tests, it looks like that it only comes into play when an object is instantiated in that it appears to call uniq! on the object's association list which just deletes them in memory at that moment. If any duplicate associations are added they still show up in both the object's list and in the database. This is a perfect example of why the rails documentation is awful and in desperate need clarification and/or revision.
seaneshbaugh
`:uniq` has nothing to do with preventing duplicated entries in database...
j.
A: 

I believe this works...

class Tagging < ActiveRecord::Base
   validate :validate_uniqueness 

   def validate_uniqueness
      taggings = Tagging.find(:all, :conditions => {
         :tag_id => self.tag_id,
         :taggable_id => self.taggable_id,
         :taggable_type => self.taggable_type }) 

      errors.add_to_base("Your error message") unless taggings.empty? 
   end 
end

Let me know if you get any errors or something with that :]

j.
+1  A: 

You could do it a couple of ways.

Define a custom add_tags method that loads all the existing tags then checks for and only adds the new ones.

Example:

def add_tags *new_tags
  new_tags = new_tags.first if tags[0].kind_of? Enumerable #deal with Array as first argument
  new_tags.delete_if do |new_tag|
    self.tags.any? {|tag| tag.name == new_tag.name}
  end
  self.tags += new_tags
end

You could also use a before_save filter to ensure that the list of tags doesn't have any duplicates. This would incur a little more overhead because it would happen on EVERY save.

Daniel Beardsley
This is pretty much exactly what I want. However I'm not certain how you'd go about overloading the tags<< method. I tried copying the add_tags method and just renaming it "tags<<" but it gives me NoMethodError: undefined method `tags' for #<Story:0x4d3e9fc> whenever I try to call it. I'd really like the << method to be overloaded since it seems more natural to use that.
seaneshbaugh
Oh yeah.. I forgot, you have to define a tags method that returns an object which has a `<<` method. BUT, self.tags << new_tags wouldn't work anyway, because that will just try to add an array of tags as an element in the tags collection. You would want to use `self.tags += new_tags` for that line. Answer updated.
Daniel Beardsley