チーズは何枚消えた?
Eloquent groupBy の基礎
問題
あなたは、ハンバーガー屋さんの在庫管理システムを開発しています。
「最新の確定在庫数」と、「その後の注文履歴ログ」から、「現時点の推定在庫数」を計算する API を実装してください。
推定在庫数 API の仕様:
GET /ingredient/nowにアクセスすると、最新の確定在庫数から、ログ集計した消費数を引いた推定在庫数が、食材ごとに表示されます- 表示は、JSON 形式で出力されます
Ingredient モデル及び確定在庫数の仕様:
ここでの Ingredient とは、食材の意味です。
- 在庫数の確認(棚卸し)のたびに、各食材の個数を入力し、保存します
- 棚卸し日時のカラム(
created_at)及び、以下の各食材カラムを持ちます- buns (パン)
- cheese (スライスチーズ)
- patty (ハンバーグ)
- pickles (ピクルス)
- ketchup (ケチャップソース)
- teriyaki (てりやきソース)
- 最新の確定在庫数は、
created_atが最も新しいものを取得します - 仕様上は CRUD システムですが、今回の問題では、Seeder で入力してしまってかまいません
Order モデル及びログ集計の仕様:
- 注文メニュー名(
menu)と、注文日時(created_at)をカラムに持ちます - あるメニューが注文されるたびに、その時刻とメニュー名が保存されます
- つまり、あるメニューが2個注文された場合、同じ時刻のデータが2行追加されます
- あるメニューが何個注文されたかは、
where及びgroupBy、selectRawなどを組み合わせて取得できます - 各メニューがどの食材を何個消費するかは、メニューのマスタデータ(後述)が与えられます
- 仕様上は CRUD システムですが、今回の問題では、Seeder で入力してしまってかまいません
メニューのマスタデータの仕様:
- そのハンバーガー屋さんのメニュー一覧が与えられます
- 各バーガーにつき、食材の必要数(buns が何枚、patty が何枚、などの情報)が JSON 形式で与えられます
学習上の緩和事項:
入門用の学習のため、以下は実装する必要はありません。
(もちろん、実装できそうならチャレンジしてみてください。)
- 認証・認可機能
Ingredient及びOrderモデルの入力画面実装(今回は、Seeder でデータ入力するだけで十分です)- 在庫の数え間違いや、オーダーミス、調理ミス、同時接続によるログのエラー等の対策
- 在庫がマイナスになった場合のバリデーション(在庫数がゼロにならない確定在庫数及びログデータが与えられます)
利用できるデータ:
ハンバーガー屋さんのデータ に、以下のデータを用意しています。
- ハンバーガー屋さんのメニューのマスターデータ
- ある1日の営業前時点の在庫数データ(Ingredient のデータ作成に利用してください)
- その日の営業中の注文履歴ログ(Order のデータ作成に利用してください)
- その日の営業後時点の在庫数データ(テストのアサーションに利用してください)
問題は、以下の手順で解いてください。
- Red:小さいテストを作成し、失敗を確認してください
- Green:テストを成功させてください
- Refactor:整理・整頓してください
- 必要に応じて、1から3を繰り返してください
ヒント
背景知識
- Query Builder の公式ドキュメント
- groupBy メソッド
- selectRow メソッド
便利なアサーションの例
アサーションの調べ方 も合わせてご覧ください。
今回は、以下を使うのではないかと思います。
-
assertSame($expected, $actual)(PHPUnit : $expected === $actual か)
-
assertJsonPath($path, $value)(HTTP テスト : JSON の $path が $value か)
解答例
続きを読む
実行環境:
- Laravel v13.12.0
- PHP 8.4
- PHPUnit
Red1: 各モデルにデータが入力されているか
テストを作成します。
bash:
php artisan make:test HamburgerTest
tests/Feature/HamburgerTest.php:
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use App\Models\Ingredient;
use App\Models\Order;
use Database\Seeders\IngredientSeeder;
use Database\Seeders\OrderSeeder;
class HamburgerTest extends TestCase
{
use RefreshDatabase;
public function test_Ingredientモデルにデータが入力されているか(): void
{
$this->seed([IngredientSeeder::class]);
$start = Ingredient::latest()->first();
$this->assertSame($start->buns, 800);
}
public function test_Orderモデルにデータが入力されているか(): void
{
$this->seed([OrderSeeder::class]);
$first = Order::oldest()->first();
$this->assertSame($first->menu, 'double-cheeseburger');
}
}
テストの失敗を確認します。
Green1
Ingredient, Order モデル、マイグレーション、シーダーを作成します。
bash:
php artisan make:model Ingredient -ms
php artisan make:model Order -ms
database/migrations/日時_create_ingredients_table.php:
public function up(): void
{
Schema::create('ingredients', function (Blueprint $table) {
$table->id();
$table->integer('buns');
$table->integer('cheese');
$table->integer('patty');
$table->integer('pickles');
$table->integer('ketchup');
$table->integer('teriyaki');
$table->timestamps();
});
}
database/migrations/日時_create_orders_table.php:
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->enum('menu', [
'hamburger',
'cheeseburger',
'teriyakiburger',
'double-burger',
'double-cheeseburger',
]);
$table->timestamps();
});
}
database/seeders/IngredientSeeder.php:
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Collection;
public function run(): void
{
$data = <<<END
buns,cheese,patty,pickles,ketchup,teriyaki
800,100,500,800,700,500
END;
[$keys, $vals] = explode(PHP_EOL, $data);
$keys = str_getcsv($keys, escape: '\\');
$vals = str_getcsv($vals, escape: '\\');
$map = array_combine($keys, $vals);
$map['created_at'] = '2026-05-29T08:00:00+09:00';
$map['updated_at'] = '2026-05-29T08:00:00+09:00';
DB::table('ingredients')->insert($map);
}
database/seeders/OrderSeeder.php:
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Collection;
public function run(): void
{
$data = <<<END
2026-05-29T09:03:53+09:00 double-cheeseburger
(中略)
2026-05-29T18:44:32+09:00 double-burger
END;
$data = new Collection(explode(PHP_EOL, $data))
->map(function(string $dat):array {
[$datetime, $menu] = explode(' ', $dat);
$result = [
'created_at' => $datetime,
'updated_at' => $datetime,
'menu' => $menu,
];
return $result;
});
DB::table('orders')->insert($data->toArray());
}
テストの成功を確認します。
Refactor1
特にありません。
Red2: データを集計し、チーズの枚数が合うか
テストを追加します。
tests/Feature/HamburgerTest.php:
use App\Service\IngredientCountService;
public function test_営業終了後のチーズの枚数が合うか(): void
{
$this->seed([
IngredientSeeder::class,
OrderSeeder::class,
]);
$ics = app(IngredientCountService::class);
$this->assertSame(56, $ics->now()['cheese']);
}
Green2
サービスを作成します。(名前はサービスでなくても構いませんが、慣例的にサービスを使うそうです)
bash:
mkdir ./app/Service/
touch ./app/Service/IngredientCountService.php
app/Service/IngredientCountService.php:
<?php
namespace App\Service;
use App\Models\Ingredient;
use App\Models\Order;
class IngredientCountService
{
private array $master;
public function __construct() {
$json = <<<END
[
{
"uri": "hamburger",
"name": "ハンバーガー",
"ingredients": {
"buns": 2,
"patty": 1,
"pickles": 2,
"ketchup": 1
}
},
(中略。以下同様にデータを挿入)
]
END;
$this->master = [];
$master_array = json_decode($json, true);
foreach($master_array as $data) {
$this->master[$data['uri']] = $data['ingredients'];
}
}
public function now():array {
$latest_ingredient = Ingredient::latest()->first();
$result = $latest_ingredient->toArray();
$latest_datetime = $latest_ingredient->created_at;
$orders = Order::where('created_at', '>', $latest_datetime)
->get();
foreach($orders as $order) {
$menu = $order->menu;
foreach($this->master[$menu] as $ingredient => $ing_count) {
$result[$ingredient] -= $ing_count;
}
}
return $result;
}
}
テストの成功を確認します。
Refactor2
すべてのデータをオブジェクトとして取ってきて、ループを回していますが、SQLでメニューごとの集計をすることが可能です。
app/Service/IngredientCountService.php:
public function now():array {
$latest_ingredient = Ingredient::latest()->first();
$result = $latest_ingredient->toArray();
$latest_datetime = $latest_ingredient->created_at;
$order_counts = Order::where('created_at', '>', $latest_datetime)
->groupBy('menu')
->selectRaw('menu, COUNT(*) AS cnt')
->get();
foreach($order_counts as $order_count) {
$menu = $order_count['menu'];
$menu_count = $order_count['cnt'];
foreach($this->master[$menu] as $ingredient => $ing_count) {
$result[$ingredient] -= $ing_count * $menu_count;
}
}
return $result;
}
Red3: 適切に JSON データを返すか
テストを追加します。
tests/Feature/HamburgerTest.php:
public function test_apiの確認(): void
{
$this->seed([
IngredientSeeder::class,
OrderSeeder::class,
]);
$response = $this->get('/ingredient/now');
$response->assertJsonPath('cheese', 56);
}
Green3
ルーティング、コントローラーを作成します。
bash:
php artisan make:controller IngredientController
routes/web.php:
Route::get('/ingredient/now', [IngredientController::class, 'now']);
app/Http/Controllers/IngredientController.php:
<?php
namespace App\Http\Controllers;
use App\Service\IngredientCountService;
class IngredientController extends Controller
{
public function now(IngredientCountService $ics):array {
return $ics->now();
}
}
Refactor3
特にありません。
解説
続きを読む
groupBy の使い方
SQL には、集計関数が用意されています。
今回のように、メニューごとのデータ数を数えることが可能です。
(他に、数値を合算したり、平均したりもできます)
$order_counts = Order::where('created_at', '>', $latest_datetime)
->groupBy('menu')
->selectRaw('menu, COUNT(*) AS cnt')
->get();
foreach($order_counts as $order_count) {...}
SQL は集合論なので、グループに分けるような処理の表現は得意です。
一方、 ORM はオブジェクト指向なので、メソッドのつながり(メソッドチェーン)で表現する必要があります。
groupBy に関しては、先に SQL の処理(GROUP BY 句)を調べてから、それを素直にメソッドで表現したものとして読むと、すっきり分かりやすくなる気がしました。
Eloquent ビルダーと DB ファサードのクエリビルダー
Eloquent ビルダーによる処理の結果は、戻り値がそのモデルのインスタンスのコレクション型になります。
たとえば今回の場合、戻り値は App\Models\Order (本来は個々の注文履歴)のコレクションになります。
欲しい情報は、メニューごとの集計結果なので、型としての意味合いは間違っていますし、一部の Eloquent メソッドは動きません。
集計結果を取得するという目的は達成しているので、これでも問題はないのですが、もし型が気持ち悪いと感じる方は、 use Illuminate\Support\Facades\DB から DBファサードのクエリビルダを使う方が、素直かもしれません。
クエリビルダの戻り値は、標準クラスなので、 Order 型ではなくなるからです。
$order_counts = DB::table('orders')
->where('created_at', '>', $latest_datetime)
->groupBy('menu')
->selectRaw('menu, COUNT(*) AS cnt')
->get();
foreach($order_counts as $order_count) {
dump(get_class($order_count)); // stdClass
}
できる限り Eloquent を使うべきか、それとも DB ファサードと混合しても良いかは、各実装チームのルールによると思います。