- DBに関する知見
- モデルとマイグレーションとリソースコントローラ生成
- Laravelにないバリデーションを作りたい
- URLのパラメータのバリデーション
- データの整合性
- リソースコントローラ
- 何故か覚えられないファイルストレージ
- HTTPエラー一覧
- Sail(とDocker)
- Request
- テスト
- 雑記:Laravelのここが古い
DBに関する知見
なぜか忘れる
- 4つのテーブルをいい感じに操作することを目指さない。中間テーブルを含めて3つまでが最大値だと考える。
- 多対多を1対多的に扱うのなら、「リレーション名()->where('テーブル名.id', $id)」とするといい。
- 多対多の関係で、片方のDBのカラムのみを参照したいのなら、mapなどを使って「$モデル->{任意のキー名} = 計算結果」とした方が無難。
リレーション
関係 | table_a | table_b | table_c | リレーション(table_aからtable_b) | |
---|---|---|---|---|---|
1対1 | id | table_a_id | hasOne | ||
1対1 | table_b_id | id | belongsTo | ||
1対多 | id | table_a_id |
hasMany( TableB::class, 'table_a_id', 'id' ) |
||
多対多 | id |
table_a_id table_c_id |
id |
belongsToMany( TableC::class ) belongsToMany( TableC::class, 'table_b', 'table_b.table_a_id', 'table_b.table_c_id' ) |
|
〜経由で1つへ紐づく | id | table_a_id | table_b_id |
hasOneThrough(TableC, TableB) hasOneThrough( TableC::class, TableB::class, 'table_b.table_a_id', 'table_c.table_b_id', 'table_a.id', 'table_b.id') |
マイグレーションで「SQLSTATE[01000]: Warning: 1265 Data truncated for column」
こういう状況でも発生する。
実データより小さい型にしようとすると出るエラーなので、レコードを削除すれば通る。
複数のモデルを使ってwhereしたい
リレーション先のデータだけを絞り込む
use App\Models\User;
$user = User::find(1);
$user->posts()->where('active', 1)->get();
括弧の有無で挙動が大きく変わる。
$user->posts->whereはPost::whereになると思う(ユーザIDで絞り込みしてくれない)
User::with('posts')している場合で、postsがCollectionインスタンスならユーザIDで絞り込みが可能
リレーション先のカラムでリレーション元を絞り込みをしたい
whereRelation()を使う。
リレーション先の存在の有無を確認したい場合はhas()
$posts = Post::whereRelation(
'comments', 'created_at', '>=', now()->subHour()
)->get();
whereHas('リレーションのメソッド名')
use Illuminate\Database\Eloquent\Builder;
// code%と似ている単語を含むコメントが少なくとも1つある投稿を取得
$posts = Post::whereHas('comments', function (Builder $query) {
$query->where('content', 'like', 'code%');
})->get();
// code%と似ている単語を含むコメントが10件以上ある投稿を取得
$posts = Post::whereHas('comments', function (Builder $query) {
$query->where('content', 'like', 'code%');
}, '>=', 10)->get();
リレーション先のデータだけ絞り込みたい
with()で絞り込む
use App\Models\User;
$users = User::with(['posts' => function ($query) {
$query->where('title', 'like', '%code%');
}])->get();
Eloquentでシリアライズする時、一時的にカラム名を変えたり非表示にしたりしたい
makeVisible()/makeHidden() vs select()
HogeModel->select('カラム', 'カラム as hoge')->get();
「SELECT カラム, カラム AS hoge FROM hoge_models」と同等。
Eloquentはクエリビルダのラッパーみたいなものっぽい。
内部的なデータの取得も減っているのでシリアライズ時もカラムの量が減る。
クエリビルダから見ると正しいが、Model側から見ると不可解な挙動になったような気がするが…思い出したら追記したい。
HogeModel::get()->makeVisible('カラム');
makeVisible()/makeHidden()はシリアライズ時の表示/非表示の制御なので好きに変更する。
内部的には「SELECT * FROM hoge_models」なのでレコードのデータはすべて持っている。
$hoge = HogeModel::get();
//1対1の場合
$hoge->relation名->makeVisible(['カラム名']);
//1対多の場合
$hoge->relation名->each(function($m){
$m->setVisible(['カラム名']);
});
makeVisibleは「リレーション.カラム名」のような書き方はできない。
リレーションで取得済みのモデルに対して、個別にmakeVisibleを行う。
(new HogeModel())->{'適当なメンバ変数名'} = '';
こういう時はシリアライズ時にどうなる…?
Eloquentでgroup byがしたい
やめておいた方がいい。
Model::select('COUNT(カラム)')->groupBy('カラム')->get()
これで取得できるが、Modelの型でgroupByの結果を格納したオブジェクトが出来る。
直感的ではなく、型が半端に同じなので事故の元。
DBファサードを使った方が無難だと思う。
DB::table('テーブル名')->select(DB::raw('COUNT(カラム)'))->groupBy('カラム')->get()
こうするとstdClassのようなオブジェクトに値を入れた状態でデータを返す。
updated_atのタイミングで他のカラムも更新したい
- Modelの$dispatchesEventsを使ってイベント経由で更新する
- メリット:Laravelの内部挙動を気にする必要がない
- デメリット:クエリを2回発行する
- ModelのupdateTimestampsメソッドをオーバーライドする
- 正確にはHasTimestampsトレイトのメソッド
- メリット:クエリ発行が1回で済む
- デメリット:publicなメソッドとはいえドキュメントに明記されていないので、Laravelのバージョンアップで使えなくなるかもしれない
toArray()、toJson()の日付のフォーマットをどうにかしたい
DBの値をCarbonにするとISO 8601形式の日付になる。
モデルのメソッドのserializeDateをオーバーライドすると一括で好きなフォーマットに出来る。
/**
* 配列/JSONシリアル化の日付の準備
*
* @param \DateTimeInterface $date
* @return string
*/
protected function serializeDate(DateTimeInterface $date)
{
return $date->format('Y-m-d');
}
LaravelがサポートしていないDBの型・関数を扱うには
static::addGlobalScope('addSelect', function (Builder $builder) {
$builder
->addSelect('*')
->addSelect(DB::raw('1 AS one'))
->addSelect(DB::raw('2 AS two'));
});
Eloquentのグローバルスコープに書く場合。
MySQLのgeometryのような、SQL内で関数を使って値を取得する必要がある場合に使える。
Model::select('使うカラム名', 'カラム名', ...)->selectRaw('関数を伴う値 as カラム名')
この方法を使うと初めからテーブルのカラムが存在するかの如くデータを扱える。
ローカルスコープに設定しておくと楽が出来る。
下記はDBへ何度もSQLを発行しかねないデメリットが大きいので使わない方がいい
MySQLのgeometryのように、データをinsertする時にST_GeomFromTextの戻り値を指定しないといけないものを指す。
クエリ発行数が増えるが、ミューテタを使ってSELECT 関数名(?)でinsert時に入れるデータを求めると良い。
他に良い方法があれば教えて欲しい…。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class User extends Model
{
protected function firstName(): Attribute
{
return Attribute::make(
//SELECTでnameカラムを持つレコードを1行作ったので
//[0]->nameで関数の結果を取り出す
set: fn ($value) => DB::select('SELECT 関数名(?) as name',[$value])[0]->name
);
}
}
モデルとマイグレーションとリソースコントローラ生成
artisan make:model モデル名 -mcr
モデル名なので単数形。DBの名前はデフォルトでは複数形になる。
モデルとマイグレーションとリソースコントローラを生成。
テストをするならfを加えてファクトリもどうぞ。
モデル作成後のTODO
- マイグレーションファイルの編集・実行
- unsignedBigIntegerが外部キーと同じ型
- マイグレーション時にsoftDeletesを指定した場合、Modelにuse SoftDeletesを追加する
- protected $fillableにカラム名を設定する
- 必要があればグローバルスコープを設定
- ファクトリを生成した場合、ファクトリ内のdefinition()でダミーデータを設定する
- artisan make:test テスト名Test
- テストにuse RefreshDatabaseを追加
Laravelにないバリデーションを作りたい
バリデーションを配列にして、関数を使う。
$request->validate([
function($attribute, $value, $fail){
//バリデーションエラーの時は以下を実行する
return $fail('エラーメッセージ');
}
]);
Laravelでクロージャを使った自作バリデーションの作成 - Qiita
URLのパラメータのバリデーション
Route::get('/user/{name}', function ($name) {
//
})->where('name', '[A-Za-z]+');
Route::get('/user/{id}', function ($id) {
//
})->where('id', '[0-9]+');
Route::get('/user/{id}/{name}', function ($id, $name) {
//
})->where(['id' => '[0-9]+', 'name' => '[a-z]+']);
ただし、あくまで正規表現としてしかバリデーションが出来ない。
php artisan make:request StoreBlogPost
DBの内容と比較する場合はフォームリクエストバリデーションを使う。
authorize()でfalseの場合は403。
$this->route('comment')でURLのパラメータにもアクセス可能。
あくまでバリデーションと同じ位置づけなので、ルートモデル結合を解決した後の値が入る。
rules()で返す配列は通常のバリデーションと同じ。
データの整合性
データの整合性を維持する機構がいくつかある。
※設計指針
機能 | 特徴 | 使い時 | |
---|---|---|---|
Scope |
Modelの機能。 SQLの単位でデータを制限可能。 複数のテーブルを使おうとすると面倒になる。 事前にデータを求めてwhereInを使えばよいかもしれない。 |
全データに制限を設けたい時 使用者の人数が多いデータに対して使うと良いかも |
|
middleware |
複数のURLに使用可能。 |
||
フォームリクエストバリデーション |
コントローラの1メソッドで使用可能。 バリデーションを汎用的にしたもの。 1リクエストに対してPHP的に可能な事ならなんでも制限出来る。 アクセス不可の場合403を返す。 rules()はコントローラのメソッド毎に変わりがちなので、authorize()のみ使用した方が再利用はしやすい。 |
リクエスト単位で制限を設けたい場合 SQL的にデータを制限できなかった時はこちらを使うと良いかも |
|
Policy |
コントローラの1メソッドか、リソースコントローラに対して使用可能。
1リクエストに対してPHP的に可能な事ならなんでも制限出来る。 アクセス不可の場合403を返す。 URLのパラメータにデータがない場合も考慮されている。 |
|
リソースコントローラ
動詞 | URI | アクション | ルート名 |
---|---|---|---|
GET | /photos |
index | photos.index |
GET | /photos/create |
create | photos.create |
POST | /photos |
store | photos.store |
GET | /photos/{photo} |
show | photos.show |
GET | /photos/{photo}/edit |
edit | photos.edit |
PUT/PATCH | /photos/{photo} |
update | photos.update |
DELETE | /photos/{photo} |
destroy | photos.destroy |
PUT/PATCH/DELETEは@methodを追加
<form action="/foo/bar" method="POST"> @method('PUT') </form>
- 暫定設計指針
何故か覚えられないファイルストレージ
use Illuminate\Support\Facades\Storage;
//S3を使う場合
Storage::disk('s3')
//diskの引数はドライバ名で、ディレクトリ名ではない
//ドキュメントに下記のような記載があって紛らわしい
Storage::disk('avatars')
//テストで使う。これ以降のS3を指定するStorageの処理がS3を経由せずテスト出来る
Storage::fake('s3')
ドライバの設定はconfig/filesystems.php
のdisksを参照の事。
Storage::disk()やStorage::fake()は設定したドライバしか指定出来ない。
HTTPエラー一覧
ステータスコード | どういう状態だと出るか |
---|---|
302 |
|
403 |
|
404 |
|
405 | HTTPのmethodが間違っている |
419 | |
422 | XHRでデータを取得した時、バリデーションをパス出来なかった |
503 |
|
Sail(とDocker)
Sail導入済みのプロジェクトを始める場合
# GitなどでプロジェクトをWSL2内に展開
cd 「プロジェクトのパス」
docker run --rm -v "$(pwd)":/opt -w /opt laravelsail/php82-composer:latest bash -c 'composer require laravel/sail --dev'
cp .env.example .env
./vendor/bin/sail up
これでsailが使えるようになった。
※docker runで使っているコンテナはSailで開発を開始する時に使うシェルを参考にしている
※何か足りてない気がする
動かない時に確認するリスト
- Sailで使われるDockerfileを独自で作った場合、最新版のSailのDockerfileから必要なコマンドを追加する
- コンテナが起動しない場合、Docker DesktopのVolumesを削除してsail build --no-cache
- .envで、他のコンテナと接続する場合はdocker-composeの名前を*_HOSTに設定する(Docker内部で使われるドメイン名を指定する)
- 「usr/bin/env: 'bash\r': No such file or directory」が出る場合、コンテナに送るシェルスクリプトの改行がCRLFになっている。LFにしてsail build --no-cache
Request
<form method="POST">@csrf
<input type="text" name="text_input"><input type="password" name="password_input"><textarea name="textarea_input"></textarea><select name="select_input"></select><input type="file" name="file_input"><input type="hidden" name="hidden_input"><input type="email" name="email_input"><input type="number" name="number_input"><input type="date" name="date_input"><input type="color" name="color_input"><input type="range" name="range_input"><input type="search" name="search_input"><input type="tel" name="tel_input"><input type="url" name="url_input"><input type="time" name="time_input"><input type="datetime-local" name="datetime_local_input"><input type="month" name="month_input"><input type="week" name="week_input"><button type="button" name="button_button">ボタン</button><button type="submit" name="submit_button">送信</button><button type="reset" name="reset_button">リセット</button>
<textarea name="textarea_input_default">default text area</textarea><select name="select_input_default"></select><input type="file" name="file_input_default"><input type="hidden" name="hidden_input_default"></form>
dd($request->all(), $_POST);
array:36 [▼ // app/Http/Controllers/SandBoxController.php:16 "_token" => "DLS6wGkN7IkFh3MzFfMqJsqfRFq3TE9CXHrSW2Nm" "text_input" => null "password_input" => null "textarea_input" => null "file_input" => null "hidden_input" => null "email_input" => null "number_input" => null "date_input" => null "color_input" => "#000000" "range_input" => "50" "search_input" => null "tel_input" => null "url_input" => null "time_input" => null "datetime_local_input" => null "month_input" => null "week_input" => null "text_input_default" => "default text" "password_input_default" => "defaultpassword" "textarea_input_default" => "default text area" "select_input_default" => "option1_default" "file_input_default" => null "hidden_input_default" => null "email_input_default" => "default@example.com" "number_input_default" => "123" "date_input_default" => "2023-01-01" "color_input_default" => "#ff0000" "range_input_default" => "5" "search_input_default" => "default search" "tel_input_default" => "123-456-7890" "url_input_default" => "http://example.com" "time_input_default" => "12:00" "datetime_local_input_default" => "2023-01-01T12:00" "month_input_default" => "2023-01" "week_input_default" => "2023-W01" ]array:36 [▼ // app/Http/Controllers/SandBoxController.php:16 "_token" => "DLS6wGkN7IkFh3MzFfMqJsqfRFq3TE9CXHrSW2Nm" "text_input" => "" "password_input" => "" "textarea_input" => "" "file_input" => "" "hidden_input" => "" "email_input" => "" "number_input" => "" "date_input" => "" "color_input" => "#000000" "range_input" => "50" "search_input" => "" "tel_input" => "" "url_input" => "" "time_input" => "" "datetime_local_input" => "" "month_input" => "" "week_input" => "" "text_input_default" => "default text" "password_input_default" => "defaultpassword" "textarea_input_default" => "default text area" "select_input_default" => "option1_default" "file_input_default" => "" "hidden_input_default" => "" "email_input_default" => "default@example.com" "number_input_default" => "123" "date_input_default" => "2023-01-01" "color_input_default" => "#ff0000" "range_input_default" => "5" "search_input_default" => "default search" "tel_input_default" => "123-456-7890" "url_input_default" => "http://example.com" "time_input_default" => "12:00" "datetime_local_input_default" => "2023-01-01T12:00" "month_input_default" => "2023-01" "week_input_default" => "2023-W01" ]
select, type="radio", type="checkbox"は値がない場合はサーバに送られない
(同名でtype="hidden"を作ると送れる)
type="submit"はボタンを押した時のみ送れる
PHP的には""だが、Laravel的には空文字はnull扱いになる
$request->has()でパラメータの存在確認ができる
テスト
Viewで使われる特定の変数をテストしたい
assertViewHas('変数名', 値);
HTMLの特定の箇所をテストしたい
$response->assertSeeInOrder([]);
配列の1要素がHTMLの1行扱いになる
厳密に1行で判定するので、開始タグで改行している場合も影響を受ける
第二引数にfalseを入れる
時刻のテストに失敗する
assertJsonではCarbonの比較で失敗する。
assertExactJsonを使う。
response()->streamDownloadでレスポンスを返した結果をテストしたい
ヘッダ以外はテストできない?
getContent()で値が取れそうな気がするが、どうやら常にfalseを返すみたい。
exec('curl ~');など、HTTPで通信出来るコマンドからデータを取得する方法しか思いつかなかった。
symfony/src/Symfony/Component/HttpFoundation/StreamedResponse.php at 6.4 · symfony/symfony · GitHub
時刻を固定する
Carbon::setTestNow(Carbon::parse('2024/1/10 8:00'));
テストするとよさそうな項目
- ユーザが違った場合
- 複数登録可能な場合、複数登録した時
- 存在しないIDを指定した場合
- 不正な値が入った場合
- 登録したデータが存在しなかった
- パラメータが存在しなかった場合
- リレーション先が削除 or ソフトデリートされた場合
- コマンドやタスクスケジュールとして叩かれた時の動作(Auth::user()がnullのケース)
雑記:Laravelのここが古い
ルーティングが偶発的凝集
別のファイルに分割可能なので、分けるとハッピーかも。
リソースコントローラが偶発的凝集
リソースコントローラの形はそのままで、使用は避けた方がいいかもしれない。
もちろん、凝集が正しい場合は使ってもいい。
configがenum非対応
定数をとりあえずconfigに書く時代ではないのかもしれない。