Ridgepole を使ってみる


RDBMS のスキーマ管理ツール Ridgepole を使ってみたのでメモ。

## 公式

Ridgepole 
http://ridgepole.codenize.tools/

winebarrel/ridgepole 
https://github.com/winebarrel/ridgepole

winebarrel/ridgepole-example 
https://github.com/winebarrel/ridgepole-example

## インストール

```shell-session
% gem install ridgepole mysql2 --no-rdoc --no-ri
% ridgepole -v       
ridgepole 0.6.4
```

## 使ってみる

テーブル定義などは ridgepole-example のものを使わせて頂いた。

作業用に適当なディレクトリを作成。

```shell-session
% mkdir ridgepole-example && cd ridgepole-example
```

データベースを2つ作成。

```shell-session
% mysql -u root -e "CREATE DATABASE IF NOT EXISTS blog DEFAULT CHARACTER SET utf8;"
% mysql -u root -e "CREATE DATABASE IF NOT EXISTS blog_prod DEFAULT CHARACTER SET utf8;"
```

Schemafile を作成。

```shell-session
% cat << _EOF_ > Schemafile 
create_table "articles", force: true do |t|
  t.string   "title"
  t.text     "text"
  t.datetime "created_at"
  t.datetime "updated_at"
end

create_table "comments", force: true do |t|
  t.string   "commenter"
  t.text     "body"
  t.integer  "article_id"
  t.datetime "created_at"
  t.datetime "updated_at"
end

add_index "comments", ["article_id"], name: "index_comments_on_article_id", using: :btree
_EOF_
```

ドライラン実行。
DB 接続設定は引数でも指定できるようなのでここでは引数を使ってみた。

```shell-session
% ridgepole -c '{adapter: mysql2, socket: /tmp/mysql.sock, database: blog}' --apply --dry-run -f Schemafile
Apply `Schemafile` (dry-run)
create_table("articles", {}) do |t|
  t.string("title", {:limit=>255})
  t.text("text", {:limit=>65535})
  t.datetime("created_at", {})
  t.datetime("updated_at", {})
end

create_table("comments", {}) do |t|
  t.string("commenter", {:limit=>255})
  t.text("body", {:limit=>65535})
  t.integer("article_id", {:limit=>4})
  t.datetime("created_at", {})
  t.datetime("updated_at", {})
end
add_index("comments", ["article_id"], {:name=>"index_comments_on_article_id", :using=>:btree})

# CREATE TABLE `articles` (
# `id` int(11) auto_increment PRIMARY KEY,
# `title` varchar(255),
# `text` text,
# `created_at` datetime,
# `updated_at` datetime)
# ENGINE=InnoDB
# CREATE TABLE `comments` (
# `id` int(11) auto_increment PRIMARY KEY,
# `commenter` varchar(255),
# `body` text,
# `article_id` int(11),
# `created_at` datetime,
# `updated_at` datetime)
# ENGINE=InnoDB
# CREATE  INDEX `index_comments_on_article_id` USING btree ON `comments` (
# `article_id`)
```

当然DB接続設定はファイルに書く事も出来るので、ここでファイルを作成しとく。

```shell-session
% cat << _EOF_ > database.yml
development:
  adapter: mysql2
  encoding: utf8
  socket: /tmp/mysql.sock
  database: blog
  username: root
production:
  adapter: mysql2
  encoding: utf8
  socket: /tmp/mysql.sock
  database: blog_prod
  username: root
_EOF_
```

Rails の database.yml と同じ形式なのでそのまま使う事も可能。

実際に DB に適用してみる。
ここでは `development` 環境の DB にのみ適用したいので `-E development` を指定。

```shell-session
% ridgepole -c database.yml -E development --apply -f Schemafile
Apply `Schemafile`
-- create_table("articles", {})
   -> 0.1952s
-- create_table("comments", {})
   -> 0.0540s
-- add_index("comments", ["article_id"], {:name=>"index_comments_on_article_id", :using=>:btree})
   -> 0.0734s
```

