cakephp3
ユニットテスト

CakePHP3でデータベースのトランザクションを使う

CakePHP3でデータベースのトランザクションを使う方法をまとめました。
CakePHP3のソースコードをみながらまったり理解していきます。
最後にトランザクションな処理をユニットテストがしやすいコードに改良するテクニックを紹介しています。

お急ぎの方は「トランザクションを実装する」をみてください。

サンプルが動作する環境

CakePHP3のバージョンです。

> bin\cake version
3.5.0

テーブルは次の3つを使います。

-- タグ
CREATE TABLE tags(
  id   INTEGER AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(255)
);

-- 記事
CREATE TABLE articles(
  id INTEGER AUTO_INCREMENT PRIMARY KEY,
  contents TEXT
);

-- 記事とタグの関連付け
CREATE TABLE article_tags(
  id INTEGER AUTO_INCREMENT PRIMARY KEY,
  article_id INTEGER,
  tag_id INTEGER
);

ArticleTagsはArticlesとTagsを関連付けるためのテーブルです。
これで多対多の関係を避けます。
これを事前にbakeしてください。(modelだけでOKです。controllerは不要です。)

そして、SampleComponentというコンポーネントを作ります。
処理はSampleComponentに追加します。
bakeします。

bin/cake bake component sample

複数行INSERTのトランザクション

1つのテーブルに複数行まとめてINSERTしたい。
そして、複数INSERTのうち、どれか一つでも失敗した場合はロールバックする。

こういった要件があるとしましょう。
トランザクションを使えばいいですね。
まずは、テストケースを作ります。
こうなります。

SampleComponent.php
public function testInsertTags(){
    //3番目に失敗する空データを指定する。
    $tags = ['java', 'php', '', 'javascript'];

    $beforeCount = $this->Tags->find()->count();
    $this->Sample->insertTags($tags);
    $afterCount = $this->Tags->find()->count();

    //失敗の場合は1件も追加されないことを確認する。
    $this->assertEquals(0, $afterCount - $beforeCount);
}

そして、テストのため、Tags.nameは空白を拒否するようにしましょう。

TagsTable.php
public function validationDefault(Validator $validator)
{
...
    $validator
        ->scalar('name')
        ->notBlank('name');
...
}

単純なsave()のループでは要件を満たせない

まずは、入門書通りの実装では、要件を満たせないことを確認しましょう。

さて、
複数INSERTの実装で、いちばん簡単なのは次のプログラムです。

SampleComponent.php
public function insertTags($tags){
    foreach($tags as $tg){
        $tagEntity = $this->Tags->newEntity(['name' => $tg]);
        $result = $this->Tags->save($tagEntity);

        if(!$result)
            return false;
    }

    return true;
}

これはsave()を繰り返し呼んでいるだけです。
そして、どれか一つでもsave()が失敗すると、処理を途中でやめます。

では、phpunitでテストしてみましょう。
テストケースを再掲します。

SampleComponent.php
public function testInsertTags(){
    //3番目に失敗する空データを指定する。
    $tags = ['java', 'php', '', 'javascript'];

    $beforeCount = $this->Tags->find()->count();
    $this->Sample->insertTags($tags);
    $afterCount = $this->Tags->find()->count();

    //失敗の場合は1件も追加されないことを確認する。
    $this->assertEquals(0, $afterCount - $beforeCount);
}

$tagsの3番目の要素は''になっているので、ここで失敗するはずです。
実行すると、次のようになります。

1) App\Test\TestCase\Controller\Component\SampleComponentTest::testInsertTags
Failed asserting that 2 matches expected 0.

上記の通り、失敗するまでの2レコードが追加されています。
ロールバックされていませんね。
ですので、要件は満たしていません。

なぜロールバックされなかったのでしょう?
理由は1回のsave()メソッド単位でcommitするからです。
これはCakePHP3のソースコードをみればわかります。

CakePHP3のソースコードを見てみる

CakePHP3のソースコードで確かめましょう。
save()はこちら。
https://api.cakephp.org/3.4/source-class-Cake.ORM.Table.html#1623-1745
save処理の本体_processSave()をクロージャにいれて_executeTransaction()に渡しています。

こちらはその_executeTransaction()です。
https://api.cakephp.org/3.4/source-class-Cake.ORM.Table.html#1442-1458

