3

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
  • 16,322
  • 10
  • 91
  • 108
Anand
  • 3,490
  • 4
  • 30
  • 61

4 Answers4

6

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,557
  • 1
  • 11
  • 16
3

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
  • 7,844
  • 4
  • 47
  • 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.

jamesmarkcook
  • 588
  • 5
  • 11
0

Please checkout this simple gem set_as_primary which does the same thing. It supports other features too.

The simplest way to handle the primary or default flag to your Rails models.

Santosh
  • 1,221
  • 9
  • 15