CUBE SUGAR CONTAINER

技術系のこと書きます。

CentOS: rpm でファイルが含まれるパッケージを調べる

なんか毎回忘れるのでメモっておく。 パッケージシステムの基盤として rpm を使っている Linux ディストリビューションでファイルがどのパッケージに含まれるか調べるやり方。

使った環境は次の通り。

$ cat /etc/redhat-release
CentOS Linux release 7.2.1511 (Core)
$ uname -r
3.10.0-327.18.2.el7.x86_64

ファイルがどのパッケージに含まれるか調べるには rpm コマンドに -qf オプションをつけてパスを指定する。

$ rpm -qf /usr/include/stdio.h
glibc-headers-2.17-106.el7_2.6.x86_64

ただし、注意点としてはこのやり方だと存在しないファイルを調べることはできない。 例えば上記で出てきたパッケージをアンインストールしてみよう。

$ sudo yum remove -y glibc-headers

当然ファイルはもうなくなっている。

$ ls /usr/include/stdio.h
ls: /usr/include/stdio.h にアクセスできません: そのようなファイルやディレクトリはありません

この状況で rpm コマンドを使っても含まれるパッケージを調べることはできない。

$ rpm -qf /usr/include/stdio.h
エラー: ファイル /usr/include/stdio.h: そのようなファイルやディレクトリはありません

いじょう。

Ubuntu: apt-file でファイルが含まれるパッケージを調べる

あるファイルがどのパッケージに含まれているかを知りたくなる場面は意外と多い。 例えば何かをビルドするときにヘッダファイルがないといわれて、それがどのパッケージに含まれているか調べたいとか。 あるいは何かをパッケージングするときに、それが依存しているパッケージを調べたいとか。 そういったときは Linux ディストリビューションのパッケージシステムが APT なら apt-file を使うと楽ができる。

使った環境は次の通り。

$ cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=14.04
DISTRIB_CODENAME=trusty
DISTRIB_DESCRIPTION="Ubuntu 14.04.4 LTS"
$ uname -r
3.19.0-25-generic

下準備

まずは apt-file をインストールする。

$ sudo apt-get -y install apt-file

次にファイルのインデックスを更新する。 要するに、何が何処にあるかという情報を取得してくる作業だ。

$ sudo apt-file update

ファイルが含まれるパッケージを調べる

例えば stdio.h って何処に含まれていたっけなーと調べるときは apt-file の search サブコマンドを実行する。

$ apt-file search /usr/include/stdio.h
libc6-dev: /usr/include/stdio.h

これで libc6-dev に含まれていることが分かった。

apt-file の便利なところはファイルがインストールされていなくても調べられるところ。 例えば先ほどの libc6-dev をアンインストールする。

$ sudo apt-get -y remove libc6-dev

もちろん、これで調べたいファイルは存在していない。

$ ls /usr/include/stdio.h
ls: cannot access /usr/include/stdio.h: No such file or directory

それでも apt-file はパスを指定すれば、それがどこに含まれるか教えてくれる。

$ apt-file search /usr/include/stdio.h
libc6-dev: /usr/include/stdio.h

べんり。

パッケージシステムが、どのファイルが何処にあるかをちゃんとインデックスしているおかげだね。 めでたしめでたし。

SQL: ナイーブツリーと閉包テーブルモデル

今回のエントリは以前かいた SQL のアンチパターン「ナイーブツリー」に関する記事の続き。

blog.amedama.jp

再帰クエリをサポートしていない RDBMS で再帰的な構造のスキーマを作りたいときの解決策のひとつとして閉包テーブルモデルを扱う。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.11.4
BuildVersion:   15E65
$ mysql --version
mysql  Ver 14.14 Distrib 5.7.12, for osx10.11 (x86_64) using  EditLine wrapper

下準備

まずは下準備として MySQL にデータベースを作るところまで。

今回使ったのは Mac OS X なので MySQL は Homebrew でインストールする。

$ brew install mysql

インストールできたら MySQL サーバを起動しよう。

$ mysql.server start
Starting MySQL
. SUCCESS!

起動したサーバにクライアントで接続する。

$ mysql -u root

テストに使うデータベースを用意する。

mysql> CREATE DATABASE IF NOT EXISTS closure_table;
Query OK, 1 row affected (0.01 sec)

作ったデータベースに切り替えよう。

mysql> use closure_table;
Database changed

ここまでで、ひとまず下準備はおわり。

作るもの

題材としては引き続きブログのコメントを表現するテーブルにする。 コメントは親子関係を持っていて、あるコメントには複数の子のコメントがつく。

まずは、こういう構造を作るところまで。 数字はコメントの識別子を表している。

f:id:momijiame:20160517214829p:plain

テーブルを作る

まずは肝心のコメントを表すテーブルを用意しよう。 ただし、ここにはナイーブツリーのように親子関係を表すカラムは登場しない。

mysql> CREATE TABLE comments (
    -> id INTEGER NOT NULL,
    -> message TEXT NOT NULL,
    -> PRIMARY KEY (id)
    -> );
Query OK, 0 rows affected (0.04 sec)

内包テーブルモデルでは、代わりに親子関係を表現するための専用のテーブルを用意する。 tree_paths テーブルには ancestor (先祖) と descendant (子孫) というふたつのカラムがある。 そして、それぞれのカラムは外部キーとしてコメントのテーブルの主キーを参照している。

mysql> CREATE TABLE tree_paths (
    ->   ancestor INTEGER NOT NULL,
    ->   descendant INTEGER NOT NULL,
    ->   PRIMARY KEY(ancestor, descendant),
    ->   FOREIGN KEY(ancestor) REFERENCES comments (id),
    ->   FOREIGN KEY(descendant) REFERENCES comments (id)
    -> );
Query OK, 0 rows affected (0.02 sec)

テスト用のデータを投入する

閉包テーブルモデルにおいてコメントを追加するときは、まずはコメント本体のテーブルにレコードを追加する。

mysql> INSERT INTO comments (id, message) VALUES (1, 'ガルパンはいいぞ');
Query OK, 1 row affected (0.01 sec)

そして、それとは別に親子関係のテーブルにレコードを追加する。

mysql> INSERT INTO tree_paths (ancestor, descendant) VALUES (1, 1);
Query OK, 1 row affected (0.00 sec)

自分自身を先祖であり子孫として登録するので、コメント本体と親子関係で最低でも 2 つはレコードが必要になる。

最初に追加したコメントに子供を追加してみよう。 まずは、最初と同じようにコメント本体と自分自身の親子関係を追加する。

mysql> INSERT INTO comments (id, message) VALUES (2, 'それな');
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO tree_paths (ancestor, descendant) VALUES (2, 2);
Query OK, 1 row affected (0.00 sec)

そして、ここからがポイント。 親子関係については追加で先祖が ID:1 のレコードで、子孫が ID:2 (自分自身) として登録する。

mysql> INSERT INTO tree_paths (ancestor, descendant) VALUES (1, 2);
Query OK, 1 row affected (0.00 sec)

親子関係については、自分自身の親だけでなく、親の親、その親の親の...というように、すべての親に自分自身を登録していく。

例えば、次の例であれば ID:3 は ID:1, ID:2, ID:3 の子孫ということになる。

