2

I want to know the best way to have an uniqueness constraint enforced on two related model attributes in rails that are both no primary keys

class Parent > ApplicationRecord
  has_many :children
  :name
end

class Child > ApplicationRecord
  :name
end

I want to enforce that (parent.name, child.name) is unique for every parent. e.g.

  • (parent1, child1) and (parent2, child1) is allowed
  • (parent1, child1) and (parent1, child1) is a violation

Ideally, I would enforce this in Postgres, however I have only seen the option to add uniqueness constraints on multiple columns of the same table.

Alternatively, I have written a custom validator for rails that does what I want, but this is cumbersome. There needs to be a better solution...

For completeness, here is the constraints validator which requires one to add a children function to a model returning the list of children.

class NamePairValidator < ActiveModel::Validator
  def validate(record)
    record.children.values.each do |model_children|
      names = model_children.to_a.collect {|model| model.name}
      if (names.select{|name| names.count(name) > 1 }.size > 0)
        record.errors[:name] << 'Path leading to this resource has no unique name'
      end
    end
  end
end

(in Parent.rb)

def children
  {children: :children}
end

Migrations:

class CreateDomains < ActiveRecord::Migration[5.0]
  def change
    create_table :domains do |t|
      t.string :name
      t.string :domain_type
      t.timestamps
    end
  end
end

class CreateSubjects < ActiveRecord::Migration[5.0]
  def change
    create_table :subjects do |t|
      t.string     :name
      t.string     :subject_type
      t.timestamps
    end
  end
end

class CreateJoinTableDomainSubject < ActiveRecord::Migration[5.0]
  def change
    create_join_table :domains, :subjects do |t|
      t.index [:domain_id, :subject_id]
      t.index [:subject_id, :domain_id]
    end
  end
end
  • Can you show how you wrote migrations file for both your models? Because you could also add an index to be sure of the integrity at a DB level e.g https://robots.thoughtbot.com/the-perils-of-uniqueness-validations – Kruupös Jul 20 '17 at 08:50
  • If you want to do it on database level you could change `parent_id` column and `child_id` into `parent_name` and `child_name` columns and add unique constraints on them. With some ActiveRecord hackery it is possible to use those columns as foreign keys. I'm not sure about this solution it is just a thought. – P. Boro Jul 20 '17 at 09:02
  • Thx! I think you got a pivot table! That's why Nithin's answer does not work. Please have a look at this link: http://guides.rubyonrails.org/association_basics.html#the-has-many-through-association. It might help you with the syntax and the relation – Kruupös Jul 20 '17 at 09:02
  • My solution won't work with pivot table unfortunately. – P. Boro Jul 20 '17 at 09:08
  • Any suggestions how I might go about implementing a generic solution for more than 2 levels deep? e.g. A has many B, B has many C, C has many D. A, B, C, and D all have a name attribute. The path resembles that of a REST-full resource. All A, B, C and D models have a ID field as well. e.g. a/b must be unique, but also a/b/c and a/b/c/d – Kevin van den Bekerom Jul 20 '17 at 09:29

2 Answers2

3

I just used similar one in my code

validates :child_name, uniqueness: { scope: :parent_id }

More..

(i) https://apidock.com/rails/ActiveRecord/Validations/ClassMethods/validates_uniqueness_of

(ii) Validate uniqueness of multiple columns

Nithin
  • 3,679
  • 3
  • 30
  • 55
  • For clarity, in which model do I add this validation (Parent, or Child) ? – Kevin van den Bekerom Jul 20 '17 at 08:30
  • `Child` model, also `child_name` is a example attr – Nithin Jul 20 '17 at 08:31
  • Suggestion does not seem to work in my case. Got `ActiveRecord::StatementInvalid: PG::UndefinedColumn: ERROR: column subjects.domain_id does not exist` (In my situation I have Domain as Parent, and Subject as Child). Got error when running `db:migrate db:seed` – Kevin van den Bekerom Jul 20 '17 at 08:44
  • So what is the name of the foreign key column that connect Subject to Domain in your code? Use this instead. – Robert Pankowecki Jul 20 '17 at 08:55
  • @RobertPankowecki I have added the migrations to the initial question. This should clarify how Domain is connected to Subject I hope. – Kevin van den Bekerom Jul 20 '17 at 08:58
  • @KevinvandenBekerom how do you know which domain is connected to which subject? Based on the same `name` ? I don't understand which column from `subjects` references which column from `domains` ? – Robert Pankowecki Jul 20 '17 at 09:02
  • @RobertPankowecki I believe rails enforces this by the Rails Models. As you can see, there is a table connecting Domains to Subjects based on id. – Kevin van den Bekerom Jul 20 '17 at 09:04
  • @KevinvandenBekerom I just realized your are using intermediate table... You should be using `has_many through:` or `has_and_belongs_to_many` in such case http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#module-ActiveRecord::Associations::ClassMethods-label-Many-to-many – Robert Pankowecki Jul 20 '17 at 09:13
0

Insipered by the-has-many-through-association of the official doc of ruby on rails:

class CreateAppointments < ActiveRecord::Migration[5.0]
  def change
    create_table :domains do |t|
      t.string :name, null: false
      t.string :domain_type
      t.timestamps
    end

    create_table :subjects do |t|
      t.string     :name, null: false
      t.string     :subject_type
      t.timestamps
    end

    create_table :fields do |t|
      t.belongs_to :domain, index: true
      t.belongs_to :subject, index: true
      t.timestamps
    end
  end
end

Note

  • I took the initative to rename your model JoinTableDomainSubject by Field to be more readable.

  • I also force name field not be nil to check uniqueness. (adding null: false in migrations files and validates :name, presence: true in both models)

Now the dedicated classes:

class Subject < ApplicationRecord
  has_many :fields
  has_many :domains, through: :fields

  validates :name, presence: true
end

class Domain < ApplicationRecord
  has_many :fields
  has_many :subjects, through: :fields

  validates :name, presence: true
end

class Field < ApplicationRecord
  belongs_to :domain
  belongs_to :subject

  validate :domain_and_subject_names_uniqueness

  private

  def domain_and_subject_names_uniqueness
    if class.includes(:domain, subject)
            .where(domain: { name: domain.name }, subject: { name: subject.name })
            .exists?
      errors.add :field, 'duplicity on names'
    end
  end
end

Since the models are associated, I can use Field.first.domain to access Domain model of a given Field and vice versa.

Kruupös
  • 5,097
  • 3
  • 27
  • 43
  • Usage changed of adding subject to domain. I did: `@domain.subjects << Subject.new(params) @domain.save`. This does not seem to work anymore... – Kevin van den Bekerom Jul 20 '17 at 12:22
  • Yes. Got this error: `NoMethodError in SubjectsController#create` `undefined method subject_name' for # Did you` `mean? subject` – Kevin van den Bekerom Jul 20 '17 at 12:32
  • Moving the validation to Subject model, and scoping to domain_id also doesn't work sadly... – Kevin van den Bekerom Jul 20 '17 at 12:47
  • Thanks. Indeed, `subject_name` does not exists. You can remove `validates_uniqueness_of :subject_name scope: domain_name` and do a custom check, which is not very efficient. Sorry, I'm working on something better – Kruupös Jul 20 '17 at 12:48
  • @KevinvandenBekerom I tried to edit my answer with a custom validator but I'm unsure of what it will do. Please inform me if you succeed. – Kruupös Jul 20 '17 at 13:13
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/149723/discussion-between-owl-max-and-kevin-van-den-bekerom). – Kruupös Jul 20 '17 at 13:21