$atomicがtrueのときはCake\Database\Connection::transactional()が呼ばれていますね。
では、transactional()はどうなっているのか?
それがこちらです。
https://api.cakephp.org/3.5/source-class-Cake.Database.Connection.html#652-688

  • 引数の関数$callbackを実行して、falseが返るとrollbackする。
  • trueが返るとcommitする。

このような実装のため、ロールバックしなかったのですね。

トランザクションを実装する

うまくいかない原因はわかりました。
トランザクションがsave()単位になっていたからですね。

先ほど読んだソースコードの中にある、transactional()を使えば解決できそうです。
改良版がこちら。

SampleComponent.php
public function insertTags($tags){
    //saveを行う処理をクロージャにいれる。
    $saveProcess = function() use($tags){
        foreach($tags as $tg){
            $tagEntity = $this->Tags->newEntity(['name' => $tg]);

            //save()単位の原子性を無効に。
            $result = $this->Tags->save($tagEntity,['atomic' => false]);

            if(!$result)
                return false;
        }
        return true;
    };//end saveProcess

    //DBのコネクションを取得する。
    $conn = $this->Tags->getConnection();
    return $conn->transactional($saveProcess);
}

ポイントがあります。

  • save処理をクロージャにいれる。
  • save()単位の原子性を無効にする。
    • save()のオプションでatomicをfalseにします。
  • DBのコネクションを取得してtransactional()を呼ぶ。

テストを実行すると、、、

PHPUnit 6.3.0 by Sebastian Bergmann and contributors.

........                                                            8 / 8 (100%)

Time: 293 ms, Memory: 12.00MB

OK (8 tests, 36 assertions)

通りました!
Connectionについてはこちら。
https://api.cakephp.org/3.5/class-Cake.Database.Connection.html

Connectionを使えば、
1つのトランザクションで複数のテーブルを操作することもできます。

1つのトランザクションで複数のテーブルを操作する

1つのトランザクションで複数のテーブルを操作したいときも、
同じ方法でできます。
試してみましょう。

  • Article
  • Tag
  • ArticleTags

をそれぞれINSERTするinsertArticle()を作成しましょう。

つぎはロールバックしたことを確認するためのテストケースです。

SampleComponentTest.php
public function testInsertArticle(){
    //2番目に失敗する空データを指定する。
    $tags    = ['cakephp3', '', 'phpunit', 'php'];

    $article = ['contents' => 'Hello, CakePHP3!'];

    //実行前のレコード数を取得
    $beforeCountArticle = $this->Articles->find()->count();
    $beforeCountTag = $this->Tags->find()->count();
    $beforeCountArticleTag = $this->ArticleTags->find()->count();

    //INSERT処理を実行
    $this->Sample->insertArticle($article, $tags);

    //実行後のレコード数を取得
    $afterCountArticle = $this->Articles->find()->count();
    $afterCountTag = $this->Tags->find()->count();
    $afterCountArticleTag = $this->ArticleTags->find()->count();

    //rollbackされていることを確認する。
    $this->assertEquals(0, $afterCountArticle - $beforeCountArticle);
    $this->assertEquals(0, $afterCountTag - $beforeCountTag);
    $this->assertEquals(0, $afterCountArticleTag - $beforeCountArticleTag);
}

ちょっと冗長ですね。
このテストは、それぞれのテーブルにレコードが追加されないことを確認しています。
つまり、ロールバックすることを期待しています。

さて、つぎはinsertArticle()です。

SampleComponent.php
public function insertArticle($article, $tags){
    $saveProcess = function() use($article, $tags){
        //articleのINSERT
        $articleEntity = $this->Articles->newEntity($article);
        $articleEntity = $this->Articles->save($articleEntity, ['atomic' => false]);
        if($articleEntity === false)
            return false;

        //tagとarticle_tagのINSERT
        foreach($tags as $tg){
            $tagEntity = $this->Tags->newEntity(['name' => $tg]);
            $tagEntity = $this->Tags->save($tagEntity,['atomic' => false]);

            if($tagEntity === false)
                return false;

            //article_tagのINSERT
            $articleTagEntity = $this->ArticleTags->newEntity([
                'article_id' => $articleEntity->id,
                'tag_id'     => $tagEntity->id
            ]);
            $articleTagEntity = $this->ArticleTags->save($articleTagEntity, ['atomic' => false]);

            if($articleTagEntity === false)
                return false;
        }//end foreach

        return true;
    };//end saveProcess

    $conn = $this->Articles->getConnection();
    return $conn->transactional($saveProcess);
}

