【輪読会資料】達人に学ぶSQL徹底指南書第2版 第二部後半 読書メモ
以下の記事は2019/1/16 コワーキングスペース秋葉原Weeybleで行われる輪読会
[秋葉原] 達人に学ぶSQL徹底指南書 輪読会 第2部 魔法のSQL(第2部 RDBの世界) のための読書メモとなります。
以下の書籍の 第二部 9 18 GROUP BY と PARTITION BY ~ 23 SQLにおける存在の改装 のメモです。
達人に学ぶSQL徹底指南書 第2版 初級者で終わりたくないあなたへ (CodeZine BOOKS)
- 作者: ミック
- 出版社/メーカー: 翔泳社
- 発売日: 2018/10/11
- メディア: 単行本(ソフトカバー)
- この商品を含むブログを見る
前提として
私のSQLスキルはMySQLを前提として基本的な知識のみで、書籍としてSQL本を読むのは、リファレンスを除いてこれが2冊目となります。まだまだ知らない事だらけ、という事を前提に以下のメモを読んでいただければ幸いです。
18 GROUP BY と PARTITION BY
GROUP BY と PARTITION BYは似てる、ということだが、そもそもPARTITION BYを使った事がなった。
GOROUP BY は分けたあとに代表だけ表示
PARTITION BY は分けた後にそのまま全て表示
しかし、この2つはいずれも『指定されたキーで分割をしている』という事らしい、違いはGROUP BY はキーで分割後に『集約してまとめる』操作が入る。
これらで集合されたものは以下の特徴を持つ
- いずれも空集合でない
- ずべての部分集合の輪が分割する前の集合一致
- 互いにことなる任意の2つの部分集合が共通部分を持たない
集合論的にはパッキリ割れるし余りや重複も出ない。完全な割り当てをしてくれる。
この集合部分のひと塊を『類(るい)』と呼ぶ
群論とSQL
MODという除算の余りを出す関数がSQLにはある(PHPだと %)これを使って、多数のデータのランダムサンプリング、もしくは、ほぼ同等のデータに振り分けをすることが簡単にできるので、MODでの振り分けは覚えておくと良いかもしれない。
SELECT MOD(num, 3) AS modulo, num FROM Natural ORDER BY modulo, num;
3で割った際の余りの数で振り分けをした例
modulo num -------- ----- 0 0 0 3 0 6 1 1 1 4 1 7 2 2 2 5 2 8 ...
当たり前だが多数のレコードのあるテーブルのユニークID(抜け漏れなし)を特定の数の余りで振り分けると、ほぼ同じ数でのグルーピングが可能となる。
19 手続き型から宣言型・集合指向へ頭を切り替える7箇条
はじめに
SQLはプログラミングにある手続き型の言語ではなく、宣言・集合型言語である、ここはこの概念を理解し生かすための実践ガイドとする章。
1. IF文やCASE文は、CASE式で置き換える。SQLはむしろ関数型言語と考え方が近い
MySQLにあるIFはむしろ特殊、またCASEは文(手続き)ではなく、一種の関数(数式の方の意味)としてとらえる
2. ループはGROUP BY句とウィンドウ関数で置き換える
SQLには文単位のループも存在しない。
3. テーブルの行に順序はない
テーブルはファイルよりも抽象度が高い。テーブルのレコードを『ファイル』の様に捉えない、テーブルは数学の「集合(set)」の一種である。 ビューにORDER BY を入れても意味はない、Oracleのrownum の様な考えも特殊な仕様ととらえる。
4. テーブルを集合とみなそう
テーブルの抽象性を理解するには自己結合を使ってみると良い。自己結合を使うと、好きな数だけ、集合を追加し、操作することができる。
5. EXISTS述語と「量化」の概念を理解する
SQLを支える理論は集合の他にも『述語論理』がある。「複数行を一単位として」扱う際、述語理論では『量化子』だが、SQLではEXISTSとなる。 また、使いこなす必要があるのは NOT EXISTSの方が大事。読みづらいがパフォーマンスが格段に良い。
6. HAVING句の真価を学ぶ
WHEREと異なりSQLを集合論としてとらえる事が出来るHAVING, をもっと活用しよう HAVINGの練習をすると知らず知らずに集合論の本質の理解が進むでしょう。
7. 四角を描くな、円を描け
SQLは手続きではない四角と矢印の手順図とは概念がことなり、ベン図(〇の中に〇が現れる図)として集合でとらえる事が重要
感想
俄然なにが筆者をここまで執拗にSQL集合論を書かせるに至ったのかに興味がわいてきた。
20 神のいない理論
SQLの元になる論理学の話。従来は真と偽になる2値原理の古典論理が、1920年代に新たな3値論理が生まれSQLに生かされたらしい。
汝、場合により命題の真偽を捨てよ
3値論理学の体形をはじめて作ったのはポーランドの論理学者J.ウカシェヴィッツ、関数型言語のポーランド記法「3+2」を「+3 2」を考案した人
論理学で真偽の二つ以外にも、「わからない、未知」となる概念の存在を提案した。
論理学の革命
神、宗教が支配していた時代は神による論理が主となる、神は全てを知っており、全ての真偽を知っている。という思想から、2値原理を当然としていたが、人間は全ての真理を知らないし、神に全てを訪ねも答えてくれる訳ではないので、「知らない、未知」という状態を論理学として取り入れるべき、という流れが出て来た。そもそもこのような提案ができたのは、宗教による支配が弱くなった時代背景もある。
人間の為の論理
データベースを扱うのは神ではなく人間なので、人間の認知や知識を表現するのに適した論理である3価理論(NULL, unknown)が採用された。 しかし、皮肉にもそれによって、人間の直感に反する奇妙な論理計算を導入せざるを得なくなった。
21 SQLと再帰集合
集合のなかに集合を含む入れ子の集合、「再帰集合」の扱いを知るのは重要という話。
実務の中の再帰集合
そもそもノイマンは何故自然数を再帰集合で定義しようとしたのか?(P57)
0 = ∅ 1 = {0} 2 = {0, 1} 3 ={0, 1, 2} ...
ノイマンの先輩たち
ノイマン以前に自然数を集合で定義した数学者、哲学者がいる。
ゴットローブ・フレーゲ 哲学者 述語理論をほぼ独力で創始した
エルンスト・ツェルメロ 数学者 集合論の体系整備、整列可能定理と選択公理
それぞれの自然数の機能的定義
自然数 | ノイマン型 | ツェルメロ型 | フレーゲ型 |
---|---|---|---|
0 | ∅ | ∅ | {∅} |
1 | {∅} | {∅} | {∅,{∅}} |
2 | {∅,∅} | {{∅}} | {∅,{∅},{∅,{∅}}} |
3 | {∅,∅,∅} | {{{∅}}} | {∅,{∅},{∅,{∅}}{∅},{∅,{∅}}}} |
... |
疑問
数とは何か?
ペアノの自然数の5つの公理
- 最初の数が存在する
- 任意の自然数aはその後者が存在する
- 最初の数はいかなる自然数の後者でもない
- 異なる自然数は異なる後者を持つ
- 最初の数がある性質を満たし、aがある性質を満たせばその後者もその性質を満たすとき、すべての自然数はその性質を満たす
ある自然数の次を数える関数を、後者関数と呼び suc(x) とかく。
0 = 0 1 = suc(0) 2 = suc(1) = suc(suc(0)) 3 = suc(2) = suc(suc(suc(0))) ...
ノイマンはペアノの自然数の定義に見合う構成方法を考えたといえる。 これにより、自然数を構成する材料として∅を使う必要もなくなった。
0: λfx.x 1: λfx.fx 2: λfx. f(fx) 3: λfx.f(f(fx))
正直この辺までくるとちょっとよくわからない…
SQLの魔術と科学
ランキング算出のクエリの理屈にこういうものがある、と把握することによって世界が広がり理解が深まるのではないだろうか?
22 NULL撲滅委員会
実務に置いて厄介なNULLにどう対処していけばよいかの指針を提案している
決意表明~スベテノDBエンジニアニ告グ~
(デザイナーにおけるIE死ね死ね団的なノリノリである) NULLは人間の感覚的にはわかりやすく設計段階でついついいれてしまうが、システムが複雑化するととても厄介なものになる。そこでここではより具体的な提案をまとめた章となっている。
なぜNULLがそんなに悪いのか
- SQLのコーディングにあたり、人間の直感に反する3値論理を考慮しないといけない
- IS NULL , IS NOT NULL を指定する場合、インデックスの利用に制限が入りパフォーマンスが低下する
- 四則演算またはSQL関数の引数にNULLが含まれると「NULLの伝播」が起こる
- SQLの結果を受け取るホスト言語において、NULLの組み込み方が標準化されていない。また、DBMS間でもNULLの扱いに関する仕様が不統一
- 通常の列の値と異なり、NULLは行のどこかに余分なビットを持つことで実装されている。記憶領域の圧迫や検索パフォーマンス悪化の要因となる。
- NULLを含むカラムに作成するユニークインデックスの「ユニーク」の意味がRDBMSで異なる。例:複数のNULLを含む列にユニークインデックスを作成する際、エラーになったりならなかったり。
- NULLは値ではない為、ORDER BY 句によるソートの際のルールを意識する必要がある。NULLは定義含まれないが、実際は最大値か最小値として扱われ、実装によってデフォルトが異なるのでややこしい事になる。
もっとも忌むべき理由は1.
また3.も厄介 四則演算にNULL が入ると計算結果がNULLとなってしまう。
4~7は仕様違いによる厄介さ
しかしNULLを完全に排除することはできない
しかし、実務では『重要でない列にNULLが入ってくるのは目をつむる』位が実際の運用でのルールになっている。
カラムの制約でNOT NULL をしたとしても、外部結合や CUBE,ROLLUP付きのGROUP BY句を使うとNULLが入り込んでしまう。
コッドさんはNULL撲滅の最右翼、著者もそこに近い所に行きたい心情ではあるが、エンジニアの現実感覚として以下の方針を提案している
NULLは薬、用法容量を守って使う。使わざる得ない時のみに使う
次からは具体的なNULL排除の指針を提案してゆく
コードの場合ーー未コード化用コードを割り振る
true,false の様な2つの値のみ入るレコードは3つ目の値、未定義などに、NULLではなく、数字を当てはめると良い
例えば性別
1:男性 2: 女性 3:未知 9:適用不能 といった具合
例えばここに人ではなく、企業アカウントとしてレコード登録する際は 9:適用不能 を選択させる コッドの提案した『未知』と『適用不能』を値として設ける方法。
また、不明なレコードを入れなければならない場合、例えば数値として9999を入れるのではなく、そのカラムでは普段は使わない文字列 XXXX などを使うと良いだろう。 9999 という数字を持つユーザーが実際に存在する可能性があるなら数値は避けるべき。
名前の場合ーー「名無しの権兵衛」を割り振る
不明を表すデフォルトの値を入れると良い『不明』『UNKNOWN』など開発チーム内で共通了解の取られたものを入れると良い
数値の場合ーー0で代替する
NULLを0に変換してDBへ登録すると良い。筆者が実務で困った事は経験上あまりないらしい。
しかし、どうしても0とNULLを区別したい時だけNULLを許可すると良い。
日付の場合ーー最大値・最小値で代替する
日付が開始日や終了日を意味する場合は「0001-01-01」「9999-12-31」等の最大値、最小値を使うと良い。
しかし、デフォルト値がそもそもわからない誕生日など「未知」のNULLに相当する場合はNULLを許可しても良いでしょう。
指針のまとめ
- まずデフォルト値を入れられないか検討する。
- どうしようもない場合だけNULLを許可する。
というのが筆者の指針となる。
23 SQLにおける存在の階層
GROUP BY を使って集約をすると、集約キーを除いて、元のテーブルの列をそのまま参照できなくなるが、これは存在の階層を厳密に区別するSQL理論の現れ、ここからSQLの本質にせまる。
述語論理における階層、集合論における階層
P84 EXISTS述語の使い方を復習
EXISTSは高階関数である
2階 テーブルの集合
1階 テーブル(行集合)
0階 行
実はGROUP BY 句はEXISTS同様に階層がある。
なぜ集約すると、もとのテーブルの列を参照できなくなるのか?
以下の様なテーブルがあり、チームごとの平均年齢を出すクエリを考える
Teams
member | team | age |
---|---|---|
大木 | A | 28 |
逸見 | A | 19 |
新藤 | A | 23 |
山田 | B | 40 |
久本 | B | 29 |
橋田 | C | 30 |
... |
SELECT team, AVG(age) FROM Tems GROUP BY team;
これはちゃんと出力されるが、 次のはエラーになる。
-- チーム単位に集約するクエリ? SELECT team, AVG(age), age FROM Tems GROUP BY team;
理由はSELECT句に追加されたage 列を選択する事が出来ない為。 標準SQLではテーブル集約をした際、SELECT句に書ける要素は以下となる。
- GROUP BY句で指定した集約キー
- 集約関数(SUM, AVGなど)
- 定数
上記のエラーになったクエリの場合、 age は集約できない個人の年齢を差している。 個人の年齢は集約された際の「集団についての属性」ではない、当たり前だが、集団の統計属性なら出力できるが、個人は出力できない。と考えると良い。 過去のMySQLでは特別に可能だったが8.0以降ではやはりエラーになる様になった
GROUP BY で集約を行うと、SQLが扱う「行」という0階の存在から、「行の集合」という1階の存在に変化するため、「行」の属性は参照不能となる。
つまり、GOROUP BY で集団化されたものに、個人の〇〇を訪ねても駄目、という風に解釈すると良い。
もし、上記のクエリで名前を尋ねたい、とするなら仮に以下の様に MAXのmemberを求めるしかない
-- 正常 SELECT team, AVG(age), MAX(member) FROM Tems GROUP BY team;
このクエリを応用すると「チームでの最高齢の年齢の人物」を求めるクエリも書ける。
-- チーム最高齢者も出力 SELECT team, MAX(age) (SELECT MAX(member) FROM Teams T2 WHERE T2.team = T1.team AND T2.age = MAX(T1.age) ) AS oldest FROM Tems T1 GROUP BY team;
team max(age) oldest ------ ---------- --------- A 28 大木 B 40 山田 ...
通常、WHERE句で集約関数は使えないが、このサブクエリ内では使える。MAX(T1.age)
理由は外側のT1テーブルを集約(1行目の MAX(age)
かな? )したことによって、SELECT句集約関数が参照可能になる為。
その代わり今度は逆にサブクエリ内で age を裸で利用することはできない。つまり集約したルールはサブクエリと外のクエリで合わせなら利用できる。という事らしい。
単元集合も立派な集合です
GROUP BYをする際、そもそも、元のレコードでも1要素しかないものを集約した場合、どう動くか? (例となるTeamテーブルにはCチームに1メンバーのみのレコードがある)
実はこれも1単位でも1グループとして扱う。やはり階が0階から1階上がるという事、なので注意しましょう。ということ。
【輪読会資料】PHPフレームワーク Laravel Webアプリケーション開発 9章テスト 9-3『WebAPIテスト』
以下の記事は2019年1月10日、コワーキングスペース秋葉原Weeybleにて行われる [秋葉原] Laravel Webアプリケーション開発 輪読会 (9章 テスト)の輪読会資料の一部となります。 今回は 9-3 章部分の『WebAPIテスト』部分の記事をアップします。
また、元になっている書籍は以下となります。
PHPフレームワーク Laravel Webアプリケーション開発 バージョン5.5 LTS対応
- 作者: 竹澤有貴,栗生和明,新原雅司,大村創太郎,丸山弘詩
- 出版社/メーカー: ソシム
- 発売日: 2018/09/26
- メディア: 単行本
- この商品を含むブログを見る
9-3 WebAPIテスト
WebAPIを検証するテストコードの実装
ここではよりユニットテストより粒度の高い大きいフィーチャーテストの解説となる
LaravelではHTTPリクエストをシミュレートしてテストする機能が用意されている、この機能を利用した例の説明となる。
9-3-1 WebAPIテスト機能
まず下記にあるような単純なAPIを扱う
routes\api.php
抜粋
<?php Route::get('/ping', function () { return response()->json(['message' => 'pong']); });
/api/ping
にアクセスすると json [message => 'pong'] が得られるごく単純なAPIとなっている
$ curl http://larabook.test/api/ping {"message":"pong"}
テストクラスの生成
テストクラスの生成は artisan make:test
コマンドを使う。コマンドにクラス名指定で、 test/Feature/
以下にテストクラスファイルが生成される。 --unit
オプションは使わないので注意
9.3.1.3 フィーチャーテストクラスの生成例
$ php artisan make:test Api/PingTest Test created successfully.
生成されたファイル tests\Feature\Api\PingTest.php
コマンドでパスを切った場合はディレクトリもよろしく作られる
9.3.1.4 生成されたフィーチャーテストクラスの名前空間
<?php // 名前空間の生成もフィーチャーテストとしてよろしくやってくれる namespace Tests\Feature\Api; use Tests\TestCase; use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Foundation\Testing\RefreshDatabase; // 省略
HTTPリクエストの送信
HTTPリクエストのテスト実施は、疑似的なHTTPリクエストを送信するメソッドを利用する。 call
メソッドはHTTPリクエストを疑似的に送信してくれる。
9.3.1.5 callメソッドの定義
<?php public function call { // 以下使用の際の引数の順序となっている? $method, // HTTPメソッド $uri, // URI $parameters = [], // 送信パラメータ $cookies = [], // cookie $files = [], // アップロードファイル $server = [], // サーバパラメータ $content = null // RAWリクエストボディ }
9.3.1.6 callメソッドの実行例
<?php // GETリクエスト クエリストリング $response = $this->call('GET', '/api/get?class=motogp&no=99'); // GETリクエスト $parameters $response = $this->call('GET', '/api/get', [ 'class' => 'motogp', 'no' => '99' ]); // POSTリクエスト $response = $this->call('POST', '/api/post', [ 'email' => 'a@example.com', 'password' => 'secret-password', ]);
また、jsonの場合は以下の定義となる
9.3.1.7 jsonメソッドの定義
<?php public function json($method, $uri, array $data = [], array $headers = [])
第3引数のリクエストヘッダは自動でも設定される
call,jsonメソッドにはHTTPメソッド毎のラッパー関数が用意されている。 通常はラッパーメソッドを利用し、細かなパラメーター指定が必要な場合にcall,jsonメソッドを利用すると良い。
メソッド | 送信する疑似HTTPリクエスト |
---|---|
get(\$uri, /$headers = ) | GETリクエスト |
getJson(\$uri, \$headers = ) | GETリクエスト(JSON) |
post(\$uri, \$data, \$headers = ) | POSTリクエスト |
postJson(\$uri, \$data, \$headers = ) | POSTリクエスト(JSON) |
put(\$uri, \$data, \$headers = ) | PUTリクエスト |
putJson(\$uri, \$data, \$headers = ) | PUTリクエスト(JSON) |
patch(\$uri, \$data, \$headers = ) | PATCHリクエスト |
patchJson(\$uri, \$data, \$headers = ) | PATCHリクエスト(JSON) |
delete(\$uri, \$data, \$headers = ) | DELETEリクエスト |
deleteJson(\$uri, \$data, \$headers = ) | DELETEリクエスト(JSON) |
HTTPレスポンスのアサーション
送信したHTTPリクエストの結果検証をするアサーションメソッドがある。 Tests\TestCaseクラスにもあるが、 Illuminate\Foundation\Testing\TestResponceクラスの方が特化したアサーションの記述が容易なので作者はこちらを薦めている。
Illuminate\Foundation\Testing\TestResponceクラスの主なアサーションメソッドは以下 9.3.1.10 レスポンスをアサーションする主なメソッド(抜粋)
メソッド | 内容 |
---|---|
assertStatus(\$status) | HTTPステータスコードが引数と一致していれば成功 |
assertSuccessful() | HTTPステータスコードが2XXなら成功 |
assertRedirect(\$url = null) | 次のいずれか 201,301,302,303,307,308 かつ、ヘッダのuriの値が app('uri)->to(\$uri) の値と一致すれば成功 |
assertHeader(\$headerName, \$value = null) | レスポンスヘッダが存在(\$valueがnullの場合)もしくは該当ヘッダの値が$valueと一致すれば成功) |
assertExactJson(array \$data, \$strict = false) | レスポンスボディのJSONをデコードした配列が$dataと一致すれば成功 |
assertJson(array \$data, \$strict = false) | レスポンスボディのJSONをデコードした配列に$dataが含まれていれば成功 |
以上を反映した/api/ping
のAPIテストの例は以下の通り
<?php declare(strict_types=1); namespace Tests\Feature\Api; use Tests\TestCase; class PingTest extends TestCase { /** * @test */ public function get_ping() { $response = $this->get('/api/ping'); // HTTPステータスコードを検証 $response->assertStatus(200); // レスポンスボディのJSONを検証 $response->assertExactJson(['message' => 'pong']); } }
テストしてみる
$ ./vendor/bin/phpunit tests/Feature/Api/PingTest.php PHPUnit 6.5.9 by Sebastian Bergmann and contributors. . 1 / 1 (100%) Time: 1.26 seconds, Memory: 12.00MB OK (1 test, 2 assertions)
このようにWebAPIのテストでは疑似的なリクエスト送信をしてレスポンスが検証可能となっている。
9-3-2 テスト対象のAPI
9-2 で行った「データベーステスト」のポイント加算処理を利用して、ポイント加算を行うWebAPIのテストを通じてフィーチャテストの解説。
WebAPIの概要
リクエストJSONに含まれるcustomer_idに該当する顧客に対して add_point で指定したポイントを加算する
9.3.2.2 ポイント加算APIのクラス構成(左下4クラスはDBテストで使用したもの)
各クラスの概要説明、ソースコードはクラス名のリンクをクリックで閲覧可能
AddPointActionクラス
app\Http\Actions\AddPointAction.php
HTTPに関する処理とユースケースの実行を担う
API処理の親ファイル バリデートを __invoke の引数にフォームリクエストを入れる事で行う。また、 AddPointUseCaseクラスをコンストラクトインジェクションして処理を引っ張ってきてる
AddPointRequestクラス
app\Http\Requests\AddPointRequest.php
要はバリデートの為のフォームリクエストファイル
customer_id
と add_point
を 'required|int'
のバリデートをしている
9.3.2.5 ルーティングの追加(routes/api.php)
<?php // 追加 use App\Http\Actions\AddPointAction; // 中略 Route::put('/customers/add_point', AddPointAction::class);
AddPointActionクラスはのメソッドは __invoke
しているので、クラス名のみの指定となっている
コントローラーやアクションクラスは標準設定だと App\Http\Controller
以下に置くことが必要だが、以下の設定を変更することでカスタマイズできる。
9.3.2.6 app\Providers\RouteServiceProvider の設定変更
<?php // before // protected $namespace = 'App\Http\Controllers'; // after protected $namespace = ''; // 空文字にする
AddPointUseCaseクラス
app\UseCases\AddPointUseCase.php
配下のAddPointService
, EloquentCustomer
, EloquentCustomerPoint
をコンストラクタインジェクションする。
run
メソッドで カスタマーの情報(\$customerId, \$pointEvent, \$addPoint, \$now)
を引数に、ポイント加算処理を走らせ、返り値として保有したcuntomer_point
tableのpoint
を返す。
検証に失敗した際は、app\Exceptions\PreConditionException
にtrow そちらに処理は書かれてないが、下記のapp\Exceptions\Handler
側でPreConditionException
が例外instanceされた際ステータス400とエラーメッセージを出力させる。
9.3.2.9 app\Exceptions\Handlerクラスによるエラーレスポンスの設定(抜粋)
<?php /** * Render an exception into an HTTP response. * * @param \Illuminate\Http\Request $request * @param \Exception $exception * @return \Illuminate\Http\Response */ public function render($request, Exception $exception) { if ($exception instanceof PreConditionException) { return response()->json(['message' => trans($exception->getMessage())], Res::HTTP_BAD_REQUEST); } return parent::render($request, $exception); }
では以上のAPIを実際に使用してみる。
http://larabook.test/api/customers/add_point
にcostomer_id = 1 が 10point Addするcurlコマンドを叩く
$ curl -v -H "Accept: application/json" -H "Content-type: application/json" -X PUT -d \ '{"customer_id":1,"add_point":10}' http://larabook.test/api/customers/add_point -w "\n"
9.3.2.10 curlコマンドによる実行例
vagrant@homestead:~/lalabook/chapter09$ curl -v -H "Accept: application/json" \ -H "Content-type: application/json" -X PUT -d \ '{"customer_id":1,"add_point":10}' \ http://larabook.test/api/customers/add_point -w "\n" * Trying 192.168.10.10... * TCP_NODELAY set * Connected to larabook.test (192.168.10.10) port 80 (#0) > PUT /api/customers/add_point HTTP/1.1 > Host: larabook.test > User-Agent: curl/7.58.0 > Accept: application/json > Content-type: application/json > Content-Length: 32 > * upload completely sent off: 32 out of 32 bytes < HTTP/1.1 200 OK < Server: nginx/1.14.0 (Ubuntu) < Content-Type: application/json < Transfer-Encoding: chunked < Connection: keep-alive < Cache-Control: no-cache, private < Date: Tue, 08 Jan 2019 09:00:50 GMT < X-RateLimit-Limit: 60 < X-RateLimit-Remaining: 59 < * Connection #0 to host larabook.test left intact {"customer_point":110}
ステータスはHTTP/1.1 200 OK
となる。最後の行にcustomer_point
が110に増加したjsonが返ってきているのが確認できる
当然だが同じコマンドを叩く都度DBが更新され、customer_point
が10増加する
次にエラーとなる値で確認 add_point = 0 でPUTしてみる
vagrant@homestead:~/lalabook/chapter09$ curl -v -H "Accept: application/json" -H "Content-type: application/json" -X PUT -d '{"customer_id":1,"add_point":0}' http://larabook.test/api/customers/add_point -w "\n" * Trying 192.168.10.10... * TCP_NODELAY set * Connected to larabook.test (192.168.10.10) port 80 (#0) > PUT /api/customers/add_point HTTP/1.1 > Host: larabook.test > User-Agent: curl/7.58.0 > Accept: application/json > Content-type: application/json > Content-Length: 31 > * upload completely sent off: 31 out of 31 bytes < HTTP/1.1 400 Bad Request < Server: nginx/1.14.0 (Ubuntu) < Content-Type: application/json < Transfer-Encoding: chunked < Connection: keep-alive < Cache-Control: no-cache, private < Date: Tue, 08 Jan 2019 09:15:55 GMT < X-RateLimit-Limit: 60 < X-RateLimit-Remaining: 59 < * Connection #0 to host larabook.test left intact {"message":"add_point should be equals or greater than 1"}
ステータスがHTTP/1.1 400 Bad Request
となって、最後の行にエラーメッセージが返信されてきているのが確認できる。
9-3-3 APIテストの実装
テストでは状況別で以下の4つのケースを実装する
- ポイント加算が正常完了する
- バリテーションエラーになる
- add_pointが事前条件エラーになる
- customer_idが事前条件エラーになる
以下のクラスにテストを実装してゆく
tests\Feature\Api\AddPointTest
9.3.3.1 tests\Feature\Api\AddPointTestクラス(準備部分を抜粋)
<?php declare(strict_types=1); namespace Tests\Feature\Api; use App\Eloquent\EloquentCustomer; use App\Eloquent\EloquentCustomerPoint; use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class AddPointTest extends TestCase { use RefreshDatabase; // <---(1) const CUSTOMER_ID = 1; protected function setUp() { parent::setUp(); //(1-2) Carbon::setTestNow(); // (2) テストに必要なレコードを登録 factory(EloquentCustomer::class)->create([ 'id' => self::CUSTOMER_ID, ]); factory(EloquentCustomerPoint::class)->create([ 'customer_id' => self::CUSTOMER_ID, 'point' => 100, ]); } // 以下にテストを追加して行く }
(1) DBを利用しているので use RefreshDatabase;
をしている
(1-2)Carbon::setTestNow();
テスト用の日時を生成している
PHPで日付時刻の処理を書くなら Carbon がおすすめ より引用
setTestNow() はCarbonが基準とする現在の日時をモックで設定できる機能です。つまりロジック内でCarbonを適切に扱うことで時間に関するテストをユニットテストでも書けるようになります。
(2)テストに必要な値は9.2.2.7同様 factoryを使用して準備する。
正常ケース
APIで100point保持の会員に10point追加した際の動作を確認するシナリオ。
ここまでくると、だいたいパッと見て何やってるか判断できる位にはなりたい所。
9.3.3.1 tests\Feature\Api\AddPointTestクラス(正常ケースのテスト部分を抜粋)
<?php /** * @test */ // 正常ケースのテスト public function put_add_point() { // (3) API実行 $response = $this->putJson('/api/customers/add_point', [ 'customer_id' => self::CUSTOMER_ID, 'add_point' => 10, ]); // (4) HTTPレスポンスアサーション $response->assertStatus(200); $expected = ['customer_point' => 110]; $response->assertExactJson($expected); // (5) データベースアサーション $this->assertDatabaseHas('customer_points', [ 'customer_id' => self::CUSTOMER_ID, 'point' => 110, ]); $this->assertDatabaseHas('customer_point_events', [ 'customer_id' => self::CUSTOMER_ID, 'event' => 'ADD_POINT', 'point' => 10, 'created_at' => Carbon::now(), ]); }
バリテーションエラーとなるケース
9.3.3.2 バリテーションエラーのテストメソッド
<?php /** * @test */ public function put_add_point_バリデーションエラー() { // (2) API実行 パラメーター無しでアクセスしてる $response = $this->putJson('/api/customers/add_point', [ ]); // (3) HTTPレスポンスアサーション $response->assertStatus(422); $expected = [ 'message' => 'The given data was invalid.', 'errors' => [ 'customer_id' => [ 'The customer id field is required.', ], 'add_point' => [ 'The add point field is required.', ], ], ]; $response->assertExactJson($expected); }
(3) responce 422 はLaravelのバリテーションエラーのステータスコードとなっている。
assertExactJson
メソッドで期待値と一致するかを検証)でエラーメッセージの検証をしているが、レスポンスボティ全体を検証できるメリットと、テストメソッドの可読性を上げることができる為に利用している。
逆にランダムな値を含み、全体一致が難しい値等の検証は。assertJson
メソッド(配列で指定した値が含まれているか検証)を使う
9.3.3.3 assertJsonメソッドでレスポンスJSONの一部を検証
<?php /** * @test */ public function put_add_point_バリデーションエラー_errorsのみ検証() { $response = $this->putJson('/api/customers/add_point', [ ]); $response->assertStatus(422); // errorsキーのみ検証 $expected = [ 'errors' => [ 'customer_id' => [ 'The customer id field is required.', ], 'add_point' => [ 'The add point field is required.', ], ], ]; $response->assertJson($expected); }
また、JSONで帰って来た値の中に検証すべきメッセージが存在するかを確認したい場合、レスポンスのJSONを配列に変換して、key別に検証する以下の方法もある
便利な方法なので覚えておくと良い
9.3.3.4 レスポンスボディのJSONを配列に変換して検証
<?php /** * @test */ public function put_add_point_バリデーションエラー_キーのみ検証() { $response = $this->putJson('/api/customers/add_point', [ ]); $response->assertStatus(422); // レスポンスボディJSONを配列に変換して検証 $jsonValues = $response->json(); $this->assertArrayHasKey('errors', $jsonValues); $errors = $jsonValues['errors']; $this->assertArrayHasKey('customer_id', $errors); $this->assertArrayHasKey('add_point', $errors); }
add_pointが事前条件エラーとなるケース
add_pointが0かマイナス値の場合正常なエラーメッセージが返るかを検証するテストメソッド
9-1-4 データプロバイダを使っている(配列で検証する値を複数用意して @dataProvider アノテーションで宣言《コメントにメソッド指定》)
復習: @dataProvider アノテーション 一見コメントだが、このメソッドの配列を使ってテストしろって指示出来る奴
<?php /** * @test * @dataProvider dataProvider_put_add_point_add_point事前条件エラー */
9.3.3.4 add_pointの事前条件エラーを検証するメソッド
<?php /** * @test * @dataProvider dataProvider_put_add_point_add_point事前条件エラー */ public function put_add_point_add_point事前条件エラー(int $addPoint) { // (1) API実行 $response = $this->putJson('/api/customers/add_point', [ 'customer_id' => self::CUSTOMER_ID, 'add_point' => $addPoint, ]); // (2) HTTPレスポンスアサーション $response->assertStatus(400); $expected = [ 'message' => 'add_point should be equals or greater than 1', ]; $response->assertExactJson($expected); } public function dataProvider_put_add_point_add_point事前条件エラー() { return [ [0], [-1], ]; }
customer_idが事前条件とエラーとなるケース
customer_idが存在しないid(999)だった場合に正常なエラーが返るかをテストするメソッド
9.3.3.5 customer_idの事前条件エラーを検証するテストメソッド
<?php /** * @test */ public function put_add_point_customer_id事前条件エラー() { // (1) API実行 $response = $this->putJson('/api/customers/add_point', [ 'customer_id' => 999, 'add_point' => 10, ]); // (2) HTTPレスポンスアサーション $response->assertStatus(400); $expected = [ 'message' => 'customer_id:999 does not exists', ]; $response->assertExactJson($expected); }
最後に該当ファイルのテストを実施してテストが正常に終わる事を確認
vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Feature/Api/AddPointTest.php PHPUnit 6.5.9 by Sebastian Bergmann and contributors. ........ 8 / 8 (100%) Time: 3.15 seconds, Memory: 20.00MB OK (8 tests, 20 assertions)
無事正常なテストとして完了した
9-3-4 WebAPIテストに便利な機能
最後にWebAPIテストに便利な機能の紹介
tests\Feature\Api\MiddlewareTest.php
にテストコードが記載されている
ミドルウェアの無効化
HTTPリクエスト送信時にルーティング等で設定されているミドルウェアを無効にできる。
withoutMiddleware メソッドに無効にしたいミドルウェアクラス名を指定すると実行されなくなる
9.3.4.1 ミドルウェアを無効にする
<?php /** * @test */ public function TeaPotMiddlewareを無効() { $response = $this->withoutMiddleware(TeaPotMiddleware::class) ->getJson('/api/live'); $response->assertStatus(200); }
全ミドルウェアを無効にするには withoutMiddleware
を引数無しで実行するか
use Illuminate\Foundation\Testing\WithoutMiddleware;
を宣言する
9.3.4.2 全ミドルウェアを無効にする
<?php /** * @test */ public function 全てのミドルウェアを無効() { $response = $this->withoutMiddleware() ->getJson('/api/live'); $response->assertStatus(200); }
9.3.4.3 全ミドルウェアを無効にする(WithoutMiddlewareトレイト)
<?php declare(strict_types=1); namespace Tests\Feature\Api; use Illuminate\Foundation\Testing\WithoutMiddleware; use Tests\TestCase; class WithoutMiddlewareTest extends TestCase { use WithoutMiddleware; /** * @test */ public function 全てのミドルウェアを無効() { $response = $this->getJson('/api/live'); $response->assertStatus(200); } }
認証
認証が必要なAPIテストには以下の二つの方法がある
tests\Feature\Api\AuthTest.php
にサンプルコードがある
テスト時は通常のリクエストと同様ミドルウェアで認証が実行される
9.3.3.4 認証トークンを送信する例
<?php /** * @test */ public function guard_api() { // 認証ユーザーを事前に生成 factory(User::class)->create([ 'name' => 'Mike', 'api_token' => 'token1' ]); // 認証トークンをリクエストヘッダに設定して送信 $response = $this->withHeaders([ 'Authorization' => 'Bearer token1' ])->getJson('/api/user'); $response->assertStatus(200); $response->assertJson([ 'name' => 'Mike', ]); }
9.3.4.5 actingAsメソッドによる認証ユーザー設定例
<?php /** * @test */ public function actingAsで認証ユーザ設定() { // 認証ユーザーを事前に生成 $user = factory(User::class)->create([ 'name' => 'Mike', 'api_token' => 'token1' ]); // ミドルウェアを無効にして、actingAsメソッドに認証ユーザを設定 $response = $this->withoutMiddleware() ->actingAs($user) ->getJson('/api/user'); $response->assertStatus(200); $response->assertJson([ 'name' => 'Mike', ]); }
これだとテストを実行するとコントローラーやアクションではAuth::user()
等でユーザーを取得できるらしい
コンポーネントのモック(Fake)
MailやNotification等が絡んだ機能でそのままテストをしてしまうと、例えばテストでMailが送信されてしまうが、このようなコンポーネントをモックにすれば、ミドルウェアに接続しないでテストが可能となる。ここではMailのモッククラスを紹介する。
MailのモックはMailファサードのfakeメソッドを利用する。fakeメソッドを利用するとサービスコンテナに登録されているインスタンスがモッククラスに置き換わる
注意 : アサーションメソッドを利用するにはsendメソッドでメールを送信する際に
Illuminate\Contracts\Mail\Mailable
インターフェースを実装したクラスを引数に指定する必要がある
9.3.4.6 MailFakeのアサーションメソッド
メソッド | 内容 |
---|---|
assertSent | 指定されたメールが送信された事を検証 |
assertNotSent | 指定されたメールが送信されてない事を検証 |
assertNottingSent | メールが送信されてない事を検証 |
assertQueued | 指定されたメールがメールキューに登録された事を検証 |
assertNotQueued | 指定されたメールが送信されてない事を検証 |
assertNottingQueued | メールキューに登録されてない事を検証 |
9.3.4.7 アサーションメソッドで送信内容が検証できる送信例 (routes\api.php
)
<?php Route::post('/send-email', function (Request $request, Mailer $mailer) { // Mailableインターフェースを実装したクラス $mail = new \App\Mail\Sample(); // sendメソッドにMailableインターフェースを実装したクラスを指定 $mailer->to($request->get('to'))->send($mail); return response()->json('ok'); });
9.3.4.8 ファサードのfakeメソッドを利用した例 (tests\Feature\Api\MailTest.php
抜粋)
<?php /** * @test */ public function Mailファサードfakeを利用したテスト() { Mail::fake(); // <--- (1) MailFakeに置き換え $response = $this->postJson('/api/send-email-facade', [ 'to' => 'a@example.com', ]); $response->assertStatus(200); // (2) MailFakeを利用したアサーション // 第二引数が数値になると、送信された件数を検証する Mail::assertSent(Sample::class, 1); // (3) 送信した$mailableの値を検証 // 第二引数をDIにしてメソッドを検証に使える、hasToは有無の検証なのでbool値が返る Mail::assertSent(Sample::class, function (Mailable $mailable) { return $mailable->hasTo('a@example.com'); }); }
別の方法で、モックを利用せずにメール送信を防ぐ方法もある。logを利用すると送信内容をログファイルに出力する。テスト実行時のみメールドライバを切り替えるには以下の行を追加する。
9.3.4.9 MAIL_DRIVERをlogに設定 (phpunit.xml
抜粋)
<php> <env name="APP_ENV" value="testing"/> <env name="CACHE_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/> <env name="QUEUE_DRIVER" value="sync"/> <env name="DB_DATABASE" value="app_test"/> <env name="MAIL_DRIVER" value="log"/> <!-- arrayを logにする --> </php>
9章は以上となる
まとめ 感想
すげえ疲れた、そもそもテストをするにもそれに至るまで、元のアプリケーション実装を読み込む必要があった、しかし見慣れない書き方満載で、テストに行くまでに調べる事も多く理解するまでが大変だった。しかし理にかなった設計というものを垣間見た気がする。
おそらく筆者の方もテストの章を通じて、アプリケーションの設計思想を見せたかったのやもしれない。
とはいえ、現状なんとかコードを読むことは出来るが、自分で書ける気はまだしない。しかし処理を分離するとテストもし易くなるというメリットはなんとなくつかめた。
最初にフレームワークに触れた時も、当時のベタな書き方に対して、こんなの使いこなせる気がしない!と思ったものだが、やはりしばらくすれば慣れてくるのだろう。
この章の資料を作るにあたってコード以外の部分のスキルも身に付いた。 vagrantの環境追加に始まり開発環境でのsslをchromeでも閲覧可能にする方法の確立にはじまったが、エディタにvscodeを使うようになってツールに助けられた部分も大きい。複数のファイル間の呼び出し元クラスへ簡単に渡り歩けてコードを確認できり、ターミナルでシームレスにコマンドを叩いてテスト動作を確認できたりしたのはメリットだった。また、plantUMLを初めて使ってみて、書籍を同じクラス図等が作れるようになったり、自分のアプリケーションのコードと見比べたりも簡単に出来たので、理解の助けになった気がする。ツールはやはり良いものを使うべきだなぁ。という事を実感。 そしてこの3記事に渡るMarkdownのテキストも、全てまとめるとここが今2000行目!かなりのボリュームとなった(ソースコードは書籍GitHubのコピペが大半で申し訳ないが...) 今後は当初の目的であった自分のアプリのテストをゴリゴリ書いてデグレしにくい体制を早く作りたい。
【輪読会資料】PHPフレームワーク Laravel Webアプリケーション開発 9章テスト 9-2『データベーステスト』
以下の記事は2019年1月10日、コワーキングスペース秋葉原Weeybleにて行われる [秋葉原] Laravel Webアプリケーション開発 輪読会 (9章 テスト)の輪読会資料の一部となります。 今回は 9-2 章部分の『データベーステスト』部分の記事をアップします。
また、元となっている書籍はこちらとなります。
PHPフレームワーク Laravel Webアプリケーション開発 バージョン5.5 LTS対応
- 作者: 竹澤有貴,栗生和明,新原雅司,大村創太郎,丸山弘詩
- 出版社/メーカー: ソシム
- 発売日: 2018/09/26
- メディア: 単行本
- この商品を含むブログを見る
- 9-2 データベーステスト
- 9-2-1 テスト対象のテーブルとクラス
- 9-2-2 データベーステストの基礎
- 9-2-3 Eloquentクラスのテスト
- 9-2-4 サービスクラスのテスト
- 9-2-5 モックによるテスト(サービスクラス)
9-2 データベーステスト
データベースを利用したテストコードの実装
DBを利用するテストでは以下の様な手間がかかる作業が多い
- テスト用DBの設定
- テスト用レコードの登録
- 対象クラスの処理後のレコード検証
本節はこれらを解説する
9-2-1 テスト対象のテーブルとクラス
本節で使うDBを確認する。あるアプリケーションの一部を見立てた会員のポイントを加算する処理を例に解説する
テーブル構成
9.2.1.1 テーブル構成を示すER図
各tableの概要
テーブル名 | 概要 |
---|---|
customers | ユーザー情報 |
customer_points | ユーザーの現在ポイントを収納 |
customer_point_events | ポイント変化値とイベント名を収納 |
処理シナリオ
- customer_point_events テーブルで加算イベント追加
- customer_points テーブルが保持するポイントを加算
- 1, 2, を同一トランザクションで実施
- 処理失敗の際はロールバック
実装クラス
9.2.1.5 ポイント加算処理のクラス構成
以下、各classのコードの記述があるが省略、詳しくは書籍、もしくはリンク先GitHubのコードを参照の事
app\Services\AddPointService.php
Serviceクラス : add メソッドで、複数のtableにtransaction付きでデータを書き込む複数メソッドの実行が記述されている
app\Eloquent\EloquentCustomerPointEvent.php
extends Model : customer_point_events
tableと関連付けし、registerメソッドで各カラム(cuntomer_id, event, point, created_at)にデータをsaveしている
app\Eloquent\EloquentCustomerPoint.php
extends Model : addPointメソッドでcustomer_point
テーブルの該当 customer_id のpointを更新している
app\Model\PointEvent.php
Model : 処理に必要な個別の項目の定義(DBカラム)と、呼び出し定義(getで始まるメソッド)記述のみをしている
9-2-2 データベーステストの基礎
テスト実行前にDBの状態を整える必要がある。テストに影響を与える環境を常に同じ状態に整えてからテストを実行することが重要。
テスト用データベースを利用
テスト用のDBを用意する 9.2.2.1 テスト用DBの作成例(MySQL)
$ mysqladmin create app_test # これでcreate database できるの知らんかった…
テスト時はこちらを使うようphpunit.xmlで設定する。
9.2.2.2 テスト用DBの設定(phpunit.xml 抜粋)
<php> <env name="APP_ENV" value="testing"/> <env name="CACHE_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/> <env name="QUEUE_DRIVER" value="sync"/> <!-- ここでテスト用データベースを指定 --> <env name="DB_DATABASE" value="app_test"/> <env name="MAIL_DRIVER" value="log"/> </php>
テスト用トレイトの利用
RefreshDatabaseトレイトについて
テスト実行時に自動でマイグレーションを行わせるには、RefreshDatabaseトレイトを使う。使用には以下の様に use させる
<?php use Illuminate\Foundation\Testing\RefreshDatabase; class EloquentCustomerPointEventTest extends TestCase { use RefreshDatabase; // 自動でマイグレーション実行 // (省略) }
現在のDBをテスト開始時に自動で migrate:refresh してくれる。
自動で(入れ子の)トランザクションがされるので、テスト時の内容は全てロールバックされ元に戻る
他にもDB制御するテスト用トレイトはある。詳細は書籍を見てください。
Factoryでテスト用レコードの準備
テストに必要なレコードを登録するにはFactoryが便利
利用は以下のコマンド make:factory を使用する
引数は生成するFactory名を指定
命名ルールは [Eloquentクラス名 + Factory] にすると良い。
9.2.2.4 make:factoryコマンドの実行例
$ php artisan make:factory EloquentCustomerFactory Factory created successfully.
上記コマンドで以下にファイルが生成される
database\factories\EloquentCustomerFactory.php
9.2.2.5 生成されたEloquentCustomerFactory
<?php use Faker\Generator as Faker; $factory->define(Model::class, function (Faker $faker) { return [ // ]; });
9.2.2.6 EloquentCustomer用に変更したCuntomerFactory
<?php declare(strict_types=1); // use で該当のEroquentクラスを呼ぶ use App\Eloquent\EloquentCustomer; // 5章.データベース 5-2-4で使用したFaker(ダミーデータライブラリ)を使用 use Faker\Generator as Faker; // define の第一引数に対象のEloquentクラス名を指定 $factory->define(EloquentCustomer::class, function (Faker $faker) { return [ // Eroquantに設定するプロパティを連想配列で指定,Fakerでそれっぽい人名が入る 'name' => $faker->name, ]; });
ちなみに use で呼ばれる該当のEroquentクラス
App\Eloquent\EloquentCustomer;
の中身はお馴染みの
Model = table名でがっつり定義してるこれ
<?php declare(strict_types=1); namespace App\Eloquent; use Illuminate\Database\Eloquent\Model; /** * @property int $id * @property string $name */ final class EloquentCustomer extends Model { protected $table = 'customers'; }
9.2.2.7 factory関数の利用例
<?php // cuntomerテーブルに1レコード登録 factory(EloquentCustomer::class)->create(); // cuntomerテーブルに1レコード登録(nameを指定) factory(EloquentCustomer::class)->create([ 'name' => '太郎', ]); // cuntomerテーブルに3レコード登録 factory(EloquentCustomer::class, 3)->create();
データベースのアサーション
テスト中に変更したDBのレコード検証には、DBのアサーションメソッドを利用すると良い。
以下は実行例、使い方を見たまんまで書いてある
9.2.2.8 データベースのアサーションメソッド例
<?php // customersテーブルにid=1のレコードが存在すれば成功 $this->assertDatabaseHas('customers', [ 'id' => 1, ]); // customersテーブルにid=100のレコードが存在しなければ成功 $this->assertDatabaseHasMissing('customers', [ 'id' => 100, ]);
上記の様なレコードの有無で検証できないケース、例えばレコード数を測りたい場合はEloquentやクエリビルダを利用して検証する
9.2.2.9 クエリビルダを利用したアサーション例
<?php //customerテーブルに5件のレコードがあれば成功 $this->assertSame(5, \DB::table('customers')->count());
9-2-3 Eloquentクラスのテスト
では実際にDBのテストを行って行く
EloquentCustomerPointEventクラスのテストを行う。
app\Eloquent\EloquentCustomerPointEvent.php
(customer_point_events
tableと関連付けし、registerメソッドで各カラム(cuntomer_id, event, point, created_at)にデータをsaveしている)
テスト対象は registerメソッド
テストの際の事前条件と事後条件を想定する
- 事前条件: customersテーブルに対象レコードがある。
- 事後条件: customer_point_eventsにレコードが追加されている。
以下は実装したテストクラス
9.2.3.1 EloquentCustomerPointEventTestクラス(書籍はEloquentCustomerPointTest
とあり校正ミス)
<?php declare(strict_types=1); namespace Tests\Unit\AddPoint; use App\Eloquent\EloquentCustomer; use App\Eloquent\EloquentCustomerPointEvent; use App\Model\PointEvent; use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class EloquentCustomerPointEventTest extends TestCase { use RefreshDatabase; // <---(1) /** * @test */ public function register() { $customerId = 1; // (2) テストデータ登録 // name入れてないのはP405 9.2.2.6 factory側でFakerで指定されてる為っぽい factory(EloquentCustomer::class)->create([ 'id' => $customerId, ]); // テスト対象メソッドの実行 // コンストラクタの引数(DBカラム名)をそのままの順番で入れてる $event = new PointEvent( $customerId, '加算イベント', 100, Carbon::create(2018, 8, 4, 12, 34, 56) ); // test対象のクラスをnewしてDBに登録 $eloquet = new EloquentCustomerPointEvent(); $eloquet->register($event); // (4) データベースレコードのアサーション // 今登録したレコードがあるかを確認 $this->assertDatabaseHas('customer_point_events', [ 'customer_id' => $customerId, 'event' => $event->getEvent(), 'point' => $event->getPoint(), 'created_at' => $event->getCreatedAt(), ]); } }
本には書いてないがテストを実施してみる
vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/AddPoint/EloquentCustomerPointEventTest.php PHPUnit 6.5.9 by Sebastian Bergmann and contributors. . 1 / 1 (100%) Time: 8.45 seconds, Memory: 16.00MB OK (1 test, 1 assertion)
8秒以上かかった。初回はテスト用のDB作成からmigrateも走るので無理もないかも。
次にEloquentCustomerPointクラスのテストの実装
app\Eloquent\EloquentCustomerPoint.php
addPointメソッドでcustomer_point
テーブルの該当 customer_id のpointを更新している
addPointメソッドは下記の事前条件と事後条件を想定している
- 事前条件1: customersテーブルに対象のレコードがある
- 事前条件2: customer_pointテーブルに対象のレコードがある
- 事後条件: customer_pointテーブルの対象レコードにpointが加算されている
これも一つ前のテストとほとんど同じ構成となっている。違いとしては addPointで元の 100ポイントから、10ポイント加算で110ポイントになっているかをテストしている。
9.2.3.2 EloquentCustomerPointTestクラス
<?php declare(strict_types=1); namespace Tests\Unit\AddPoint; use App\Eloquent\EloquentCustomer; use App\Eloquent\EloquentCustomerPoint; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class EloquentCustomerPointTest extends TestCase { use RefreshDatabase; /** * @test */ public function addPoint() { // (1) テストに必要なレコードを登録 $customerId = 1; // 不要と思い込んでましたが、こっちのtableのidがPKなので必要ですね。 factory(EloquentCustomer::class)->create([ 'id' => $customerId, ]); // ここは必須 factory(EloquentCustomerPoint::class)->create([ 'customer_id' => $customerId, 'point' => 100, ]); // (2) テスト対象メソッドの実行 $eloquent = new EloquentCustomerPoint(); $result = $eloquent->addPoint($customerId, 10); // (3) テスト結果のアサーション $this->assertTrue($result); $this->assertDatabaseHas('customer_points', [ 'customer_id' => $customerId, 'point' => 110, ]); } }
同様にテストを実施してみた。今度は2秒程度。テスト用のtableが出来た状態からのテストなので早かった。
vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/AddPoint/EloquentCustomerPointTest.php PHPUnit 6.5.9 by Sebastian Bergmann and contributors. . 1 / 1 (100%) Time: 2.38 seconds, Memory: 16.00MB OK (1 test, 2 assertions)
このようにDBを利用するEloquentクラスのテストもFactoryやデータベースアサーションを利用することで手軽なテストができる。
9-2-4 サービスクラスのテスト
AddPointServiceクラスのテスト方法について
app\Services\AddPointService.php
add メソッドで、複数のtableにtransaction付きでデータを書き込む複数メソッドの実行が記述されている
addメソッドの事前条件と事後条件の想定は以下の通り
- 事前条件1: customersテーブルに対象レコードがある
- 事前条件2: customer_pointテーブルに対象レコードがある
- 事後条件1: customer_point_eventsテーブルに対象レコードがある
- 事後条件2: customer_pointsテーブルの対象レコードにpointが加算されている
9.2.4.1 AddPointServiceTestクラス
<?php declare(strict_types=1); namespace Tests\Unit\AddPoint; use App\Eloquent\EloquentCustomer; use App\Eloquent\EloquentCustomerPoint; use App\Model\PointEvent; use App\Services\AddPointService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Tests\TestCase; class AddPointServiceTest extends TestCase { use RefreshDatabase; const CUSTOMER_ID = 1; protected function setUp() { parent::setUp(); // (1) テストに必要なレコードを登録 factory(EloquentCustomer::class)->create([ 'id' => self::CUSTOMER_ID, ]); factory(EloquentCustomerPoint::class)->create([ 'customer_id' => self::CUSTOMER_ID, 'point' => 100, ]); } /** * @test * @throws \Throwable */ public function add() { // (2) テスト対象メソッドの実行 $event = new PointEvent( self::CUSTOMER_ID, '加算イベント', 10, Carbon::create(2018, 8, 4, 12, 34, 56) ); /** @var AddPointService $service */ $service = app()->make(AddPointService::class); $service->add($event); // (3) テスト結果のアサーション $this->assertDatabaseHas('customer_point_events', [ 'customer_id' => self::CUSTOMER_ID, 'event' => $event->getEvent(), 'point' => $event->getPoint(), 'created_at' => $event->getCreatedAt(), ]); $this->assertDatabaseHas('customer_points', [ 'customer_id' => self::CUSTOMER_ID, 'point' => 110, ]); } }
テンプレートメソッドを使う際はparent
で継承元メソッドを呼ぶことを忘れないようにする
parent::setUp()
テストメソッド内に前提条件を書いても良いが、不明瞭になるので、setUpメソッドに事前条件を書くのが望ましい。
ここで、サービスクラスのインスタンス化をしてる?(この書き方良くわかってない)
<?php /** @var AddPointService $service */ $service = app()->make(AddPointService::class);
テスト実行結果
vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/AddPointServiceTest.php PHPUnit 6.5.9 by Sebastian Bergmann and contributors. . 1 / 1 (100%) Time: 2.37 seconds, Memory: 16.00MB OK (1 test, 2 assertions)
9-2-5 モックによるテスト(サービスクラス)
一つ前の 9-2-4 と別で、EloquentクラスをモックにしてDBにアクセスせずに、サービスクラスの処理のみをテストする方法もある。
無名クラスを使って、テスト対象のクラスが読み込む子供のクラスをその場で使い捨て利用する感じ。
9.5.5.1 モックを利用したAddPointSErviceクラスのテスト
<?php declare(strict_types=1); namespace Tests\Unit; use App\Eloquent\EloquentCustomerPoint; use App\Eloquent\EloquentCustomerPointEvent; use App\Model\PointEvent; use App\Services\AddPointService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Tests\TestCase; class AddPointServiceWithMockTest extends TestCase { use RefreshDatabase; private $customerPointEventMock; private $customerPointMock; protected function setUp() { parent::setUp(); // (1) Eloquentクラスのモック化 $this->customerPointEventMock = new class extends EloquentCustomerPointEvent { /** @var PointEvent */ public $pointEvent; public function register(PointEvent $event) { $this->pointEvent = $event; } }; $this->customerPointMock = new class extends EloquentCustomerPoint { /** @var int */ public $customerId; /** @var int */ public $point; public function addPoint(int $customerId, int $point): bool { $this->customerId = $customerId; $this->point = $point; return true; } }; } /** * @test * @throws \Throwable */ public function add() { // (2) テスト対象メソッドの実行 $customerId = 1; $event = new PointEvent( $customerId, '加算イベント', 10, Carbon::create(2018, 8, 4, 12, 34, 56) ); $service = new AddPointService( $this->customerPointEventMock, $this->customerPointMock ); $service->add($event); // (3) テスト結果のアサーション $this->assertEquals($event, $this->customerPointEventMock->pointEvent); $this->assertSame($customerId, $this->customerPointMock->customerId); $this->assertSame(10, $this->customerPointMock->point); } }
個人的な解釈
テスト対象のAddPointService.php
の一部はこうなっている
<?php public function __construct( // 2人の子供を起こして使える様に設計されているので... EloquentCustomerPointEvent $eloquentCustomerPointEvent, EloquentCustomerPoint $eloquentCustomerPoint ) { $this->eloquentCustomerPointEvent = $eloquentCustomerPointEvent; $this->eloquentCustomerPoint = $eloquentCustomerPoint; $this->db = $eloquentCustomerPointEvent->getConnection(); }
テストコードは無名クラスとしてDBにアクセスするEloquentCustomerPointEvent
とEloquentCustomerPoint
をsetUp()
メソッド内でnewして privateプロパティにしている。
AddPointServiceWithMockTest.php
抜粋
<?php private $customerPointEventMock; private $customerPointMock; protected function setUp() { // 省略 $this->customerPointEventMock = new class extends EloquentCustomerPointEvent { // 省略 } $this->customerPointMock = new class extends EloquentCustomerPoint { // 省略 } }
これをadd()
メソッド内で、AddPointService
をnewさせる際に、コンストラクタインジェクションの引数に読み込んで利用している。
AddPointServiceWithMockTest.php
抜粋
<?php $service = new AddPointService( $this->customerPointEventMock, $this->customerPointMock );
なんとか理解はできたが、自分で書いてサクサク利用できるようになるには、時間がかかりそう。
【輪読会資料】PHPフレームワーク Laravel Webアプリケーション開発 9章テスト 9-1『ユニットテスト』
以下の記事は2019年1月10日、コワーキングスペース秋葉原Weeybleにて行われる [秋葉原] Laravel Webアプリケーション開発 輪読会 (9章 テスト)の輪読会資料の一部となります。 今回は 9-1 章部分の『ユニットテスト』部分の記事をアップします。
また、元になっている書籍は以下となります。
PHPフレームワーク Laravel Webアプリケーション開発 バージョン5.5 LTS対応
- 作者: 竹澤有貴,栗生和明,新原雅司,大村創太郎,丸山弘詩
- 出版社/メーカー: ソシム
- 発売日: 2018/09/26
- メディア: 単行本
- この商品を含むブログを見る
PHPフレームワーク Laravel Webアプリケーション開発 輪読会資料
Chapter 9 テスト
テストコード実装の基礎と実践
目次
- 9-1 ユニットテスト
- 9-1-1 テスト対象クラス
- 9-1-2 テストクラスの生成
- 9-1-3 テストメソッドの実装
- 9-1-4 データプロバイダの活用
- 9-1-5 例外のテスト
- 9-1-6 テストの前処理・後処理
- 9-1-7 テストの設定
- 9-2 データベーステスト
- 9-2-1 テスト対象のテーブルとクラス
- 9-2-2 データベーステストの基礎
- 9-2-3 Eloquentクラスのテスト
- 9-2-4 サービスクラスのテスト
- 9-2-5 モックによるテスト(サービスクラス)
- 9-3 WebAPIテスト
なお、説明中のサンプルコードは以下のリンクより取得したものです。
PHPフレームワーク Laravel Webアプリケーション開発 laravel-socym/chapter09 by GitHub
9-1 ユニットテスト
Lravelでサポートされるテスト機能
余談
いつも見るサンプルコードの冒頭の奴declare(strict_types=1);
は、
厳密な型チェックモードに設定している。
これを記述することによって型指定をして、間違った値が入るとエラーを出すそうです。
9-1-1 テスト対象クラス
ここではポイント算出のメソッドを元にtestを行う
app\Services\CalculatePointService.php
<?php declare(strict_types=1); namespace App\Services; use App\Exceptions\PreConditionException; final class CalculatePointService { /** * @param int $amount * @return int * @throws PreConditionException */ public static function calcPoint(int $amount): int { if ($amount < 0) { throw new PreConditionException('購入金額が負の数'); } if ($amount < 1000) { return 0; } if ($amount < 10000) { $basePoint = 1; } else { $basePoint = 2; } return intval($amount / 100) * $basePoint; } }
要約すると以下の処理をするメソッド
買い物をした際に付く、〇〇ポイントの算出をする機能。以下のルールで行う
購入金額 | ポイント |
---|---|
0~999 | ポイント無し |
1,000~9,999 | 100円につき1ポイント |
10,000以上 | 100円につき2ポイント |
9-1-2 テストクラスの生成
ユニットテストを記述するテストクラスは以下のコマンドで生成する
$ php artisan make:test CalculatePointServiceTest --unit Test created successfully.
以下のパスにファイルが生成される
tests\Unit\CalculatePointServiceTest.php
9.1.2.4 生成されたCalculatePointServiceTestクラス
<?php namespace Tests\Unit; use Tests\TestCase; use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Foundation\Testing\RefreshDatabase; class CalculatePointServiceTest extends TestCase { /** * A basic test example. * * @return void */ public function testExample() { $this->assertTrue(true); } }
ここで大事な補足
書籍にはないが、 Testクラスを作成したらテスト対象となるクラスを use させないと駄目。
今回のテスト対象はApp\Services\CalculatePointService
なので以下の一行をTestファイルのクラス宣言前に追加する。
<?php namespace Tests\Unit; use Tests\TestCase; use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Foundation\Testing\RefreshDatabase; // この行を追加 use App\Services\CalculatePointService; class CalculatePointServiceTest extends TestCase { // 省略 }
リスト9.1.2.2 testsディレクトリの構成
tests ├─Feature // フィーチャ機能テストのディレクトリ │ └─ExampleTest.php ├─Unit // ユニットテストのディレクトリ │ ├─CalculatePointServiceTest.php // 生成されたテストクラス(これ以外はデフォルト) │ └─ExampleTest.php ├──CreatesApplication.php └──TestCase.php // テスト基底クラス
テストクラスを実装する際は Tests\TestCase クラスを継承することが多いが、フレームワークの機能を使わない場合は PHPUnit\Framework\TestCase クラスを直接敬称しても問題ない。
9.1.2.3 テストクラスのクラス図
生成されたtestClassには以下の様な test... で始まるメソッドが作られる
test... から始まるメソッドがテストメソッドとして実行される。
9.1.2.4 抜粋 生成された CalculatePointServiceTest クラス
<?php public function testExample() { $this->assertTrue(true); }
別の方法でコメントに @test アノテーションを付ける方法もある。作者はこっちをお勧めしてる
参考: アノテーションについて
9.1.2.5 @testアノテーション
<?php /** * @test */ public function Example() { $this->assertTrue(true); }
さらにアノテーションと、メソッド名を日本語にすると(え!?) test結果の判別が付けやすくなる
<?php /** * @test */ public function divide_除数がゼロなら例外を投げる() { // (略) }
9.1.2.6 テストの実行例
phpunitコマンドに続けてテストクラスファイルを指定する
$ ./vendor/bin/phpunit tests/Unit/CalculatePointServiceTest.php PHPUnit 6.5.9 by Sebastian Bergmann and contributors. . 1 / 1 (100%) Time: 1.94 seconds, Memory: 10.00MB OK (1 test, 1 assertion)
9.1.2.8 テストの失敗例
vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/CalculatePointServiceTest.php PHPUnit 6.5.9 by Sebastian Bergmann and contributors. F 1 / 1 (100%) Time: 1.04 seconds, Memory: 10.00MB There was 1 failure: 1) Tests\Unit\CalculatePointServiceTest::Example Failed asserting that false is true. /home/vagrant/larabook/chapter09/tests/Unit/CalculatePointServiceTest.php:19 FAILURES! Tests: 1, Assertions: 1, Failures: 1.
コマンドの際 phpunit コマンドの引数を省略すると 全テストが実行される。よく使うので覚えておくと良い
9.1.2.9 全てのテストの実行例
$ ./vendor/bin/phpunit
但し、アプリケーションの規模が大きい場合は、テスト実行時間が恐ろしい事になるので安易やらない方が良い。(Ruby on Railsでの経験則)
9-1-3 テストメソッドの実装
ポイント付与のサンプルコードを元にテストメソッドを実装する
ここでは、ポイント算出ルールの境界値に沿ってテストを記述する
まず、購入金額が0円のパターンを検査する
0円の場合ポイントが0になるか?
9.1.3.2 主なアサーションメソッド(抜粋)
メソッド | 内容 |
---|---|
assertSame | 型も含めて期待値と値が一致するかを検証 |
assertTrue | 値がtrueかどうかを検証 |
assertReqExp | 値が正規表現にマッチするかどうかを検証 |
assertArrayHasKey | 値が配列の場合、指定したキーが存在するかを検証 |
tests\Unit\CalculatePointServiceTest.php
に以下のメソッドを追加
<?php /** * @test */ public function calcPoint_購入金額が0ならポイントは0() { $result = CalculatePointService::calcPoint(0); $this->assertSame(0, $result); // $result が0である事を検証 }
テストを実行すると2つのtestが通る事を確認できる
vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/CalculatePointServiceTest PHPUnit 6.5.9 by Sebastian Bergmann and contributors. .. 2 / 2 (100%) Time: 4.43 seconds, Memory: 10.00MB OK (2 tests, 2 assertions)
同様に今度は購入金額1000円でのテストを書く
<?php /** * @test */ public function calcPoint_購入金額が1000ならポイントは10() { $result = CalculatePointService::calcPoint(1000); $this->assertSame(10, $result); // $result が10である事を検証 }
同様にテストが3つ通る事を検証
vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/CalculatePointServiceTest PHPUnit 6.5.9 by Sebastian Bergmann and contributors. ... 3 / 3 (100%) Time: 1.03 seconds, Memory: 10.00MB OK (3 tests, 3 assertions)
こんな感じでテスト項目を増やすって事らしい
9-1-4 データプロバイダの活用
テストメソッドは引数と戻り値の組み合わせのみだが、データプロバイダは同じ処理に対して、異なるパラメータや引数を渡してテストする事ができて便利。
テストメソッドに渡すパラメータを指定するメソッドを用意する
データプロバイダメソッドは public
にする必要がある
9.1.4.1 データプロバイダメソッドの例
<?php public function dataProvider_for_calcPoint(): array { return [ '購入金額が0なら0ポイント' => [0, 0], '購入金額が999なら0ポイント' => [0, 999], '購入金額が1000なら10ポイント' => [10, 1000], ]; }
データプロバイダを利用するには、テストメソッドに @dataProvider
アノテーションを指定する。
9.1.4.2 データプロバイダを利用したテストメソッド
<?php /** * @test * @dataProvider dataProvider_for_calcPoint */ public function calcPoint(int $expected, int $amount) { $result = CalculatePointService::calcPoint($amount); $this->assertSame($expected, $result); }
仮に以下の値をあえて間違った上でテストを実行してみる
'購入金額が1000なら10ポイント' => [0, 1000],
vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/CalculatePointServiceTest PHPUnit 6.5.9 by Sebastian Bergmann and contributors. .....F 6 / 6 (100%) Time: 1.34 seconds, Memory: 10.00MB There was 1 failure: 1) Tests\Unit\CalculatePointServiceTest::calcPoint with data set "購入金額が1000なら10ポイント" (0, 1000) Failed asserting that 10 is identical to 0. /home/vagrant/larabook/chapter09/tests/Unit/CalculatePointServiceTest.php:59 FAILURES! Tests: 6, Assertions: 6, Failures: 1.
1エラーとなりエラーとなった配列のキー(日本語)と、配列の組み合わせ(0,1000)が出力されるので、エラー詳細が明快になる。
より多くのテストパターンを実装、数値の境界部分の値を追加している
9.1.4.6 データプロバイダメソッドに要素を追加
<?php public function dataProvider_for_calcPoint() : array { return [ '購入金額が0なら0ポイント' => [0, 0], '購入金額が999なら0ポイント' => [0, 999], '購入金額が1000なら10ポイント' => [10, 1000], '購入金額が9999なら99ポイント' => [99, 9999], '購入金額が10000なら200ポイント' => [200, 10000], ]; }
こんな感じでデータプロバイダメソッドでは複数の値、パラメータでテストが行える。
9-1-5 例外のテスト
throw で投げられた例外処理をテストするには、以下の利用方法がある
- try/catch の利用
- expectException メソッド
- @expectedExcepsion アノテーション
作者が薦めるのは3つめのアノテーション、ここでは
- 例外がスローされるか?
- スローされた例外が意図したものであるか?
を検証する
言葉の意味は expect Exception (期待される 例外)となる
以下3つの例はこのコードのみで完結する自己循環型のテスト例で、CalculatePointService
をテストしている訳では無い
try/catchの利用
通常のPHPコードと同様にテスト対象を tryで囲む奴を使う
<?php /** * @test */ public function exception_try_catch() { try { throw new \InvalidArgumentException('message', 200); $this->fail(); // (1)例外がスローされない時はテストを失敗させる } catch (\Throwable $e) { // 指定した例外クラスがスローされているか $this->assertInstanceOf(\InvalidArgumentException::class, $e); // スローされた例外のコードを検証 $this->assertSame(200, $e->getCode()); // スローされた例外のメッセージを検証 $this->assertSame('message', $e->getMessage()); } }
InvalidArgumentException
引数の型が期待する型と一致しなかった場合にスローされる例外。
expectException メソッドの利用
<?php /** * @test */ public function exception_expectedException_method() { // 指定した例外クラスがスローされているか $this->expectException(\InvalidArgumentException::class); // スローされた例外のコードを検証 $this->expectExceptionCode(200); // スローされた例外のメッセージを検証 $this->expectExceptionMessage('message'); throw new \InvalidArgumentException('message', 200); }
@expectedExcepsion アノテーションの利用
作者押しの例外テスト方法
アノテーション(コメント)部分に必要事項を書いてしまう
<?php /** * @test * @expectedException \InvalidArgumentException * @expectedExceptionCode 200 * @expectedExceptionMessage message */ public function exception_expectedException_annotation() { throw new \InvalidArgumentException('message', 200); }
9.1.5.4 購入金額が負数の場合のテスト
テスト例となる CalculatePointService
classのメソッドに以下の様な throw 処理がある、これをテストする
<?php if ($amount < 0) { throw new PreConditionException('購入金額が負の数'); }
ちなみにPreConditionException
だが、ファイルを見ても特に何も書いていないが、ちゃんとしたアプリケーションならここにちゃんと例外を起こした際に書くべき処理を書けって事かな?ここでのテストはちゃんとそこに正しい値が投げられるようになっているかを検証するまでを行う。
app\Exceptions\PreConditionException.php
<?php declare(strict_types=1); namespace App\Exceptions; final class PreConditionException extends \Exception { }
例外テストの具体例
<?php /** * @test * @expectedException \App\Exceptions\PreConditionException * @expectedExceptionMessage 購入金額が負の数 */ public function calcPoint_購入金額が負の数なら例外をスロー() { CalculatePointService::calcPoint(-1); }
マイナスの値がcalcPoint
に投げられた際を検証するために
アノテーションで ’@expectedException'で使用される例外処理先\App\Exceptions\PreConditionException
を指定する
つまり『この異常な値を放り込んだら、この Exceptionにこのメッセージが飛んでる?』って事をテストしてる。当然だが、メッセージをちょっとでも変えるとテストは通らずエラーになる。
9-1-6 テストの前処理・後処理
テスト前にDBに必要なの値の仕込みや、テスト後に値を削除変更する必要がある場合、テストメソッドに書くと煩雑になるので、別途専用にメソッドが用意されている。PHPUnit\Framework\TestClass
にあるテンプレートメソッド。
- 前処理 setUp メソッド
- テスト中 testメソッド
- 後処理 tearDown メソッド
の順で呼ばれて処理される
また、テストクラス毎に呼ばれる以下もある
- setUpBeforeClass メソッド
- tearDownAfterClass メソッド
これらはテストメソッドが属するテストクラス毎に1回だけ呼ばれる
9.1.6.1 テンプレートメソッドの動きを見るテスト
どんな順番で処理が実行されるかを確かめるメソッド群
<?php declare(strict_types=1); namespace Tests\Unit; use App\Services\CalculatePointService; use Tests\TestCase; class TemplateMethodTest extends TestCase { public static function setUpBeforeClass() { parent::setUpBeforeClass(); echo __METHOD__, PHP_EOL; } protected function setUp() { parent::setUp(); echo __METHOD__, PHP_EOL; } /** * @test */ public function テストメソッド1() { echo __METHOD__, PHP_EOL; $this->assertTrue(true); } /** * @test */ public function テストメソッド2() { echo __METHOD__, PHP_EOL; $this->assertTrue(true); } protected function tearDown() { parent::tearDown(); echo __METHOD__, PHP_EOL; } public static function tearDownAfterClass() { parent::tearDownAfterClass(); echo __METHOD__, PHP_EOL; } }
上記のテストを実行すると以下の様な順序でテストが行われる事が確認できる
vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/TemplateMethodTest.php PHPUnit 6.5.9 by Sebastian Bergmann and contributors. Tests\Unit\TemplateMethodTest::setUpBeforeClass .Tests\Unit\TemplateMethodTest::setUp Tests\Unit\TemplateMethodTest::テストメソッド1 Tests\Unit\TemplateMethodTest::tearDown . 2 / 2 (100%)Tests\Unit\TemplateMethodTest::setUp Tests\Unit\TemplateMethodTest::テストメソッド2 Tests\Unit\TemplateMethodTest::tearDown Tests\Unit\TemplateMethodTest::tearDownAfterClass Time: 1.1 seconds, Memory: 10.00MB OK (2 tests, 2 assertions)
これらのテンプレートメソッドを使う際はparent
で継承元メソッドを呼ぶことを忘れないようにすること
parent::setUp()
9-1-7 テストの設定
Laravelのルートディレクトリにあうphpunit.xml
ファイルで以下の設定項目の編集が可能
詳細な設定に関しては PHPUnit の公式マニュアルを参照のこと
【輪読会資料】達人に学ぶSQL徹底指南書 第2版 9, SQLで集合演算
以下の記事は2018/12/26 コワーキングスペース秋葉原Weeybleで行われる輪読会
[秋葉原] 達人に学ぶSQL徹底指南書 輪読会 第1部 魔法のSQL (集合演算/数列) の資料となります。
以下の書籍の 第一部 9 SQLで集合演算の要約です。
達人に学ぶSQL徹底指南書 第2版 初級者で終わりたくないあなたへ (CodeZine BOOKS)
- 作者: ミック
- 出版社/メーカー: 翔泳社
- 発売日: 2018/10/11
- メディア: 単行本(ソフトカバー)
- この商品を含むブログを見る
9 SQLで集合演算
見出し一覧
- SQLと集合論
- [Extra] そもそも集合論とは?
- はじめに
- 導入---集合演算に関するいくつかの注意点
- テーブル同士のコンベア---集合の相等性チェック[基本編]
- [Extra おさらい] UNION の簡単な使用例
- テーブル同士のコンベア---集合の相等性チェック[応用編]
- 差集合で関係除算を表現する
- 等しい部分集合を見つける
- 重複行を削除する高速なクエリ
- まとめ
- 演習問題
1版の記事、及びサンプルコードは以下リンク先よりDL可能
達人に学ぶSQL SQLで集合演算 集合指向言語としてのSQL:その4
サンプルコード
2版用に独自アレンジしたもの
Dropbox - 9_SQLで集合演算_サンプルコード.txt
SQLを手軽にシミュレートするにはこちらのサイト
sqlfiddle(但しdelete系は使用不可、またちょいちょい調子悪くなる)
http://sqlfiddle.com/
ORACLE Live SQL(アカウント登録が必要)
Oracle Live SQL
SQLと集合論
近年までSQLは集合演算の整備をしていなかった為、その機能は十分活用されてこなかった。
が、最近になって機能が出揃ってきた。
ここは集合演算の背景にある考え方を理解する章となる。
[Extra] そもそも集合論とは?
高校位の数学でやった筈なので、おおよそのエンジニアさんには釈迦に説法かもですが、俺が再認識したいので以下にまとめます。
部分集合 included, subset
『 A は B の部分集合である 』といい
A ⊆ B
と表現される
また、日本の高等数学では
A ⊂ B
を使用する
俺要約 要は 不等号の ≦ でのイメージをそのまま転写して『含む』で思考すれば良い
A は B に包まれる(included; 包摂あるいは内包される)などともいう
和集合 union
和 合併集合 合併(union) などともいう
『すくなくとも片方に入っているもの』を集めた集合
A ∪ B
と表現される 俺要約 要は OR関数(または)で定義する範囲の結果を示す 記号の覚え方は 広く囲む掌をイメージ
積集合 intersection
『両方ともにはいっているもの』を集めた集合
共通部分 ともいう
A ∩ B
と表現される 俺要約 要は AND関数(かつ)で定義する範囲の結果を示す 記号の覚え方は 『蓋をした範囲のみ』でイメージ
はじめに
SQLは「集合指向言語」と呼ばれている、本書のテーマも集合論に基づいたSQLにある。しかし、SQLはちょっと前まで高校で習うレベルの集合演算子すら持っていなかった。
それゆえSQLは不完全なものという批判があった。
集合 | SQL名 | 採用Ver | 記号 |
---|---|---|---|
和 | UNION | 86~ | A ∪ B |
交差 | INTERSECT | 92~ | A ∩ B |
差 | EXCEPT | 92~ | A - B |
除算 | DIVIDE BY | 未対応 | A / B ? |
しかし、現在ではSQLに基本的な集合演算子が出揃い、本格的な応用も可能となってきた。
この章では、集合演算を利用したSQLを紹介し、違った角度からSQLの本質に迫る。
導入---集合演算に関するいくつかの注意点
要は SQLの tableやview を引数に取って演算をする。中学・高校で習った集合台数と似ているが、独特な特徴もあり注意が必要。
注意1
SQLの扱う集合は重複行を許す多重集合。それに対応するALLオプションが存在する。
通常の学習における『集合論』は重複を認めないが、SQLは認める前提 多重集合(multiser, bag)である。
UNION, INTERSECT をそのまま使うと結果から重複行を排除する。
重複行を残したい際は ALL オプションを使う事(例: UNION ALL )
SELECT句のDISTINCTと反対の扱いだが これは間違い→ UNION DISTINCT
ALL オプションを付けるとパフォーマンスが向上する (ソートが行われない為)
付けないと重複を許容し、暗黙的なソートが発生するので重くなるらしい。
なのでパフォーマンスチューニングでじゃ ALLオプションを付けると良い
注意2
演算の順番に優先順位がある
UNION,EXCEPT に対して INTERSECT の方が先に実行される。
もしUNIONを優先的に実行したい場合は (カッコ) でくくって順序を指定する事(後で詳細あり)
注意3
DBMSごとに集合演算子の実装にバラツキがある
MySQLは2018年現在 INTERSECT,EXCEPT サポートしてない。
Oracle は EXCEPT が MINUS という名になっている
注意4
除算の標準的な定義がない
- 和 UNION
- 差 EXCEPT
- 積 CROSS JOIN
はあるが、
- 商 DIVIDE BY
は、標準化が遅れている。
(詳しくはP105 HAVING句の力 を参照の事)
除算を行う際は自前でクエリを作る必要がある等
テーブル同士のコンベア---集合の相等性チェック[基本編]
さて、では実践編
下記の2つのtableの中身が全て等しいか検証するには?
tbl_a +------+-------+-------+-------+ | key | col_1 | col_2 | col_3 | +------+-------+-------+-------+ | A | 2 | 3 | 4 | | B | 0 | 7 | 9 | | C | 5 | 1 | 6 | +------+-------+-------+-------+ tbl_b +------+-------+-------+-------+ | key | col_1 | col_2 | col_3 | +------+-------+-------+-------+ | A | 2 | 3 | 4 | | B | 0 | 7 | 9 | | C | 5 | 1 | 6 | +------+-------+-------+-------+
--等しいテーブル同士のケース
DELETE FROM Tbl_A; INSERT INTO Tbl_A VALUES('A', 2, 3, 4); INSERT INTO Tbl_A VALUES('B', 0, 7, 9); INSERT INTO Tbl_A VALUES('C', 5, 1, 6); DELETE FROM Tbl_B; INSERT INTO Tbl_B VALUES('A', 2, 3, 4); INSERT INTO Tbl_B VALUES('B', 0, 7, 9); INSERT INTO Tbl_B VALUES('C', 5, 1, 6);
[Extra おさらい] UNION の簡単な使用例
SELECT * FROM tbl_a UNION SELECT * FROM tbl_b; SELECT * FROM tbl_a UNION ALL SELECT * FROM tbl_b;
UNIONだけを使う方法
2つのtableが完全同一か確認したい。しかしレコード数が多い場合は以下のクエリが便利
SELECT COUNT(*) AS row_cnt FROM ( SELECT * FROM Tbl_A UNION SELECT * FROM Tbl_B ) TMP;
※SQL文末のTMP
は仮で定義するtable名みたいなもの。 適当にhoge
でもOK
このクエリの結果が tbl_a と tbl_b の行数と一致すれば、両者は等しいテーブル
SELECT count("key") from tbl_a; => 3 SELECT count("key") from tbl_b; => 3
--「B」の行が相違するケース
DELETE FROM Tbl_A; INSERT INTO Tbl_A VALUES('A', 2, 3, 4); INSERT INTO Tbl_A VALUES('B', 0, 7, 9); INSERT INTO Tbl_A VALUES('C', 5, 1, 6); DELETE FROM Tbl_B; INSERT INTO Tbl_B VALUES('A', 2, 3, 4); INSERT INTO Tbl_B VALUES('B', 0, 7, 8); INSERT INTO Tbl_B VALUES('C', 5, 1, 6);
再度 UNION のクエリを実行すると結果は 4 となり、2つのtableが異なる事がわかる。 例は * による全カラム対象だが、特定のカラムや where で特定の行範囲での比較も可能。
再度コレ
SELECT COUNT(*) AS row_cnt FROM ( SELECT * FROM Tbl_A UNION SELECT * FROM Tbl_B ) TMP;
このクエリ文は以下の様な構造として読み取れる
Tbl_A UNION Tbl_B = TMP ↓ S UNION S = S
これは数学でいう冪等性(べきとうせい idenpotency)と呼ばれる性質のもの
プログラムでは「繰り返し処理をしても最初の一度に実行したものと結果が同じになる」という事柄のニュアンスで使われる。
- C言語 ファイルインクルードを何度しても結果が変わらない特質設計
- HTTPのGETコマンド 同じ要求を繰り返し発行しても大丈夫な様になっている。
- UIのボタンを連打しても1回として認識する
これらは冪等と言える
また、この式は2つだけの比較ではなく 3個以上の値でも比較可能。
S UNION S UNION S UNION ... UNION S = S
しかし、 UNION ALL
テメーは駄目だ。
重複したら冪等性が失われる。したがって冪等性を確保するためにも ユニークキーは大事だね。 という事らしい。
おさらい 積集合 intersection
『両方ともにはいっているもの』を集めた集合
共通部分 ともいう
A ∩ B
テーブル同士のコンベア---集合の相等性チェック[応用編]
集合の相等性を調べる公式
1. (A ⊆ B) かつ (A ⊇ B) ⇔ (A = B)
2. (A ∪ B) = (A ∩ B) ⇔ (A = B)
(ド・モルガンの法則に近い理屈)
ここでは2番を使う 2番をSQLに訳すると
A UNION B = A INTERSECT B なら AとBは等しい となる。 そして A UNION B = A=B ならば A INTERSECT B = A=B が成り立つ
二つのテーブルが相等なら「等しい」、そうでなければ「異なる」を返すクエリ
SELECT CASE WHEN COUNT(*) = 0 THEN '等しい' ELSE '異なる' END AS result FROM ((SELECT * FROM Tbl_A UNION SELECT * FROM Tbl_B) MINUS -- EXCEPT は oracle では MINUS (SELECT * FROM Tbl_A INTERSECT SELECT * FROM Tbl_B)) TMP;
相違した行を表示する。(oracleでerrorとなって確認できず)
--テーブルに対するdiff:排他的和集合を求める。 (SELECT * FROM Tbl_A MINUS -- EXCEPT SELECT * FROM Tbl_B) UNION ALL (SELECT * FROM Tbl_B MINUS -- EXCEPT SELECT * FROM Tbl_A);
差集合で関係除算を表現する
現在のSQLには除算の演算子が無い為以下の方法などで自前でクエリを書く必要がある。
- NOT EXSIST を入れ子にする
- HAVING句を使った一対一対応を利用する
- 割り算を引き算で表現する
ここでは3番の方法を紹介
以下の様なtableがあるとする。
select * from Skills; +--------+ | skill | +--------+ | Java | | Oracle | | UNIX | +--------+ select * from EmpSkills; +-----------+--------+ | emp | skill | +-----------+--------+ | 平井 | C++ | | 平井 | Oracle | | 平井 | PHP | | 平井 | Perl | | 平井 | UNIX | | 渡来 | Oracle | | 相田 | C# | | 相田 | Java | | 相田 | Oracle | | 相田 | UNIX | | 神崎 | Java | | 神崎 | Oracle | | 神崎 | UNIX | | 若田部 | Perl | +-----------+--------+
ここからSkills Tableにある全ての技術(Java,Oracle, UNIX)に
精通した社員を探すクエリはどう書けば良いか?
以下が答え しかし、注意!
※MySQLは2018年現在 INTERSECT,EXCEPT サポートしてない。
※Oracle は EXCEPT が MINUS という名になっている
(Oracle LiveSQLで実践)
--差集合で関係除算(剰余を持った除算) SELECT DISTINCT emp -- empの重複をまとめる FROM EmpSkills ES1 WHERE NOT EXISTS -- サブクエリ結果の否定のものを該当させる (SELECT skill FROM Skills EXCEPT -- 差集合 引き算をさせる SELECT skill FROM EmpSkills ES2 WHERE ES1.emp = ES2.emp); --oracleの場合は EXCEPT が MINUS となる SELECT DISTINCT emp FROM EmpSkills ES1 WHERE NOT EXISTS (SELECT skill FROM Skills MINUS -- oracle の場合の差集合 SELECT skill FROM EmpSkills ES2 WHERE ES1.emp = ES2.emp);
解説
サブクエリ内(カッコ内)でしている事
平井さんの場合
| skill | +--------+ | Java | | Oracle | | UNIX | 引くことの(EXCEPT,MINUS) | 平井 | C++ | 無関係 | 平井 | Oracle | 消える | 平井 | PHP | 無関係 | 平井 | Perl | 無関係 | 平井 | UNIX | 消える = Java が残る
相田さんの場合
| skill | +--------+ | Java | | Oracle | | UNIX | 引くことの(EXCEPT,MINUS) | 相田 | C# | 無関係 | 相田 | Java | 消える | 相田 | Oracle | 消える | 相田 | UNIX | 消える = 空集合 Φ つまりここでは該当しない
このサブクエリの結果の NOT EXISTS つまり否定なので、空集合となった結果を表示させる。
すなわち、全てのスキルを持つ社員のみ表示。となる。
EMP 相田 神崎
等しい部分集合を見つける
以下のtableから数も種類も全て同じ部品を扱う供給業者のペアを求めるにはどうしたた良いか?
一応答えは AとC, BとD となっている
+-----+--------------+ | sup | part | +-----+--------------+ | A | ナット | | A | パイプ | | A | ボルト | | B | パイプ | | B | ボルト | | C | ナット | | C | パイプ | | C | ボルト | | D | パイプ | | D | ボルト | | E | ナット | | E | パイプ | | E | ヒューズ | | F | ヒューズ | +-----+--------------+
実は結構難しいパズル、理由はSQLには∅の包含関係や相当性をテストする記述がない為、集合と集合を一発で比較出来る書き方は出来ないので、ひねって答えを求める必要がある。
IN は要素として含まれるかを探す( ∈ )に相当する。 集合と集合に使うべき状態 ( ⊂ )足り得ない。
つまり、最初の比較対象である『まず定義すべき集合』が無い状態で、なんとかして、同一のものを探さないといけない訳。
ではどうするか?
まずは非等値結合で sup の組み合わせの総当たり表を作る。
SELECT SP1.sup as s1 , SP2.sup as s2 FROM SupParts SP1, SupParts SP2 WHERE SP1.sup < SP2.sup -- 業者の組み合わせを作る GROUP BY SP1.sup, SP2.sup;
すると以下の様な総当たり表が作られる
+----+----+ | s1 | s2 | +----+----+ | A | B | | A | C | | A | D | | A | E | | B | C | | B | D | | B | E | | C | D | | C | E | | D | E | | E | F | +----+----+
次にこのペアについて
(A ⊆ B) かつ (A ⊇ B) ⇔ (A = B) の公式を当てはめる
SELECT SP1.sup as s1 , SP2.sup as s2 FROM SupParts SP1, SupParts SP2 WHERE SP1.sup < SP2.sup AND SP1.part = SP2.part -- 条件1.同じ種類の部品を扱う GROUP BY SP1.sup, SP2.sup HAVING COUNT(*) = (SELECT COUNT(*) -- 条件2.同数の部品を扱う FROM SupParts SP3 WHERE SP3.sup = SP1.sup) -- s1カラムで数が同じものを探す AND COUNT(*) = (SELECT COUNT(*) FROM SupParts SP4 WHERE SP4.sup = SP2.sup); -- かつ s2カラムで数が同じものを探す
うーん、かなりややこしい…。
筆者もパズルと言っているので、無理に理解しなくともよいかと
上記クエリの結果
+----+----+ | s1 | s2 | +----+----+ | A | C | | B | D | +----+----+
しかし、もし将来的に集合の除算が可能になったら以下のようなクエリで書けるかもしれないらしい。
SELECT 'A CONTAINS B' FROM SupParts WHERE ( SELECT part FROM SupParts WHERE sup = 'A') CONTAINS -- こんなのあったらいいな、ということらしい ( SELECT part FROM SupParts WHERE sup = 'B')
業者Bび扱う部品全てを業者Aが扱っていれば 'A CONTAINS B' と表示させる。
というクエリがあったらなー。みたいな話
重複行を削除する高速なクエリ
再度、3 自己結合の使い方[P49 重複行を削除する] で使った tableで重複行を削除する問題を扱う。
Products table +-----------+-------+ | name | price | +-----------+-------+ | りんご | 50 | | みかん | 100 | | みかん | 100 | | みかん | 100 | | バナナ | 80 | +-----------+-------+
3 自己結合の使い方 に出ていたクエリを再度確認
※ rowid はMySQL使えないので注意
--重複行を削除するSQL :相関サブクエリの利用 DELETE FROM Products WHERE rowid < ( SELECT MAX(P2.rowid) FROM Products P2 WHERE Products.name = P2.name AND Products.price = P2.price ) ;
上記はパフォーマンスに問題あり、相関サブクエリを使わない方法を求める。
今度は削除対象となるrowidをサブクエリ内で全部求めてしまう。
--高速版1:削除すべき行のrowidを求めて、その行を削除 DELETE FROM Products WHERE rowid IN ( SELECT rowid -- 全体のrowid FROM Products EXCEPT -- oracleなら MINUS 引く SELECT MAX(rowid) -- 残すべきrowid FROM Products GROUP BY name, price); -- グループ複数定義で一意性を求める
table 全体 | 1 | | 2 | | 3 | | 4 | | 5 | 引くことの 残すべきID | 1 | | 4 | | 5 | 残った削除対象のID | 2 | | 3 |
--高速版2:残すべき行のrowidを求めて、それ以外の行を削除 DELETE FROM Products WHERE rowid NOT IN ( SELECT MAX(rowid) FROM Products GROUP BY name, price);
まとめ
- SQLでは集合演算機能の整備が遅れている、使える内容もDBによって異なるので注意が必要
- 集合演算子はALLオプションを付けないと重複排除を行う。その際、ソートも行われるので、パフォーマンスが悪くなる。
- UNION,INTERSECTは、冪等性を持つ、 EXCEPTは持たない
- 除算は標準的な演算子が無いので自前で作る必要がある。
- 集合の相等性を調べるには、冪等性か全単射を利用する2通りがある
- EXCEPTを使うと、補集合を簡単に表現できる
演習問題
省略
プログラミングを続ける心構えみたいなもの
プログラムのスキルを付けて行くためにコツコツやってきた際の心構えみたいなものをちょっとまとめます。 これは何も皆さんにどうこう言って上の年齢からマウント取りたいとかそういう類の事じゃなくて、自分がブログを続けて行く際に、プログラミングを学ぶ際に身に付けた事を、今度はブログの定期的な記事アップに役立てたいと思ったものをメモ程度にアップしようと思ったためです。自分メモの色合いが強いですが、何かの参考になれば幸い。
以前はてな匿名ダイアリーで読んだすげえ良い記事があって、そこにこんな様な事が書いてありました。という要約。
でも記事のクリップを思い出せない。でも、その記事を記憶を頼りに自分なりに要約するとこんな感じ。
ちょっとでも実行したらその日はOKとする。欲は出さない。
何かを成し遂げるなんてただでさえ凄すぎるんだから、構えちゃ駄目。毎日なんにもしないし、出来なくて終わるのを当たり前の状態に思うのがまず大事。
そこからまかり間違って、ちょっとでもなんか出来たらそれで大成功と思うようにする。
例えば、プログラミングやりたいなーと思ってたら、それに関する記事を読むだけでもOKにしてしまう。
もしまかり間違ってエディタ開いたりなんかしたら大成功だし、1行でもなんか書いたりコメントだけでも書いて保存すればその日はもうやんなくても良い。自分の勝ち。と思っていい。
でも、たぶんそれだけじゃ満足いかなくなる時があって、そういう時は飽きたりしんどくなったりする所までやってみる。めんどくさくなったりしんどくなったら遠慮なく辞める。
たぶんこれって物事を嫌いにならないコツにも通じてると思う。
それを、あくまでも自分のプレッシャーにならない程度に続ける。たまたま一月や二月なんにもしない日があっても、絶対に自分を責めちゃだめ。だってできなくて当たり前なので。
それでやりたい時だけやってみる事を繰り返す。
そのうち徐々にやっている時間が増えてきたらこっちのもの。それを嫌になる手前まで毎回やればいいだけ。
構えない、デキは悪くて良い。
当然、できたものは、出来の良し悪しとかも気にしなくて良いし。他人の批評も知ったこっちゃないし、途中で投げ出しているものでも良い。だって何もない所からそこまでできただけで凄いのだから。ゼロからスタートしてそこまで行ったのって本当にすごいので、それを自分でけなすのは絶対にしない。ていうかそこまでできたお前すげえな。って自分をホメると良い。
みたいな内容のものだった。
当然記憶の彼方で自分の要約や解釈がたぶんに入ったものになっているが、つまりはそういうことをいつの間にか繰り返しやって来ただけかもしれない。
で、ここから後は自分なりに心がけてきた追加の事柄。
ほんのちょっとでもやってみるを繰り返す
出来なくて良い。というのが上の内容だったりするけど、そこから一歩だけ進めて、毎回ほんのちょっとだけ、こう思うようにする。「ほんのちょっとだけやってみる」
つまりエディタ開いたり、仮想環境立ち上げたりするだけでもOK、あと、今やんなきゃ!って所と全く関係ない、楽で簡単な所をちょっといじるだけでもOK。面倒だなぁ。と思っている時に、よくやるのが文言やデザインの色とか空間の空き方をちょっとだけ変える。みたいな事。これだけでも出来たら十分と思う。
めんどくさくなったらなんにもしないか別の事をやる
処理が上手くいかなかったり、参考にしている書籍や記事の書いてある内容が理解できなくて「あーっ!」ってなったら無理しないで投げ出す。というか、ふて寝する。
もし、余裕があれば気分転換にWebのアホっぽい記事読んだり、散歩や遊んでも良いけど、最近は欲が出てきて、実装でハマったり一区切りついたら、記事を書く、とか、プログラム以外の何かをする。とかより生産性の高い事をしてやろうと思う自分がいるけど、頑張りすぎない様に適当に遊びを入れる様にもう一人の自分に監視をさせて適時、適当に遊ぶようにもしているし、最近は別の生産的な事もしようとしてる。このバランスを保つのが結構難しいけど、基本はやっぱりできなくて当たり前。の超低い所にハードルを設けるのが良いみたいです。
処理なんか動けばいい、汚くても良い
お客さんの付くサービスだとセキュリティをしっかりしたり動作を保証できるものにする必要がありますが、自分で趣味で作ったり、学習のために作っている様なものは、とにかく動けば勝ちだと思うようにしてます。プログラミングってある程度やって行くと、ルールや作法や思想的なものが多分にあって、どれを選ぶべきか?みたいな事に悩んだりすることもありますが、そんなのは後の後、必要になった時に仕方なくやれば良いと思ってます。目的は続けることなので、作法やルールに縛られて自分の中から沸くやる気を削ぐような事だけはしない方が良い。位に構えている方が、結果として続けられてより良いものを書こうという気持ちにも繋がるような気がします。
判んなくていい、わかる範囲でやれば良い
プログラムって基本的な処理の組み合わせだを愚直に続けてもそれなりに動いたり色んな事が出来たりするので、あえて高等なテクニックや処理などをして自分を苦しめる必要も無いと思ってます。でもいずれどうしてもそれをやらないと駄目な処理とかが出てくるのでその時に改めて新しい技を使えば良いのかなと思います。
ここは後で直すとかコメントに書いて放置する
かなり具体的になってきましたが、一人で書いていることがほとんどなので、遠慮なくこういうのを書いて他の部分を作り込んだりしてます。
***ToDo*** とか書いたまま放置してる所が山の様にある。
俺俺タスク管理をする
時間とかにあまり縛られないタスク管理表を自分なりに作ってます。作るべき項目を箇条書きでスプレッドシートにまとめて、適時項目を追加したり、終わったものをグレーアウトさせて表の下の方に移動させる。というものをしてます。見積もり時間と実装時間の項目も設けてますが、あくまで実感に伴う時間を書く程度にとどめ、厳密に書かない事が上手くいっているコツな様な気がしてます。また、優先順位も設けてますが、これもあまり厳密に管理せず、今必要だと思ったものや、ノってきた際には、優先度が低くてもそれを先にやる様にしています。優先度高いのにずいぶん放置してるものがあっても気にしない。ただ、セキュリティの部分やデータに異常が起こる可能性のあるものの処理だけは、優先度を高くしてちゃんとやってます。
ざーっとまとめたけど最後にこれも大事だなーと思うのがあった
精査しないで良い。ひとまず出しちゃう
という訳でこの記事もあまり文章校正や言い回しをチェックしないままアップしてみます。
言い回しとか文字の間違いを気にし過ぎると、めんどくさくなってアウトプットが億劫になる位ならほいほい出してしまう。というのも大事かもしれません。
関連記事: sakamata.hateblo.jp
Laravel フォームで配列を扱う ヘルパ関数old() でチェックボックスを扱う方法
最初の記事がとてもバズり、おかげ様でこの週のはてなブログのランキングに乗ることができました。ありがとうございます。
今回は具体的な技術のTIPSエントリーとなります。こんな感じの記事の時もありますし、おっさんらしく蘊蓄をたれたり心構え的なものや日記みたいな事も記事にしていこうと思ってますので、よかったら今後もお付き合いいただければ幸いです。
フォームのチェックボックスで、DBの boolean 値 を変更させるユーザーフォームを作っていた際、ヘルパ関数の old() やPOSTすべき値で散々悩んだので、うまくった例を記述します。
対象はユーザーフォームの 『端末を非表示にする』 というチェックボックス。カラム名は hide で 1なら非表示 0なら表示としてます。
しかもこのフォームでは複数端末が表示されるので、配列で扱うというめんどくさい状態。さらに言うなら、DBの値を引っ張りだしてきて、 true なら checkd="checked" を表示させる。 さらに バリデートエラーの際はヘルパ関数 old() でチェックの状態を保持する、という boolean が何重にも絡んでくるややこしい状態で問題の整理にかなりの時間を要しました。
前提条件としてhtmlのformで配列を扱う際には foreachなどで連続して同じ様なformを出力する必要がある場合があります。
その際に name
等は以下の様に配列として扱います。
name="hide[]"
また、連想配列で扱いたい際は
name="hide[key]"
と、書いて扱います。
参考サイト
FormのPOST送信で配列を、できれば連想配列を送信したい。
以上を踏まえた上で先に結論です。
@php if( ($mac_add->hide == 1 && old('mac_address.'.$mac_add->id.'.hide') == null) || old('mac_address.'.$mac_add->id.'.hide') == 1 ) { $check_hide[$mac_add->id] = "checked='checked'"; } else { $check_hide[$mac_add->id] = ""; } @endphp <!-- チェックされていない場合は0を送信 --> <input type="hidden" name="mac_address[{{$mac_add->id}}][hide]" value="0"> <input type="checkbox" name="mac_address[{{$mac_add->id}}][hide]" value="1" id="devise-check-{{$mac_add->id}}" {{$check_hide[$mac_add->id]}}>
viewに @php で直接設定書いてますが、さんざん悩んだので、これ以上いじりたくないという状態です。 あと、三項演算子が苦手で、htmlの中にIF文書きたくないのです。
このphpコードは要は最初のifで チェックボックスに印をつけるか否かの判定をしているのみです。
$check_hide[]
という配列に html の"checked='checked'"
を書くべきか否かを処理してます。
以下はヘルパ関数 old() で取れる hide の配列内の値です。
'mac_address.'.$mac_add->id.'.hide'
これは、以下の様に解釈します。
array[id][hide]
端末情報 mac_address の配列情報の中に端末の固有IDをキーとして配列を保持させ、さらにその中の hide キーの中身を呼び出してます。
じつは、array[][hide]
という風に配列を空にして、キーを自動で入る通し番号にしても良い気がしますが、controller側に渡して処理する際に 配列キーがDBのプライマリキーのIDだと、何かと都合が良いのでこうしてます。
この値はバリデートエラーなどでセッションに初めて保持される値で、普段は null です。
従って以下は フォームの値が 1 の状態か、 old() の値が無いなら、 true となります。
($mac_add->hide == 1 && old('mac_address.'.$mac_add->id.'.hide') == null)
さらにif関数の論理式でor条件を立ててます。
old('mac_address.'.$mac_add->id.'.hide') == 1
単純に、 old の値で 1、つまり true が入っているかを判定。
このいずれかに該当すれば、非表示状態の設定とみなし "checked='checked'"
がフォーム内に描画され、チェックが入ります。
さらに下のhtmlフォームです。
<!-- チェックされていない場合は0を送信 --> <input type="hidden" name="mac_address[{{$mac_add->id}}][hide]" value="0"> <input type="checkbox" name="mac_address[{{$mac_add->id}}][hide]" value="1" {{$check_hide[$mac_add->id]}}>
チェックボックスの前の行にhiddenでcheckboxに同じ name=""
を付けてますが、これがちょっとややこしい事になります。
知っている人には当たり前かもしれませんが、実はチェックボックスのフォームって、チェックを入れないと、POSTされないそうです。
つまり、このままですと、ユーザーが空のフォームにチェックした情報は取れますが、ユーザーが意図的にチェックを外した。という情報が取れない訳です。
そこで、 hiddenを chekboxの前に書いて、もしユーザーがチェックを入れてない場合は、 name="hide"
に 0 の値をPOSTしてやる、という事をしています。 htmlフォームで name が同じものが2つ以上あった場合は、後に書いた方の値が飛ぶ、という仕様をハックして、普段は下のフォームの値が飛び、いざチェックが外された際はhidden の値が飛ぶ、という訳です。
詳しくは以下を参照
[HTML]formでcheckboxにチェックしていない時にもパラメータを送る方法
isket.jp
これで各フォームの name="hide" に 0と1の状態がPOSTされる状態が表現できました。あとはバリデートエラー等で、再度フォームを描画する際に old 関数の値を引っ張り出すことで、フォーム直前の状態が 0か1がわかり、それを元に
"checked='checked'"
を入れるか入れないかを判定すれば良い。ということですね。
参考サイト
Laravelで多次元配列のバリデーション
qiita.com
checked="checked"はどうやって表示させるの? qiita.com
Laravelで、チェックボックスをPOSTした後の old() 問題
qiita.com
次回は軽めの記事にしようと思います。