A library's tables live in a database the library does not control.
The host may have added columns, indexes, triggers or foreign keys to
them; it may use binary_id keys, a schema prefix, several prefixes,
MySQL or SQLite, a table of fifty million rows, and rolling deploys
where old and new code run against the same schema.
PhoenixMountable.Migrations is built for that.
It needs ecto_sql, and no Phoenix: a core package uses it on its own.
The library side
defmodule MyLib.Migrations do
use PhoenixMountable.Migrations,
otp_app: :my_lib,
versions: [MyLib.Migrations.V1, MyLib.Migrations.V2, MyLib.Migrations.V3],
install_options: [primary_key: [type: {:in, [:id, :binary_id]}, default: :id]]
end
defmodule MyLib.Migrations.V1 do
use PhoenixMountable.Migration, description: "Items"
def up(ctx) do
create table(:my_lib_items, primary_key: primary_key(ctx), prefix: ctx.prefix) do
add :title, :string
end
create index(:my_lib_items, [:title], prefix: ctx.prefix)
end
def down(ctx), do: drop(table(:my_lib_items, prefix: ctx.prefix))
endEach version is a PhoenixMountable.Migration, with the Ecto.Migration
DSL. It receives a PhoenixMountable.Migrations.Context: the repo, the
adapter (:postgres, :mysql, :sqlite), the prefix, and the install
options. Match on the adapter where the SQL differs:
def up(%{adapter: :sqlite} = ctx), do: ...
def up(ctx), do: ...The host side
The host owns one pinned line per upgrade:
defmodule MyApp.Repo.Migrations.AddMyLib do
use Ecto.Migration
def up, do: MyLib.Migrations.up(version: 3)
def down, do: MyLib.Migrations.down(to: 0)
endmix phoenix_mountable.gen.migration MyLib.Migrations writes it (or a
library's own mix my_lib.gen.migration): it reads the version the
host's migrations pin, and writes the next migration with the latest
version, the attributes it needs, and notes about the versions. The
file is created by mix ecto.gen.migration, so it follows the host's
conventions.
Pinning keeps history reproducible: a developer or CI running every
migration on an empty database gets the same sequence production did.
An unpinned up() changes meaning when the dependency updates.
Tracking
The version is kept in a phoenix_mountable_schema table in the
library's prefix, written in the transaction of each version. It works
on every database (table comments do not exist on SQLite and are lost by
pg_dump --no-comments), per prefix (two instances in two schemas track
separately), and records the install options.
Install options
Options chosen once, at install, that later versions need:
MyLib.Migrations.up(version: 3, primary_key: :binary_id)They are stored, and every later version sees them in ctx, so a
version written years later adds a foreign key of the right type
(key_type(ctx)). A :primary_key option defaults to the repo's
:migration_primary_key.
Existing installs
A library moving onto PhoenixMountable declares how to read the version of a database migrated before:
use PhoenixMountable.Migrations, ..., legacy: MyLib.Migrations.Legacy
defmodule MyLib.Migrations.Legacy do
alias PhoenixMountable.Migrations.SQL
# From Oban-style table comments, or from the columns present.
def detect(ctx) do
if SQL.table_exists?(ctx.repo, ctx.adapter, ctx.prefix, "my_lib_items"), do: 2
end
endOn the first run the detected version is recorded and only later versions run.
Baselines
A library with many versions can ship a baseline creating the latest schema in one step, for fresh installs:
defmodule MyLib.Migrations.Baseline do
use PhoenixMountable.Migration, baseline: 12
def up(ctx), do: ...
endPhoenixMountable.Migrations.Test.assert_baseline_matches/3 proves it
gives the same schema as running every version.
Rolling deploys
Each version is safety: :expand (the default: new tables, nullable
columns, new indexes) or safety: :contract (dropping, renaming,
changing types). A compile-time lint warns about destructive operations
in an expand version.
compatible_with: n is the oldest schema version the library's code
still runs against. A rename is three releases: add the new column and
write both, backfill, then drop the old one in a contract version. The
generator notes contract versions, so hosts deploy the code first.
Large tables
create_concurrently(index(...))builds an index without locking writes on Postgres (and normally elsewhere). Such a version cannot run in a transaction; the generator adds@disable_ddl_transaction trueand@disable_migration_lock trueto the host migration, and running it in a transaction fails with those lines.lock_timeout("5s"by default) makes a version fail fast rather than queue behind a long transaction and block the app.- Backfills move data in batches, apart from the schema change:
defmodule MyLib.Backfills.Slugs do
use PhoenixMountable.Backfill,
table: "my_lib_items",
where: "slug IS NULL",
set: "slug = LOWER(title)",
batch_size: 1_000
end
def up(ctx) do
alter table(:my_lib_items, prefix: ctx.prefix), do: add(:slug, :string)
backfill(ctx, MyLib.Backfills.Slugs)
endBackfills run inline by default. With mode: :deferred, or
up(version: n, backfills: :deferred) from the host, they run after the
deploy in PhoenixMountable.Backfill.Runner (or
mix phoenix_mountable.backfill), batch by batch, recording a cursor
after each, so they resume after a restart. A version that needs the
data declares requires_backfills: [MyLib.Backfills.Slugs] and refuses
to run before the backfill finishes.
Host changes
- Indexes have deterministic names;
create_index_if_missing/1,drop_index_if_present/1andadd_column_if_missing/5work on every adapter (MySQL has noIF NOT EXISTSfor them), so a version runs again after a partial failure (MySQL DDL is not transactional). - SQLite cannot alter a column, so
rebuild_table/3recreates the table, copying every column, index and trigger, including the host's, and refusing tables withCHECKconstraints rather than losing them. verify/1compares the tables with a snapshot written by the test kit, ignoring what the host added.
Checks
With schema: MyLib.Migrations in the definition module, the first use
of each mount compares the database with the library:
- below
compatible_with: raisesPhoenixMountable.Migrations.OutdatedErrorwith the command to run; - between: logs a warning once, or raises with
config :phoenix_mountable, pending_migrations: :raise(set it in the host's test config so CI catches a forgotten upgrade).
mix phoenix_mountable.check checks every library in CI.
Testing
test "migrations" do
PhoenixMountable.Migrations.Test.run_suite(MyLib.Repo, MyLib.Migrations, prefix: "my_lib_test")
endruns every version up, down and up again, checks the baseline, upgrades from each version, and adds a column and an index to every table after each version (as a host might) to check that later versions keep them. Run it against each database the library supports.