3つのテーブルの操作が1つのトランザクションに含まれています。
では、テストしてみます。

PHPUnit 6.3.0 by Sebastian Bergmann and contributors.

...........                                                       11 / 11 (100%)

Time: 377 ms, Memory: 14.00MB

OK (11 tests, 41 assertions)

OKです。

補足ですが、
複数のテーブルを扱うとき、利用するConnectionはどのテーブルからgetConnection()してもOKです。
次のようなテストからわかります。

SampleComponentTest.php
public function testSameConnection(){
    $this->assertSame(
        $this->Tags->getConnection(),
        $this->Articles->getConnection()
    );
}

実行します。

PHPUnit 6.3.0 by Sebastian Bergmann and contributors.

............                                                      12 / 12 (100%)

Time: 388 ms, Memory: 14.00MB

OK (12 tests, 42 assertions)

ただし、テーブルによって取得できるConnectionが変わるという
特殊な設定をした場合はその限りではないかもしれません。m(_ _)m

別のトランザクションに含められるように改良

ちょっと前につくったinsertTags()を改良します。
今回は「save()ごとにcommitしないように」というのが課題でした。
前に作ったinsertTags()も、将来同じような問題に陥るかもしれません。
つまり、
insertTags()を別のトランザクションに含めたくなるかもしれないということです。

後続の処理が失敗したらinsertTags()ごとロールバックできるしくみを残しておきましょう。

改良です。

SampleComponent.php
public function insertTags($tags, $atomic=true){
    //saveを行う処理をクロージャにいれる。
    $saveProcess = function() use($tags){
        foreach($tags as $tg){
            $tagEntity = $this->Tags->newEntity(['name' => $tg]);

            //save()単位の原子性を無効に。
            $result = $this->Tags->save($tagEntity,['atomic' => false]);

            if(!$result)
                return false;
        }
        return true;
    };//end saveProcess

    //DBのコネクションを取得する。
    $conn = $this->Tags->getConnection();

    $result;
    //$atomic=trueであれば、成功時にcommitする
    if($atomic){
        $result = $conn->transactional($saveProcess);
    }else{
        $result = $saveProcess();
    }

    return $result;
}

引数にatomicをとっています。
atomicにfalseを指定すると、insertTags()をほかのトランザクションに組み込めます。

このままでもテストが通ります。
atomicはオプション引数だからです。

PHPUnit 6.3.0 by Sebastian Bergmann and contributors.

........                                                            8 / 8 (100%)

Time: 282 ms, Memory: 12.00MB

OK (8 tests, 36 assertions)

では、テストを修正します。
atomic=falseを指定し、途中で失敗してもrollbackしないことを確認しましょう。

SampleComponentTest.php
public function testInsertTags(){
    //3番目に失敗する空データを指定する。
    $tags = ['java', 'php', '', 'javascript'];

    $beforeCount = $this->Tags->find()->count();
    $this->Sample->insertTags($tags, false);
    $afterCount = $this->Tags->find()->count();

    //失敗するまでの2つの要素がINSERTされていることを確認する。
    $this->assertEquals(2, $afterCount - $beforeCount);
}

失敗してもrollbackしないことを期待しています。
なので、失敗するまでの2つの要素がINSERTされたままになっているかテストします。

実行します。

PHPUnit 6.3.0 by Sebastian Bergmann and contributors.

........                                                            8 / 8 (100%)

Time: 272 ms, Memory: 12.00MB

OK (8 tests, 36 assertions)

OKです。

トランザクションを複数メソッドに分割し、ユニットテストを書く

さて、今回はユニットテストを書きながら進めました。
ここに完了版のメリットが1つあります。
それは、「ユニットテストが書きやすい単位にトランザクションが分割できる」ことです。

トランザクションのプログラムは手続き的で、可読性が落ちやすいです。
それに、DBの正規化が進むと長くなりやすいです。
気が付いたら100行越えの大きいメソッドに。。。

長い!読みにくい!...大変ですね。

そうなったら不具合があっても、原因を特定するのは困難です。

この余談のテクニックを使えば、複雑なトランザクションをいくつかのメソッドに分割して、
ユニットテストが書けます。

No more monster methods!
こちらの本が参考になります。
レガシーコード改善ガイド(翔泳社公式サイト)

それでは、よいWeb開発を!!