Skip to content
Dave Strus edited this page Jul 16, 2015 · 2 revisions

In this unit, we are going to cover the following topics:

  • Active Record Enums (new in Rails 4.1)
  • Altering an existing table in a migration
  • Altering data in a migration
  • Manually specifying the up and down actions in a migration
  • Conditional model validations

We've brushed aside text posts—via the body attribute—and just dealt with links. No more! It's time to deal with text posts.

Text posts are still just instances of Post, and should probably still be created via the form at /posts/new. We just need to hide the link field and show the body field at the appropriate time.

What are some ways we could achieve that?

Let's start by actually adding a column to the database to distinguish between text posts and links.

Let's add a post_type column. The type can be either "link" or "text", but rather than storing the data as a string, we're going to take advantage of a new feature in Rails 4.1: ActiveRecord::Enum.

Declare an enum attribute where the values map to integers in the database, but can be queried by name.

In other words, our post_type column will store integer values. In our Post model, we define what these values mean. Let's make 1 represent a link, and 2 represent a text post.

Go to the shell—not the Rails console— and generate a new migration. When adding a column to an existing table, I use the naming convention add_column_name_to_table_name. In this case, we'll name the migration add_post_type_to_posts.

$ bin/rails generate migration add_post_type_to_posts
      invoke  active_record
      create    db/migrate/20141103231948_add_post_type_to_posts.rb

Let's edit the new migration.

class AddPostTypeToPosts < ActiveRecord::Migration
  def change
    add_column :posts, :post_type, :integer, default: 0
  end
end

We're adding to the posts table; we're adding a column named post_type; that column will hold an integer; and the default value will be 0.

Go ahead and migrate the database now.

$ bin/rake db:migrate
== 20141103231948 AddPostTypeToPosts: migrating ===============================
-- add_column(:posts, :post_type, :integer, {:default=>0})
   -> 0.0443s
== 20141103231948 AddPostTypeToPosts: migrated (0.0444s) ======================

Among our existing Post records, we likely have a mix of links and text posts. We can use our migration to update existing records based on appropriate logic, rather than assigning the default to all existing records.

Let's rollback our database.

$ bin/rake db:rollback
== 20141103231948 AddPostTypeToPosts: reverting ===============================
-- remove_column(:posts, :post_type, :integer, {:default=>0})
   -> 0.0083s
== 20141103231948 AddPostTypeToPosts: reverted (0.0103s) ======================

Our migration currently uses the change method to make changes to the database.

http://guides.rubyonrails.org/migrations.html#using-the-change-method

The change method is the primary way of writing migrations. It works for the majority of cases, where Active Record knows how to reverse the migration automatically.

Our original migration did nothing more than add_column. Unsurprisingly, Active Record know that to reverse that migration, it just needs to run remove_column. When it comes to messing with data, however, we need to specify migrating up and migrating down separately.

http://guides.rubyonrails.org/migrations.html#using-the-up-down-methods

The up method should describe the transformation you'd like to make to your schema, and the down method of your migration should revert the transformations done by the up method. In other words, the database schema should be unchanged if you do an up followed by a down.

Armed with this knowledge, let's edit the migration again.

class AddPostTypeToPosts < ActiveRecord::Migration
  def up
    add_column :posts, :post_type, :integer, default: 0
  end

  def down
    remove_column :posts, :post_type
  end
end

To get the same behavior as before, but using the up and down methods, we define our up method to do exactly what change did before. But now we'll have to add the down method so we're able to rollback. down needs to do nothing more or less than remove the new column.

Before we start messing with data, let's migrate and rollback again to make sure we didn't break anything. It should behave exactly as it did when we used change.

$ bin/rake db:migrate
== 20141103231948 AddPostTypeToPosts: migrating ===============================
-- add_column(:posts, :post_type, :integer, {:default=>0})
   -> 0.0144s
== 20141103231948 AddPostTypeToPosts: migrated (0.0145s) ======================

$ bin/rake db:rollback
== 20141103231948 AddPostTypeToPosts: reverting ===============================
-- remove_column(:posts, :post_type)
   -> 0.0007s
