Active Record Migrations

We currently have used direct SQL to change our database structure. This is fine if we were developing alone but wouldn't work in a team environment because the database change on our machine is not tracked anywhere, so our teammates won't be able to pick it up.

Sharing database state among members of a team can quickly become a nightmare, unless you have a strategy. Fortunately, there's an easy way of dealing with this in Rails, because Active Record provides us with the ability to create, run, and track database migrations. This makes database changes much, much more manageable by isolating each change in its own file, conveniently written in Ruby.

Migration Fundamentals

Let's imagine we don't have our database from before, and write a couple migrations to create our posts and comments tables:

### db/migrate/20150127114820_create_posts.rb ###

class CreatePosts < ActiveRecord::Migration
  def change
    create_table :posts do |t|
      t.string :title
      t.text   :body
      t.string :author

      t.timestamps null: false
    end
  end
end
### db/migrate/20150127115017_create_comments.rb ###

class CreateComments < ActiveRecord::Migration
  def change
    create_table :comments do |t|
      t.text    :body
      t.string  :author
      t.integer :post_id

      t.timestamps null: false
    end
  end
end

Here we see create_table calls used to name both tables. Then, inside the passed block, we see various calls of the format t.data_type :column_name to define the columns. And the t.timestamps at the bottom is responsible for creating created_at and updated_at columns, which are DateTime columns that are automatically set by Active Record as changes are made to the records.

As far as naming goes, there are a couple important things to note:

  1. table names should be the lowercase, plural, underscore-separated class name e.g. ModelName would have a model_names table
  2. column names for foreign keys should be the lowercase, singular, underscored-separated class name followed by _id e.g. the column to reference a post is post_id

It's critical to get the naming for these values right if you want to utilize all the conveniences that Rails can provide for your models.

Notice also that the file contains a class that inherits from ActiveRecord::Migration. Because of this the change method defined within has special meaning. Using the tools we're about to see, it can be run up or down. The create_table call does what it claims when run up, and drops the table when run down.

And finally, the filenames of these migrations is important:

db/migrate/20150127114820_create_posts.rb
db/migrate/20150127115017_create_comments.rb

Rails will run migrations in alphanumeric order by their filenames, so migration filenames are traditionally prefixed with a timestamp to maintain this order. Because of the naming above, the create_posts migration will be run first.

Also, notice that the filenames contain create_posts and create_comments. The filename for a migration should be descriptive of what it does, and this is the traditional way of creating a migration for creating a new table. Along the same line, if you wanted to, say, add a user_id column to the posts table, you'd create a migration that ended in add_user_id_to_posts.rb.

Running Migrations

So let's see these migrations in action. First, we'll use a rake command to drop the DB we presently have (this works because Rails expects our DB to be at db/development.sqlite3, and it is). Then we'll run the migrations.

$ bundle exec rake db:drop

$ bundle exec rake db:migrate
== 20150127114820 CreatePosts: migrating ======================================
-- create_table(:posts)
  -> 0.0008s
== 20150127114820 CreatePosts: migrated (0.0009s) =============================

== 20150127115017 CreateComments: migrating ===================================
-- create_table(:comments)
  -> 0.0004s
-- add_index(:comments, :post_id)
  -> 0.0004s
== 20150127115017 CreateComments: migrated (0.0009s) ==========================

This creates the tables, but it also generates a db/schema.rb file. No need to read the whole thing, but it looks like this:

### db/schema.rb ###

# encoding: UTF-8
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# Note that this schema.rb definition is the authoritative source for your
# database schema. If you need to create the application database on another
# system, you should be using db:schema:load, not running all the migrations
# from scratch. The latter is a flawed and unsustainable approach (the more migrations
# you'll amass, the slower it'll run and the greater likelihood for issues).
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(version: 20150127115017) do

  create_table "comments", force: true do |t|
    t.text     "body"
    t.string   "author"
    t.integer  "post_id"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

  create_table "posts", force: true do |t|
    t.string   "title"
    t.text     "body"
    t.string   "author"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

end

As the comment says, this schema file is the authoritative source for your database schema. If you ever forget your DB structure, you can consult this file. But never change it manually, as it's only intended to be changed by Rails.

More on Active Record Migrations

Let's look at some more information regarding Active Record Migrations:

# in our `create_table :comments` above, we could
# have defined our `post_id` column instead with:
create_table :comments do |t|
  t.references :post, index: true
  # `.references :post` makes an integer `post_id`
  # column, and we also specify that we want this
  # column to be indexed with `index: true`
end

# we can also make changes to existing tables
# assuming we already had a `users` table...
class AddAdminToUsers < ActiveRecord::Migration
  def change
    #           table   column  type
    add_column :users, :admin, :boolean, null: false, default: false
    # we also pass options to disallow, on the DBMS level,
    # `NULL` values, and default this column to `false`
  end
