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.Â