mysql> INSERT INTO comments (id, message) VALUES (3, 'パンツァー・フォー!');
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO tree_paths (ancestor, descendant) VALUES (3, 3);
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO tree_paths (ancestor, descendant) VALUES (2, 3);
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO tree_paths (ancestor, descendant) VALUES (1, 3);
Query OK, 1 row affected (0.00 sec)

これで、だいたいパターン見えてきたね!

mysql> INSERT INTO comments (id, message) VALUES (4, 'さおりんは天使');
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO tree_paths (ancestor, descendant) VALUES (4, 4);
Query OK, 1 row affected (0.01 sec)

mysql> INSERT INTO tree_paths (ancestor, descendant) VALUES (1, 4);
Query OK, 1 row affected (0.00 sec)

ようするにこういうこと

閉包テーブルモデルでは、親子関係をこういう風に持つことになる。 数字の書かれた丸がコメント本体のレコードで、矢印が親子関係のレコードを表す。

f:id:momijiame:20160517220232p:plain

つまり、先祖はすべての子孫への参照を持っているということ。

子を取得する

閉包テーブルモデルはナイーブツリーに比べて多くの情報を持っているだけあって色々なことがやりやすい。

例えばコメントの ID:1 の子孫をすべて取得してみよう。 対象は次のコメント。

mysql> SELECT *
    -> FROM comments
    -> WHERE id = 1;
+----+--------------------------+
| id | message                  |
+----+--------------------------+
|  1 | ガルパンはいいぞ         |
+----+--------------------------+
1 row in set (0.00 sec)

この操作は、親子関係のテーブルで ancestor (先祖) に ID:1 を持ったレコードから引っ張ってくれば良いことになる。

mysql> SELECT *
    -> FROM tree_paths
    -> WHERE ancestor = 1;
+----------+------------+
| ancestor | descendant |
+----------+------------+
|        1 |          1 |
|        1 |          2 |
|        1 |          3 |
|        1 |          4 |
+----------+------------+
4 rows in set (0.00 sec)

ということでコメント本体のテーブルと親子関係のテーブルを内部結合 (INNER JOIN) してやる。

mysql> SELECT *
    -> FROM comments
    -> INNER JOIN tree_paths
    -> ON tree_paths.descendant = comments.id
    -> WHERE tree_paths.ancestor = 1;
+----+--------------------------------+----------+------------+
| id | message                        | ancestor | descendant |
+----+--------------------------------+----------+------------+
|  1 | ガルパンはいいぞ               |        1 |          1 |
|  2 | それな                         |        1 |          2 |
|  3 | パンツァー・フォー!           |        1 |          3 |
|  4 | さおりんは天使                 |        1 |          4 |
+----+--------------------------------+----------+------------+
4 rows in set (0.00 sec)

ばっちり。

念のため ID:2 の子孫についても、同じように取得してみよう。

mysql> SELECT *
    -> FROM comments
    -> INNER JOIN tree_paths
    -> ON tree_paths.descendant = comments.id
    -> WHERE tree_paths.ancestor = 2;
+----+--------------------------------+----------+------------+
| id | message                        | ancestor | descendant |
+----+--------------------------------+----------+------------+
|  2 | それな                         |        2 |          2 |
|  3 | パンツァー・フォー!           |        2 |          3 |
+----+--------------------------------+----------+------------+
2 rows in set (0.01 sec)

完璧だね。

親を取得する

今度は、あるコメント (ID:3) に連なるすべての親を取得してみよう。

これはつまり、先ほどとは反対に親子関係のテーブルで descendant (子孫) がそのレコードになっているものから引っ張ってくればいい。

mysql> SELECT *
    -> FROM comments
    -> INNER JOIN tree_paths
    -> ON tree_paths.ancestor = comments.id
    -> WHERE tree_paths.descendant = 3;
+----+--------------------------------+----------+------------+
| id | message                        | ancestor | descendant |
+----+--------------------------------+----------+------------+
|  1 | ガルパンはいいぞ               |        1 |          3 |
|  2 | それな                         |        2 |          3 |
|  3 | パンツァー・フォー!           |        3 |          3 |
+----+--------------------------------+----------+------------+
3 rows in set (0.00 sec)

挿入する

コメントを追加するときは、最初にやったのと同じようにすべての先祖に自身を登録していけばいい。

mysql> INSERT INTO comments (id, message) VALUES (5, 'やだもー');
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO tree_paths (ancestor, descendant) VALUES (5, 5);
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO tree_paths (ancestor, descendant) VALUES (4, 5);
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO tree_paths (ancestor, descendant) VALUES (1, 5);
Query OK, 1 row affected (0.00 sec)

上記でツリーはこんな感じになる。

f:id:momijiame:20160517221059p:plain

削除する

次は閉包テーブルモデルにおいて、コメントを削除するときのことを考えてみよう。

もし、削除するコメントが末端のレコードであれば話はとても単純になる。 その ID を descendant (子孫) として持っている親子関係のレコードと、コメント本体を削除すれば良いだけだから。

mysql> DELETE
    -> FROM tree_paths
    -> WHERE descendant = 5;
Query OK, 3 rows affected (0.01 sec)

mysql> DELETE
    -> FROM comments
    -> WHERE id = 5;
Query OK, 1 row affected (0.00 sec)

これで、ツリーは元の状態に戻った。

f:id:momijiame:20160517220232p:plain

ただ、末端でないコメントを子孫ごと削除しようとすると少し考えることが増える。 例として ID:2 のコメントを子孫ごと削除することを考えてみることにしよう。

まずは ancestor (祖先) として 2 を参照しているレコードを確認する。 ようするに、これが削除の対象となるコメントの ID になる。

mysql> SELECT descendant
    -> FROM tree_paths
    -> WHERE ancestor = 2;
+------------+
| descendant |
+------------+
|          2 |
|          3 |
+------------+
2 rows in set (0.00 sec)

ただし、上記の ID を持ったレコードは親子関係のテーブルにおいて先祖からも参照されているということに注意しなければいけない。

そこでサブクエリ (副問い合わせ) を使って、さきほどのクエリで得られたレコードを descendant (子孫) として持つレコードを引いてこよう。

mysql> SELECT *
    -> FROM tree_paths
    -> WHERE descendant IN (
    ->   SELECT descendant
    ->   FROM tree_paths
    ->   WHERE ancestor = 2
    -> );
+----------+------------+
| ancestor | descendant |
+----------+------------+
|        1 |          2 |
|        2 |          2 |
|        1 |          3 |
|        2 |          3 |
|        3 |          3 |
+----------+------------+
5 rows in set (0.00 sec)

これが実際に削除すべき親子関係のレコードということになる。

じゃあ、実際に削除すべきレコードを検索できるようになったから早速 DELETE しちゃおう。

mysql> DELETE
    -> FROM tree_paths
    -> WHERE descendant IN (
    ->   SELECT descendant
    ->   FROM tree_paths
    ->   WHERE ancestor = 2
    -> );
ERROR 1093 (HY000): You can't specify target table 'tree_paths' for update in FROM clause

と、思ったら何かエラーになっちゃったね!

これは SQL の標準的な制約が原因となっている。 あるテーブルに変更を加える場合、同じテーブルはサブクエリに含めることができないためだ。

