layout | |||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
{% hint style="info" %} For the purposes of this page, a "migration" is a change to a Postgres database schema. {% endhint %}
Rails scans a database's schema when ActiveRecord first connects, and it keeps its knowledge cached.
This means that schema changes (like adding or removing columns) need to be paired with an app reboot.
This path is probably most suitable if you're doing deep, backwards-incompatible changes to the database schema.
- Write standard Rails migrations for your pull request.
- Merge it.
- Release your code.
- Important: until you reach and complete step #5, you'll have new code running with an old database schema. Plan ahead for this part, and mitigate user impact however you can.
- Run
bin/rake db:migrate
in the deployment environment. - Restart the app. Wait for success.
This path is probably most suitable if you're doing things that can be done idempotently and in a way that won't break old application code.
- Write migrations for your pull request, using idempotent raw SQL.
- Merge the pull request containing the migrations.
- Before releasing the code, run the migrations manually in the target environment using
cb psql $CLUSTER_ID --role application
. Verify that your db changes are working properly, and that the db continues to be healthy.⚠️ That--role application
flag is important! The app itself connects using this role, and (for continuity/consistency/predictability) it needs to have ownership over the objects it uses. So, when you're manually running migrations, it's important to use the same role as the app itself -- i.e.application
.
- Release your code changes, which gives you an app restart for free. Wait for success.
- Run
bin/rake db:migrate
in the deployment environment. This is a semi-redundant step: you already manually ran the changes in step 3. Doing it this time won't fail, because you wrote idempotent migrations. Doing it this time also updates the private Rails bookkeeping table calledschema_migrations
, declaring at the Rails level that the database schema is up to date.- You can use
fly console -a $FLY_APP_NAME -s
to open up a console on an already-running machine. (You can also open a console using a different build/image! For that, see Unusual consoles.)
- You can use
Idempotent code is code that has side effects, but only creates those side effects once -- even if it's run more than once.
This is useful in database land! Idempotent migration code can be run more than once without errors. It's like saying "hey, make this change, but only if it wasn't already made".
Rails has some conveniences for this -- look for if_exists:
and if_not_exists:
in https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html.
However, it can be useful to stick to raw SQL, for easy pasting into psql.
{% hint style="info" %}
For functions, use CREATE OR REPLACE FUNCTION
. Functions are stateless (unlike tables and indexes!), so it's okay to have the function created ahead of time by a human, and then recreated during the actual Rails migration execution. This still counts as idempotent behavior, because the function's existence and behavior remain consistent even when the migration SQL is re-run.
{% endhint %}
class IndexExample < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def up
execute("CREATE INDEX CONCURRENTLY IF NOT EXISTS index_input_list_items_on_input_list_id_and_id ON public.input_list_items USING btree (input_list_id, id)")
end
def down
execute("DROP INDEX CONCURRENTLY IF EXISTS index_input_list_items_on_input_list_id_and_id")
end
end
class ColumnExample < ActiveRecord::Migration[7.1]
def up
execute("alter table shops add column if not exists shopify_country_code text null")
execute("alter table shops drop column if exists shopify_iana_timezone_utc_offset")
end
def down
execute("alter table shops drop column if exists shopify_country_code")
execute("alter table shops add column if not exists shopify_iana_timezone_utc_offset integer null")
end
end
class FunctionExample < ActiveRecord::Migration[7.1]
def up
execute(<<~sql)
CREATE OR REPLACE FUNCTION public.contains_case_insensitive_match(jsonb_array jsonb, value text) RETURNS boolean
LANGUAGE plpgsql IMMUTABLE
AS $$
declare
element TEXT;
begin
value := trim(lower(value));
for element in select jsonb_array_elements_text(jsonb_array)
loop
if trim(lower(element)) = value then
return TRUE;
end if;
end loop;
return FALSE;
end;
$$;
sql
end
def down
execute(<<~sql.squish)
DROP FUNCTION IF EXISTS public.contains_case_insensitive_match(jsonb, text);
sql
end
end