DBICのmany_to_manyでできること

ググっても意外と情報が出てこないmany_to_manyを試してみた。DBはMySQLで。

■ DB作成

DBはこんな感じで作る。

create table users (
    id             int not null auto_increment primary key,
    name           varchar(255) not null unique
) engine=innodb;

create table roles (
    id             int not null auto_increment primary key,
    name           varchar(255) not null unique
) engine=innodb;

create table user_roles (
    id             int not null auto_increment primary key,
    user           int not null,
    role           int not null,
    foreign key(user) references users(id) on delete cascade,
    foreign key(role) references roles(id) on delete cascade
) engine=innodb;

insert into users (name) values ('taro'), ('jiro');
insert into roles (name) values ('admin'), ('manager'), ('user');
insert into user_roles (user, role) values (1, 1), (2, 2), (2, 3);

初期状態はこんな感じ。

mysql> select ur.*,u.name user_name,r.name role_name from user_roles ur join users u on u.id=ur.user join roles r on r.id=ur.role order by ur.id;
+----+------+------+-----------+-----------+
| id | user | role | user_name | role_name |
+----+------+------+-----------+-----------+
|  1 |    1 |    1 | taro      | admin     |
|  2 |    2 |    2 | jiro      | manager   |
|  3 |    2 |    3 | jiro      | user      |
+----+------+------+-----------+-----------+
3 rows in set (0.00 sec)

スキーマの設定

スキーマDBIC::Schema::Loaderにお任せ。自動生成は

で紹介されてるZIGOROuさんのスクリプトで「差分を直に書く方法」でやってます。

many_to_manyの設定は自動ではやってくれないようなので、Users.pmとRoles.pmにそれぞれこんな感じで直書き。

# in Users.pm
__PACKAGE__->many_to_many('roles' => 'user_roles', 'role');

# in Roles.pm
__PACKAGE__->many_to_many('users' => 'user_roles', 'user');

引数は「関連ResultSetにひもづけるメソッド名」「対象のテーブル名」「最終的な目的テーブルにひもづく中間テーブルのカラム名」ってとこですかね。

■ テストスクリプト実行

んで、こんなスクリプトを走らせる。

#!/usr/bin/perl
use strict;
use warnings;
use FindBin::libs;
use MyApp::Schema;

my $schema = MyApp::Schema->connect('dbi:mysql:many_to_many', 'username', 'password');
$schema->storage->debug(1);
my $u = $schema->resultset('Users');
my $r = $schema->resultset('Roles');

sub printRoles {
    my $user = shift or die;
    printf '[%s] : ', $user->name;
    map { print $_->name, ' ' } $user->roles;
    print "\n\n";
}

{
    my $taro = $u->search( { name => 'taro' } )->first;
    printRoles($taro);

    $taro->add_to_roles($r->search( { name => 'user' } ));
    printRoles($taro);

    $taro->remove_from_roles($r->search( { name => 'admin' } ));
    printRoles($taro);
}

print "----\n";

{
    my $jiro = $u->search( { name => 'jiro' } )->first;
    printRoles($jiro);

    $jiro->set_roles(
        $r->search( { name => [qw/admin user/] } )
    );
    printRoles($jiro);
}

これを実行すると、DBICSQLも交えてコンソールの出力はこんな感じに。

SELECT me.id, me.name FROM users me WHERE ( name = ? ): 'taro'
SELECT role.id, role.name FROM user_roles me  JOIN roles role ON ( role.id = me.role ) WHERE ( me.user = ? ): '1'
[taro] : admin

SELECT me.id, me.name FROM roles me WHERE ( name = ? ): 'user'
INSERT INTO user_roles (role, user) VALUES (?, ?): '3', '1'
SELECT role.id, role.name FROM user_roles me  JOIN roles role ON ( role.id = me.role ) WHERE ( me.user = ? ): '1'
[taro] : admin user

SELECT me.id, me.name FROM roles me WHERE ( name = ? ): 'admin'
DELETE FROM user_roles WHERE ( ( ( role = ? ) AND ( user = ? ) ) ): '1', '1'
SELECT role.id, role.name FROM user_roles me  JOIN roles role ON ( role.id = me.role ) WHERE ( me.user = ? ): '1'
[taro] : user

----
SELECT me.id, me.name FROM users me WHERE ( name = ? ): 'jiro'
SELECT role.id, role.name FROM user_roles me  JOIN roles role ON ( role.id = me.role ) WHERE ( me.user = ? ): '2'
[jiro] : manager user

