ruby 36 lines · 8 steps

Enforcing scoped uniqueness in Rails

A Membership model normalizes email and guarantees one member per organization, backed by a matching database index.

Explained by highlit
1class Membership < ApplicationRecord
2 belongs_to :organization
3 belongs_to :user
4 
5 before_validation :normalize_email
6 
7 validates :email, presence: true
8 validates :email,
9 uniqueness: {
10 scope: :organization_id,
11 case_sensitive: false,
12 message: "is already a member of this organization"
13 }
14 
15 validates :role, inclusion: { in: %w[owner admin member guest] }
16 
17 scope :for_organization, ->(org) { where(organization: org) }
18 
19 private
20 
21 def normalize_email
22 self.email = email.to_s.strip.downcase.presence
23 end
24end
25 
26class AddMembershipsUniqueIndex < ActiveRecord::Migration[7.1]
27 disable_ddl_transaction!
28 
29 def change
30 add_index :memberships,
31 "organization_id, lower(email)",
32 unique: true,
33 name: "index_memberships_on_org_and_lower_email",
34 algorithm: :concurrently
35 end
36end
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Normalizing input before validation makes case-insensitive uniqueness reliable and predictable.
  2. 2Model-level uniqueness validations should always be backed by a matching database unique index to close race conditions.
  3. 3A functional index on lower(email) lets the database enforce the same case-insensitive rule the model expresses.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Enforcing scoped uniqueness in Rails — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code