【PHP】PDO::lastInsertIdが期待した動作と違ったのとLaravel上で行える対策

 この記事の前提環境はPHP7.3, Laravel6.13, MySQL8.0です。それぞれの実装に踏み込んだ話なのでバージョン違いで記事と挙動が違うかもしれません。
 PDO::lastInsertIdは最後に挿入された行の ID を返す関数です。LaravelでEloquent(データベースを元にしたモデル)を保存すると自動で保存結果で割り当てられた主キーの値が付与されるのもこの関数のおかげです。
PHP: PDO::lastInsertId – Manual

// save()の動作
$user = new User($request->validated());
var_dump($user->id); // NULL
$user->save(); // saveをすると
var_dump($user->id); // int(1)

// createメソッドはsaveメソッドのラッピングなので
// createメソッドでも同じことが起きます
// Laravel6の中身
// src/Illuminate/Database/Eloquent/Model.php
    /**
     * Eloquentモデルが自身をINSERT する処理の場合、
     * オートインクリメントをしないと宣言しない限り必ずこのメソッドを通ります。
     * 
     * 与えられた $attributes を Insert して ID をモデルにセット.
     *
     * @param  \Illuminate\Database\Eloquent\Builder  $query
     * @param  array  $attributes
     * @return void
     */
    protected function insertAndSetId(Builder $query, $attributes)
    {
        $id = $query->insertGetId($attributes, $keyName = $this->getKeyName());

        $this->setAttribute($keyName, $id);
    }
// src/Illuminate/Database/Query/Builder.php
    /**
     * 新しいレコードを INSERT して主キーの値を取得.
     *
     * @param  array  $values
     * @param  string|null  $sequence
     * @return int
     */
    public function insertGetId(array $values, $sequence = null)
    {
        $sql = $this->grammar->compileInsertGetId($this, $values, $sequence);

        $values = $this->cleanBindings($values);

        return $this->processor->processInsertGetId($this, $sql, $values, $sequence);
    }
// src/Illuminate/Database/Query/Processors/Processor.php
    /**
     * INSERT と ID の取得を実行.
     *
     * @param  \Illuminate\Database\Query\Builder  $query
     * @param  string  $sql
     * @param  array  $values
     * @param  string|null  $sequence
     * @return int
     */
    public function processInsertGetId(Builder $query, $sql, $values, $sequence = null)
    {
        $query->getConnection()->insert($sql, $values);

        $id = $query->getConnection()->getPdo()->lastInsertId($sequence);

        return is_numeric($id) ? (int) $id : $id;
    }

 よくある主キーがオートインクリメント付きの符号なし整数型カラムの場合、これはうまく働きます。しかし実際の案件ではやんごとなき事情によって主キーが実質インクリメントをするユニークな文字列になることもあります。例えば、A0001, A0002, C0001, A0003 と主キーに値が割り振られていくような処理です。この時、残念なことにPDO::lastInsertIdはうまく働いてくれません。PDO::lastInsertIdを呼び出す動作はむしろ邪魔になります。

// save()の動作
$user = new User($request->validated());
$user->id = (new UserIdSequenceService())->getNewId(); // 'A0001'を取得
$user->save(); // saveをすると
var_dump($user->id); // int(0)

// createメソッドはsaveメソッドのラッピングなので
// createメソッドでも同じことが起きます

 ’A0001’が0に上書きされました。PDO::lastInsertIdが期待しない動作(’A0001’でなく0を返す)を実行し、それがModel::insertAndSetIdでプロパティidに代入されたためこのようなことが起きました。
 PDO::lastInsertIdが期待しない動作をした原因の説明になります。PDO::lastInsertIdのメソッドはテストを見る限り(さすがに実装ソースはパス)SQLのLAST_INSERT_ID関数をPHP本体とつないでいるだけの様です。
php-src/pdo_mysql_last_insert_id.phpt at master · php/php-src
 MySQL8.0のLAST_INSERT_ID関数の詳しい説明は次になります。
MySQL :: MySQL 8.0 Reference Manual :: 12.15 Information Functions
 同ページの目録のざっくりした説明にには”Value of the AUTOINCREMENT column for the last INSERT”とあります。これを訳すと”最後のINSERTのAUTOINCREMENTカラムの値”です。そんなわけで大本の時点で”最後にINSERTしたIDの値を取得”でなく”最後にINSERTした時にインクリメントされたIDの値を取得”という動作になっており、オートインクリメントできない型(文字列とか)の主キーをINSERTした時はPDO::lastInsertIdの返り値が0になります。関数名に比べてやっていることが限定的ですがPDO::lastInsertIdの挙動がオートインクリメント限定なのは”そういうものとして作られたから”というのが原因でした。

 この問題をLaravelの中で解決するにはオートインクリメントをプログラム上で実装し、IDを代入、IDの上書きを許さない、とすることです。
 オートインクリメントの実装は次の記事が詳しいです。
Laravel で連番/シーケンスを作るテクニック (採番テーブルの作り方) – Qiita
 この記事のにあるサービスクラスをちょいと案件に特化させると楽にトランザクションやID衝突が考慮済みのいい感じのオートインクリメントになります。

/**
 * @see <a href="https://qiita.com/kd9951/items/5b2f55e13a4da0f33b6b#%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9%E3%82%AF%E3%83%A9%E3%82%B9-sequenceservice">Laravel で連番/シーケンスを作るテクニック (採番テーブルの作り方) - Qiita#サービスクラス SequenceService</a>
 * 
 */
 namespace App\Services;

use App\Sequence;

class SequenceService
{
    /**
     * 例)受注番号を取得する
     * @return mixed
     */
    public function getNewOrderNo(integer $store_id)
    {
        $value = $this->getNewValueAndCommit('orders:'.$store_id);
// 整形をして整形結果を返す。次なら A + 0埋めで5桁になった連番
        return 'A'.str_pad($value, 5, '0', STR_PAD_LEFT);
    }

    /**
     * 単純に新しい番号を取得する
     *
     * @param  string    $key      同じキー名を与えると前回の続きの値を返す
     * @return int|float $sequence 基本はintだがPHPの限界値を超えるとfloatになる
     */
    protected function getNewValueAndCommit(string $key)
    {
        // config/sequence.php という設定ファイルを作って初期値を用意しておける。
        // なければ 1 からスタート
        $default = config("sequence.default.$key", 1);

        $sequence = Sequence::lockForUpdate()->find($key);
        if( !$sequence ){
            $sequence = new Sequence;
            $sequence->key = $key;
        }

        if (($sequence->sequence ?? 0) < $default) {
            $sequence->sequence = $default;
        } else {
            $sequence->sequence = ($sequence->sequence??0) + 1;
        }
        $sequence->save();

        return $sequence->sequence;
    }

}

IDを代入、IDの上書きを許さないは次のようにEloquentに記述するのがいいです。

class Product extends Model
{
// このプロパティによってモデルのINSERT時にはオートインクリメントを用いないと宣言
// オートインクリメントを用いないと宣言すると先述のID代入メソッドを動かさない
    public $incrementing = false;

    /**
     * 初期処理。デフォルト値を設定したりなどをする。
     * @return void
     */
    public static function boot()
    {
        parent::boot();
// createingはINSERT時のみに発火するイベントを定義する。イベントはクロージャで表現される。
// savingと違ってUPDATE時には動作しないのが特徴。もしsavingでイベントを定義すると不意に採番が飛んでいく
        self::creating(self::getCreatingDefaultValueClosure());
    }

    /**
     * デフォルト値でnullと未定義を穴埋めするクロージャを返す。レコード作成時限定で適用する版
     * @return Closure
     */
    private static function getCreatingDefaultValueClosure(): callable
    {
        return static function (Product $product) {
// creatingで用いるクロージャの中で自動採番サービスから新たなIDを取得する。
            $product->id = $product->id ?? (new SequenceService())->getNewOrderNo($user->getStoreId());
        };
    }
}
>株式会社シーポイントラボ

株式会社シーポイントラボ

TEL:053-543-9889
営業時間:9:00~18:00(月〜金)
住所:〒432-8003
   静岡県浜松市中央区和地山3-1-7
   浜松イノベーションキューブ 315
※ご来社の際はインターホンで「316」をお呼びください

CTR IMG