How to Manage Null Constraints With Migrations in Ruby on Rails
Ruby on Rails makes it easy to write migrations to modify your database schema.
But null constraints are a topic that requires some special attention. If you don't handle null constraints properly, a poorly written migration can easily wreak havoc on your database and bring down your production server.
What is a Null Constraint?
By default most databases allow you to store NULL
values in your table columns. Sometimes you might want to specify that a certain value has to exist, and you can apply a null constraint to enforce that.
Then whenever a record attempts to save a NULL
value, the database transaction will roll back nicely and prevent it from doing so.
Adding a Column With a Null Constraint
When you write a migration, you can specify null constraints whenever you add new columns.
Using the add_column
helper, you can add the null
keyword argument to specify if the new column allows NULL
values or not. You have the option to add the default
keyword argument too if you need to define what the default value of this column should be. This will ensure that new records have a value even if you don't specify one, so they can abide by your null constraint.
Here's how this might look:
add_column :articles, :published, :boolean, null: false, default: false
This will add a new boolean
column called published
to the articles
table. The null: false
parameter means this column does not allow NULL
values. The default: false
tells us this column will default to false whenever a value isn't specified.
This is nice because we can guarantee this field will always be either true or false now. It can't be null because the database prevents it. I'd wager that you've probably caused or at least seen a bug from code that didn't handle null values correctly.
Preventing them at the database level can significantly simplify your logic and save you from future bug squashing!
Creating a Table With Columns That Have Null Constraints
If you are creating a new table, the syntax is similar.
We will use the create_table
helper to define our table and columns.
Then append the null: false
keyword argument to any column in your create_table
call. You can also add the default:
in the same way to define a default value on a column.
create_table "articles", force: true do |t|
t.string "name", null: false
t.text "body"
t.boolean "published", default: false
end
Changing a Null Constraint on an Existing Column
Removing null constraints on existing columns is no problem, but if we add a new null constraint we run into big problems.
The thing about migrations is they need to be reversible.
You want to be able to roll them back. For example, let's say you pushed a new release to your production environment and later discovered some critical bugs. You want to be able to roll back that release to the prior version where everything worked.
But if your migrations aren't reversible, you can't always do that.
If we write a migration to allow null values, add a few records with null values, and then try to roll back the migration... that migration will fail since the table has null values and it can't enforce the new null constraint.
How to Write Reversible Null Constraint Migrations
This is critically important, but also trivially easy most of the time.
When we need to add a null constraint on a column, we just have to make sure that the column has a default value that we can use to replace the current NULL
values.
In other words, if we specify that a column cannot have any null values, we have to replace every NULL
value with a not null value in order to apply that constraint.
We can use the change_column_null
helper to update the null constraint on an existing column. It takes a few arguments and should look something like this:
def change
change_column_null(:articles, :name, false, "Untitled")
end
Digging into the arguments:
- Table name -
:articles
- Column name -
:name
- Null constraint, can this column be null?
- Default value, if this column no longer allows
NULL
, what do we replaceNULL
values with?
This migration is reversible and will let you add or remove null constraints with no issues.
Other Ways to Write Reversible Migrations
I'll admit there is some Ruby magic happening in the example above.
In a migration when you define the change
method, Active Record will try its best to roll back the migration just by doing the opposite. In other words, it knows when you call add_column
to pretty much call remove_column
on the roll back. The same is true for the change_column_null
method.
There are cases where you can't write a reversible migration all in the change
method though. One example is if you need to do a data migration or backfill some data of some kind too.
In these scenarios, it's best to refer to the older style of writing migrations.
The Up and Down Methods
When you run a migration, say with rake db:migrate
or rake db:migrate:rollback
, under the hood your migration is either running the up
or down
case.
You can also run rake db:migrate:status
to see which migrations are currently up or down (in other words which have run or not).
If you're writing a migration that isn't reversible, it's best to fall back to defining the specific up
and down
methods instead of jamming everything into the change
method and relying on magic.
Here's a quick example of the up
and down
methods:
def up
add_column :articles, :published, :boolean
rename_column :articles, :featured, :temporary_featured
end
def down
rename_column :articles, :temporary_featured, :featured
remove_column :articles, :published
end
The idea is we explicitly tell the database how to roll back our migration with the down
method. We have more control over what happens.
Thought most of the time change
is good enough!