== 20141103231948 AddPostTypeToPosts: reverted (0.0008s) ======================

Since that worked, we'll move on to altering the data. Upon adding the new column, we want to assign existing records with a non-blank link value to have a post_type of 0. We want existing records with non-blank body value to have a post_type of 1. If we happen to have any old records with both fields filled in, we'll make them links, simply by making that assignment second.

To rollback that change, we can still just drop the post_type column.

class AddPostTypeToPosts < ActiveRecord::Migration
  def up
    add_column :posts, :post_type, :integer, default: 0
    Post.find_each do |post|
      post.post_type = 1 unless post.body.blank?
      post.post_type = 0 unless post.link.blank?
      post.save!
    end
  end

  def down
    remove_column :posts, :post_type
  end
end

We can do a tad better than that though.

  • We can add an additional condition to ensure that we don't unnecessarily update the same record twice.
  • We can wipe out the body values for those posts which are links. If you think you'll ever want to roll this migration back, you won't be able to undo this. But if you're sure, go for it.
  • We can change our conditions from unless to if, as it's less confusing to read.
def up
  add_column :posts, :post_type, :integer, default: 0
  Post.find_each do |post|
    post.post_type = 1 if post.body.present? && post.link.blank?
    post.update_attributes(post_type: 0, body: '') if post.link.present?
    post.save!
  end
end

Now try migrating again.

$ bin/rake db:migrate
== 20141103231948 AddPostTypeToPosts: migrating ===============================
-- add_column(:posts, :post_type, :integer, {:default=>0})
   -> 0.0121s
== 20141103231948 AddPostTypeToPosts: migrated (0.0244s) ======================

The output won't look any different than it did before. But try poking around at your existing posts in the Rails console (exit the console and restart it first). You should find that your existing posts have values for post_type, unless they had blank values for both link and body.

Notice the use of the save! method on our Active Record model. While save will return false if the record fails to save, save! will raise an exception. When run as part of a database transaction, the exception will rollback the entire transaction. Migrations are always executed in a transaction, so the migration will fail should there be a problem updating existing data.

Now we need to update our model to treat post_type as an enum, rather than a normal integer.

app/models/post.rb

class Post < ActiveRecord::Base
  validates :title, length: { maximum: 255 }, presence: true
  enum post_type: [:link, :text]
end

Because :link is at postition 0 in the array, it will correspond to a post_type column value of 0. Likewise for :text in array position 1

Now that we have a place in our model to differentiate between posts of different types, we need to show two different versions of our form. Let's add a second link to the sidebar for creating text posts.

app/views/layouts/application.html.erb

        <nav id="sidebar">
          <%= link_to 'Submit a new link', new_post_path %>
          <%= link_to 'Submit a new text post', new_post_path %>
        </nav>

The links go to exactly the same page. So how do we make sure the right form fields are showing on /posts/new? We can make them show based on the current value of post_type in the form object (@post). But first, we have to make sure that value is being set appropriately in PostsController.

Posts are links by default. Let's add a query string to our "text post" link, which we can then use in the controller.

The documentation for link_to demonstrates adding a query string to a path via the options hash on a path helper:

link_to "Nonsense search", searches_path(foo: "bar", baz: "quux")
# => <a href="/searches?foo=bar&amp;baz=quux">Nonsense search</a>

Let's make a new sidebar link that take us to new_post_path, but with a query string parameter specifying that it's to be a text post.

app/views/layouts/application.html.erb

        <nav id="sidebar">
          <%= link_to 'Submit a new link', new_post_path %>
          <%= link_to 'Submit a new text post', new_post_path(post_type: :text) %>
        </nav>

If you follow that link, you'll find that it sends us to http://localhost:3000/posts/new?post_type=text. Nothing has changed on our form yet, naturally. But with the query parameter, we can make it happen.

Let's put a binding.pry in our controller action.

app/controllers/posts_controller.rb

 7  def new
 8    @post = Post.new
 9    binding.pry
10  end

Click the link to submit a new text post again, and head over to the server output.

     7: def new
     8:   @post = Post.new
 =>  9:   binding.pry
    10: end

