Migrations


The bin/rails generate migration AddPublicToBooks command, when executed at a particular time in the past creates this file: db/migrate/20231026144625_add_public_to_books.rb

Right away I can hear people yelling about using a reserved word as a column name (public) and I think it's fine here. PUBLIC was a reserved word in SQL-92 and we're using PostgreSQL. Migrations are important though so the bigger they are, the more people should be involved. Anki Books is not at a scale where seconds of downtime is unacceptable. It might look a little clever in the Ruby code depending on how the sharply the language is being used.

class AddPublicToBooks < ActiveRecord::Migration[7.0]
  def change
  end
end

The timestamp in the file name is related to that particular time that the command was run. This is Ruby code in a scaffolded migration. We edit it to this:

class AddPublicToBooks < ActiveRecord::Migration[7.0]
  def change
    add_column :books, :public, :boolean
  end
end

This pretty much translates into an ALTER TABLE SQL statement that adds a column of type boolean to the articles table called public. Before doing any migration, also run the command: bin/rails db:migrate:status. This task uses a special table that Rails puts in the database for you, the schema_migrations table. The output shows what migrations are up and which are down:

   up     20231015101723  Add complete to articles
   up     20231015102321  Update reading writing complete for existing articles
   up     20231015102702  Add default values for articles writing reading complete
   up     20231015102811  Add not null for articles writing reading complete
  down    20231026144625  Add public to books

The schema_migrations table stores the timestamps of the migrations that have been run. By running bin/rails db:migrate, all the down migrations are run (in this case, only one migration is down). The output of that command hopefully looks good:

== 20231026144625 AddPublicToBooks: migrating =================================
-- add_column(:books, :public, :boolean)
   -> 0.0152s
== 20231026144625 AddPublicToBooks: migrated (0.0156s) ========================

And in the schema.rb file that is created by the db:schema task as part of the db:migrate task, we get an additional line:

create_table "books", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
  t.string "title", null: false
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.uuid "parent_book_id"
  t.boolean "public" # t.boolean "public" is new
end

The schema.rb file can be used to build the database from scratch and is committed to source control carefully. 

Then we can run the command bin/rails generate migration UpdateBooksPublicToFalse and this is the output:

invoke  active_record
create    db/migrate/20231026145631_update_books_public_to_false.rb

This time, we edit it to this:

class UpdateBooksPublicToFalse < ActiveRecord::Migration[7.0]
  def up
    execute <<-SQL
      UPDATE books SET public = false;
    SQL
  end

  def down
    execute <<-SQL
      UPDATE books SET public = NULL;
    SQL
  end
end

A really experienced Rails developer probably knows a more magical way, but this just executes the SQL statements to either make the public column false on all records on the table, or NULL, depending on if it is run up or down. You should always write a down method with migrations in case you need to undo that change. Sometimes, it is not possible to write a reversible migration, for example a migration that drops data is not going to magically give you any lost data back. Technically, you could reverse it if you don't care about data but unless you're in a development environment, you don't want to do that.

So we do another bin/rails db:migrate and get:

== 20231026145631 UpdateBooksPublicToFalse: migrating =========================
-- execute("      UPDATE books SET public = false;\n")
   -> 0.0101s
== 20231026145631 UpdateBooksPublicToFalse: migrated (0.0102s) ================

A quick way to test that it can be run up and down is the bin/rails db:migrate:redo command which outputs this:

== 20231026145631 UpdateBooksPublicToFalse: reverting =========================
-- execute("      UPDATE books SET public = NULL;\n")
   -> 0.0021s
== 20231026145631 UpdateBooksPublicToFalse: reverted (0.0022s) ================

== 20231026145631 UpdateBooksPublicToFalse: migrating =========================
-- execute("      UPDATE books SET public = false;\n")
   -> 0.0018s
== 20231026145631 UpdateBooksPublicToFalse: migrated (0.0019s) ================

Now that all the records in books have false, we can add a not null constraint to the column to enforce that it cannot have NULL as the value. So we do a bin/rails generate migration AddNotNullToBooksPublic and edit another new migration file:

class AddNotNullToBooksPublic < ActiveRecord::Migration[7.0]
  def change
    change_column_null :books, :public, false
  end
end

Then we run that one up (by the way, it's never a bad idea to check the state of the migrations with bin/rails db:migrate:status to make sure you are where you think you are). We can see that the schema.rb file has changed on one line:

create_table "books", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
  t.string "title", null: false
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.uuid "parent_book_id"
  t.boolean "public", null: false # this line changed
end

If we had tried to set the not null constraint on the table when it did have NULL values, the migration would have crashed. This is really undesirable when deploying to a lot of servers at once. Then we do another migration to set the default value to be false:

class AddDefaultPublicForBooks < ActiveRecord::Migration[7.0]
  def change
    change_column_default :books, :public, false
  end
end

This ensures that the existing functionality of the app that has not been specified to create a book public true or false will continue to work by defaulting to use false. We run the tests to see them pass at this point (rspec command). A couple minutes and a couple hundred little green dots later, we can continue developing. 
Previous Next