こういったときは、サブクエリに別名をつけて回避する。 次のクエリでは、サブクエリに AS を使って x という名前をつけている。 こうすれば先ほどの制約に引っかかることがない。

mysql> SELECT *
    -> FROM tree_paths
    -> WHERE descendant IN (
    ->   SELECT x.descendant
    ->   FROM (
    ->     SELECT descendant
    ->     FROM tree_paths
    ->     WHERE ancestor = 2
    ->   ) AS x
    -> );
+----------+------------+
| ancestor | descendant |
+----------+------------+
|        1 |          2 |
|        2 |          2 |
|        1 |          3 |
|        2 |          3 |
|        3 |          3 |
+----------+------------+
5 rows in set (0.00 sec)

検索できる内容は、もちろんおんなじ。

けど、今度はちゃんと DELETE にしたときエラーにならずレコードが削除できる。

mysql> DELETE
    -> FROM tree_paths
    -> WHERE descendant IN (
    ->   SELECT x.descendant
    ->   FROM (
    ->     SELECT descendant
    ->     FROM tree_paths
    ->     WHERE ancestor = 2
    ->   ) AS x
    -> );
Query OK, 5 rows affected (0.01 sec)

めでたしめでたし、と行きたいところだけど、まだコメント本体が残っている。 ツリー的には、こういう状態だ。

f:id:momijiame:20160518234123p:plain

ただ、親子関係は先ほど既に削除してしまったので、それを元に削除はできない。 実際のところ、閉包テーブルモデルでは親子関係のレコードを削除しただけで済ませるときもあるみたいだ。

もし、どうしてもコメント本体も削除したいのであれば孤児になったレコードを対象にすることになるかな? ようするに、親子関係のテーブルでどこからも参照されていないコメントのこと。 必然的に、さっき親子関係を削除したものが対象になる。

mysql> SELECT *
    -> FROM comments
    -> WHERE id NOT IN (
    ->   SELECT DISTINCT ancestor
    ->   FROM tree_paths
    -> ) AND id NOT IN (
    ->   SELECT DISTINCT descendant
    ->   FROM tree_paths
    -> );
+----+--------------------------------+
| id | message                        |
+----+--------------------------------+
|  2 | それな                         |
|  3 | パンツァー・フォー!           |
+----+--------------------------------+
2 rows in set (0.00 sec)

これらを削除して一丁上がり。

mysql> DELETE
    -> FROM comments
    -> WHERE id NOT IN (
    ->   SELECT DISTINCT ancestor
    ->   FROM tree_paths
    -> ) AND id NOT IN (
    ->   SELECT DISTINCT descendant
    ->   FROM tree_paths
    -> );
Query OK, 2 rows affected (0.00 sec)

孤児になったレコードがなくなったので、ツリーの状態はこうなる。

f:id:momijiame:20160518234243p:plain

まとめ

今回は SQL のアンチパターン「ナイーブツリー」の解決策のひとつとして閉包テーブルモデルについて扱った。

閉包テーブルモデルの特徴は次の通り。

メリット

親子関係を外部キー制約を使って表現するので、整合性がリレーショナル・データベースとして担保できる。 検索や挿入などが少ないクエリで済む。 階層の深さに明確な限界がない。

デメリット

階層が深くなるごとに、それを表現するためのレコードが増えていく。 つまり、たくさんのデータを扱うことになってディスクの容量を圧迫する。

SQL: ナイーブツリーと経路列挙モデル

今回のエントリは以前かいた SQL のアンチパターン「ナイーブツリー」に関する記事の続き。

blog.amedama.jp

再帰クエリをサポートしていない RDBMS で再帰的な構造を表現するための解決策のひとつ経路列挙モデルを扱う。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.11.4
BuildVersion:   15E65
$ mysql --version
mysql  Ver 14.14 Distrib 5.7.12, for osx10.11 (x86_64) using  EditLine wrapper

下準備

まずは下準備として MySQL にデータベースを作るところまで。

今回使ったのは Mac OS X なので MySQL は Homebrew でインストールする。

$ brew install mysql

インストールできたら MySQL サーバを起動しよう。

$ mysql.server start
Starting MySQL
. SUCCESS!

起動したサーバにクライアントで接続する。

$ mysql -u root

テストに使うデータベースを用意する。

mysql> CREATE DATABASE IF NOT EXISTS path_enumeration;
Query OK, 1 row affected (0.00 sec)

作ったデータベースに切り替えよう。

mysql> use path_enumeration;
Database changed

ここまでで、ひとまず下準備はおわり。

経路列挙モデル

さて、ここからいよいよ経路列挙モデルを使ったテーブルを作ってデータを投入していく。

経路列挙モデルではレコードの親子関係をカラムの中に列挙する。 次のテーブル定義では path カラムがそれを格納する場所に当たる。

mysql> CREATE TABLE comments (
    -> id INTEGER NOT NULL,
    -> path TEXT,
    -> message TEXT NOT NULL,
    -> PRIMARY KEY (id)
    -> );
Query OK, 0 rows affected (0.02 sec)

構造としては、こんなかんじ。 ナイーブツリーと比べると、外部キーで自身を参照していたカラムがテキストになっている。

mysql> DESC comments;
+---------+---------+------+-----+---------+-------+
| Field   | Type    | Null | Key | Default | Extra |
+---------+---------+------+-----+---------+-------+
| id      | int(11) | NO   | PRI | NULL    |       |
| path    | text    | YES  |     | NULL    |       |
| message | text    | NO   |     | NULL    |       |
+---------+---------+------+-----+---------+-------+
3 rows in set (0.10 sec)

それでは、テストデータを投入していこう。 path カラムには、レコードの識別子をセパレータで分割することで親子関係を表現したものを入れる。

mysql> INSERT INTO comments (id, path, message) VALUES (1, '1/', 'ガルパンはいいぞ');
Query OK, 1 row affected (0.01 sec)

mysql> INSERT INTO comments (id, path, message) VALUES (2, '1/2/', 'それな');
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO comments (id, path, message) VALUES (3, '1/2/3/', 'パンツァー・フォー!');
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO comments (id, path, message) VALUES (4, '1/4/', 'さおりんは天使');
Query OK, 1 row affected (0.00 sec)

ここからは、投入したデータを使って色々な操作を試してみることにしよう。

子を取得する

まずは、特定のレコードを親として持つ、すべてのレコードを取得してみよう。

例えば id=1 のコメントに連なるすべてのコメントを取得することを考える。 まずは、そのレコードを参照しよう。

mysql> SELECT *
    -> FROM comments
    -> WHERE id = 1;
+----+------+--------------------------+
| id | path | message                  |
+----+------+--------------------------+
|  1 | 1/   | ガルパンはいいぞ         |
+----+------+--------------------------+
1 row in set (0.00 sec)

得られたレコードの path カラムを見て、末尾に % (ワイルドカード) を付与してパターン検索する。 これで path カラムが '1/' から始まるすべて、つまり id=1 が親になっているすべてのレコードが手に入る。

mysql> SELECT *
    -> FROM comments
    -> WHERE path LIKE '1/%';
+----+--------+--------------------------------+
| id | path   | message                        |
+----+--------+--------------------------------+
|  1 | 1/     | ガルパンはいいぞ               |
|  2 | 1/2/   | それな                         |
|  3 | 1/2/3/ | パンツァー・フォー!           |
|  4 | 1/4/   | さおりんは天使                 |
+----+--------+--------------------------------+
4 rows in set (0.01 sec)

