Migrations

View Source

Kura migrations provide version-tracked DDL operations for managing your database schema.

Creating a Migration

Migration modules must:

  • Be named m<YYYYMMDDHHMMSS>_<descriptive_name>
  • Implement the kura_migration behaviour
  • Export up/0 and down/0 returning a list of operations
-module(m20250101120000_create_users).
-behaviour(kura_migration).
-include_lib("kura/include/kura.hrl").

-export([up/0, down/0]).

up() ->
    [{create_table, ~"users", [
        #kura_column{name = id, type = id, primary_key = true},
        #kura_column{name = name, type = string, nullable = false},
        #kura_column{name = email, type = string, nullable = false},
        #kura_column{name = inserted_at, type = utc_datetime, nullable = false},
        #kura_column{name = updated_at, type = utc_datetime, nullable = false}
    ]}].

down() ->
    [{drop_table, ~"users"}].

Place migration files in src/migrations/ (or any subdirectory under src/). Kura automatically discovers them by scanning the application's compiled modules for names matching the m<YYYYMMDDHHMMSS>_<name> pattern — no configuration needed.

Since migrations are regular .erl files in src/, they are compiled normally by rebar3. If you use {src_dirs, [{"src", [{recursive, true}]}]} in your rebar.config, subdirectories like src/migrations/ are included automatically.

DDL Operations

Create Table

{create_table, ~"table_name", [
    #kura_column{name = id, type = id, primary_key = true},
    #kura_column{name = name, type = string, nullable = false},
    #kura_column{name = score, type = integer, default = 0},
    #kura_column{name = active, type = boolean, default = true}
]}.

Column options:

  • primary_keytrue | false (default: false)

  • nullabletrue | false (default: true)

  • default — literal value (integer, float, binary, boolean) or undefined for none

Drop Table

{drop_table, ~"table_name"}.

Alter Table

{alter_table, ~"users", [
    {add_column, #kura_column{name = bio, type = text}},
    {drop_column, old_field},
    {rename_column, old_name, new_name},
    {modify_column, score, float}
]}.

Create Index

Indexes use a map-based options format with auto-generated names following Ecto conventions ({table}_{columns}_index):

%% Simple unique index
{create_index, ~"users", [email], #{unique => true}}.
%% Generates: CREATE UNIQUE INDEX "users_email_index" ON "users" ("email")

%% Non-unique index
{create_index, ~"posts", [user_id], #{}}.
%% Generates: CREATE INDEX "posts_user_id_index" ON "posts" ("user_id")

%% Composite index
{create_index, ~"users", [first_name, last_name], #{}}.
%% Generates: CREATE INDEX "users_first_name_last_name_index" ON ...

%% Partial index
{create_index, ~"users", [email], #{unique => true, where => ~"email IS NOT NULL"}}.
%% Generates: CREATE UNIQUE INDEX "users_email_index" ON "users" ("email") WHERE email IS NOT NULL

The index name is auto-generated via kura_migration:index_name/2. If you need a custom name, the legacy 5-tuple format is still supported:

{create_index, ~"my_custom_idx", ~"users", [email], [unique]}.

Drop Index

{drop_index, ~"users_email_index"}.

Raw SQL

{execute, ~"ALTER TABLE users ADD CONSTRAINT age_check CHECK (age >= 0)"}.

Running Migrations

%% Run all pending migrations
{ok, AppliedVersions} = kura_migrator:migrate(my_repo).

Each migration runs in its own transaction. If a migration fails, it is rolled back and subsequent migrations are not attempted.

Rolling Back

%% Roll back the last migration
{ok, RolledBack} = kura_migrator:rollback(my_repo).

%% Roll back the last N migrations
{ok, RolledBack} = kura_migrator:rollback(my_repo, 3).

Checking Status

Status = kura_migrator:status(my_repo).
%% Returns: [{Version, Module, up | pending}, ...]

Schema-Level Indexes

Instead of manually writing index operations in migrations, you can declare indexes on your schema module. This is the recommended approach — it keeps index definitions alongside your schema and allows rebar3_kura to auto-generate the migration operations for you.

-module(my_user).
-behaviour(kura_schema).
-include_lib("kura/include/kura.hrl").

-export([table/0, fields/0, indexes/0]).

table() -> ~"users".

fields() ->
    [#kura_field{name = id, type = uuid, primary_key = true, nullable = false},
     #kura_field{name = username, type = string, nullable = false},
     #kura_field{name = email, type = string},
     #kura_field{name = phone_number, type = string}].

indexes() ->
    [{[username], #{unique => true}},
     {[email], #{unique => true}},
     {[phone_number], #{unique => true, where => ~"phone_number IS NOT NULL"}}].

Unique indexes declared via indexes/0 are automatically registered as changeset constraints — no manual unique_constraint/2 calls needed. When a PostgreSQL unique violation fires (e.g. users_email_index), it maps to {email, <<"has already been taken">>} on the changeset.

Schema Migrations Table

Kura automatically creates a schema_migrations table to track which migrations have been applied. This table is created on first use of migrate/1, rollback/1, or status/1.