POST DIRECTORY
Category software development

Recently, a project we’ve been working on at Haught Codeworks got to the point where we needed to add additional columns to the database for one particular model. This meant that for any future instances of this model, those columns could be populated upon creating the instance, but any past instances would not have those fields associated with them. For the sake of this example, let’s say we work at a restaurant that has many servers and many menu items. Each evening the restaurant is open for business, there is an associated dinner shift. Our models start off looking like this:

def Restaurant
  has_many :servers
  has_many :menu_items
  has_many :dinner_shifts
end

def DinnerShift
  belongs_to :restaurant
end

At this point, we can find the servers and menu items associated with a dinner shift by going through the restaurant that the dinner shift belongs to. But what if we want to bring in an additional server just for one shift, or what if we want to add a special one night only menu item? At this point, we’d have to change the associations through the restaurant, when we’d like to only be changing them for one dinner shift instance. We can do this by associating an array of server_ids and an array of menu_items with our dinner shift model, leaving us with this migration and change to our schema:

class AddServersMenuItemsToDinnerShift < ActiveRecord::Migration
  def change
    change_table :dinner_shifts do |t|
      t.text :server_ids, :menu_item_ids, array: true, default: []
    end
  end
end

Now we’re all set if we simply want to update servers or menu items for one night only. But what if we need to make a change to a past dinner shift? There are a few options that we could use to accomplish this task. I could have used a migration, but decided that was not the best choice as we don’t want data transforming code in migrations, and we wouldn’t want that code to run each time migrations are run. I could have added the server_ids and menu_item_ids to past instances through the console, but I wanted my solution to be repeatable and testable. Enter the rake task. Rake is built into the Rails code base and is an efficient way to generate one-off processes that can be easily run.

I used the rails generator to create a rake task in the restaurant_project namespace with the task name backfill_dinner_shifts.

rails generate task restaurant_project backfill_dinner_shifts

This creates a file named restaurant_project.rake in the lib/tasks folder, and gives me an outline where I can fill in an informative description that I’ll be able to read when I run rake -T to see all my tasks.

namespace :restaurant_project do
  desc "Add servers and menu items to existing dinner_shifts."
  task backfill_dinner_shifts: :environment do
  end
end

In order to help with debugging, and be aware of what’s happening as I run the task, I’ll add a log method to display custom messages to standard out and the Rails log file.

def log(msg)
  puts msg
  Rails.logger.info msg
end

As different parts of the task run, I can set messages to display based on task progress, errors, and successes. It’s also helpful to display when the task starts running, keep a count of how many records were updated, and display total running time.

Now for the contents of the backill_dinner_shifts task block, I want to write code that will find all the dinner shifts. For each one, if the shift already has servers or menu items associated with it, I’ll skip it and move on to the next shift.

start_time = Time.now
successful_records = 0
skipped_records = 0
failed_records = 0
dinner_shifts = DinnerShift.all

dinner_shifts.each do |shift|
  if shift.server_ids.present? || shift.menu_item_ids.present?
    skipped_records += 1
    log("Skipping shift #{shift.id}: servers or menu items are present.")
    next
  end
  ...
end

I’ll pluck distinct server_ids and menu_item_ids off of the dinner shift’s associated restaurant and add them each to an array.

unless shift.restaurant.present?
  skipped_records += 1
  msg = "Skipping shift #{shift.id}: no associated restaurant."
  next
end

server_ids = shift.restaurant.servers.pluck(:id)
menu_item_ids = shift.restaurant.menu_items.pluck(:id)

Finally, I’ll set the server_ids and the menu_item_ids on the dinner shift and save the dinner shift via update_columns, as I don’t want to update the shift’s timestamps.

if shift.update_columns(server_ids: server_ids, menu_item_ids: menu_item_ids)
  successful_records += 1
  log("Successfully updated shift #{shift.id}")
else
  failed_records += 1
  log("Failed to update shift #{shift.id}")
end

Once I’ve put it all together, here’s the complete code for the task:

namespace :restaurant_project do
  desc "Add servers and menu items to existing dinner_shifts."

  def log(msg)
    puts msg
    Rails.logger.info(msg)
  end

  task backfill_dinner_shifts: :environment do
    log("Starting task to set server_ids and menu_item_ids on dinner_shifts missing those attributes.")

    start_time = Time.now
    successful_records = 0
    skipped_records = 0
    failed_records = 0
    dinner_shifts = DinnerShift.all

    dinner_shifts.each do |shift|
      if shift.server_ids.present? || shift.menu_item_ids.present?
        skipped_records += 1
        log("Skipping shift #{shift.id}: servers or menu items are present.")
        next
      end

      unless shift.restaurant.present?
        skipped_records += 1
        msg = "Skipping shift #{shift.id}: no associated restaurant."
        next
      end

      server_ids = shift.restaurant.servers.pluck(:id)
      menu_item_ids = shift.restaurant.menu_items.pluck(:id)

      if shift.update_columns(server_ids: server_ids, menu_item_ids: menu_item_ids)
        successful_records += 1
        log("Successfully updated shift #{shift.id}")
      else
        failed_records += 1
        log("Failed to update shift #{shift.id}")
      end
    end
    log("Task completed in #{Time.now - start_time} seconds. Out of #{dinner_shifts.count} total shifts, #{successful_records} shifts updated successfully; #{skipped_records} shifts skipped; #{failed_records} shifts failed.")
  end
end

I can run the task with the command rake restaurant_project:backfill_dinner_shifts. (Note that in Rails 5, all rake commands are routed through the rails command.) While the task runs, I should see my messages in standard out or the log informing me of the progress of the task. Upon task completion, I can visit my site and confirm that everything is working as expected and my past dinner shifts now have associated servers and menu items.

As opposed to uploading a script or using the console to add information to past sessions, my rake task is part of the app. I can access and reuse model logic to build my task. It’s in version control and part of a pull request that can be reviewed, tested, and run in different environments. I can remove the rake task when I’m finished with it so it won’t be accidentally executed in the future. You probably run Rails’ built-in Rake tasks often, but I hope this post encourages you to try writing some custom tasks of your own.

''