Conor's Blog -

Defer database constraints with Rails + PostgreSQL

January 01, 2020

Database constraints are great. They define the rules the data in our DB must abide by, upholding the integrity of the data it contains. You’re probably familiar with constraints like NOT NULL, UNIQUE, PRIMARY/FOREIGN KEY etc. but are there cases where we would rather ignore a particular constraint?

I recently ran into this issue when updating the item positions in a list.

Say you have a list that has_many list_items. You would likely have a multicolumn index on the list_items table on list_id and position to ensure that not list has more than one list item in the same position.

This makes sense and is a good constraint to apply but what happens when we try to update the position of one or more of the existing list items?

Take the following list for example:

1. albatross
2. eagle
3. birdy

Updating the birdy record from position 3 to 2 will violate our contraint raising an exception when we try to make the update as that position is already held by eagle.

One workaround is to keep track of what all the new positions will be in our application logic, clear all of the list item positions, and subsequently update with the newly calculated positions.

While this approach works, it complicates our application code and involves unnecessary writes to the DB.

Alternatively we can make the uniquness constraint on list_id and position deferrable while updating the list item positions:

...
  def up
    remove_index :list_items, [:list_id, :position]
    execute <<~SQL
      ALTER TABLE list_items
        ADD CONSTRAINT list_items_position UNIQUE (list_id, position)
        DEFERRABLE INITIALLY IMMEDIATE;
      SQL
  end

  def down
    execute <<~SQL
      ALTER TABLE list_items
        DROP CONSTRAINT list_items_position;
    SQL
    add_index :list_items, [:list_id, :position], unique: true
  end
...

Now in ListItemsController#update:

...
  ActiveRecord::Base.transaction do
    ActiveRecord::Base.connection.execute('SET CONSTRAINTS list_items_position DEFERRED')

    list_item_ids.each_with_index do |id, index|
      list_item = ListItem.find(id)
      # We use update_attribute to avoid triggering application level constraints.
      list_item.update_attribute(:position, index)
    end
  end
...

We can now reorder our list items, deferring the constraint until the end of the transaction.


Written by Conor Broderick who works and takes photos in Dublin.