テーブルが作成された事を確認。

```sql
% mysql -uroot blog -e "SHOW TABLES; SHOW CREATE TABLE articles\G SHOW CREATE TABLE comments\G"
+----------------+
| Tables_in_blog |
+----------------+
| articles       |
| comments       |
+----------------+
*************************** 1. row ***************************
       Table: articles
Create Table: CREATE TABLE `articles` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `title` varchar(255) DEFAULT NULL,
  `text` text,
  `created_at` datetime DEFAULT NULL,
  `updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
*************************** 1. row ***************************
       Table: comments
Create Table: CREATE TABLE `comments` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `commenter` varchar(255) DEFAULT NULL,
  `body` text,
  `article_id` int(11) DEFAULT NULL,
  `created_at` datetime DEFAULT NULL,
  `updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `index_comments_on_article_id` (`article_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8
```

同様にして `production` にも適用しておく。

```shell-session
% ridgepole -c database.yml -E production --apply -f Schemafile
```

## テーブル定義のエクスポート

既に存在する DB 上のテーブル定義を Schemafile にエクスポートする事も出来る。

まずは先ほど作成した `development` 用 DB に適当にカラムを追加してみる。

```sql
% mysql -uroot blog -e 'ALTER TABLE `articles` ADD `category_id` INT DEFAULT NULL AFTER `text`; SHOW CREATE TABLE articles\G'
*************************** 1. row ***************************
       Table: articles
Create Table: CREATE TABLE `articles` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `title` varchar(255) DEFAULT NULL,
  `text` text,
  `category_id` int(11) DEFAULT NULL,
  `created_at` datetime DEFAULT NULL,
  `updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
```

テーブル定義をエクスポート。

```shell-session
% mkdir schemas
% ridgepole -c database.yml -E development --export --output schemas/Schemafile 
Export Schema to `schemas/Schemafile`
```

出力された Schemafile を確認。
追加したカラムも反映されている。

```shell-session
% cat schemas/Schemafile 
create_table "articles", force: :cascade do |t|
  t.string   "title",       limit: 255
  t.text     "text",        limit: 65535
  t.integer  "category_id", limit: 4
  t.datetime "created_at"
  t.datetime "updated_at"
end

create_table "comments", force: :cascade do |t|
  t.string   "commenter",  limit: 255
  t.text     "body",       limit: 65535
  t.integer  "article_id", limit: 4
  t.datetime "created_at"
  t.datetime "updated_at"
end

add_index "comments", ["article_id"], name: "index_comments_on_article_id", using: :btree
```

テーブル毎にスキーマファイルを分割したい場合は `--split` オプションを指定する。

```shell-session
% ridgepole -c database.yml -E development --export --split --output schemas/Schemafile
Export Schema
  write `schemas/articles.schema`
  write `schemas/comments.schema`
  write `schemas/Schemafile`
```

この機能があるので、ローカルでは直接 DB を変更して開発し、定義が固まったらエクスポートしてコミット、という方法も取れる。
DSL に慣れない場合は当面この方法でも良さそう。

## テーブル定義変更

今度は Schemafile 側を変更してみる。

```ruby
% sed -i '/"text".*$/a \  t.text     "author",      limit: 65535' schemas/articles.schema
% cat schemas/articles.schema
create_table "articles", force: :cascade do |t|
  t.string   "title",       limit: 255
  t.text     "text",        limit: 65535
  t.text     "author",      limit: 65535
  t.integer  "category_id", limit: 4
  t.datetime "created_at"
  t.datetime "updated_at"
end
```

`--apply --dry-run` してみて発行される ALTER 文を確認。

```shell-session
% ridgepole -c database.yml -E development --apply --dry-run -f schemas/Schemafile
Apply `schemas/Schemafile` (dry-run)
add_column("articles", "author", :text, {:limit=>65535, :after=>"text"})

# ALTER TABLE `articles` ADD `author` text AFTER `text`
```

