2

Suppose I have a Genre model that can be associated with either an instance of Book or Movie. Something like,

class Genre < ActiveRecord::Base
  belongs_to :genreable, polymorphic: true
end

class Book < ActiveRecord::Base
  has_many :genres, as: :genreable
end

class Movie < ActiveRecord::Base
  has_many :genres, as: :genreable
end

Now, suppose I want to normalize my genre data, so each genre has only a single entry in the Genres table:

class Genre < ActiveRecord::Base
  has_many :genre_instances
end

class GenreInstance < ActiveRecord::Base
  belongs_to :genre
  belongs_to :genreable, polymorphic: true
end

class Book < ActiveRecord::Base
  has_many :genre_insances, as: :genreable
  has_many :genres, through: :genre_instances
end

class Movie < ActiveRecord::Base
  has_many :genre_insances, as: :genreable
  has_many :genres, through: :genre_instances
end

My genre_instances table has fields for genre_id, genreable_id, and genreable_type.

So, if genre 5 is "adventure" and movie 13 is "Point Break", I could have an entry in genre_instances like genre_id: 5, genreable_id: 13, genreable_type: 'Movie'.

And if "Tom Sawyer" is book 13, I might have another entry like genre_id: 5, genreable_id: 13, genreable_type: 'Book'.

How can I enforce uniqueness in the genre_instances table in a way that accounts for the 'type' column? I want to ensure "Point Break" can only have a single "adventure" entry, without preventing "Tom Sawyer" from also having such an entry.

Edit: Thanks to cwsault for the answer below. It was dead-on. I also added a unique index at the database level with a migration like so:

class CreateGenreInstances < ActiveRecord::Migration
  def change
    create_table :genre_instances do |t|
      t.references :genre
      t.references :genreable, polymorphic: true

      t.timestamps
    end

    add_index :genre_instances, [:genre_id, :genreable_id, :genreable_type],
      unique: true, name: 'genre_and_genreable'
  end
end

...and a side-note: I had to specify the name of the index because ActiveRecord's auto-generated index-name was over the 64-character limit.

Took it all for a test run and it's working just as expected.

ivan
  • 6,032
  • 9
  • 42
  • 65

1 Answers1

3

Use the :scope property of uniqueness validations (documentation). So for example, in the GenreInstance model, try:

class GenreInstance < ActiveRecord::Base

    ...

    validates :genre_id, :uniqueness => {
        :scope => [:genreable_id, :genreable_type]
    }
emd

Here's a simplified example from one of my projects, which had articles categorized under various topics:

class Article < ActiveRecord::Base
    has_many :topic_mappings, :as => :entity, :dependent => :destroy
    has_many :topics, :through => :topic_mappings
end

class Topic < ActiveRecord::Base
    has_many :topic_mappings, :dependent => :destroy
end

class TopicMapping < ActiveRecord::Base
    belongs_to :entity, :polymorphic => true
    belongs_to :topic

    validates :topic_id, :presence => true
    validates :entity_id, :presence => true
    validates :entity_type, :presence => true

    validates :topic_id, :uniqueness => {
        :scope => [:entity_id, :entity_type]
    }
end

Note that you should also be enforcing uniqueness via unique database indices as well (in this case, a composite index on the relevant fields), otherwise concurrent requests to create an object could cause a race condition, in which Rails checks the database for existence of both new items before inserting either.

Also, for non-polymorphic associations, it's possible to do:

:scope => [:model_name]

...but a quick test indicates that it only checks the _id field even with polymorphic associations, hence needing to specify both the _id and _type fields.

cwsault
  • 112
  • 5