(I sent this post to my list a few months back. If you like it, and want to read more like it, you should sign up!)

I ran into a weird situation the other day with one of my apps.

Say you have an Article model, and these articles are first created as drafts. These drafts should be lightweight. You want to be able to create and edit them right away, without needing a body, or even a title.

But when you publish these articles, you need to have some extra validation. They should have a title, a body, and all the rest of that stuff.

You could write up a bunch of if statements to do the checking yourself. But you wouldn’t have all the nice form handling you get from Rails. And I’ve found that going with Rails for the things Rails handles well will save you a lot of headache later on. Is there still a way to use validations for this?

if and unless?

Validations support :if and :unless parameters, but this isn’t always so clean:

app/models/article.rb
1
2
3
4
5
6
7
8
9
class Article < ActiveRecord::Base
  # validate in draft mode
  validates_presence_of :author_id

  # validate only when published
  validates_presence_of :body, unless: lambda { |o| o.draft? }

  ...
end

Besides, this isn’t the way I want to publish an article. I want to think about publishing an article as an action, not a state the article is in, so setting and checking an attribute isn’t the right answer.

A little-known, lightly documented Rails feature

This problem is a perfect fit for Rails’ validation contexts. I first learned about custom validation contexts from a gist DHH wrote. But it was hard to find a good explanation of what these were, and how to use them.

The documentation for custom contexts is pretty thin. The only mention of it I could find in the API docs was here. And the API docs for these validations don’t mention custom contexts at all! This makes it especially hard – if you don’t know what something’s called, how do you find more documentation on it?

I finally found a good overview here: http://blog.arkency.com/2014/04/mastering-rails-validations-contexts/. It’s worth a read. Let’s see what it looks like for our example:

app/models/article.rb
1
2
3
4
5
6
7
8
class Article < ActiveRecord::Base
  # validate in draft mode
  validates_presence_of :author_id

  # validate only when published
  validates_presence_of :body, on: :publish
  ...
end
app/models/article_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class ArticleController < ApplicationController
  respond_to :html

  def create
    @article = Article.create(article_params)
    respond_with @article
  end

  def publish
    @article = Article.find(params[:id])
    @article.attributes = article_params
    @article.save(context: :publish)
    respond_with @article
  end

  private
  def article_params
    params.
      require(:article).
      permit(:body, :title, :author_id)
  end
end

Not too bad! The only weird thing is having to use save instead of update in the publish method. That happens because update can’t take a validation context as a parameter. (Maybe that’s an opportunity for a pull request?)

Beyond saving

Custom validation contexts are useful for more than just saving a record in different ways. Contexts work with valid?:

1
@article.valid?(:publish)

so you can use validation contexts anywhere you want to answer the question: “Does this object have this property? And if not, why not?”

DHH creates a method ending in -able? that delegates to valid?(:context)). I really like the look of that:

app/models/article.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Article
  validate :has_been_published, on: :view
  validate :is_not_spam, on: :view

  def viewable?
    valid? :view
  end

  private

  def has_been_published
    if published_at.future?
      errors.add(:published_at, "is in the future")
    end
  end

  def is_not_spam
    if is_spam?(body)
      errors.add(:body, "has been detected as spam")
    end
  end
end

This way, when you check it, you get more information than just true or false:

1
2
3
unless @article.viewable?
  # @article.errors is now filled out
end

Is the article viewable? Well, why not?

Just lightweight enough

There are a few other ways you could get this kind of validation behavior. You could build custom ActiveModel service objects with their own validations. You could use :if or :unless on your validations. But like a lot of the stuff in Rails, custom validation contexts are a good compromise between readability and flexibility.

Did you like this post? You should read these:

Finished another Rails tutorial and still don’t know how to start?

Have you slogged through the same guide three times and still can't retain enough to write apps on your own?

In my free 7-part course, you’ll discover the fastest way to learn and remember new Rails ideas, so you can use them when you need them. And you'll learn to use what you already know to build your own Rails project.



Comments