## DB との差分を確認

現在の DB 上の定義との差分も確認出来る。
~~ただ、 Schemafile を分割している場合は全てカレントディレクトリに存在していないといけないっぽい。~~
コメント欄で教えて頂いた通り、バージョン 0.5.1 で分割された Schemafile でも `--diff` が使えるようになった。

```shell-session
% ridgepole -E development --diff database.yml schemas/Schemafile
add_column("articles", "author", :text, {:limit=>65535, :after=>"text"})

# ALTER TABLE `articles` ADD `author` text AFTER `text`
```

`development` 環境を Schemafile にダンプしてあるので、`-E production` を指定すれば `production` 環境との差分も確認可能。

```shell-session
% ridgepole -E production --diff database.yml schemas/Schemafile
add_column("articles", "author", :text, {:limit=>65535, :after=>"text"})
add_column("articles", "category_id", :integer, {:limit=>4, :after=>"author"})

# ALTER TABLE `articles` ADD `author` text AFTER `text`
# ALTER TABLE `articles` ADD `category_id` int(11) AFTER `author`
```

また、 DB 接続設定ファイルを別ファイルに分ければエクスポートを経由しなくても DB 同士の差分を比較できた。

```yaml:development.yml 
adapter: mysql2
encoding: utf8
socket: /tmp/mysql.sock
database: blog
username: root
```

```yaml:production.yml 
adapter: mysql2
encoding: utf8
socket: /tmp/mysql.sock
database: blog_prod
username: root
```

```shell-session
% ridgepole --diff production.yml development.yml
add_column("articles", "category_id", :integer, {:limit=>4, :after=>"text"})

# ALTER TABLE `articles` ADD `category_id` int(11) AFTER `text`
```

`--diff` オプションの引数は `current` `expected` の順だが、 `--reverse` を指定すると逆向きの DDL を出力してくれる。

```shell-session
% ridgepole -E production --diff database.yml schemas/Schemafile --reverse
remove_column("articles", "author")
remove_column("articles", "category_id")

# ALTER TABLE `articles` DROP `author`
# ALTER TABLE `articles` DROP `category_id`
```

## パーティション

`SHOW CREATE TABLE` した時にコメントで出力されるパーティションをエクスポート出来るようにするには別途 `activerecord-mysql-awesome` gem が必要な模様。
インストール。

```shell-session
% gem install activerecord-mysql-awesome
Fetching: activerecord-mysql-awesome-0.0.8.gem (100%)
Successfully installed activerecord-mysql-awesome-0.0.8
1 gem installed
```

適当にパーティションを追加。

```sql
% mysql -uroot blog -e 'ALTER TABLE `articles` PARTITION BY HASH(id) PARTITIONS 8; SHOW CREATE TABLE articles\G'
*************************** 1. row ***************************
       Table: articles
Create Table: CREATE TABLE `articles` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `title` varchar(255) DEFAULT NULL,
  `text` text,
  `category_id` int(11) DEFAULT NULL,
  `created_at` datetime DEFAULT NULL,
  `updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
/*!50100 PARTITION BY HASH (id)
PARTITIONS 8 */
```

再度スキーマをエクスポート。
`activerecord-mysql-awesome` を有効にするための `--enable-mysql-awesome` オプションを指定。

```
% ridgepole -c database.yml -E development --enable-mysql-awesome --export --split --output schemas/Schemafile                                            
Export Schema
  write `schemas/articles.schema`
  write `schemas/comments.schema`
  write `schemas/Schemafile`
```

以下のようにパーティションが出力されている事を確認。

```ruby
% cat schemas/articles.schema                                                                          
create_table "articles", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8\n/*!50100 PARTITION BY HASH (id)\nPARTITIONS 8 */" do |t|
  t.string   "title",       limit: 255
  t.text     "text",        limit: 65535
  t.integer  "category_id", limit: 4
  t.datetime "created_at"
  t.datetime "updated_at"