念のため id=2 でも確認しておこう。 まずは、対象のレコードの path カラムを確認する。

mysql> SELECT *
    -> FROM comments
    -> WHERE id = 2;
+----+------+-----------+
| id | path | message   |
+----+------+-----------+
|  2 | 1/2/ | それな    |
+----+------+-----------+
1 row in set (0.00 sec)

そして末尾にワイルドカードをつけてパターン検索する。

mysql> SELECT *
    -> FROM comments
    -> WHERE path LIKE '1/2/%';
+----+--------+--------------------------------+
| id | path   | message                        |
+----+--------+--------------------------------+
|  2 | 1/2/   | それな                         |
|  3 | 1/2/3/ | パンツァー・フォー!           |
+----+--------+--------------------------------+
2 rows in set (0.00 sec)

ばっちり。

親を取得する

次は、特定のレコードを子として持つ、すべてのレコードを取得してみよう。

例えば id=3 のコメントの親を取得してみよう。 先ほどと同じように、まずはそのレコードの path カラムを確認する。

mysql> SELECT *
    -> FROM comments
    -> WHERE id = 3;
+----+--------+--------------------------------+
| id | path   | message                        |
+----+--------+--------------------------------+
|  3 | 1/2/3/ | パンツァー・フォー!           |
+----+--------+--------------------------------+
1 row in set (0.00 sec)

そして、その path カラムを今度はパターン検索の左辺に置く。 右辺は path カラムとワイルドカードを連結した文字列にする。 これで '1/%', '1/2/%', '1/2/3/%' のレコードが '1/2/3/' とマッチする。

mysql> SELECT *
    -> FROM comments
    -> WHERE '1/2/3/' LIKE CONCAT(path, '%');
+----+--------+--------------------------------+
| id | path   | message                        |
+----+--------+--------------------------------+
|  1 | 1/     | ガルパンはいいぞ               |
|  2 | 1/2/   | それな                         |
|  3 | 1/2/3/ | パンツァー・フォー!           |
+----+--------+--------------------------------+
3 rows in set (0.00 sec)

ちなみに、文字列の連結は RDBMS によって異なる点に注意しよう。 MySQL では CONCAT() 関数だけど、ものによっては「||」演算子を使うこともある。

挿入する

次はレコードを挿入してみよう。

例えば id=4 のコメントの子として新しいコメントを追加することを考える。 まずは、追加したいコメントの親となるレコードの path カラムの内容を確認する。

mysql> SELECT *
    -> FROM comments
    -> WHERE id = 4;
+----+------+-----------------------+
| id | path | message               |
+----+------+-----------------------+
|  4 | 1/4/ | さおりんは天使        |
+----+------+-----------------------+
1 row in set (0.00 sec)

追加するコメントでは、親の path カラムの末尾に自身の識別子を連結したものを path カラムにする。

mysql> INSERT
    -> INTO comments (id, path, message)
    -> VALUES (5, '1/4/5/', 'やだもー');
Query OK, 1 row affected (0.01 sec)

削除する

次は、とある要素の配下を含んだ削除について。

例えば id=4 以下のレコードを削除したい場合を考える。 ゆかりんもいいなと思い直したのかもしれない。

mysql> SELECT *
    -> FROM comments
    -> WHERE path LIKE '1/4/%';
+----+--------+-----------------------+
| id | path   | message               |
+----+--------+-----------------------+
|  4 | 1/4/   | さおりんは天使        |
|  5 | 1/4/5/ | やだもー              |
+----+--------+-----------------------+
2 rows in set (0.00 sec)

そんなときは SELECT のときに使ったパターンでそのまま DELETE すればいい。

mysql> DELETE
    -> FROM comments
    -> WHERE path LIKE '1/4/%';
Query OK, 2 rows affected (0.00 sec)

ばっちり。

まとめ

今回は SQL のアンチパターン「ナイーブツリー」の解決策のひとつとして経路列挙モデルについて扱った。

経路列挙モデルの特徴は次の通り。

メリット

参照・挿入・削除などの操作は少ないクエリで済む。 ただし親の付け替えなど更新の操作では多くのクエリが必要になる点には注意が必要。

デメリット

リレーショナル・データベースで親子関係の整合性が担保されない。 これは親子関係を表現するのにリレーショナル・データベースの外部キー制約を使っていないためだ。 特定のレコードがどういった親や子を持っているかといった整合性はアプリケーション側で担保しなければいけない。

また、親子関係を表現するのにテキストなどの有限なサイズのカラムを使うので階層の深さの限界がそれに依存する。

その他

識別子をカラムに入れるので、主キーが AUTOINCREMENT のときは挿入に必要なクエリが増えそう? 主キーが AUTOINCREMENT のときは採番されるのが挿入時点なので

  1. レコードを INSERT する
  2. 採番された主キーを確認する
  3. それにもとづいて path カラムを更新する

という手順になるんじゃなかろうか。

SQL: ナイーブツリーと再帰クエリ

今回は「SQLアンチパターン」の中で紹介されているナイーブツリーというアンチパターンについて見てみることにする。

www.oreilly.co.jp

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.11.4
BuildVersion:   15E65
$ mysql --version
mysql  Ver 14.14 Distrib 5.7.12, for osx10.11 (x86_64) using  EditLine wrapper
$ sqlite3 --version
3.8.10.2 2015-05-20 18:17:19 2ef4f3a5b1d1d0c4338f8243d40a2452cc1f7fe4

ナイーブツリーとは

リレーショナル・データベースで再帰的な構造を表現したいときに発生しうるアンチパターン。 例えば木構造などがその代表。 このとき、その構造をそのままスキーマに落としこむと外部キーで自身を参照するテーブルを作ることになる。 しかし、それをやってしまうとデータを入れるときは良いとしても取り出すときに困ったことになる。

このアンチパターンは RDBMS が再帰クエリをサポートしていると発生しない。 しかし、再帰クエリをサポートしていない RDBMS もあるので、そのときは注意が必要になる。

今回は再帰クエリをサポートしていない MySQL でナイーブツリーがどう困ったことになるのかをまず見ておく。 その上で再帰クエリをサポートしている SQLite ではアンチパターンが発生しないことも確認する。

題材としてはブログのコメントを表現するテーブルにする。 コメントは親子関係を持っていて、あるコメントには複数の子のコメントがつく。

下準備

まずは MySQL をインストールする。 今回は Mac OS X を使ったので Homebrew で入れる。

$ brew install mysql

MySQL を起動する。

$ mysql.server start
Starting MySQL
. SUCCESS!

MySQL に接続したら動作確認用のデータベースを用意する。

$ mysql -u root
mysql> CREATE DATABASE IF NOT EXISTS naivetree;
Query OK, 1 row affected, 1 warning (0.00 sec)

使用するデータベースを作ったものに切り替える。

mysql> use naivetree;
Database changed

題材とするテーブルは次の通り。 comments テーブルは parent_id カラムを外部キーとして自身を参照することになっている。

mysql> CREATE TABLE comments (
    -> id INTEGER NOT NULL, 
    -> parent_id INTEGER, 
    -> message TEXT NOT NULL, 
    -> PRIMARY KEY (id), 
    -> FOREIGN KEY(parent_id) REFERENCES comments (id)
    -> );