[1] pry(#<PostsController>)>

Let's see what @post.post_type is at the moment.

[1] pry(#<PostsController>)> @post.post_type
=> "link"

The enum macro provides another way to check each label:

[1] pry(#<PostsController>)> @post.link?
=> true

Whichever way we check, we see that post_type is set to the default, link. When we've been passed a post_type in the query string, we want to assign the value from the query string instead. Let's have a look at our params hash.

[2] pry(#<PostsController>)> params
=> {"post_type"=>"text", "action"=>"new", "controller"=>"posts"}

Our query string param showed up as expected. Now we can assign the value from that param to @post.post_type. That param won't always be there, however, so we'll want to check for its presence before making the assignment.

[3] pry(#<PostsController>)> @post.post_type = params[:post_type] if params[:post_type].present?
=> "text"

Seems to work. Let's quit pry and update the controller.

app/controllers/posts_controller.rb

 7  def new
 8    @post = Post.new
 9    @post.post_type = params[:post_type] if params[:post_type].present?
10  end

Now, a fat lot of good this does us, as we haven't changed anything in the view. First, let's add a hidden field for post_type, so that we know the value will persist when the form is submitted.

We'll add it just above the submit button.

  <%= f.hidden_field :post_type %>
  <%= f.submit %>

Refresh the ?post_type=text version of your form, and have a look at the source in your browser. You should see that the hidden field for post_type is set correctly.

  <div>
    <input id="post_post_type" name="post[post_type]" type="hidden" value="text" />
    <input name="commit" type="submit" value="Create Post" />
  </div>

That's great! Of course, we don't want to start littering our data with a bunch of text posts that have link values, but no body. It's time to hide and show form fields appropriately.

Let's only show the link field if our post is of type "link". We could check the instance variable (@post) directly, but we really want to check the form object. We can access that via the object method on our FormBuilder object:

f.object

It's easy to display our link field only when f.object is a link.

app/views/posts/new.html.erb

  <% if f.object.link? -%>
    <fieldset>
      <%= f.label :link %>
      <%= f.text_field :link %>
    </fieldset>
  <% end -%>

Now take a look at both versions of the form. The link field should appear and disappear appropriately. Now, we need to do the same for the body field.

We need to remove the HTML comment, and change the ERB comments into ERB expressions.

app/views/posts/new.html.erb

  <fieldset>
    <%= f.label :body %>
    <%= f.text_area :body %>
  </fieldset>

Now we just need to wrap it in a condition similar to the one we used for link.

app/views/posts/new.html.erb

  <% if f.object.text? -%>
    <fieldset>
      <%= f.label :body %>
      <%= f.text_area :body %>
    </fieldset>
  <% end -%>

Now both versions of the form should have appropriate fields. Now try adding some posts of each type.

Check out your posts in the console. You can quickly see all link posts with Post.link and Post.text. The enum macro gives us those finders for free. Cool, huh?

It shouldn't take long to discover that post_type is actually not getting set correctly for text posts. What gives? The param is being passed to the controller with the correct value. Hmm.

The issue is that we are not permitting the post_type attribute to be mass assigned. We need to update the first line of create to permit this new attribute.

app/controllers/posts_controller.rb

13    post = Post.new(params.require(:post).permit(:title, :link, :body, :post_type))

We didn't encounter this issue in posts#new, because it wasn't part of a mass-assignment.

After making that change, try creating some new text posts, and check them out in the console.

If it's working, now is a good time to commit. Don't forget to check your git status, and maybe even git diff, to make sure you're committing what you expect.

$ git add .
$ git commit -m "Add post#post_type and allow creation of text posts."

Before we move on, let's think about our validations. Right now, title is the only required attribute. Really, link should be required for link posts, and body should be required for text posts. Thankfully, we can add conditional validations.

app/models/post.rb

  validates :link, presence: true, if: :link?
  validates :body, presence: true, if: :text?

The symbols at the end of each of those lines represents a method to be called. If that method returns a truthy value, then the validation will be enforced. If not, the validation will be skipped.

Try it out. All good? Let's commit again.

$ git add .
$ git commit -m "Add conditional validations for links and text posts."