How to Manage Null Constraints With Migrations in Ruby on Rails
Tags: ruby, rails, webdev
Published: 2021-11-14 15:47:22

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:

```ruby
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.

```ruby
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:

```ruby
def change
  change_column_null(:articles, :name, false, "Untitled")
end
```

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:

```ruby
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!