Query OK, 0 rows affected (0.01 sec)

こんなシンプルな構造になっている。

mysql> DESC comments;
+-----------+---------+------+-----+---------+-------+
| Field     | Type    | Null | Key | Default | Extra |
+-----------+---------+------+-----+---------+-------+
| id        | int(11) | NO   | PRI | NULL    |       |
| parent_id | int(11) | YES  | MUL | NULL    |       |
| message   | text    | NO   |     | NULL    |       |
+-----------+---------+------+-----+---------+-------+
3 rows in set (0.01 sec)

テスト用のデータを投入する。 コメント id=1 はコメント id=2 と id=4 の子を持っている。 そしてコメント id=2 はコメント id=3 を子に持っている。

mysql> INSERT INTO comments (id, parent_id, message) VALUES (1, null, 'ガルパンはいいぞ');
Query OK, 1 row affected (0.01 sec)

mysql> INSERT INTO comments (id, parent_id, message) VALUES (2, 1, 'それな');
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO comments (id, parent_id, message) VALUES (3, 2, 'パンツァー・フォー!');
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO comments (id, parent_id, message) VALUES (4, 1, 'さおりんは天使');
Query OK, 1 row affected (0.01 sec)

これで準備が整った。

アプリケーション側で再帰する

例えばコメント id=1 の子をすべて取得したいときを考えてみる。

この構造で考えられるひとつのやり方はアプリケーション側で構造を再帰的に辿るというもの。 しかし、これはコメントの数が増えるごとに線形にクエリの発行数が増えるためスケールしない。 あきらかにダメなアプローチ。

mysql> SELECT *
    -> FROM comments
    -> WHERE id = 1;
+----+-----------+--------------------------+
| id | parent_id | message                  |
+----+-----------+--------------------------+
|  1 |      NULL | ガルパンはいいぞ         |
+----+-----------+--------------------------+
1 row in set (0.00 sec)

mysql> SELECT *
    -> FROM comments
    -> WHERE parent_id = 1;
+----+-----------+-----------------------+
| id | parent_id | message               |
+----+-----------+-----------------------+
|  2 |         1 | それな                |
|  4 |         1 | さおりんは天使        |
+----+-----------+-----------------------+
2 rows in set (0.00 sec)

mysql> SELECT *
    -> FROM comments
    -> WHERE parent_id = 2;
+----+-----------+--------------------------------+
| id | parent_id | message                        |
+----+-----------+--------------------------------+
|  3 |         2 | パンツァー・フォー!           |
+----+-----------+--------------------------------+
1 row in set (0.00 sec)

外部結合を使う

コメントの数が増えてもスケールさせるには少なくともクエリ一発で取り出せないといけない。 そこで、次に考えられるのが子の parent_id カラムと親の id カラムを外部結合するやり方。 しかし、これも階層の数があらかじめ決め打ちになっているときにしか使えない。

例えば次のようにする。 ただし、これでは階層が 2 までの構造にしか対応できない。

mysql> SELECT c1.*, c2.*
    -> FROM comments c1
    -> LEFT OUTER JOIN comments c2
    -> ON c2.parent_id = c1.id
    -> WHERE c1.id = 1;
+----+-----------+--------------------------+------+-----------+-----------------------+
| id | parent_id | message                  | id   | parent_id | message               |
+----+-----------+--------------------------+------+-----------+-----------------------+
|  1 |      NULL | ガルパンはいいぞ         |    2 |         1 | それな                |
|  1 |      NULL | ガルパンはいいぞ         |    3 |         1 | さおりんは天使        |
+----+-----------+--------------------------+------+-----------+-----------------------+
2 rows in set (0.00 sec)

さっきのクエリだとテストデータの階層には足りないから、次は 3 に増やして...ってこれは無理だね。

mysql> SELECT c1.*, c2.*, c3.*
    -> FROM comments c1
    -> LEFT OUTER JOIN comments c2
    -> ON c2.parent_id = c1.id
    -> LEFT OUTER JOIN comments c3
    -> ON c3.parent_id = c2.id
    -> WHERE c1.id = 1;
+----+-----------+--------------------------+------+-----------+-----------------------+------+-----------+--------------------------------+
| id | parent_id | message                  | id   | parent_id | message               | id   | parent_id | message                        |
+----+-----------+--------------------------+------+-----------+-----------------------+------+-----------+--------------------------------+
|  1 |      NULL | ガルパンはいいぞ         |    2 |         1 | それな                |    3 |         2 | パンツァー・フォー!           |
|  1 |      NULL | ガルパンはいいぞ         |    4 |         1 | さおりんは天使        | NULL |      NULL | NULL                           |
+----+-----------+--------------------------+------+-----------+-----------------------+------+-----------+--------------------------------+
2 rows in set (0.01 sec)

こんな感じでナイーブツリーを作ってしまうとデータを取り出すときに苦労する。

再帰クエリ (WITH RECURSIVE) を使う

ナイーブツリーを作っても問題ないのは使う RDBMS が再帰クエリをサポートしているとき。 再帰クエリを使うと、その名の通り再帰的な構造を辿ることができる。

例えば再帰クエリをサポートしている SQLite を試してみよう。 SQLite は Mac OS X であれば最初から使えるはず?

$ sqlite3 naivetree.db

テーブルを作る。

sqlite> CREATE TABLE comments (
   ...> id INTEGER NOT NULL,
   ...> parent_id INTEGER,
   ...> message TEXT NOT NULL,
   ...> PRIMARY KEY (id),
   ...> FOREIGN KEY(parent_id) REFERENCES comments (id)
   ...> );

テストデータを投入する。

sqlite> INSERT INTO comments (id, parent_id, message) VALUES (1, null, 'ガルパンはいいぞ');
sqlite> INSERT INTO comments (id, parent_id, message) VALUES (2, 1, 'それな');
sqlite> INSERT INTO comments (id, parent_id, message) VALUES (3, 2, 'パンツァー・フォー!');
sqlite> INSERT INTO comments (id, parent_id, message) VALUES (4, 1, 'さおりんは天使');

見やすいようにラインモードにする。

sqlite> .mode line

再帰クエリ (WITH RECURSIVE) を使ってコメント id=1 に連なるコメントを取得してみる。

sqlite> WITH RECURSIVE r AS (
   ...> SELECT * FROM comments WHERE id = 1
   ...> UNION ALL
   ...> SELECT comments.* FROM comments, r WHERE comments.parent_id = r.id
   ...> )
   ...> SELECT * FROM r;
       id = 1
parent_id =
  message = ガルパンはいいぞ

       id = 4
parent_id = 1
  message = さおりんは天使

       id = 2
parent_id = 1
  message = それな

       id = 3
parent_id = 2
  message = パンツァー・フォー!

ばっちり。

例えば id=2 のときはこんなかんじ。

sqlite> WITH RECURSIVE r AS (
   ...> SELECT * FROM comments WHERE id = 2
   ...> UNION ALL
   ...> SELECT comments.* FROM comments, r WHERE comments.parent_id = r.id
   ...> )
   ...> SELECT * FROM r;
       id = 2
parent_id = 1
  message = それな

       id = 3
parent_id = 2
  message = パンツァー・フォー!

すばらしい。