SELECT me.id, me.name FROM roles me WHERE ( ( ( name = ? ) OR ( name = ? ) ) ): 'admin', 'user'
DELETE FROM user_roles WHERE ( user = ? ): '2'
INSERT INTO user_roles (role, user) VALUES (?, ?): '1', '2'
INSERT INTO user_roles (role, user) VALUES (?, ?): '3', '2'
SELECT role.id, role.name FROM user_roles me  JOIN roles role ON ( role.id = me.role ) WHERE ( me.user = ? ): '2'
[jiro] : admin user

やってることは上から以下の通り。

  • taro の roles を表示
  • taro に user の role を追加
  • taro の admin の role を削除
  • jiro の roles を表示
  • jiro の roles を admin, user に設定

$user->rolesで、users → user_roles → rolesとテーブルをたどってresultsetが取得できてます。

add_to_roles, remove_from_rolesでそれぞれrolesの追加と削除。

set_rolesは既存のレコードを全て一度deleteした後で改めて指定のデータをinsertしていますね。既存のデータを見て差分のSQLを発行しているわけではない、というのは気に留めておいた方がいいかも。

実行後のDBの様子はこうです。

+----+------+------+-----------+-----------+
| id | user | role | user_name | role_name |
+----+------+------+-----------+-----------+
|  4 |    1 |    3 | taro      | user      |
|  5 |    2 |    1 | jiro      | admin     |
|  6 |    2 |    3 | jiro      | user      |
+----+------+------+-----------+-----------+

■ もう一度、続けざまに実行してみる

上記のスクリプトをもう一度走らせると、今度はこうなります。

SELECT me.id, me.name FROM users me WHERE ( name = ? ): 'taro'
SELECT role.id, role.name FROM user_roles me  JOIN roles role ON ( role.id = me.role ) WHERE ( me.user = ? ): '1'
[taro] : user

SELECT me.id, me.name FROM roles me WHERE ( name = ? ): 'user'
INSERT INTO user_roles (role, user) VALUES (?, ?): '3', '1'
SELECT role.id, role.name FROM user_roles me  JOIN roles role ON ( role.id = me.role ) WHERE ( me.user = ? ): '1'
[taro] : user user

SELECT me.id, me.name FROM roles me WHERE ( name = ? ): 'admin'
DELETE FROM user_roles WHERE ( ( ( role = ? ) AND ( user = ? ) ) ): '1', '1'
SELECT role.id, role.name FROM user_roles me  JOIN roles role ON ( role.id = me.role ) WHERE ( me.user = ? ): '1'
[taro] : user user

----
SELECT me.id, me.name FROM users me WHERE ( name = ? ): 'jiro'
SELECT role.id, role.name FROM user_roles me  JOIN roles role ON ( role.id = me.role ) WHERE ( me.user = ? ): '2'
[jiro] : admin user

SELECT me.id, me.name FROM roles me WHERE ( ( ( name = ? ) OR ( name = ? ) ) ): 'admin', 'user'
DELETE FROM user_roles WHERE ( user = ? ): '2'
INSERT INTO user_roles (role, user) VALUES (?, ?): '1', '2'
INSERT INTO user_roles (role, user) VALUES (?, ?): '3', '2'
SELECT role.id, role.name FROM user_roles me  JOIN roles role ON ( role.id = me.role ) WHERE ( me.user = ? ): '2'
[jiro] : admin user

add_to_rolesがもう一度実行されて、taroのrolesが「user user」になってしまいました。

remove_from_rolesは対象のレコードはもうないけど、delete文を発行しているようです。

set_rolesは全てdeleteしてからinsertし直すので、結果は変わっていませんね。

どれも共通して、現状に関わらず指定のSQLを問答無用で発行します(一応mysqlのクエリログも確認したけど、確かに発行されてました)。


そして、この時点でのDBの様子はこう。

+----+------+------+-----------+-----------+
| id | user | role | user_name | role_name |
+----+------+------+-----------+-----------+
|  4 |    1 |    3 | taro      | user      |
|  7 |    1 |    3 | taro      | user      |
|  8 |    2 |    1 | jiro      | admin     |
|  9 |    2 |    3 | jiro      | user      |
+----+------+------+-----------+-----------+

■ まとめ

  • many_to_manyのrelationはDBIC::Schema::Loaderで自動的に設定されない
  • 設定するとadd_to_XXXX, remove_from_XXXX, set_XXXXのメソッドが生える
  • どの子もDBの状態を見ずにSQLを発行するのでチェックするなら自前で

add_to_XXXX等にカンするドキュメントの記述はこのへん