商品の在庫数管理など一般的な個数管理に必要とされる、Eloquentのメソッドdecrementを紹介します。

準備

まず、商品テーブルの作成からです。migrationを作成します。

$ php artisan make:migration create_products_table --create=products

作成されたファイルを編集して、name, price, inventoryの項目を追加します。


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

class CreateProductsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('products', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->char('name', 100); // 商品名
            $table->decimal('price', 10, 0); // 価格
            $table->smallInteger('inventory'); // 在庫数
            $table->timestamps();
        });
    }

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

これをmigrateしてテーブルを作成します。

$ php artisan migrate

次に、tinkerを立ち上げて商品レコードを作成します。

$ php artisan tinker
Psy Shell v0.9.12 (PHP 7.2.16 — cli) by Justin Hileman
>>> use App\Product;
>>> Product::create(['name' => '商品', 'price' => 1000, 'inventory' => 10]);
=> App\Product {#3064
     name: "商品",
     price: 1000,
     inventory: 10,
     updated_at: "2020-03-27 19:35:37",
     created_at: "2020-03-27 19:35:37",
     id: 1,
   }

トランザクションで整合性を保持

商品の在庫から購入数分だけ差し引く処理として、まず考えられるのは以下のようになります(引き続きtinkerを使用します)。

>>> use App\Product;
>>> $product = Product::find(1);
=> App\Product {#3056
     id: 1,
     name: "商品",
     price: "1000",
     inventory: 10,
     created_at: "2020-03-27 19:35:37",
     updated_at: "2020-03-27 20:03:27",
   }
>>> $product->update(['inventory' => $product->inventory - 1]);
=> true
>>> $product->refresh();
=> App\Product {#3056
     id: 1,
     name: "商品",
     price: "1000",
     inventory: 9,
     created_at: "2020-03-27 19:35:37",
     updated_at: "2020-03-27 20:04:56",
   }
>>> 

つまり、商品をオブジェクトに読み込んで、現在の在庫数の属性(inventory)の値から、必要な個数(ここでは1個)を引いた値でDBレコードを更新(update)します。

上で実行されたSQL文を確認のために見てみましょう。レコード読み込み、更新、さらに読み込みと3つのSQL文となります。

>>> sql();
=> [
     [
       "query" => "select * from `products` where `products`.`id` = ? limit 1",
       "bindings" => [
         1,
       ],
       "time" => 14.83,
     ],
     [
       "query" => "update `products` set `inventory` = ?, `products`.`updated_at` = ? where `id` = ?",
       "bindings" => [
         9,
         "2020-03-27 20:04:56",
         1,
       ],
       "time" => 5.79,
     ],
     [
       "query" => "select * from `products` where `products`.`id` = ? limit 1",
       "bindings" => [
         1,
       ],
       "time" => 8.23,
     ],
   ]

このやり方、一見良さそうに見えますが、大きな問題があります。

ウェブのように同時に同じ処理が可能な環境において、レコードを読み込んだ時と書き込むときの間に在庫数がすでに更新されているかもしれないことです。その場合には在庫数は不正確になってしまいます。

これを防ぐには、上のプログラムをトランザクションで囲むことです。

DB::beginTransaction();

$product = Product::find(1);

$product->update(['inventory' => $product->inventory - 1]);

DB::commit();

トランザクションでは複数のSQL文の実行があたかも1つのように実行してくれるので、その間の作業の整合性を保持してれます。

よりシンプルに

先のトランザクションでも問題ないですが、以下のようEloquentdecrementのメソッドを使用して同様な処理も可能です。

>>> Product::where('id', '=', 1)->decrement('inventory', 1);
=> 1
>>> sql();
=> [
     [
       "query" => "update `products` set `inventory` = `inventory` - 1, `products`.`updated_at` = ? where `id` = ?",
       "bindings" => [
         "2020-03-27 20:18:48",
         1,
       ],
       "time" => 9.34,
     ],
   ]

こうなるとSQL文は1つなので、整合性保持のためのトランザクションの必要はなくなります。

さて、注意する必要があるのは、上の例を見て、以下でも同じことができるのでは?と思われることです。

>>> Product::find(1)->decrement('inventory', 1);
=> 1

しかし、このSQL文を見てみると、以下のように読み込みと更新の2つのSQL文になっています。これではトランザクションが必要となります。

>>> sql();
=> [
     [
       "query" => "select * from `products` where `products`.`id` = ? limit 1",
       "bindings" => [
         1,
       ],
       "time" => 12.63,
     ],
     [
       "query" => "update `products` set `inventory` = `inventory` - 1, `products`.`updated_at` = ? where `id` = ?",
       "bindings" => [
         "2020-03-27 20:22:48",
         1,
       ],
       "time" => 2.58,
     ],
   ]

在庫がゼロにならないようにするには

在庫数を減らすときに、在庫数がゼロにならないようにするには、チェックが必要です。これも以下のように実行するなら1つのSQL文で済みます。

>>> Product::where('id', '=', 1)->whereRaw('inventory - 1 >= 0')->decrement('inventory', 1);
=> 1
>>> sql();
=> [
     [
       "query" => "update `products` set `inventory` = `inventory` - 1, `products`.`updated_at` = ? where `id` = ? and inventory - 1 >= 0",
       "bindings" => [
         "2020-03-27 20:38:46",
         1,
       ],
       "time" => 10.09,
     ],
   ]

最初の行の実行で返り値が1となっていること気づいたと思いますが、そこでは更新されたレコードの数が返っています。

もし、実行の前の在庫数がすでにゼロなら、

>>> Product::where('id', '=', 1)->update(['inventory' => 0]);
=> 1
>>> Product::where('id', '=', 1)->whereRaw('inventory - 1 >= 0')->decrement('inventory', 1);
=> 0

0が返って更新はなかった、ということです。

By khino