end

And migrations go both up and down. We already know that $ bundle exec rake db:migrate runs them up, but let's take a look at how to roll them back, and also how to see what's been run:

$ bundle exec rake db:rollback

== 20150127115017 CreateComments: reverting ===================================
-- remove_index(:comments, {:column=>:post_id})
   -> 0.0006s
-- drop_table(:comments)
   -> 0.0002s
== 20150127115017 CreateComments: reverted (0.0026s) ==========================

$ bundle exec rake db:migrate:status

database: .../blog-app/db/development.sqlite3

 Status   Migration ID    Migration Name
--------------------------------------------------
   up     20150127114820  Create posts
  down    20150127115017  Create comments

$ bundle exec rake db:migrate

== 20150127115017 CreateComments: migrating ===================================
-- create_table(:comments)
   -> 0.0010s
-- add_index(:comments, :post_id)
   -> 0.0005s
== 20150127115017 CreateComments: migrated (0.0016s) ==========================

$ bundle exec rake db:migrate:status

database: .../blog-app/db/development.sqlite3

 Status   Migration ID    Migration Name
--------------------------------------------------
   up     20150127114820  Create posts
   up     20150127115017  Create comments

And it's also worth noting that Active Record automatically handles the setting of the timestamps columns (created_at and updated_at) for us:

post = Post.last
post.updated_at
# => Sat, 31 Jan 2015 10:59:33 UTC +00:00

post.body = 'foobar'
# => "foobar"
post.save
# => true

post.updated_at
# => Sat, 31 Jan 2015 11:49:38 UTC +00:00

Reversible and Irreversible Migrations

So far we've been using the change method in our migrations. This is a very useful method that allows us to include code that changes the database when a migration is run. And depending on the code we use, it may also be reversed when we rollback a migration. But, not all actions in a database migration can be reversed automatically. Rails uses ActiveRecord::Migration::CommandRecorder for recording commands used in migration, it also knows how to reverse them. Refer to this list of possible actions to take in a migration file. These are all the actions that are reversible. If a common Rails database command you use isn't in this list, then it cannot be automatically reversed. In that case, you'll have to use up and down methods to write what happens when we run a migration(up) and when we roll it back(down).

Add last_commented_on to Posts

So far we've seen various operations that can be performed through Active Record migrations. We've seen how to add a column to a table, migrate those changes, and how to roll them back if necessary. Let's practice some of what we've learned thus far. We want to add a new column to the posts table. This column will be called last_commented_on, we'll use an Active Record callback to update this column whenever a new comment is created on a Post object. The column should be of type DateTime and have a value of the time of the last created comment for that post.

First, let's generate our migration file with the following command:

rails g migration add_last_commented_on_to_posts
class AddLastCommentedOnToPosts < ActiveRecord::Migration[5.0]
  def change
    add_column :posts, :last_commented_on, :datetime
  end
end

Next execute the migration we just created:

bundle exec rake db:migrate

The next step is to set up a callback in the Comment model to handle the updates necessary for the Post column, last_commented_on.

class Comment < ActiveRecord::Base
  # ...

  after_save :update_last_commented_on

  private

  def update_last_commented_on
    self.post.last_commented_on = self.created_at
  end
end

See how we're using after_save method. We use after_save because the Comment created_at column only gets populated after a save. Once that column has been set, we can then assign it to last_commented_on field for the Post related to this Comment. So, using after_save allows us to specify a callback method to run after a Comment is saved to the db. To be extra safe, we could add the option on: :create to our after_save callback. But, this extra safeguard isn't really necessary, since created_at is only set on Comment creation.

Now, let's test out our new Active Record callback in the rails console.

irb(main):001:0> post = Post.first
  Post Load (0.2ms)  SELECT  "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<Post id: 1, title: "Blog 1", body: "Lorem ipsum dolor sit amet.", author: "Kevin", created_at: "2016-05-07 00:00:00", updated_at: "2016-05-07 00:00:00", last_commented_on: nil>
irb(main):002:0> comment = post.comments.create body: 'hello', author: 'jaba'
   (0.0ms)  begin transaction
  SQL (0.5ms)  INSERT INTO "comments" ("body", "author", "post_id", "created_at", "updated_at")
  VALUES (?, ?, ?, ?, ?)  [["body", "hello"], ["author", "jaba"], ["post_id", 1], ["created_at",
  2016-08-09 18:58:36 UTC], ["updated_at", 2016-08-09 18:58:36 UTC]]
   (3.7ms)  commit transaction
=> #<Comment id: 10, body: "hello", author: "jaba", post_id: 1, created_at: "2016-08-09 18:58:36", updated_at: "2016-08-09 18:58:36">
irb(main):003:0> post.last_commented_on == comment.created_at
=> true

That seems like some good practice for using migrations and callbacks.

For more resources on Active Record migrations: