Laravel(Eloquent)のsaveメソッドを使ったらMySQLのtimestamp型で謎な挙動が発生した話

こんにちは、エンジニアの @akase244 です。

MySQLのtimestamp型をLaravelで利用した際にちょっとハマってしまったので、今回はその件について手順を再現しながらまとめてみようと思います。

検証用テーブルを作成するための準備

今回ハマったポイントを再現するために、LaravelのCLIを使ってモデルとマイグレーションファイルを作成します。

$ php artisan make:model Member --migration
Model created successfully.
Created Migration: 2019_01_01_234515_create_members_table

作成したマイグレーションを以下のように編集します。
会員情報を保存するための「members」というテーブル名で名前(name)、メールアドレス(email)、 有効期限(expired_at)をカラムとして持つようにします。
なお、今回の検証のミソは以下の2点です。

  • expired_attimestamp型 にする。
  • マイグレーションファイルで nullable() と記述しない場合、Null不許可のカラムとなる。
$ cat database/migrations/2019_01_01_234515_create_members_table.php
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateMembersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('members', function (Blueprint $table) {
            $table->increments('id');
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('expired_at');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('members');
    }
}

次に作成したモデルを以下のように修正します。
データ更新時の更新対象カラムのホワイトリスト($fillable)に名前(name)、メールアドレス(email)、 有効期限(expired_at)を指定します。
また、有効期限(expired_at)は扱いやすいように(Carbonインスタンスでやりとりできるように) $dates プロパティに指定しておきます。

$ cat app/Models/Member.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Member extends Model
{
    protected $fillable = [
        'name',
        'email',
        'expired_at',
    ];

    protected $dates = [
        'expired_at',
    ];
}

マイグレーションを実行してテーブルを作成

以下の手順でマイグレーションを実行します。

$ php artisan migrate
Migrating: 2019_01_01_234515_create_members_table
Migrated:  2019_01_01_234515_create_members_table

DB(MySQL)に接続して、作成されたテーブルの内容を確認します。
有効期限(expired_at)がtimestamp型で作成され、Null不許可のため自動的に on update CURRENT_TIMESTAMP の指定がついていることが確認できます。
参考URL: TIMESTAMP および DATETIME の自動初期化および更新機能

$ mysql -u ユーザー名 -p DB名
mysql> desc members;
+------------+------------------+------+-----+-------------------+-----------------------------+
| Field      | Type             | Null | Key | Default           | Extra                       |
+------------+------------------+------+-----+-------------------+-----------------------------+
| id         | int(10) unsigned | NO   | PRI | NULL              | auto_increment              |
| name       | varchar(255)     | NO   |     | NULL              |                             |
| email      | varchar(255)     | NO   | UNI | NULL              |                             |
| expired_at | timestamp        | NO   |     | CURRENT_TIMESTAMP | on update CURRENT_TIMESTAMP |
| created_at | timestamp        | YES  |     | NULL              |                             |
| updated_at | timestamp        | YES  |     | NULL              |                             |
+------------+------------------+------+-----+-------------------+-----------------------------+

テストデータを作成

検証用のテーブルが作成できましたので、ここからはコマンドラインツールのtinkerを使って説明を進めていきます。
tinkerが起動したら、モデルのcreateメソッドを使ってテストデータを作成します。
ここではnameに「テスト太郎」、emailに「test_tarou@example.com」、expired_atに「現在日時の6ヶ月後」を指定しています。

$ php artisan tinker
Psy Shell v0.9.7 (PHP 7.2.9 — cli) by Justin Hileman
>>> \App\Models\Member::create([
...   'name' => 'テスト太郎',
...   'email' => 'test_tarou@example.com',
...   'expired_at' => \Carbon\Carbon::now()->addMonth(6),
... ]);
=> App\Models\Member {#2953
     name: "テスト太郎",
     email: "test_tarou@example.com",
     expired_at: "2019-07-02 17:50:33",
     updated_at: "2019-01-02 17:50:33",
     created_at: "2019-01-02 17:50:33",
     id: 1,
   }

モデルのfindメソッドでデータが登録されているか確認してみます。

>>> \App\Models\Member::find(1);
=> App\Models\Member {#2965
     id: 1,
     name: "テスト太郎",
     email: "test_tarou@example.com",
     expired_at: "2019-07-02 17:50:33",
     created_at: "2019-01-02 17:50:33",
     updated_at: "2019-01-02 17:50:33",
   }

テストデータを更新

