TL;DR
- Add a scoped unique index on
:primary
column.
- Override
def primary=
to ignore falsey values, remove primary
uniqueness validation (if any) from model.
- Add a
before_save
callback that un-primaries any other records.
Discussion
It's 7 years after the ask, but none of the answers quite hit the mark, so here goes...
OP has two desired behaviours for the system:
- Enforce a constraint that at most one post has
primary=true
.
- If a new post is set to be
primary=true
, all other posts must be marked primary=false
There are three "layers" to consider:
- Database-level data integrity - at most one post can be
primary=true
- Model-level validations (or maybe not!)
- UI/UX level - maybe we can prevent invalid data reaching app to begin with.
A common misconception is that Rails validations ensure data integrity, but since there are .update_all
and other validation-skipping mechanisms, they are unreliable at best.
Let's make sure the database itself only supports one primary! This is easy with scoped indexes. Unfortunately, only Postgres supports them, not available for MySQL. YMMW.
In a DB migration
add_index(
:posts,
"primary",
where: "(primary IS TRUE)",
unique: true,
name: :posts_uniq_primary_idx,
)
This will ensure data integrity. If we attempt to save another primary
, we'll get this error:
ActiveRecord::RecordNotUnique:
PG::UniqueViolation: ERROR: duplicate key value violates unique constraint "posts_uniq_primary_idx"
Normally, we'd slap on a uniqueness validation and call it a day, but OP wants to support primary switching, and a validation would prevent that, so instead let's disable the ability to un-primary a record via normal means (update_all
and friends can sidestep this, but we can't do anything about that).
def primary=(value)
return unless value.present?
super(value)
end
And make sure that creating or updating a record as the new primary
makes all other records not primary:
class Post < ApplicationRecord
before_save :ensure_one_primary!
private
def ensure_one_primary!
return unless primary_changed?(to: true)
Post.where(primary: true).where.not(id: id).update_all(primary: false)
end
end
Bonus for UI - You'll likely be rendering primary
as a radiobutton or a dropdown. Render the element as disabled for the record which already is primary
, since a form submit of setting it to false
wouldn't do anything anyway.
Closing thoughts
You probably don't want one primary Post in the whole table, likely it's scoped to some account. If so, the unique index would have to be not only on "primary"
column alone, but a composite index on "primary, account_id"
, and ensure_one_primary!
callback would need to make sure to un-primary only posts belonging to new primary's account etc.