2012年10月10日水曜日

[CakePHP]HABTMでのデータ保存について(両モデル新規登録・複数行登録)

カレントモデルと同時にHABTM(hasAndBelongsToMany)の関係を持つ
モデルのデータを同時に保存したい場合、色々の問題があり、
それに対して検討した結果を紹介する。

以下で紹介したように、HABTMしているモデルのデータは
idの配列となっている必要がある。

[CakePHP]アソシエイションを持つモデルをsaveall()する際に受け付けるデータ構造

つまり、view側にて、idが取得できる状態が前提となっている
ようだ。なので、id以外の項目のみを送信しても、普通には保存できない。
ただ、一般にウェブサービスで使われている「タグ」などの機能は、
ユーザがその画面でタグ自体を作成することが想定される。
その場合、カレントモデルとHABTMの関係を持つモデルの両方を
新規登録する必要がある。

その場合、どのように実現するか。
いくつかの方法を検証してみた。

1.中間テーブルのsaveallメソッドを使用する。

 

3.7.6.6 hasMany through (The Join Model)

cookbookで紹介されているこのやり方では、
実際はHABTMを利用しているわけではなく、中間テーブルからの
belongsToを利用した保存方法だ。
この方法だと、以下のようにid以外の項目での保存が可能だ。
両方のモデルにidをつけてくれるし、中間テーブルにも
そのidでレコードを挿入してくれる。

※Theme habtm Tagという関係を想定

配列構造①

Array
(
    [Theme] => Array
        (
            [content] => test33です。
            [explanation] => test33です。
        )
    [Tag] => Array
        (
            [content] => 村田さん
        )
)

一つ注意点があり、Themeがカレントだと、なぜか、
ThemesTagからのbelongsToのアソシエイションをbindしなければ
ならない。ThemesTagモデルでアソシエイションを設定したとしても。
でないと、ThemesTagに空のレコードを挿入するのみとなってしまう。
以下のサイトを参考にした。

Habtm | habtm な関係にあるモデルで、両方とも未登録なデータを同時に保存したい

複数レコードの挿入はできるだろうか。
まず以下のような配列構造にしてみた。
結果Tagが保存されなかった。

配列構造②

Array
(
    [Theme] => Array
        (
            [content] => test33です。
            [explanation] => test33です。
        )
    [Tag] => Array
        (
             0=>array('content'=>'村田さん')
             1=>array('content'=>'児島さん'),
             2=>array('content'=>'本'),
        )
)

次に以下のような配列構造にしてみた。
こちらは、ThemesTagsに2行の空行(createdとupdatedは入った)だけ
が挿入されたのみだった。

配列構造③
Array
(
    [1] => Array
        (
            [Theme] => Array
                (
                    [content] => test34です。
                    [explanation] => test34です。
                )
            [Tag] => Array
                (
                    [content] => 村田さん
                )
        )
    [2] => Array
        (
            [Theme] => Array
                (
                    [content] => test34です。
                    [explanation] => test34です。
                )
            [Tag] => Array
                (
                    [content] => 児島さん
                )
        )
)

次に2回に分けてsaveallしてみたらどうかと考え以下のようにしてみた。

コード例①

        $data=array(
                'Theme'=>array(
                    'content'=>'test35です。',
                    'explanation'=>'test35です。'
                ),
                'Tag'=>array(
                    'content'=>'村田さん'
                )
         );
        $data2=array(
                'Theme'=>array(
                ),
                'Tag'=>array(
                    'content'=>'児島さん'
               )
         );
        $this->Theme->saveall($data);
        $data2['Theme']['id']=$this->Theme->id;
        $this->Theme->saveall($data2);

すると、見事に保存することができた。以下のようなSQLが発行された。

