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.
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:
ModelName
would have a model_names
table
_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
.
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.
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
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).
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: