6

In the typical rails (4.2.x) blog app, I have a Post model. The post has a boolean column called primary. I want to enforce a model level constraint that at most one post has primary=true. If user sets a new post to be primary=true, all other posts must be marked primary=false before saving this post.

I could do this in the controller, when a post is created or updated to be primary=true, by changing all other posts to primary=false. Something like:

# in posts_controller#create and #update
...
if @post.primary
  [Post.all - self].select(&:primary).each do {|p|p.primary = false; p.save}
end
@post.save!
...

However, I want this to be a model level constraint, so I can add validations, unit tests, etc. that there is only one post with primary=true. If I use a callback like before_commit, then I may run into an infinite loop since updating the older posts in a new post's before_commit will trigger the older posts' before_commit, etc.

How do I enforce this behavior at the model level?

Promise Preston
  • 24,334
  • 12
  • 145
  • 143
Anand
  • 3,690
  • 4
  • 33
  • 64

4 Answers4

8

ActiveRecord has some update attributes methods that don't trigger callbacks like post.update_column, Post.update_all, etc. So you can use these in a callback like

before_save :set_primary

private
def set_primary
  Post.where.not(id: id).update_all(primary: false)
end
Van Huy
  • 1,607
  • 1
  • 13
  • 16
4

It might be worth considering a slightly different approach, where you instead use a singleton model -- say, Primaries -- which has a "post_id" that is set to the ID of the primary post. You could even make this a foreign key for extra elegance and automatic back-referencing to detect whether a given Post is primary or not.

(See https://stackoverflow.com/a/12463209/128977 for one approach to making an ActiveRecord singleton model.)

The advantages over coordinating a primary flag between all Post records are:

  • atomic updates -- Using a "before_save" to update all other Posts to primary=false could in theory fail on the save action, leaving no primary=true record... or multiple saves at once could get dicey/racey, though I'm not certain how ActiveRecord handles threading here.
  • scalability -- The number of Post records no longer matters when you use a single value to point to the primary post. Granted, your SQL backend should handle this pretty well, but updating 1-2 records is still faster than checking all of them.

Community
  • 1
  • 1
DreadPirateShawn
  • 8,164
  • 4
  • 49
  • 71
2

One approach you could use is to implement a custom validator on the model that prevents other primary Posts from being saved to the DB if one already exists.

You could then define a class method on Post to reset the primary Post to a normal Post and then set a different Post as the primary one.

The Custom Validator (app/validators/primary_post_validator.rb)

class PrimaryPostValidator < ActiveModel::Validator
  def validate(record)
    if record.primary
      record.errors[:primary] << "A Primary Post already exists!" if
        Post.where(primary: true).any?
    end
  end
end

Post Model

class Post < ApplicationRecord
  validates_with PrimaryPostValidator

  def self.reset_primary!
    self.update_all(primary: false)
  end
end

schema.rb

create_table "posts", force: :cascade do |t|
  # any other columns you need go here.
  t.boolean  "primary",        default: false, null: false
  t.datetime "created_at",                      null: false
  t.datetime "updated_at",                      null: false
end

This set up will allow you to control which Post gets assigned as the primary Post from a controller, and handle occasions where you need to swap the primary Post. I think it's a bad idea allowing the saving of a model to affect other records in the DB as you originally requested.

One way of handling this logic in the controller:

def make_primary
  @post = Post.find(params[:id])
  Post.reset_primary!
  @post.update_attributes(primary: true)
end

While this appears contrived compared to the accepted answer, I believe it gives you a much greater level of control over which Post gets set to primary and when. This solution will also work with validations, not skipping them like in the answer above.

James
  • 608
  • 6
  • 11
0

TL;DR

  1. Add a scoped unique index on :primary column.
  2. Override def primary= to ignore falsey values, remove primary uniqueness validation (if any) from model.
  3. 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:

  1. Enforce a constraint that at most one post has primary=true.
  2. If a new post is set to be primary=true, all other posts must be marked primary=false

There are three "layers" to consider:

  1. Database-level data integrity - at most one post can be primary=true
  2. Model-level validations (or maybe not!)
  3. 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.

Epigene
  • 3,634
  • 1
  • 26
  • 31