1    SHOW FULL COLUMNS FROM `themes`        6    6    4
2    SELECT CHARACTER_SET_NAME FROM INFORMATION_SCHEMA.COLLATIONS WHERE COLLATION_NAME= 'utf8_general_ci';        1    1    1
3    SHOW FULL COLUMNS FROM `users`        8    8    3
4    SHOW FULL COLUMNS FROM `items`        6    6    3
5    SHOW FULL COLUMNS FROM `tags`        4    4    3
6    SHOW FULL COLUMNS FROM `themes_tags`        5    5    3
7    START TRANSACTION        0        0
8    INSERT INTO `themes` (`content`, `explanation`, `modified`, `created`) VALUES ('test35です。', 'test35です。', '2012-10-10', '2012-10-10')        1        0
9    SELECT LAST_INSERT_ID() AS insertID        1    1    0
10    INSERT INTO `tags` (`content`, `modified`, `created`) VALUES ('村田さん', '2012-10-10', '2012-10-10')        1        0
11    SELECT LAST_INSERT_ID() AS insertID        1    1    0
12    INSERT INTO `themes_tags` (`theme_id`, `tag_id`, `modified`, `created`) VALUES (3879, 78, '2012-10-10', '2012-10-10')        1        0
13    SELECT LAST_INSERT_ID() AS insertID        1    1    0
14    COMMIT        0        0
15    START TRANSACTION        0        0
16    SELECT COUNT(*) AS `count` FROM `themes` AS `Theme` WHERE `Theme`.`id` = 3879         1    1    0
17    SELECT COUNT(*) AS `count` FROM `themes` AS `Theme` WHERE `Theme`.`id` = 3879         1    1    0
18    UPDATE `themes` SET `id` = 3879, `modified` = '2012-10-10' WHERE `themes`.`id` = 3879        0        0
19    INSERT INTO `tags` (`content`, `modified`, `created`) VALUES ('児島さん', '2012-10-10', '2012-10-10')        1        0
20    SELECT LAST_INSERT_ID() AS insertID        1    1    0
21    INSERT INTO `themes_tags` (`theme_id`, `tag_id`, `modified`, `created`) VALUES (3879, 79, '2012-10-10', '2012-10-10')        1        0
22    SELECT LAST_INSERT_ID() AS insertID        1    1    0
23    COMMIT

for文で回せばいくつでも保存できそうだ。
ただし、これは、トランザクションが二つにわかれてしまっているが。

ただ、この方法のデメリットとしては、同じ内容のTagを作ってしまう
可能性があるので、それを回避する処理を入れる必要があるという点。

また、以下で議論されているよう連続した保存処理の場合、
Model::create()を毎回実行しないと最後の行しか反映されない、
と思っていたが、このやり方だと、create()しなくてもきちんと保存されていた。
中間テーブルのsaveallを使用しているからだろうか。

CakePHPのsaveメソッドでINSERTするつもりがUPDATEになってしまう場合
連続したsaveメソッドの使い方について

2.habtamable.phpを使用する。


こちらは有志の方が作ったbehaviorだ。
以下でダウンロード可能。

https://github.com/teknoid/cakephp-habtamable-behavior/blob/master/models/behaviors/habtamable.php

使い方はダウロードしたフォルダに英語のreadmeが入っている。

こちらは配列構造①の形で保存可能。

また、その場合、「村田さん」というタグがすでに
存在する場合は、Tagsテーブルに新規レコードを追加せず、
既存のレコードのidを取得し、中間テーブルに挿入してくれる。
また、メリットとして、Themeのsaveall()を使うため、
以下のように、Themeがもつほかのアソシエイションモデルのデータも
同時に保存することができる。

こちらは
Theme hasMany Item
Theme hasAndBelongsToMany Tag
というアソシエイションを持つ場合。

配列構造④