まとめ

今回は再帰的な構造をリレーショナル・データベースで表現するときに作ってしまいがちなアンチパターンのナイーブツリーについて書いた。 再帰クエリをサポートしていない RDBMS でナイーブツリーを作りこんでしまうと、なかなか難儀なことになる。 そしてメジャーな RDBMS の中でも MySQL が再帰クエリをサポートしていないことがとても悩ましい。

再帰的な構造を表現するのに、再帰クエリをサポートしていない RDBMS では次のような解決策がある。

  • 経路列挙モデル
  • 入れ子集合モデル
  • 閉包ツリーモデル

上記については、また別の機会に。

追記

経路列挙モデル編

blog.amedama.jp

閉包テーブルモデル編

blog.amedama.jp

Git のサブモジュールを使ってみる

今回は Git の中でもちょっととっつきにくいサブモジュール機能を使ってみることにする。 サブモジュール機能を使うと、外部のある Git リポジトリを別の Git リポジトリの中に取り込んで使うことができる。 これは例えばひとつの巨大なリポジトリを何らかのポリシーに基いて分割することに使える。

今回は Git を簡単に試すためにすべてのリポジトリをローカルホスト上に作ることにする。 環境には Ubuntu 14.04 LTS を使った。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 14.04.4 LTS
Release:    14.04
Codename:   trusty
$ uname -r
3.16.0-30-generic

下準備

まずは Git をインストールする。

$ sudo apt-get -y install git

最近の Git はうるさいのでユーザ名とメールアドレスを適当に登録しておく。

$ git config --global user.name "myname"
$ git config --global user.email "myemail@example.jp"

今回は Git の転送プロトコルに SSH を使うので OpenSSH をインストールしておく。

$ sudo apt-get -y install openssh-client

毎回パスワードを入力するのは大変なので公開鍵を作っておこう。 次のコマンドを入力したら何も入力せずにエンターキー押下し続けてパスワードなしの公開鍵を用意する。

$ ssh-keygen -t rsa -C default
Generating public/private rsa key pair.
Enter file in which to save the key (/home/vagrant/.ssh/id_rsa):
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
...(省略)

できた公開鍵をローカルホスト自身に登録しよう。 登録するときはパスワード認証なので聞かれたら今のユーザのパスワードを入力する

$ ssh-copy-id -i ~/.ssh/id_rsa.pub localhost
...(省略)...
vagrant@localhost's password:

Number of key(s) added: 1

Now try logging into the machine, with:   "ssh 'localhost'"
and check to make sure that only the key(s) you wanted were added.

これでローカルホストにパスワードなしでログインできるようになった。

$ ssh localhost
Welcome to Ubuntu 14.04.4 LTS (GNU/Linux 3.16.0-30-generic x86_64)

 * Documentation:  https://help.ubuntu.com/
Last login: Sat Apr 16 12:23:31 2016 from 10.0.2.2

ここまでで下準備がおわった。

リポジトリを用意する

次に Git のリモートリポジトリをふたつ作っておく。 ひとつ目はサブモジュールを取り込む側で、もうひとつがサブモジュールとして取り込まれる側になる。

サブモジュールを取り込む側のリポジトリを main-repo という名前で /var/tmp ディレクトリに作る。

$ mkdir /var/tmp/main-repo
$ cd /var/tmp/main-repo
$ git init --bare --share
Initialized empty shared Git repository in /var/tmp/main-repo/

そしてサブモジュールとして取り込まれる側のリポジトリを sub-repo という名前で /var/tmp ディレクトリに作る。

$ mkdir /var/tmp/sub-repo
$ cd /var/tmp/sub-repo
$ git init --bare --share
Initialized empty shared Git repository in /var/tmp/sub-repo/

ローカルリポジトリは /tmp 以下に用意しよう。 まずはサブモジュールとして取り込まれる側の sub-repo をクローンする。

$ cd /tmp/
$ git clone localhost:/var/tmp/sub-repo

これで /var/tmp にある sub-repo のリモートリポジトリが /tmp 以下にクローンされた。

$ ls | grep sub-repo
sub-repo

サブモジュールとして取り込まれる側にファイルを用意する

サブモジュールとして取り込まれる側にファイルが何もないと分かりづらいので、まずは適当なものを用意しておこう。

$ cd sub-repo/
$ cat << 'EOF' > greet.txt
Hello, World!
EOF

作ったファイルをインデックスに追加して、コミットしたらリモートリポジトリにプッシュする。

$ git add -A
$ git commit -m "Initial commit for sub-repo"
$ git push origin master

これでサブモジュールとして取り込まれる側のリポジトリにファイルが追加された。

取り込む側のリポジトリにサブモジュールを追加する

次は先ほどファイルを追加した sub-repo を main-repo でサブモジュールとして取り込む。

まずは main-repo をクローンしてこよう。

$ cd /tmp/
$ git clone localhost:/var/tmp/main-repo

クローンできたらローカルリポジトリのディレクトリに移動する。

$ ls | grep main-repo
main-repo
$ cd main-repo/

そして git submodule add コマンドを使ってサブモジュールを追加する。 最初の引数がサブモジュールとして取り込むリポジトリで、その次が取り込むときのディレクトリの名前になる。

$ git submodule add localhost:/var/tmp/sub-repo sub
Cloning into 'sub'...
remote: Counting objects: 3, done.
remote: Total 3 (delta 0), reused 0 (delta 0)
Receiving objects: 100% (3/3), done.
Checking connectivity... done.

取り込んだサブモジュールのディレクトリには先ほど sub-repo に用意したファイルが追加されていることがわかる。

$ ls sub/
greet.txt
$ cat sub/greet.txt
Hello, World!

git status コマンドで状態を確認するとふたつのファイルがインデックスに追加されていることがわかる。

$ git status
On branch master

Initial commit

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)

    new file:   .gitmodules
    new file:   sub

.gitmodules のファイルにはサブモジュールとしてどこのリポジトリが何という名前のディレクトリとして取り込まれているかが書かれている。

$ cat .gitmodules
[submodule "sub"]
    path = sub
    url = localhost:/var/tmp/sub-repo

サブモジュールを追加した状態でコミットしてプッシュしておく。

$ git commit -m "Initial commit"
$ git push origin master

ここまでで Git リポジトリには別の Git リポジトリをサブモジュールとして取り込むことができることがわかった。

サブモジュール側のリポジトリを変更する

次はサブモジュール側のリポジトリが変更されたときの挙動を確認しておく。

sub-repo のローカルリポジトリに移動して最初の用意したファイルの内容を書き換えよう。

$ cd /tmp/sub-repo/
$ cat << 'EOF' > greet.txt
Hello, Git!
EOF

変更した内容をインデックスに追加したらコミットしてプッシュしておく。

$ git add -A
$ git commit -m "change message"
$ git push origin master

さて、それでは main-repo に戻ってどうなっているかを確認する。 ディレクトリの直下で git pull しても変更があるようには見えていない。

$ cd /tmp/main-repo
$ git pull
Already up-to-date.

もちろんファイルの内容も変更されていない。

$ cat sub/greet.txt
Hello, World!

では、どうやってサブモジュール側の更新を反映すれば良いのかという話になる。 これには、サブモジュールのディレクトリに移動した上で git pull コマンドを実行しなきゃいけないようだ。

