how to avoid duplicates in a has_many :through relationship?
Asked Answered
P

9

41

How can I achieve the following? I have two models (blogs and readers) and a JOIN table that will allow me to have an N:M relationship between them:

class Blog < ActiveRecord::Base
  has_many :blogs_readers, :dependent => :destroy
  has_many :readers, :through => :blogs_readers
end

class Reader < ActiveRecord::Base
  has_many :blogs_readers, :dependent => :destroy
  has_many :blogs, :through => :blogs_readers
end

class BlogsReaders < ActiveRecord::Base
  belongs_to :blog
  belongs_to :reader
end

What I want to do now, is add readers to different blogs. The condition, though, is that I can only add a reader to a blog ONCE. So there mustn't be any duplicates (same readerID, same blogID) in the BlogsReaders table. How can I achieve this?

The second question is, how do I get a list of blog that the readers isn't subscribed to already (e.g. to fill a drop-down select list, which can then be used to add the reader to another blog)?

Papillose answered 24/11, 2008 at 22:56 Comment(0)
I
7

What about:

Blog.find(:all,
          :conditions => ['id NOT IN (?)', the_reader.blog_ids])

Rails takes care of the collection of ids for us with association methods! :)

http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html

Incurable answered 25/11, 2008 at 14:39 Comment(2)
Also, I wanted to mention that this is probably the better method, as the accepted answer selects ALL data from the row(s) (e.g., the_reader.blogs) whereas my answer selects only the ids from the rows (e.g., the_reader.blog_ids). This is a big performance hit!Incurable
this is a better solution and should be the right answer. Thanks Josh.Stevenson
M
100

Simpler solution that's built into Rails:

 class Blog < ActiveRecord::Base
     has_many :blogs_readers, :dependent => :destroy
     has_many :readers, :through => :blogs_readers, :uniq => true
    end

    class Reader < ActiveRecord::Base
     has_many :blogs_readers, :dependent => :destroy
     has_many :blogs, :through => :blogs_readers, :uniq => true
    end

    class BlogsReaders < ActiveRecord::Base
      belongs_to :blog
      belongs_to :reader
    end

Note adding the :uniq => true option to the has_many call.

Also you might want to consider has_and_belongs_to_many between Blog and Reader, unless you have some other attributes you'd like to have on the join model (which you don't, currently). That method also has a :uniq opiton.

Note that this doesn't prevent you from creating the entries in the table, but it does ensure that when you query the collection you get only one of each object.

Update

In Rails 4 the way to do it is via a scope block. The Above changes to.

class Blog < ActiveRecord::Base
 has_many :blogs_readers, dependent:  :destroy
 has_many :readers,  -> { uniq }, through: :blogs_readers
end

class Reader < ActiveRecord::Base
 has_many :blogs_readers, dependent: :destroy
 has_many :blogs, -> { uniq }, through: :blogs_readers
end

class BlogsReaders < ActiveRecord::Base
  belongs_to :blog
  belongs_to :reader
end

Update for Rails 5

The use of uniq in the scope block will cause an error NoMethodError: undefined method 'extensions' for []:Array. Use distinct instead :

class Blog < ActiveRecord::Base
 has_many :blogs_readers, dependent:  :destroy
 has_many :readers,  -> { distinct }, through: :blogs_readers
end

class Reader < ActiveRecord::Base
 has_many :blogs_readers, dependent: :destroy
 has_many :blogs, -> { distinct }, through: :blogs_readers
end

class BlogsReaders < ActiveRecord::Base
  belongs_to :blog
  belongs_to :reader
end
Mcgrath answered 24/11, 2008 at 22:56 Comment(3)
I think there is a problem with this approach if your join model has any other fields. For example,a positions field so that each child can be positioned within its parent. blog.readers << reader # blog_readers.position = 1; blog.readers << reader # blog_readers.position = 2 As second blog_readers has a different position the uniq setting doesn't see it as an existing entry and allows it to be createdCarlo
If you have a default scope that orders your blogs, you will need to unscope that (or DISTINCT will fail), you can use this: has_many :blogs, -> { unscope(:order).uniq }, through: :blog_readersMelisent
To update @Melisent answer for Rails 5.2 has_many :blogs, -> { unscope(:order).distinct }, through: :blog_readersDayflower
S
39

