DEV Community

Nicholas Dill
Nicholas Dill

Posted on • Originally published at testsuite.io

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Digging into the arguments:

  1. Table name - :articles
  2. Column name - :name
  3. Null constraint, can this column be null?
  4. Default value, if this column no longer allows NULL, what do we replace NULL 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
Enter fullscreen mode Exit fullscreen mode

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!

Oldest comments (0)