end
```

試しに `--mysql-use-alter` オプションも併せて指定してみたが、`PARTITION` 作成文は `ALTER` 文としては出力されない模様。

このスキーマを `production` 用 DB に適用してみる。
`create_tagble` で出力されているので、一旦 `articles` を `DROP TABLE` してから実行。

```shell-session
% mysql -uroot blog_prod -e 'DROP TABLE `articles`;'
% ridgepole -c database.yml -E production --merge -f schemas/Schemafile 
Merge `schemas/Schemafile`
[WARNING] No difference of schema configuration for table `comments`. (if you changed some options, please reconfirm your Schemafile)
-- create_table("articles", {:options=>"ENGINE=InnoDB DEFAULT CHARSET=utf8\n/*!50100 PARTITION BY HASH (id)\nPARTITIONS 8 */"})
   -> 0.1177s
```

確認。

```sql
% mysql -uroot blog_prod -e 'SHOW CREATE TABLE `articles`\G'
*************************** 1. row ***************************
       Table: articles
Create Table: CREATE TABLE `articles` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `title` varchar(255) DEFAULT NULL,
  `text` text,
  `category_id` int(11) DEFAULT NULL,
  `created_at` datetime DEFAULT NULL,
  `updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
/*!50100 PARTITION BY HASH (id)
PARTITIONS 8 */
```

ちゃんとパーティションも作成されたっぽい。

## unsigned 型

`category_id` の型を `unsigned int` に変更。

```sql
% mysql -uroot blog -e 'ALTER TABLE `articles` MODIFY `category_id` INT unsigned DEFAULT NULL; SHOW CREATE TABLE `articles`\G'
*************************** 1. row ***************************
       Table: articles
Create Table: CREATE TABLE `articles` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `title` varchar(255) DEFAULT NULL,
  `text` text,
  `category_id` int(10) unsigned DEFAULT NULL,
  `created_at` datetime DEFAULT NULL,
  `updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
/*!50100 PARTITION BY HASH (id)
PARTITIONS 8 */
```

`articles` テーブルのスキーマをエクスポート。
`--tables` オプションに続いてテーブル名を指定すると該当のテーブルのみエクスポートされる。
が、その場合親の `Schemafile` も指定したテーブルのスキーマファイルのみが `require` されるように書き換わってしまうので注意。

また、 `activerecord-mysql-awesome` を有効にするための `--enable-mysql-awesome` オプションが無いと `unsigned` が出力されない模様。

```shell-session
# --enable-mysql-awesome 無し
% ridgepole -c database.yml -E development --tables articles --export --split --output schemas/Schemafile
# unsigned の情報が消えてしまっている
% cat schemas/articles.schema 
create_table "articles", force: :cascade do |t|
  t.string   "title",       limit: 255
  t.text     "text",        limit: 65535
  t.integer  "category_id", limit: 4
  t.datetime "created_at"
  t.datetime "updated_at"
end

# --enable-mysql-awesome 有り
% ridgepole -c database.yml -E development --enable-mysql-awesome --tables articles --export --split --output schemas/Schemafile

# unsigned: true が出力された
% cat schemas/articles.schema                                                                                                   
create_table "articles", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8\n/*!50100 PARTITION BY HASH (id)\nPARTITIONS 8 */" do |t|
  t.string   "title",       limit: 255
  t.text     "text",        limit: 65535
  t.integer  "category_id", limit: 4,     unsigned: true
  t.datetime "created_at"
  t.datetime "updated_at"
end
```

他の DB に反映させるには `--apply` 時にも `--enable-mysql-awesome` オプションの指定が必要なので注意する。

## 外部キー制約

外部キー制約を追加してみる。

手元で使っていた 5.6 ではパーティションが存在すると外部キー制約を作成できないようなので一旦 `articles` を `DROP` して作り直し。

```shell-session
% mysql -uroot blog -e 'DROP TABLE `articles`'
% ridgepole -c database.yml -E development --tables articles --apply -f schemas/Schemafile
% mysql -uroot blog -e 'SHOW CREATE TABLE `articles`\G'
*************************** 1. row ***************************
       Table: articles