$ cd sub/
$ git pull
remote: Counting objects: 5, done.
remote: Total 3 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (3/3), done.
From localhost:/var/tmp/sub-repo
   2bb962e..e9e786e  master     -> origin/master
Updating 2bb962e..e9e786e
Fast-forward
 greet.txt | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

これでやっと更新内容が反映された。

 $ cat greet.txt
 Hello, Git!

サブモジュールの原理

トップディレクトリに戻って git status コマンドを実行するとサブモジュールとして取り込んだ sub ディレクトリに変更があるように見えている。

$ cd ../
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

    modified:   sub (new commits)

no changes added to commit (use "git add" and/or "git commit -a")

ここで git diff コマンドを実行してみるとサブモジュール機能がどのように内容を管理しているかが分かる。 サブモジュールとして取り込まれたディレクトリの差分は、実際にはコミットの識別子としてしか表示されない。

$ git diff
diff --git a/sub b/sub
index e69271a..7717d35 160000
--- a/sub
+++ b/sub
@@ -1 +1 @@
-Subproject commit e69271a5d2f2d84a87c730ac79424049cd38d542
+Subproject commit 7717d35bad558cecc83507c7862aa4092d7e1730

つまりサブモジュール機能では、あるリポジトリのどの時点 (コミットの識別子) の状態を取り込む、という考え方で内容を管理しているようだ。

なので、サブモジュール側に変更があってそれを使いたいときは、明示的にそれに追随する必要がある。 手順としては、まずサブモジュールを取り込んでいる側のリポジトリで参照するコミットを更新 (git pull) する。 そして、その内容をインデックスに追加したらコミットしてプッシュすれば完了。

$ git add sub
$ git commit -m "update submodule"
$ git push origin master

ちなみに先ほどのコミットの識別子は sub-repo の方に記録されているそれを示している。

$ cd /tmp/sub-repo/
$ git log -n 1
commit 7717d35bad558cecc83507c7862aa4092d7e1730
Author: myname <myemail@example.jp>
Date:   Mon Apr 18 02:55:32 2016 -0400

    change message

取り込んでいる側からサブモジュールのリポジトリの内容を変更する

先ほどはサブモジュールとして取り込まれる側のリポジトリを変更してから、それを取り込んだ側で更新された内容に追従する方法を扱った。 今度は取り込んでいる側から直接サブモジュールのリポジトリを更新してみよう。

main-repo に移動したらサブモジュールのディレクトリにあるファイルの内容を変更する。

$ cd /tmp/main-repo/
$ cat << 'EOF' > sub/greet.txt
Hello, Submodule!
EOF

この状態でトップディレクトリで git status コマンドを実行するとサブモジュールのディレクトリに変更があるという表示になっている。

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
  (commit or discard the untracked or modified content in submodules)

    modified:   sub (modified content)

no changes added to commit (use "git add" and/or "git commit -a")

それではサブモジュールのディレクトリに加えた変更をリモートリポジトリに反映しよう。 それにはディレクトリに移動した上で変更をインデックスに追加してコミット、プッシュする。

$ cd sub/
$ git add -A
$ git commit -m "change message from main-repo"
$ git push origin master
Counting objects: 5, done.
Writing objects: 100% (3/3), 263 bytes | 0 bytes/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To localhost:/var/tmp/sub-repo
   e9e786e..ec80564  master -> master

注意点としては、この時点ではまだサブモジュールを取り込んだ側のリポジトリが参照しているコミットは更新されていないこと。 main-repo のトップディレクトリで git status コマンドを実行すると、それがわかる。

$ cd ..
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

    modified:   sub (new commits)

no changes added to commit (use "git add" and/or "git commit -a")

なので、こちらでも更新されたコミットを参照するようにしてコミット、プッシュしよう。

$ git add sub/
$ git commit -m "update submodule"
$ git push origin master

サブモジュールとして取り込まれている側の sub-repo の方に移動して git pull してみよう。 すると main-repo の方で加えた変更がちゃんとリモートリポジトリに反映されていることがわかる。

$ cd /tmp/sub-repo/
$ git pull
remote: Counting objects: 5, done.
remote: Total 3 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (3/3), done.
From localhost:/var/tmp/sub-repo
   e9e786e..ec80564  master     -> origin/master
Updating e9e786e..ec80564
Fast-forward
 greet.txt | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
$ cat greet.txt 
Hello, Submodule!

新規にクローンするとき

ここまでは既存のリポジトリにサブモジュールを追加してから使い続ける流れを確認してきた。 今度は、既にサブモジュールが追加されているリポジトリをクローンして使いはじめるまでの流れについて書く。

まずは main-repo を main-repo2 という名前のローカルリポジトリとしてクローンしてくる。

$ cd /tmp/
$ git clone localhost:/var/tmp/main-repo main-repo2

移動して ls コマンドを実行するとサブモジュールのディレクトリはあることがわかる。

$ cd main-repo2/
$ ls
sub

ただしサブモジュールのディレクトリに ls コマンドを実行しても中身が空っぽになっている。

$ ls sub/

はて greet.txt はどこにいったんですかと思いながら sub ディレクトリの中で git pull しても何も起こらない。

$ cd sub/
$ git pull
Already up-to-date.

実はサブモジュールを含むリポジトリを新たに使いはじめるには一連の操作が必要になっている。 具体的には git submodule init コマンドと git submodule update コマンドを実行する。

$ git submodule init
Submodule 'sub' (localhost:/var/tmp/sub-repo) registered for path '../sub'
$ git submodule update
Cloning into 'sub'...
remote: Counting objects: 9, done.
remote: Compressing objects: 100% (3/3), done.
remote: Total 9 (delta 0), reused 0 (delta 0)
Receiving objects: 100% (9/9), done.
Checking connectivity... done.
Submodule path '../sub': checked out '34682bf1f50234509b5c2b634a7cfb3f44fa4959'

これでサブモジュールにファイルが現れる。

$ ls
greet.txt
$ cat greet.txt 
Hello, Submodule!

これでサブモジュールをひと通り使う方法がわかった。

まとめ

  • Git にはサブモジュールという機能がある
  • サブモジュールを使うと別のリポジトリを取り込むことができる
  • サブモジュールでは外部リポジトリの特定のコミットを参照している
  • サブモジュールを含むリポジトリは使い始めに一連の手順が必要になる

めでたしめでたし。

Python: flake8 のプラグインを書いてみる

flake8 は Python の linter の一種。 linter というのはソースコードを解析して問題がある箇所を見つけるツールの総称のことをいう。

flake8 の特徴としてプラグイン機構が備わっていることが挙げられる。 このプラグインは自分で自作できる。 つまり、独自の linter を flake8 に組み込むことができるということ。 今回は、そのプラグインを自分で書いてみることにしよう。

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.11.4
BuildVersion:   15E65
$ python --version
Python 3.5.1

インストール

flake8 のインストールには pip を使う。

$ pip install flake8

インストールすると flake8 コマンドが使えるようになる。 バージョン情報と共にカッコ内に表示されている内容 (pyflakes や mccabe) はロードされたプラグインの一覧になっている。

$ flake8 --version
2.5.4 (pep8: 1.7.0, pyflakes: 1.0.0, mccabe: 0.4.0) CPython 3.5.1 on Darwin