This should take care of your first question:

class BlogsReaders < ActiveRecord::Base
  belongs_to :blog
  belongs_to :reader

  validates_uniqueness_of :reader_id, :scope => :blog_id
end
Stevenson answered 24/11, 2008 at 23:8 Comment(3)
I've been trying to figure this out for a long time, and this never occurred to me! Great solution! Thanks!Cymogene
Please read carefully about Concurrency and integrity here apidock.com/rails/ActiveRecord/Validations/ClassMethods/…Clingfish
I think this works fine in Rails 5 (working for me anyway)Castellany
H
20

The Rails 5.1 way

class Blog < ActiveRecord::Base
 has_many :blogs_readers, dependent:  :destroy
 has_many :readers,  -> { distinct }, through: :blogs_readers
end

class Reader < ActiveRecord::Base
 has_many :blogs_readers, dependent: :destroy
 has_many :blogs, -> { distinct }, through: :blogs_readers
end

class BlogsReaders < ActiveRecord::Base
  belongs_to :blog
  belongs_to :reader
end
Higherup answered 9/8, 2016 at 8:12 Comment(2)
Reasoning: github.com/rails/rails/pull/9683 and github.com/rails/rails/commit/…Ehlke
@Higherup But it is still insert the data in middle table blog_readers . how to prevent that ?Bactericide
I
7

What about:

Blog.find(:all,
          :conditions => ['id NOT IN (?)', the_reader.blog_ids])

Rails takes care of the collection of ids for us with association methods! :)

http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html

Incurable answered 25/11, 2008 at 14:39 Comment(2)
Also, I wanted to mention that this is probably the better method, as the accepted answer selects ALL data from the row(s) (e.g., the_reader.blogs) whereas my answer selects only the ids from the rows (e.g., the_reader.blog_ids). This is a big performance hit!Incurable
this is a better solution and should be the right answer. Thanks Josh.Stevenson
M
2

The answer at this link shows how to override the "<<" method to achieve what you are looking for without raising exceptions or creating a separate method: Rails idiom to avoid duplicates in has_many :through

Mainsheet answered 3/1, 2011 at 15:5 Comment(0)
M
2

The top answer currently says to use uniq in the proc:

class Blog < ActiveRecord::Base
 has_many :blogs_readers, dependent:  :destroy
 has_many :readers,  -> { uniq }, through: :blogs_readers
end

This however kicks the relation into an array and can break things that are expecting to perform operations on a relation, not an array.

If you use distinct it keeps it as a relation:

class Blog < ActiveRecord::Base
 has_many :blogs_readers, dependent:  :destroy
 has_many :readers,  -> { distinct }, through: :blogs_readers
end
Mordancy answered 4/8, 2017 at 16:1 Comment(0)
S
1

I'm thinking someone will come along with a better answer than this.

the_reader = Reader.find(:first, :include => :blogs)

Blog.find(:all, 
          :conditions => ['id NOT IN (?)', the_reader.blogs.map(&:id)])

[edit]

Please see Josh's answer below. It's the way to go. (I knew there was a better way out there ;)

Stevenson answered 25/11, 2008 at 0:42 Comment(1)
you could also do this in one statement using find_by_sql.Stevenson
E
1

I do the following for Rails 6

class BlogsReaders < ActiveRecord::Base
  belongs_to :blog
  belongs_to :reader

  validates :blog_id, uniqueness: { scope: :reader_id }
end

Don't forget to create database constraint to prevent violations of a uniqueness.

Eindhoven answered 30/8, 2021 at 19:45 Comment(0)
L
-2

Easiest way is to serialize the relationship into an array:

class Blog < ActiveRecord::Base
  has_many :blogs_readers, :dependent => :destroy
  has_many :readers, :through => :blogs_readers
  serialize :reader_ids, Array
end

Then when assigning values to readers, you apply them as

blog.reader_ids = [1,2,3,4]

When assigning relationships this way, duplicates are automatically removed.

Lytta answered 2/2, 2016 at 22:32 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.