/ Tags: RAILS-CODE / Categories: SOLUTIONS

Validate Uniqueness Of A Combination Of Two Columns In Rails

A user can only be a member of an organisation once. A product can only have one price per currency. The rule is not “this column is unique” but “this pair is unique”, and it needs both a validation and a composite index.

Description

validates :column, uniqueness: { scope: :other_column } produces a friendly error message. It runs a SELECT before the INSERT, so two concurrent requests can both pass it — which is why the database constraint is not optional. A composite unique index enforces it atomically. The column order in that index matters for query performance: put the column you filter by alone in the leading position. Rescue ActiveRecord::RecordNotUnique where a race is realistic, so the user gets a validation error rather than a 500. For a nullable column in the pair, use a partial index — SQL treats NULLs as distinct, so a plain unique index will not stop two rows with the same value and a NULL.

Sample input:

  Membership.create!(user_id: 1, organisation_id: 7)
  Membership.create!(user_id: 1, organisation_id: 7)   # rejected


Sample Output:

  # => ["User is already a member of this organisation"]

Answer

  # Migration
  class AddUniqueIndexToMemberships < ActiveRecord::Migration[7.1]
    disable_ddl_transaction!

    def change
      add_index :memberships, [:organisation_id, :user_id],
                unique: true,
                algorithm: :concurrently,
                name: "idx_memberships_org_user"
    end
  end

  # Model
  class Membership < ApplicationRecord
    belongs_to :user
    belongs_to :organisation

    validates :user_id,
              uniqueness: {
                scope: :organisation_id,
                message: "is already a member of this organisation"
              }
  end

  # Handle the race the validation cannot cover
  def create
    @membership = Membership.new(membership_params)

    if @membership.save
      redirect_to @membership.organisation
    else
      render :new, status: :unprocessable_entity
    end
  rescue ActiveRecord::RecordNotUnique
    @membership.errors.add(:user_id, "is already a member of this organisation")
    render :new, status: :unprocessable_entity
  end

  # Nullable column in the pair — NULLs are distinct, so use a partial index
  # add_index :prices, [:product_id, :currency],
  #           unique: true, where: "discarded_at IS NULL"

  # Case-insensitive pair
  # validates :name, uniqueness: { scope: :account_id, case_sensitive: false }
  # add_index :teams, "account_id, LOWER(name)", unique: true

Learn More

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More