使ってみる

まずは flake8 の基本的な使い方を再確認しておく。

次のようにシンプルなサンプルコードを用意する。 実はこれは一箇所だけ Python の標準的なコーディングスタイルである PEP8 に違反した箇所が含まれている。

$ cat << 'EOF' > helloworld.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

def main():
    print('Hello, World!')


if __name__ == '__main__':
    main()
EOF

このソースコードに対して flake8 を実行すると PEP8 に違反した箇所を見つけることができる。 ちなみに関数定義の前には二行の空行が必要、というルールに違反していた。

$ flake8 helloworld.py
helloworld.py:4:1: E302 expected 2 blank lines, found 1

プラグインを追加してみよう

次は flake8 にプラグインを足してみることにする。 サードパーティ製のプラグインがいくつかある中で、今回は flake8-todo プラグインを試してみる。 こちらも pip を使ってインストールしよう。

$ pip install flake8-todo

インストールすると flake8 コマンドのバージョン情報の中に flake8-todo が表示されるようになる。

$ flake8 --version
2.5.4 (pep8: 1.7.0, pyflakes: 1.0.0, flake8-todo: 0.4, mccabe: 0.4.0) CPython 3.5.1 on Darwin

この flake8-todo というプラグインはソースコードのコメントの中にある TODO や XXX といった文字列を検出できる。 これらのコメントは特に規定があるわけではないものの、デファクトスタンダードとしてよく使われている。 例えば TODO は文字通り「これからやること」、XXX は「問題があるけどそうしている理由がある」、FIXME は「問題があるので直すべきところ」などを表している。

動作を試すために TODO が含まれたソースコードを用意しよう。

$ cat << 'EOF' > helloworld.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-


def main():
    print('Hello, World!')  # TODO(momijiame): something


if __name__ == '__main__':
    main()
EOF

これを flake8-todo がインストールされた flake8 コマンドで実行すると TODO が残っている箇所を見つけることができる。

$ flake8 helloworld.py
helloworld.py:6:31: T000 Todo note found.

プラグインを作ってみる

使い方の説明がおわったところで、次は flake8 のプラグインを書いてみよう。 flake8 のプラグイン機構は setuptools という Python のデファクトスタンダードなパッケージング機構にある pkg_resources という仕組みを利用している。

今回作るプラグインは flake8-helloworld という名前にしよう。 プラグインは Python パッケージにする必要があるため、まずはそれ用のディレクトリを用意する。

$ mkdir flake8-helloworld

作業用ディレクトリの中にプラグインが含まれた Python モジュールを追加する。 名前は flake8_helloworld.py にした。 この中で定義している HelloWorldChecker というクラスがまずい箇所を見つけるチェッカーになる。

$ cat << 'EOF' > flake8-helloworld/flake8_helloworld.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import ast

__version__ = '0.0.1'


class HelloWorldChecker(object):
    name = 'helloworld'
    version = __version__

    def __init__(self, tree, filename):
        self.tree = tree

    def run(self):
        for child in ast.iter_child_nodes(self.tree):
            yield child.lineno, -1, 'X999 Hello, World!', type(self)
EOF

チェッカのインターフェースについて解説していく。 まず、チェッカはチェック対象のファイル毎にインスタンス化される。 そのとき init() メソッドにはソースコードの AST オブジェクトと、そのファイル名が渡される。 これは何故かというと flake8 のチェック対象は必ず Python モジュール (*.py) だからだ。

AST モジュールというのは Python モジュールを構文解析したオブジェクトのこと。 これについては以前このブログで詳しく書いているので、そちらも併せて参照のこと。 ちなみに今回作ったチェッカーは AST オブジェクトが持っている子要素毎にメッセージを返すという何ら実用性のないものになっている。

blog.amedama.jp

そして、実際にソースコードの内容をチェックするのが run() メソッドになっている。 このメソッドはジェネレータとして実装する。 ソースコードを解析して問題があったときは yield でその詳細を返す。 返す内容はタプルで (問題のある行番号, 問題のある列, 問題に関する詳細,問題を見つけたチェッカー) となっているようだ。

ソースコードを読んだところ、このインターフェースは flake8 ではなく、その内部で使っている pep8 というツールに由来しているようだった。 https://gitlab.com/pycqa/pep8/blob/1.7.0/pep8.py#L1521

問題に関する詳細に相当する文字列には少し注意点がある。 それぞれの問題には一意な識別子を含める必要があるらしい。 識別子のフォーマットは英字 1 文字 + 3 桁の数字になっている。 この詳細は以下に詳しく書かれていた。

http://flake8.readthedocs.org/en/latest/warnings.html

その上で run() メソッドでは「識別子 + 半角スペース + 問題を説明した文章」というフォーマットで返す必要がある。 先ほどのサンプルコードでは X999 という識別子を使って返していた。

次にパッケージングに必要なセットアップスクリプトを用意する。 ここでのポイントはふたつある。 まずひとつ目がパッケージング対象を py_modules 引数で 'flake8_helloworld' と指定しているところ。 そして、ふたつ目が肝心の flake8 のプラグインを指定するところだ。 これには entry_points 引数を使う。 具体的には flake8.extension というキーで flake8 のチェッカを指定する。 チェッカは「チェッカ名 = パッケージのパス:チェッカとして動作するクラス」として指定する。 こうしておくと flake8 が pkg_resources を使って、このチェッカを見つけることができる。

$ cat << 'EOF' > flake8-helloworld/setup.py
# -*- coding: utf-8 -*-

from setuptools import setup


def main():
    setup(
        name='flake8-helloworld',
        version='0.0.1',
        zip_safe=False,
        py_modules=['flake8_helloworld'],
        install_requires=[
            'flake8',
        ],
        entry_points={
            'flake8.extension': [
                'flake8_helloworld = flake8_helloworld:HelloWorldChecker',
            ],
        },
    )


if __name__ == '__main__':
    main()
EOF

この時点でディレクトリは次のような状態になっている。

$ cd flake8-helloworld
$ tree
.
├── flake8_helloworld.py
└── setup.py

0 directories, 2 files

蛇足だけど tree コマンドは Mac OS X にデフォルトで入っていないので Homebrew を使ってインストールしよう。

$ brew install tree

これで準備が整ったので flake8-helloworld パッケージをインストールしよう。 インストールには先ほど作ったセットアップスクリプトを使う。

$ python setup.py install

上手くいくと flake8 のバージョン情報の中に「helloworld」が追加される。

$ flake8 --version
2.5.4 (pep8: 1.7.0, helloworld: 0.0.1, pyflakes: 1.0.0, flake8-todo: 0.4, mccabe: 0.4.0) CPython 3.5.1 on Darwin

試しに flake8 コマンドでセットアップスクリプトを処理してみよう。

$ flake8 setup.py
setup.py:3:0: X999 Hello, World!
setup.py:6:0: X999 Hello, World!
setup.py:23:0: X999 Hello, World!

自作のチェッカーが返した X999 のメッセージが表示されている。 ばっちりだ。

まとめ

今回は Python の linter のひとつである flake8 について見てきた。 flake8 の特徴のひとつとしてプラグイン機構が備わっている点が挙げられる。 このプラグインを実装するとオリジナルのルールに基いてソースコードをチェックすることができるようになる。

めでたしめでたし。