Laravel(Eloquent)のsaveメソッドは更新処理の場合に同じ値で更新しようとすると更新処理が実行されません。ではその動きを確認してみましょう。

まず変数 $member に先ほど作成したデータを格納します。

>>> $member = \App\Models\Member::find(1);
=> App\Models\Member {#2961
     id: 1,
     name: "テスト太郎",
     email: "test_tarou@example.com",
     expired_at: "2019-07-02 17:50:33",
     created_at: "2019-01-02 17:50:33",
     updated_at: "2019-01-02 17:50:33",
   }

モデルのsaveメソッドを使って登録時と同じ内容で更新してみます。

>>> $member->fill([
...   'name' => 'テスト太郎',
...   'email' => 'test_tarou@example.com',
...   'expired_at' => \Carbon\Carbon::parse('2019-07-02 17:50:33'),
... ])->save();
=> true

データを確認してみると、 updated_at に変化がないので更新されていないことがわかります。

>>> \App\Models\Member::find(1);
=> App\Models\Member {#2969
     id: 1,
     name: "テスト太郎",
     email: "test_tarou@example.com",
     expired_at: "2019-07-02 17:50:33",
     created_at: "2019-01-02 17:50:33",
     updated_at: "2019-01-02 17:50:33",
   }

なぜ更新されないのかはLaravelのソースを確認してみるとわかります。(今回はv5.6.38のタグが打たれているソースで確認しました)