Array
(
    [Theme] => Array
        (
            [content] => test33です。
            [explanation] => test33です。
        )
    [Item] => Array
        (
            [0] => Array
                (
                    [code] => B0017LURGI
                    [point] => 10
                )
            [1] => Array
                (
                    [code] => 4774135038
                    [point] => 9
                )
    [Tag] => Array
        (
            [content] => 村田さん
        )
)

では、複数レコードの保存についてはどうだろうか。
配列構造②のようにしたところ、Tagの保存ができなかった。
配列構造③のようにしたところ、一つ目のデータはきちんと
保存できたのだが、二つ目のデータは空行などのおかしなデータ
しか入らなかった。

コード例①のようにしたところ、ThemesとTagsに対するレコード挿入は
適切に行われたのだが、ThemesTagの2行目を挿入する直前に1行目を
削除してしまうという現象が起きた。

こちらは2度目のsaveall直前で以下のそれぞれを試してもダメだった。

①$this->Theme->create();
②$this->Theme->create(false);
③$this->Theme->create(null);
④$this->Theme->id=null;
⑤$this->Theme->ThemesTag->create();
⑥$this->Theme->ThemesTag->create(false);
⑦$this->Theme->ThemesTag->create(null);
⑧$this->Theme->ThemesTag->id=null;
⑨$this->Theme->ThemesTag->Theme_id=null;


【まとめ】
1.中間テーブルのsaveallメソッドを使用する。
 メリット:
  ・id以外のデータで登録可能
  ・両モデルの新規登録可能
  ・saveallを複数回に分けることにより複数行の挿入が可能。

 デメリット:
  ・複数行挿入する場合、トランザクションが行数分となる。
  ・同じデータがあっても新しい行を挿入してしまう。(回避策をたてる必要あり)

2.habtamable.phpを使用する。
 メリット:
  ・id以外のデータで登録可能
  ・両モデルの新規登録可能
  ・同じデータがある場合は、既存のレコードのIDを使用してくれる。
  ・他の他のアソシエイションを持つモデルのデータも同時に保存できる。

 デメリット:
  ・複数行の登録ができない。


【結局どうしたか】
上記で紹介した二つとも、どっちもどっち、と思ったので、
通常のhabtmの保存方法、idでの保存で、以下を実現する方法を考える
ことにした。
  ・両モデルの新規登録可能
  ・同じデータがある場合は、既存のレコードのIDを使用してくれる。

やり方としては、(Tagが対象モデルと想定)
①viewからはid以外の情報が送られるようにする。
 (新規も既存も混在)

②それをcontroller側で、idの配列に変換する。
 (既存に同じデータがあるものは既存のid、無いものは
  そこでTagsテーブルにsaveし、idを取得する。)

③それをsaveallに渡すデータ配列に組み込む。
 (最終的に以下のような配列構造となるようにする。)

Array
(
    [Theme] => Array
        (
            [content] => 本
            [explanation] => 楽しい本
            [user_id] => 4
        )
    [Tag] => Array
        (
            [0] => 2
            [1] => 68
        )
)

このやり方だと、もし、新規のTagが発生しない場合は、
トランザクションは1回で済むというメリットがある。
新規のTagがある場合は、その数+1回のトランザクションとなる。
「1.中間テーブルのsaveallメソッドを使用する。」のやり方の
場合は、新規があろうとなかろうと、Tag数分のトランザクションが
発生してしまうし、他のアソシエイションのデータについても別トランザクション
で保存する必要があるので、こちらのほうが良いと考えた。

また、CakePHPで通常想定されるのhabtmの保存方法に近いほう
がよいかとも思ったので。

※CakePHPのバージョンはcakephp-cakephp-1.3.15-9-gacd25c3.zip


【追加 2012/11/17】
この記事で何度かトランザクションが分かれてしまうことを問題にして
いるが、下記の記事にあるようbegin()とcommit()で解決できそうだ。
つまり、一つのトランザクションにできそう。
ただ、下記のブログで工夫している、複数のモデルを使うとわかりにくくなる
ことへの対応まではしていない。

CakePHPで複数テーブルに対するトランザクションを使う場合

0 件のコメント:

コメントを投稿