Skip to main content

README.md

# erlang_migrate

**Erlang/OTP database migration library — pixel-perfect reference implementation of [golang-migrate/migrate v4](https://github.com/golang-migrate/migrate).**

**Erlang/OTP 数据库迁移库 —— 像素级对标 [golang-migrate/migrate v4](https://github.com/golang-migrate/migrate) 的设计实现。**

> Supported databases / 支持的数据库:**PostgreSQL 10+** · **MySQL 8+** · **SQLite 3+**

---

## Design Philosophy / 设计理念

`erlang_migrate` directly inherits the architectural philosophy of `golang-migrate/migrate/v4`:

`erlang_migrate` 直接继承 `golang-migrate/migrate/v4` 的架构哲学:

### 1. Source/Database Separation / 来源与数据库分离

golang-migrate decouples migration **source** (where SQL files come from) from migration **target** (which database to run against).
`erlang_migrate` follows the same separation: `erlang_migrate_source` handles file discovery, `erlang_migrate_pg` handles PostgreSQL execution.

golang-migrate 将迁移**来源**(SQL 文件从哪里读)与迁移**目标**(运行在哪个数据库)彻底解耦。
`erlang_migrate` 遵循同样的分离:`erlang_migrate_source` 负责文件发现,`erlang_migrate_pg` 负责 PostgreSQL 执行。

### 2. Dirty State Machine / Dirty 状态机

Every migration is executed in a **two-phase commit** pattern:

每个迁移以**两阶段提交**模式执行:

```
set_version(V, dirty=true)   ← mark as in-progress / 标记为执行中
run SQL                       ← execute migration / 执行迁移
set_version(V, dirty=false)  ← mark as complete / 标记为完成
```

If the process crashes between phases, `dirty=true` is permanently recorded, blocking future runs until `force/2` is called.
This is identical to golang-migrate's `SetVersion(v, true)``Run()``SetVersion(v, false)` pattern.

如果进程在两阶段之间崩溃,`dirty=true` 会被永久记录,阻止后续运行直到调用 `force/2`这与 golang-migrate 的 `SetVersion(v, true)``Run()``SetVersion(v, false)` 模式完全一致。

### 3. Advisory Lock for Distributed Safety / Advisory Lock 分布式安全

Both golang-migrate and `erlang_migrate` use the database's own advisory lock mechanism to prevent concurrent migrations across multiple application nodes. The lock is always released in an `after` block (equivalent to Go's `defer`), ensuring no orphaned locks.

golang-migrate 和 `erlang_migrate` 都使用数据库自身的 advisory lock 机制,防止多个应用节点并发执行迁移。锁始终在 `after` 块中释放(等价于 Go 的 `defer`),确保不会产生孤立锁。

### 4. Version as Integer / 版本号为整数

Versions are **unsigned integers** — either sequential (1, 2, 3…) or Unix timestamps. There is no semantic versioning. The ordering is strict numeric, making "which migration runs next" deterministic and unambiguous.

版本号是**无符号整数**——可以是顺序整数(1, 2, 3…)或 Unix 时间戳。没有语义版本控制,排序是严格数值排序,使"下一个运行哪个迁移"具有确定性且无歧义。

### 5. Plain SQL Only / 仅使用纯 SQL

Migrations are plain `.sql` files. No ORM, no DSL, no code generation. The SQL you write is exactly what runs against the database. This keeps migrations auditable, portable, and debuggable.

迁移是纯 `.sql` 文件。没有 ORM,没有 DSL,没有代码生成。你写的 SQL 就是直接在数据库上运行的 SQL。这使迁移可审计、可移植、可调试。

---

## Migration File Rules / 迁移文件规则

### File Naming Pattern / 文件命名规则

| Rule / 规则 | Pattern / 格式 | Example / 示例 |
|-------------|----------------|----------------|
| Up migration / 正向迁移 | `{version}_{title}.up.sql` | `00000001_create_users.up.sql` |
| Down migration / 反向迁移 | `{version}_{title}.down.sql` | `00000001_create_users.down.sql` |
| Version format / 版本格式 | Positive integer / 正整数 | `1`, `00000001`, `20240101120000` |
| Title format / 标题格式 | `[a-z0-9_]+` | `create_users`, `add_email_index` |
| Separator / 分隔符 | Underscore `_` between version and title | `00000001_create_users` |
| Extension / 扩展名 | `.up.sql` or `.down.sql` | `.up.sql` |

### Version Rules / 版本号规则

| Rule / 规则 | Description / 说明 | Valid / 合法 | Invalid / 非法 |
|-------------|---------------------|-------------|----------------|
| Must be positive integer / 必须是正整数 | No zero, no negative / 非零,非负 | `1`, `100` | `0`, `-1` |
| Zero-padded recommended / 建议补零 | For consistent sorting / 保证排序一致 | `00000001` | `1` (still works / 也能用) |
| Unix timestamp allowed / 允许 Unix 时间戳 | 14-digit preferred / 推荐 14 位 | `20240101120000` ||
| No gaps required / 不要求连续 | Gaps are fine / 允许跳号 | `1, 2, 5, 10` ||
| Must be unique / 必须唯一 | Duplicate versions are rejected / 重复版本会被拒绝 || Two files with same version |

### Directory Rules / 目录规则

| Rule / 规则 | Description / 说明 |
|-------------|---------------------|
| Flat directory / 平铺目录 | No subdirectories scanned / 不扫描子目录 |
| Any filename is scanned / 扫描所有文件 | Only `.up.sql` and `.down.sql` are processed / 只处理 `.up.sql``.down.sql` |
| `.up.sql` required / `.up.sql` 必须存在 | Every version must have an up file / 每个版本必须有 up 文件 |
| `.down.sql` optional / `.down.sql` 可选 | If missing, `down/2` will error for that version / 缺少则 `down/2` 该版本会报错 |
| Must be readable / 必须可读 | File permission errors abort the scan / 权限错误会中止扫描 |

### SQL Content Rules / SQL 内容规则

| Rule / 规则 | Description / 说明 |
|-------------|---------------------|
| Multi-statement supported / 支持多语句 | `epgsql:squery` executes the full file / `epgsql:squery` 直接执行整个文件 |
| No explicit transaction needed / 无需显式事务 | Each migration runs in its own auto-transaction / 每个迁移在自身事务中运行 |
| DDL and DML both allowed / DDL 和 DML 均可 | `CREATE TABLE`, `INSERT`, `ALTER`, etc. / 均支持 |
| Empty file allowed / 允许空文件 | Acts as a no-op version marker / 作为无操作版本标记 |
| Comments allowed / 允许注释 | Standard SQL `--` and `/* */` / 标准 SQL 注释均可 |

### Example Directory Layout / 示例目录布局

```
priv/migrations/
  00000001_create_users.up.sql       ← required / 必须
  00000001_create_users.down.sql     ← recommended / 建议
  00000002_add_email_index.up.sql
  00000002_add_email_index.down.sql
  00000003_add_roles_table.up.sql
  00000003_add_roles_table.down.sql
  20240101120000_add_audit_log.up.sql
  20240101120000_add_audit_log.down.sql
```

---

## Configuration / 配置

All behaviour is controlled by a single `Config` map passed to every API call.
**No changes to `erlang_migrate` source are needed** — everything is configured at call site.

所有行为通过传入每个 API 调用的 `Config` map 控制。
**无需修改 `erlang_migrate` 源码** —— 所有定制均在调用方配置。

### Three key customisation points / 三个核心定制项

| Key / 键 | What it controls / 控制什么 | Default / 默认值 |
|----------|-----------------------------|-----------------|
| `driver` | Which database backend to use / 使用哪个数据库后端 | `erlang_migrate_pg` |
| `dir`    | Where migration SQL files live / 迁移 SQL 文件目录 | *(required / 必填)* |
| `table`  | Name of the tracking table / 迁移状态跟踪表名 | `<<"schema_migrations">>` |

```erlang
%% PostgreSQL — default driver, custom path and table name
%% PostgreSQL —— 默认驱动,自定义路径和表名
Config = #{
    conn   => Conn,
    driver => erlang_migrate_pg,            % default, can be omitted / 默认可省略
    dir    => "priv/migrations/postgres",   % your SQL file directory / 你的迁移文件目录
    table  => <<"myapp_schema_migrations">> % custom tracking table / 自定义跟踪表名
},
ok = erlang_migrate:up(Config).

%% MySQL 8+
Config = #{
    conn   => Conn,
    driver => erlang_migrate_mysql,
    dir    => "priv/migrations/mysql",
    table  => <<"myapp_schema_migrations">>
},
ok = erlang_migrate:up(Config).

%% SQLite 3+
Config = #{
    conn   => Conn,
    driver => erlang_migrate_sqlite,
    dir    => "priv/migrations/sqlite",
    table  => <<"myapp_schema_migrations">>
},
ok = erlang_migrate:up(Config).
```

> The tracking table is created automatically on first run if it does not exist.
> Lock ID is auto-derived from the table name, so different table names are lock-isolated.
>
> 跟踪表在首次运行时自动创建(如不存在)。
> 锁 ID 从表名自动派生,不同表名之间的锁互相隔离。

---

## Quick Start / 快速开始

```erlang
%% 1. Connect to PostgreSQL / 连接 PostgreSQL
{ok, Conn} = epgsql:connect(#{
    host     => "localhost",
    port     => 5432,
    database => "mydb",
    username => "user",
    password => "pass"
}),

%% 2. Build config — see "Configuration" section for driver/dir/table options
%% 构建配置 —— driver/dir/table 定制项见上方「Configuration」章节
Config = #{
    conn => Conn,
    dir  => "priv/migrations"
},

%% 3. Apply all pending migrations / 应用所有待执行迁移
ok = erlang_migrate:up(Config),

%% 4. Apply next 2 migrations / 应用接下来 2 个迁移
ok = erlang_migrate:up(Config, 2),

%% 5. Check current version and dirty flag / 查询当前版本和 dirty 状态
{ok, Version, Dirty} = erlang_migrate:version(Config),

%% 6. Roll back 1 migration / 回滚 1 个迁移
ok = erlang_migrate:down(Config, 1),

%% 7. Roll back all applied migrations / 回滚全部迁移
ok = erlang_migrate:down(Config),

%% 8. Jump to a specific version (auto up or down) / 跳转到指定版本(自动判断方向)
ok = erlang_migrate:goto(Config, 5),

%% 9. Force-set version after manual recovery / 手动恢复后强制设置版本
ok = erlang_migrate:force(Config, 5),

%% 10. Drop schema_migrations table (tests only) / 删除 schema_migrations 表(仅测试用)
ok = erlang_migrate:drop(Config).
```

---

## API Reference / API 参考

| Function / 函数 | golang-migrate equivalent | Description / 说明 |
|-----------------|--------------------------|---------------------|
| `up(Config)` | `Up()` | Apply all pending migrations / 应用所有待执行迁移 |
| `up(Config, N)` | `Steps(+N)` | Apply up to N pending migrations / 应用最多 N 个待执行迁移 |
| `down(Config)` | `Down()` | Roll back all applied migrations / 回滚所有已应用迁移 |
| `down(Config, N)` | `Steps(-N)` | Roll back N migrations / 回滚 N 个迁移 |
| `goto(Config, Version)` | `Migrate(version)` | Migrate to exact version (auto up/down) / 迁移到指定版本(自动判断方向) |
| `force(Config, Version)` | `Force(version)` | Force set version, clears dirty flag / 强制设置版本,清除 dirty 标志 |
| `version(Config)` | `Version()` | Return `{ok, Version, Dirty}` / 返回版本和 dirty 状态 |
| `drop(Config)` | `Drop()` *(partial)* | Drop `schema_migrations` table / 删除 schema_migrations 表 |

> **Note on `drop/1` / `drop/1` 说明**
>
> golang-migrate's `Drop()` drops **all tables** in the target database.
> `erlang_migrate:drop/1` only drops the `schema_migrations` tracking table.
> Use in tests only.
>
> golang-migrate 的 `Drop()` 会删除目标数据库中的**所有表**> `erlang_migrate:drop/1` 仅删除 `schema_migrations` 状态跟踪表。仅在测试环境使用。

---

## Config Keys / 配置项

| Key / 键 | Required / 必填 | Default / 默认值 | Description / 说明 |
|----------|-----------------|-----------------|---------------------|
| `conn` | yes / 是 || Database connection pid / 数据库连接进程 |
| `dir` | yes / 是 || Path to migration files / 迁移文件目录路径 |
| `driver` | no / 否 | `erlang_migrate_pg` | Driver module / 驱动模块,见下方驱动说明 |
| `table` | no / 否 | `<<"schema_migrations">>` | Tracking table name / 迁移状态表名 |
| `lock_id` | no / 否 | `erlang:phash2(Table)` | Advisory lock ID (auto-derived) / 锁 ID(自动派生)|
| `lock_timeout` | no / 否 | `15000` | Lock wait timeout in ms / 获锁等待超时毫秒数 |
| `logger` | no / 否 | `undefined` | `fun(Level, Msg) -> ok` callback / 日志回调函数 |

---

## Schema Migrations Table / 状态跟踪表

```sql
CREATE TABLE schema_migrations (
    version    BIGINT PRIMARY KEY,
    dirty      BOOLEAN NOT NULL DEFAULT false,
    applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

The table always contains **at most one row** — the current version state.
Every `set_version` call is a `DELETE` + optional `INSERT` (when `Version =/= undefined`).
An empty table means no migrations have been applied.

表中**永远最多只有一行**——即当前版本状态。
每次 `set_version` 调用都是 `DELETE` + 可选 `INSERT``Version =:= undefined` 时只 DELETE)。
空表表示尚未执行任何迁移。

This is identical to golang-migrate's `TRUNCATE + INSERT` semantics, ensuring `force/2` always produces a clean single-row state with no stale dirty rows.

这与 golang-migrate 的 `TRUNCATE + INSERT` 语义完全一致,确保 `force/2` 总能产生干净的单行状态,不存在残留 dirty 行。

---

## Dirty State / Dirty 状态

If a migration fails mid-execution, the `dirty` flag is set to `true`. All future `up/down/goto` calls will refuse to proceed.

迁移执行中途失败,`dirty` 标志被设为 `true`。所有后续 `up/down/goto` 调用均会拒绝执行。

```
  ┌──────────┐   up/goto    ┌──────────────┐  success   ┌──────────┐
  │  clean   │ ────────────►│  executing   │ ──────────►│  clean   │
  │ (v=N)    │              │  dirty=true  │             │ (v=N+1)  │
  └──────────┘              └──────────────┘             └──────────┘
                              failure / error
                            ┌──────────────┐
                            │    dirty     │  ← blocks all future runs / 阻断所有操作
                            │  dirty=true  │
                            └──────────────┘
                        manual DB fix + force(Config, V)
                            ┌──────────────┐
                            │    clean     │
                            │  dirty=false │
                            └──────────────┘
```

Recovery steps / 恢复步骤:

1. Inspect the database and fix any partial state manually / 检查数据库并手动修复部分状态
2. Call `erlang_migrate:force(Config, LastGoodVersion)` / 调用 `force/2` 设置最后一个正常版本

---

## Concurrency Safety / 并发安全

`erlang_migrate` uses `pg_advisory_lock` — equivalent to golang-migrate's database-layer advisory lock.
Safe for multi-node Erlang clusters. Only one node executes migrations at a time.

`erlang_migrate` 使用 `pg_advisory_lock`,等价于 golang-migrate 的数据库层 advisory lock。
对多节点 Erlang 集群安全。同一时刻只有一个节点执行迁移。

Lock timeout is configurable via `lock_timeout` in Config (default `15000`ms, matching golang-migrate).
Internally uses `pg_try_advisory_lock` + 100ms retry loop until deadline.

锁超时通过 Config 中的 `lock_timeout` 配置(默认 `15000`ms,与 golang-migrate 一致)。
内部使用 `pg_try_advisory_lock` + 100ms 重试循环直到超时。

```erlang
Config = #{
    conn         => Conn,
    dir          => "priv/migrations",
    lock_timeout => 5000,
    logger       => fun(Level, Msg) ->
        logger:log(Level, "erlang_migrate: ~s", [Msg])
    end
}.
```

---

## Implementation Status / 实现进度

| Feature / 功能 | golang-migrate | erlang_migrate | Status / 状态 |
|----------------|---------------|----------------|---------------|
| `up` all |`Up()` |`up/1` | ✅ Done |
| `up` N steps |`Steps(+N)` |`up/2` | ✅ Done |
| `down` all |`Down()` |`down/1` | ✅ Done |
| `down` N steps |`Steps(-N)` |`down/2` | ✅ Done |
| `goto` version |`Migrate(v)` |`goto/2` | ✅ Done |
| `force` version |`Force(v)` |`force/2` | ✅ Done |
| `version` + dirty |`Version()` |`version/1``{ok, V, Dirty}` | ✅ Done |
| `drop` state table | ✅ all tables | ✅ state table only | ⚠️ Partial |
| Advisory lock ||`pg_advisory_lock` | ✅ Done |
| Dirty state machine ||| ✅ Done |
| Migration history | ❌ single-row | ❌ single-row + `applied_at` | ✅ Done |
| Lock timeout | ✅ 15s |`lock_timeout` ms (default 15s) | ✅ Done |
| GracefulStop | ✅ channel || 🔲 Planned |
| Logger interface | ✅ pluggable | ✅ optional `logger` fun/2 in Config | ✅ Done |
| CLI tooling || ❌ library only | 🔲 Future |
| Source abstraction | ✅ 15+ sources | filesystem only | 🔲 Future |
| Multi-database | ✅ 15+ | PostgreSQL / MySQL / SQLite | ✅ Done |
| Integration tests | ✅ Docker | 🔲 planned | 🔲 Planned |

---

## Installation / 安装

### PostgreSQL

Add `epgsql` to your own `deps`. `erlang_migrate` has **zero hard dependencies**.

在你的 `deps` 中添加 `epgsql``erlang_migrate` **没有任何硬依赖**
```erlang
{deps, [
    {erlang_migrate, "0.2.1"},
    {epgsql, "4.8.0"}
]}.
```

```erlang
Config = #{conn => Conn, dir => "priv/migrations"},
ok = erlang_migrate:up(Config).
```

### MySQL 8+

Add `mysql` to your own `deps`, then set `driver => erlang_migrate_mysql` in Config.

在你的项目 `deps` 中添加 `mysql`,Config 中指定驱动即可。

```erlang
{deps, [
    {erlang_migrate, "0.2.1"},
    {mysql, "1.8.0"}           %% add mysql driver yourself / 自行添加驱动依赖
]}.
```

```erlang
{ok, Conn} = mysql:start_link([{host, "localhost"}, {user, "root"},
                                {password, "pass"}, {database, "mydb"}]),
Config = #{conn => Conn, dir => "priv/migrations", driver => erlang_migrate_mysql},
ok = erlang_migrate:up(Config).
```

### SQLite 3+

Add `esqlite` to your own `deps`, then set `driver => erlang_migrate_sqlite` in Config.

在你的项目 `deps` 中添加 `esqlite`,Config 中指定驱动即可。

```erlang
{deps, [
    {erlang_migrate, "0.2.1"},
    {esqlite, "0.8.1"}         %% add esqlite driver yourself / 自行添加驱动依赖
]}.
```

```erlang
{ok, Conn} = esqlite3:open("mydb.sqlite"),
Config = #{conn => Conn, dir => "priv/migrations", driver => erlang_migrate_sqlite},
ok = erlang_migrate:up(Config).
```

### From GitHub / 从 GitHub 安装

```erlang
{deps, [
    {erlang_migrate, {git, "https://github.com/imboy-pub/erlang_migrate.git", {tag, "v0.2.1"}}}
]}.
```

---

## Development / 开发

```bash
rebar3 compile
rebar3 eunit
```

---

## License / 许可证

Apache 2.0 — see [LICENSE](LICENSE)