saveメソッドが呼ばれると、当然ですがModel.phpのsaveメソッドにやってきます。

    /**
     * Save the model to the database.
     *
     * @param  array  $options
     * @return bool
     */
    public function save(array $options = [])
    {
        $query = $this->newModelQuery();
        ・
        ・
        ・

今回は既存レコードに対しての更新処理なので、saveメソッド内の$this->existsの判定がtrueとなり、HasAttributes.phpのisDirtyメソッドの呼び出しを行います。

        // If the model already exists in the database we can just update our record
        // that is already in this database using the current IDs in this "where"
        // clause to only update this model. Otherwise, we'll just insert them.
        if ($this->exists) {
            $saved = $this->isDirty() ?
                        $this->performUpdate($query) : true;
        }

isDirtyメソッドではgetDirtyメソッドの呼び出しを行います。

    public function isDirty($attributes = null)
    {
        return $this->hasChanges(
            $this->getDirty(), is_array($attributes) ? $attributes : func_get_args()
        );
    }

getDirtyメソッドからさらにoriginalIsEquivalentメソッドが呼び出され、この中で既存のレコードの値とこれから更新しようとしている値の比較を行い、一致しないカラムが存在する場合に限りsave内のperformUpdateメソッドで更新処理が実行されます。

    public function getDirty()
    {
        $dirty = [];
        foreach ($this->getAttributes() as $key => $value) {
            if (! $this->originalIsEquivalent($key, $value)) {
                $dirty[$key] = $value;
            }
        }
        return $dirty;
    }

つまり、今回の更新処理ではisDirtyメソッドがfalseとなり、performUpdateメソッドが呼ばれずに処理が終了しているというわけです。

参考URL: Eloquentのメソッド saveとupdateは処理が異なる

ハマった更新処理を再現

saveメソッドの動きがわかったところでいよいよ本題です。
次はメールアドレスのみ値を変更して、名前と有効期限は登録時の内容で更新処理を実行してみます。

>>> $member->fill([
...   'name' => 'テスト太郎',
...   'email' => 'test_tarou@example.jp',
...   'expired_at' => \Carbon\Carbon::parse('2019-07-02 17:50:33'),
... ])->save();
=> true

データを確認してみると、
emailが「test_tarou@example.com」から「test_tarou@example.jp」に、
updated_atが「2019-01-02 17:50:33」から「2019-01-02 18:38:34」に更新されているのは当然ですが、
なぜかexpired_atが「2019-07-02 17:50:33」から「2019-01-02 09:38:34」に更新されてしまっています!!

>>> \App\Models\Member::find(1);                                                                                    
=> App\Models\Member {#2962
     id: 1,
     name: "テスト太郎",
     email: "test_tarou@example.jp",
     expired_at: "2019-01-02 09:38:34",
     created_at: "2019-01-02 17:50:33",
     updated_at: "2019-01-02 18:38:34",
   }

いったい何が起きたのか

今回はメールアドレスのみ値を変更しようとしているので、さきほど説明したgetDirtyメソッドでは以下のような配列が返却されます。

[
  'email' => 'test_tarou@example.jp',
  'updated_at' => '2019-01-02 18:38:34',
]

tinkerでsaveメソッドの前に DB::enableQueryLog(); を実行し、saveメソッド実行直後に DB::getQueryLog(); を行うと、saveメソッド内で発行されたSQLが取得できます。これを見てみるとgetDirtyメソッドの戻り値を元に更新処理をしていることがわかります。

>>> DB::enableQueryLog();
・
・
>>> saveの処理
・
・
>>> DB::getQueryLog();
=> [
     [
       "query" => "select * from `members` where `members`.`id` = ? limit 1",
       "bindings" => [
         1,
       ],
       "time" => 15.36,
     ],
     [
       "query" => "update `members` set `email` = ?, `updated_at` = ? where `id` = ?",
       "bindings" => [
         "test_tarou@example.jp",
         "2019-01-02 18:38:34",
         1,
       ],
       "time" => 5.71,
     ],
   ]

では、なぜexpired_atが「2019-07-02 17:50:33」から「2019-01-02 09:38:34」に更新されてしまったのか?
もうお気づきですね?
そうです、expired_atにはテーブル作成時にon update CURRENT_TIMESTAMPの指定がついていました。
MySQLのドキュメントには「自動更新されたカラムは、行内のほかのカラムの値がその現在の値から変更されると、現在のタイムスタンプに自動的に更新されます。」と説明されています。
expired_atを既存レコードと同じ値で更新しようとすると、Laravel側の処理によって更新対象とならないので、この影響でMySQLが自動的に現在日時に更新しているというわけです。しかも、Laravel側ではtimezoneを「Asia/Tokyo」で設定していますが、MySQL側ではUTCになっていたため9時間ズレるというおまけつき。

回避方法は?

MySQLのドキュメントには「ほかのカラムが変更したときに、自動更新したカラムが更新しないようにするには、明示的にこれを現在の値に設定します。ほかのカラムが変更しない場合でも、自動更新カラムを更新するには、明示的にこれを必要な値に設定します」との記載がありますが、Laravelの仕様を考えるとデータ不整合の発生は避けられなさそうです。
しかし、Null不許可の状態でon update CURRENT_TIMESTAMPの指定を外すのはできなさそう。
ではどうするのかと調べていたところ「Laravel & MySQL auto-adding “on update current_timestamp()” to timestamp fields」という記事を見つけました。
この中に「The fix in Laravel to avoid this behaviour is to add nullable() to the migration, like this.」と、なんとも微妙な解決策が書いてありました。
つまり、Null許可のカラムに変更してNullが入らないようにアプリケーション側で制御するという方法しかなさそうです。

まとめ

回避方法がわかったので、expired_atをNull許可に変更すべくこのようなマイグレーションファイルを作成して実行してみると。。。

$table->timestamp('expired_at')->nullable()->change();

こんな感じにエラーが発生してしまいます。

$ php artisan migrate

Doctrine\DBAL\DBALException  : Unknown column type "timestamp" requested. Any Doctrine type that you use has to be registered with \Doctrine\DBAL\Types\Type::addType(). You can get a list of all the known types with \Doctrine\DBAL\Types\Type::getTypesMap(). If this error occurs during database introspection then you might have forgotten to register all database types for a Doctrine Type. Use AbstractPlatform#registerDoctrineTypeMapping() or have your custom types implement Type#getMappedDatabaseTypes(). If the type name is empty you might have a problem with the cache or forgot some mapping information.

これについてはLaravelのマイグレーションに関するドキュメント内に以下のように説明されています。
つまり、changeメソッドで変更できる型の種類が決まっており、その中にtimestamp型が含まれないということみたいです。

Only the following column types can be "changed": bigInteger, binary, boolean, date, dateTime, dateTimeTz, decimal, integer, json, longText, mediumText, smallInteger, string, text, time, unsignedBigInteger, unsignedInteger and unsignedSmallInteger.

こうなると、

  1. テーブルを作り直してよいのであれば、 php artisan migrate:rollback で一旦テーブルをドロップ(もちろんロールバックされる対象が今回作成した1ファイルのみの場合に限りますが)しておいて、マイグレーションファイルを $table->timestamp('expired_at')->nullable(); のように修正して再度テーブルを作成する。
  2. alter table members modify expired_at timestamp null; のように直接DDLを実行する。
  3. timestamp型以外の日付型に変更する。

といったいずれかの対策を行う必要がありそうですね。

さて、長くなってしまいましたがLaravelで日付を扱う場合にtimestamp型を利用すると、このような罠にハマってしまうことになるかもしれませんので、結論としてはdatetime型の利用を強くオススメします。