Create Table: CREATE TABLE `articles` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `title` varchar(255) DEFAULT NULL,
  `text` text,
  `created_at` datetime DEFAULT NULL,
  `updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
```

外部キー制約を作成。

```sql
% mysql -uroot blog -e 'ALTER TABLE `comments` MODIFY `article_id` INT NOT NULL;'
% mysql -uroot blog -e 'ALTER TABLE `comments` ADD CONSTRAINT `fk_article_id_on_comments` FOREIGN KEY (`article_id`) REFERENCES `articles` (`ID`); SHOW CREATE TABLE `comments`\G'
*************************** 1. row ***************************
       Table: comments
Create Table: CREATE TABLE `comments` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `commenter` varchar(255) DEFAULT NULL,
  `body` text,
  `article_id` int(11) NOT NULL,
  `created_at` datetime DEFAULT NULL,
  `updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `index_comments_on_article_id` (`article_id`) USING BTREE,
  CONSTRAINT `fk_article_id_on_comments` FOREIGN KEY (`article_id`) REFERENCES `articles` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
```

作成したテーブルからスキーマファイルをエクスポート。

```shell-session
% ridgepole -c database.yml -E development --export --split --output schemas/Schemafile
% cat schemas/comments.schema
create_table "comments", force: :cascade do |t|
  t.string   "commenter",  limit: 255
  t.text     "body",       limit: 65535
  t.integer  "article_id", limit: 4,     null: false
  t.datetime "created_at"
  t.datetime "updated_at"
end

add_index "comments", ["article_id"], name: "index_comments_on_article_id", using: :btree

add_foreign_key "comments", "articles", name: "fk_article_id_on_comments"
```

ちゃんと外部キー制約もエクスポートされた。

一旦 production を `DROP` して適用してみる。

```shell-session
% mysql -uroot blog_prod -e 'DROP TABLE `comments`, `articles`'
% ridgepole -c database.yml -E production --apply -f schemas/Schemafile
Apply `schemas/Schemafile`
-- create_table("articles", {})
   -> 0.0093s
-- create_table("comments", {})
   -> 0.0073s
-- add_index("comments", ["article_id"], {:name=>"index_comments_on_article_id", :using=>:btree})
   -> 0.0142s
-- add_foreign_key("comments", "articles", {:name=>"fk_article_id_on_comments"})
   -> 0.0157s
```

適用されたっぽい。
確認。

```sql
% mysql -uroot blog_prod -e 'SHOW CREATE TABLE `comments`\G'
*************************** 1. row ***************************
       Table: comments
Create Table: CREATE TABLE `comments` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `commenter` varchar(255) DEFAULT NULL,
  `body` text,
  `article_id` int(11) NOT NULL,
  `created_at` datetime DEFAULT NULL,
  `updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `index_comments_on_article_id` (`article_id`) USING BTREE,
  CONSTRAINT `fk_article_id_on_comments` FOREIGN KEY (`article_id`) REFERENCES `articles` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
```

外部キー制約が作成された。

## 感想

Rails の Migration に慣れているなら [standalone-migrations](https://github.com/thuss/standalone-migrations "standalone-migrations") もあるけど、Ridgepole なら DB を直接弄った場合でもちゃんと管理出来る。
開発中にちょっとした変更でいちいち Migration 弄るのは面倒くさくて好きじゃないのでこれは結構嬉しいかも。
DSL も違和感ないので今 Migration 使ってる場合でも移行するのは簡単そう。

## 参考

クックパッドにおける最近のActiveRecord運用事情 - クックパッド開発者ブログ 
http://techlife.cookpad.com/entry/2014/08/28/194147

Ridgepole を PostgreSQL と組み合わせて使う - Qiita 
http://qiita.com/Tomohiro/items/89846cc2cfdf53ac993e