理論から学ぶデータベース実践入門 ch8 SELECTを攻略する (1/2)
まとめ
- SELECTには、リレーショナルな操作とそうでない操作の両方が含まれる
- リレーショナルモデルの垣根を縦横無尽に飛び越えることができてしまう
- ゆえに扱いが難しい
SELECTはSQLの心臓部
SELECTの本質
SELECTの強大さ
これはSQLそのものだ。SELECTのパーサなんて開発したら、RDBそのものを開発することになる! (Bill Karwin)
- 誇張は入っているがあながち間違いでもない
データを取得する唯一の手段
SELECTの基本構造
- 論理的な評価順(再掲)
- FROM句
- テーブルのリスト(直積)
- WHERE句
- 検索条件(制限)
- SELECT句
- カラムのリスト(射影)
- FROM句
SELECT七変化
- すべてがSELECTという万能APIに詰め込まれている
- 文脈や用法により意味が変化する点が厄介
集約関数
関数の有無だけで意味が変わる
- select list
- SELECT句におけるカラムのリスト
- select listに特定の関数があるかないかによって出力の形式が変わる
- 特定の関数: 集約関数
COUNT()
とか
COUNTの特殊性
- 空集合を対象としたとき
- COUNT(): 0
- その他: NULL
- SUM()とか
- NULL対策を怠るな(ch.7)
GROUP BYによる集約の書式
- 論理的な評価順 = 書式の順番
- (WHERE句)
- GROUP BY句
- HAVING句
- ORDER BY句
- WHERE句の検索条件にマッチしない行は集計対象外となる点に注意
- 「0件」という集計結果を含めるためには基本形を逸脱しなければならない
- 相関サブクエリ
- LEFT OUTER JOIN
- CASE式
- 「0件」という集計結果を含めるためには基本形を逸脱しなければならない
サブクエリ
- 複数のタイプがある
テーブルサブクエリ
- WHERE句の中で IN、ANY(SOME)、ALL句に伴って利用されるもの
- 【補】EXISTSも
- FROM句に現れるやつも
- 【補】UNIONやMINUSのオペランドもこれかな
スカラサブクエリ
- select listに現れるやつ
- サブクエリ結果がスカラ(1行1列)もしくはNULLでないとエラー
行サブクエリ
- サブクエリ結果が1行複数列のもの
- select listには含められない
ビュー
- リレーショナルモデルにおいて、ビューとベーステーブルとに区別はない
- 複雑さ隠蔽
- 水面下の実際は把握しづらくなる
- 性能問題に注意
- ビューにサブクエリ、UNION、集約関数などが含まれていると問題になりやすい
UNION
- 2つのSELECTを「足せる」
- 2つのSELECTは属性が同じなだけで、中身は似ても似つかないかもしれないことに注意
- 異なるテーブル
- 異なる実行計画
組み合わせは自由
- これまで挙げたSELECTは自由に組み合わせられる
理論から学ぶデータベース実践入門 ch6 ドメインの設計戦略 2/2
IDを設計するという考え方
現実世界の物体や概念を表す手段
- DBに格納するIDは、現実世界の物体や概念を表すもの
- 物体や概念を集合で表現するのがリレーショナルモデル
- 1つの属性が1つの物や概念と対応する
- IDが物や概念との全単射であるかは設計次第
- 一意になるような設計を人間がすることによってIDはIDとして機能する
- 【補】
- 【補】GitやDockerでハッシュをIDとして使用しているのも関連する話か
- 理論的には衝突が発生し、一意性が崩れる
- が、実運用上扱うもの(ファイルやコンテナイメージ)の数に鑑みて、衝突することはないと想定
ナチュラルキーとサロゲートキー
- 著者見解: いずれもRDBにとっては必要
- どちらか一方を排するべき、というのは暴論
- 本質的な違いはなく、すべて人工物である
- 「自然キー」とはいうが、宇宙の摂理として存在するわけではない
- 過去にほかの誰かが割り当てたか、自分で新しくIDを割り当てるかの違いしかない
- 用いる値がIDとして機能し得るものか、が本質
- よくあるダメな例: 名前
ナチュナルキーの使いどころと問題点
- すでに誰かが運用し・何かを識別でき・長期に渡り値に変更がなく・信頼できること
- 基礎年金番号とか
- IDのライフサイクルを吟味せよ
- IDを発行した機関が、システムのライフサイクルよりも十分に長く運用を継続してくれること
- 権威があるように見えるIDであっても、実際には運用が失敗することも
- ISBN
- 何を特定するものなのか吟味せよ
- 例: e-mailアドレス
- あくまでもe-mailアドレス自身を特定するもの
- 人を特定するものではない
- 例: e-mailアドレス
サロゲートキーの使いどころと問題点
- OK
- 識別したい対象の物や概念を表すIDがいまだこの世界に存在しない
- NG
- 不要なサロゲートキーは関数従属性を生じてしまう
- DB設計を無駄に複雑化する
リレーショナルモデルにおけるキー
- ナチュナルキーやサロゲートキーはリレーショナルモデル上の概念ではない
- リレーショナルモデルにあるのは候補キーとスーパーキーのみ
意味を含んだID
- 「青いサイクロン式の掃除機」の製品コード
CLN-CYC-0123-BL
- CLN: 掃除機
- CYC: サイクロン
- 0123: 世代?
- BL: 色
- このような属性を定義すると1NFではなくなる
- 列の値がアトミックではなくなるため
- 個々のパーツを個々の属性として定義せよ
- くっつけるのは表示上の問題であり、データの本質ではない
- こういうときにサロゲートキーを導入するのは悪い選択肢ではない
- 製品コードを個々のパーツに個々の属性に分けると、製品と1:1で対応するIDとなる属性がそのリレーションに存在しなくなるから
紙の呪縛
- 人間が視認しやすいIDを推す人々が存在する
- 「帳票に印刷したときに見やすい」
- 「ITシステムが止まっても、紙を使って、業務を遂行できる」
- 紙の文化がITシステムに侵食しDB設計を歪めてしまう
- 「表示上の問題はDBと分離せよ」という考えに反している
IDの欠陥は波及する
- あるリレーションのキーは別のリレーションにも登場する
- 外部キー
- IDの選定をしくじるとあらゆるリレーションに影響が及ぶ
色、長さ、重さなどの性質を表す属性
{製品ID, 製品カテゴリ, 製品カラー}
という属性をもつリレーションの述語をP(x,y,z)
とする
性質を述語と解釈すると、リレーショナルモデルでは扱えない
- ある物体
c
が「黒い」という述語
Black(c)
- cが黒ければ真、さもなくば偽
P(x,y,z)
のz
には述語Black(c)
が入るP(x,y,z)
は述語に対する述語となる- このような述語を扱えるのは二階述語論理であり、リレーショナルモデルでは扱えない
「集合にラベルをつけた」と解釈すればリレーショナルモデルに適合する
- 「黒」という色の名称、すなわちラベル
Black
P(x,y,z)
のz
には値Black
が入る- 一階述語論理なので、リレーショナルモデルに適合する
- 個々のラベルに重複がなければ、性質という概念を識別するIDとして使用できる
SQLによるドメインの表現
適切なデータ型を選ぶ
- SQLのデータ型はドメインと必ずしも1:1対応しない
- 最低限、ドメインの持つ値をすべてカバーできること
- 最もよくそのドメインの特徴を表すデータ型を用いること
- 本質的に数値であるものを文字列型で表すのはNG
- 無駄にデータサイズ大きい
- 数値としての演算を行えない
- 本質的に数値であるものを文字列型で表すのはNG
述語を制約で表現する
ドメインをテーブルとして表現する
JWT認証アプリケーションで認可つきCSVダウンロードを実装した話
背景
- Laravel + Vue.jsアプリケーション
- JWT認証のSPA
- つまり、すべてのリクエストの
Authorization
ヘッダにJWTを載せている
- つまり、すべてのリクエストの
axios.defaults.headers.common['Authorization'] = `Bearer ${jwt}`;
- 集計したデータをCSVファイルとしてダウンロードさせたい
- ただし、認可もつけたい
- 未ログイン状態ではダウンロードさせない
困ったこと
- RESTful APIを叩いているうちは何も問題はない
- CSVダウンロードで困った
<a href="csvダウンロードリンク">...
ではダメAuthorization
ヘッダが載らないため、認可が通らない
やったこと
サーバ側でCSV生成
- こんな感じのCsvDownloaderクラスを作り、コントローラに注入して使った
- 参考
<?php declare (strict_types = 1); namespace App\Http\Controllers\Concerns; use Illuminate\Http\Response; use Illuminate\Support\Collection; /** * データをCSVファイルとしてダウンロードする */ class CsvDownloader { /** * @param array|Collection $header 見出し * @param array|Collection $body データ本体 * @param string $fileName * @return Response */ public function downloadCsv( $header, $body, string $fileName ): Response { // 仮ファイル $stream = fopen('php://temp', 'w'); // 見出し書き込み fputcsv( $stream, collect($header)->toArray() ); // データ本体書き込み collect($body)->each( function ($row) use (&$stream) { fputcsv( $stream, $row ); } ); // ポインタの先頭へ rewind($stream); // 変換 // - 改行コード $csv = str_replace( PHP_EOL, "\r\n", stream_get_contents($stream) ); // BOM $csv= pack('C*',0xEF,0xBB,0xBF) . $csv; $httpHeaders = [ 'Content-Type' => 'text/csv', 'Content-Disposition' => 'attachment; filename="' . $fileName . '"', ]; return response( $csv, Response::HTTP_OK, $httpHeaders ); }
- 何も考えずにBOMなしUTF-8で出力すると、Excelで開いた時に文字化けしてしまう
- 回避方法は下記のようなものがある:
- フロントエンドとの兼ね合いで、SJISではなくBOMつきUTF-8にした (後述)
フロントエンド側でBlob生成
export default { ... methods: { // AjaxでサーバからCSVを落としてくる downloadCsvReport() { axios.get('/download/report') .then(response => response.data) .then(data => this.downloadCommon(data, 'filename.csv')); }, // 落としてきたCSVをBlobにしてダウンロードリンクを生成する downloadCommon(data, filename) { const anchor = document.createElement('a'); document.body.appendChild(anchor); // ここでもBOMをつける必要がある // さもないとExcelで文字化けする const bom = new Uint8Array([0xEF, 0xBB, 0xBF]); const blob = new Blob([bom, data], {type: 'text/csv'}); const objectUrl = window.URL.createObjectURL(blob); anchor.href = objectUrl; anchor.download = filename; anchor.click(); window.URL.revokeObjectURL(objectUrl); } ...
踏んだ文字化けのパターン
LINEBotプロジェクト振り返り
プロジェクト概要
- LINEBot
よかった
- LINEBotの開発経験を積めた
- 静的なドキュメントをある程度書いた
- JWT認証 + 認可付きCSVダウンロードの初実装
- サーバ側は真面目にテスト書いた
- 変更箇所が甚大な修正も安心して行えた
- きれいな設計の意識
よくなかった
- 永続化データ構造を自前で定義してしまった
- CI構築をサボった
- 当初はもっとサクッと終わる想定だったんですよ
- CI構築するほどではない、と思っていた
- Laradockコンテナのビルドに時間がかかるためCI構築の試行錯誤が億劫
- そろそろLaradockやめたい
- ビルド済イメージをdockerhubかどこかにpushしておきたい
- 当初はもっとサクッと終わる想定だったんですよ
- Visitorパターンを適用した結果、Acceptor追加時の変更箇所が甚大になった
- パターンの特徴なので、前もってわかってはいた
- 今回はドメインオブジェクトからLINE実装を分離することを選んだ
- ユースケース記述やロバストネス図を書かなかった
- 「元気な時に書こう」は永久に書かない
- migrationまわりの扱いが雑だった
- migrationの編集はGitのrebaseと同じ感覚で実施したほうがよさそう
- 一度作ってpushしたら二度と触ってはいけない
- migrationの編集はGitのrebaseと同じ感覚で実施したほうがよさそう
- プロジェクトの全体像を理解できていなかった
- 客との調整をプロキシに丸投げして実装オンリー
- 認識齟齬による手戻りが何度かあった
- 客の温度感もわからなかった
- 見積もりが甘かった
- フロントエンドはテスト書かなかった
- emacsの調整不足
- PHP(intelephense)
- Carbon/Carbonなど巨大なファイルを読むと定義ジャンプで数秒〜十数秒固まる
- Vue.js(忘れた)
- なんか常に重い
<style lang="stylus">
をちゃんと認識できてない
- PHP(intelephense)
Keep
- 静的なドキュメント書く
- 真面目にテスト書く
- きれいな設計
- そのために勉強
- PoEAAさっさと倒す
- ほいでDDD読む
- そのために勉強
Problem
- 「よくなかった」全部
Try
理論から学ぶデータベース実践入門 ch7 NULLとの戦い
まとめ
- NULLはリレーショナルモデルにとって害悪
- 2VLが3VLに変質
- 閉世界仮説の不成立
- オプティマイザ困惑
- 完全に排除できないこともある
- SQLのリレーショナルモデルを超えた表現力に起因
- 集計関数とか
- 対策が必要
- SQLのリレーショナルモデルを超えた表現力に起因
NULL
- NULL含みのテーブルは1NFの要件を満たさない
- 集合ではない => リレーションではない
NULLとは
3値論理(3VL)
NULLは演算を台無しにする
NULL + 1 NULL > 100 ...
- 値用の演算子を作用させると何やっても
NULL
になる(unknown)
検索結果が意図しないものになる可能性
- SELECTが行を返すのは、WHERE句の条件がTRUEになったときだけ
- FALSE, NULLのときは行を返さない
SELECT * FROM users WHERE age <> 20;
- ageがNULLを許す場合の意味合いは
「年齢が判明していて、かつ20歳ではないユーザ」 - 【補】排中律が成立しなくなる
NULLによる第3の論理値
- NULLがあるおかげで、論理値が3つ存在するかのように見える
- 3VL: Three Valued Logic
- 2VLより複雑
- SQLの開発効率への影響大
3値論理の限界
- 3VL自体に論理学的な誤りはない
- しかし3VLは現実を適切に表現できない
- リレーショナルモデルは現実の世界を適切に表現するためのモデル
- それが台無しになる
Unknownと曖昧さ
- Unknownってなに
- 情報が不自然に丸められてしまう
- 他の情報からの推論を行う余地がなくなる
- 論理的に正しくても値が正確でない
コラム: 量子コンピュータとNULL
- NULLは未知の値を表すマーカー
- 実際にはドメインのいずれかの値になるはず
- 量子ビット
- 0の状態と1の状態とを量子力学的に重ね合わせたもの
- 0の可能性も1の可能性も残して演算
- 量子コンピュータがあればNULLも怖くなくなるかもね!という妄想
NULLは閉世界仮説に反する
- 閉世界仮説
すべての問いがリレーションの演算だけで解決する
- NULLが演算に含まれると、この大前提が崩れる
- リレーショナルモデルが根底から崩壊する
オプティマイザへの弊害
- オプティマイザの仕事
- クエリの書き換え
- クエリのコスト見積もり
- クエリの書き換え
- 結果が等価になる範囲で、パフォーマンスが最適となるよう内部的に書き換える
- 結果の等価性は数学的な証明により裏付けられている
- NULLが含まれると、等価性を数学的に証明できる組み合わせが激減する
- クエリのコスト見積もり
- NULLになっているインデックスエントリは、インデックスの先頭もしくは最後尾にまとまって配置される
- NULL以外の値のカーディナリティが高くても、NULLが全部同列に扱われるため、IS NULLのスキャンに時間がかかる
NULL対策
テーブルを正規化する
- NULLが含まれると1NFでなくなる
- 1NFに正規化せよ
誤ったNULL対策
- カラムをNOT NULLにして、NULL代わりのデフォルト値にNULLのような意味を持たせる
- 状況はさらに悪化する
- 例: 年齢不明を
-1
としてしまう- 「20歳以下である」行を抽出しようとすると年齢不明の行も抽出されてしまう
- このようなローカルルールはテーブル定義には現れない
- ドキュメント等を通じて周知する必要のある技術的負債となる
- 例: 年齢不明を
COALESCE関数
- 正規化して、すべてのカラムをNOT NULLにしました
- それでもNULLは発生する
- 行数0の行に対する集計関数(COUNT以外)
- スカラまたは行サブクエリを実行した結果、該当する行がなかった場合
- OUTER JOIN
- CASE式でELSE省略時、どの条件にも該当しなかった場合
- NULLIF関数でわざわざNULLを生み出した場合
- やめろ
- COALESCE関数
- 使いどころ
- ダイナミックデフォルト
- 式を評価した結果がNULLだった場合のデフォルト値を設定する使い方
- SUM関数のデフォルトに0を使用
- スカラサブクエリのデフォルト値
- 式を評価した結果がNULLだった場合のデフォルト値を設定する使い方
- ダイナミックデフォルト
- 使うべきでないところ
- nullableなカラムのダイナミックデフォルト
- 前述「誤ったNULL対策」と本質的に何も変わらない
- nullableなカラムのダイナミックデフォルト
- 限界
- OUTER JOINで「マッチしない行を探す」場合などは依然として
IS NULL
が必要
- OUTER JOINで「マッチしない行を探す」場合などは依然として
空文字列の扱い
NULLを使っても良いケース
- テーブルをリレーションとして使用しないなら別にかまわない
- 正規化すらいらないだろう
- テーブルをリレーションとして使用するなら例外なく排除すべき
理論から学ぶデータベース実践入門 ch6 ドメインの設計戦略 1/2
まとめ
- ドメインの設計は、現実世界とリレーショナルモデルの世界とをつなぐ架け橋となる作業
- 恣意的
- 絶対的な正解はない
- 最も難しい
- データの本質を理解することが重要
- 本質的でないデータをDBに持ち込まない
- 表示上の都合など
- 本質的でないデータをDBに持ち込まない
- リレーショナルモデルについての理解が不可欠
- 例: IDに意味をもたせると1NFですらなくなる
ドメイン
- アプリケーションにはどのようなリレーションが必要か?
- リレーションにはどのような属性が必要か?
- 決めなければならないことは2つ
- 名前
- ドメイン(データ型)
- 決めなければならないことは2つ
ドメインとは
- リレーショナルモデルにおけるデータ型
- 属性が取りうる値の集合
- 述語論理における「論理領域」に相当する
集合の要素
- 明確な値を持っていること
- 該当しないもの
- NULL
- ポインタ
- ドメインに含まれる可能性のある値をすべて列挙するのは無謀
- ドメインは集合なので、1:1対応する述語が存在する
- それがどのような意味を持つかを想定しておけば十分
ドメインの設計戦略の概要
- 「設計」
- ノウハウや経験に基づく
- 論理的に導出される絶対的な正解や筋道がない
- 戦略や哲学が必要とされる
すべては恣意的な選択
アプリケーションの要求から生まれる
- アプリケーションが必要とするデータを洗い出さないとドメインの設計はできない
- DB全体の設計にもいえること
- DBについていくら勉強しても身につくものではない
- 適切なDB設計は、アプリケーションに対する理解なくしては有り得ない
- のでアプリケーション開発者が少なくとも関与できるべき
- アプリケーション設計手法を身につけておくべき
- DDDとか
- DBリファクタリング
データの本質を見極める
- 数値に文字列カラムを割り当てる過ち
- 学籍番号など、本質は数値であるもの
- 表示上の都合で
CHAR(8)
とかにするな- 本質的なデータと表示は分けて設計すべき
- DBが引き受けるべきは前者のみ
属性(カラム)の名前
- 名が体を表せ
- DBに限らない
- アプリケーション内で対応するクラスや変数と同じ名前をつけるべき
- アプリケーション側でクラス名や変数名をリファクタリングしたら、DB側も同期せよ
理論から学ぶデータベース実践入門 ch5 リレーションの直交性
まとめ
- RDBでしっかりと重複を解消しないのは道具としての使い方を間違っている
- 正しい答えを得られない
- 本来の性能を引き出せない
リレーションの直交性(Orthogonality)
- 複数のリレーション間の重複に関する概念
- cf. 正規化は1つのリレーション内部の重複に関する概念
- DB全体から重複を解消する作業ともいえる
- 一言で言うと「同じ値を含まない」ということ
- 「同じ属性を含まない」ではない
- もしそうなら直積しかできない
- 直交化してない状態とは??
レプリカ
- 一番わかりやすいやつ
- 全く同じ構造、同じデータのリレーションが2つ
- どちらか片方だけ使え
同じ型のリレーション
- 同じ型だが重複データをもたないやつ
- 積集合(結合の特殊なもの)が空集合になる
- 例: 水平分割したテーブル
- 月ごとにテーブルを分けてるとか
- SQLに「同じ値を『含まない』」ことを保証する直接的な機能はない
- cf. FK制約は同じ値を『含む』ことを保証
- トリガー使うとか?
見出しの一部だけが同じリレーション
- 判断が難しいやつ
- リレーションに下記が残っていると、リレーションを直接比較して直交性を判断することはできない
- 自明ではない関数従属性
- 暗黙的でない結合従属性
- 6NFまで無損失分解すれば、タプルの比較で直交性を保証できる
- 値が重複しているか、あるいは将来的に重複する可能性のある設計であるかが重要
- 見出しが重複していても、値が直交していれば無理に統合する必要はない
リレーション直交化のための戦略
正規化
- 直交性の保証のためには6NFまでの正規化が必要
- 6NFまで正規化するためには5NFまで正規化されていることが必要
- 実際のテーブルも5NFまでは正規化せよ
- 6NFは無駄な結合が多くなるため、普通しない
属性(カラム)の名前を統一する
- よくないもの
- 同じものを指しているのに異なる名前
- 見落とす可能性が高まる
- 異なるものを指しているのに同じ名前
- 同じものを指している可能性を逐一検証する手間が発生する
- 同じものを指しているのに異なる名前
- 命名における注意点
アプリケーションの整合性
すべてを直交化する必要はない
- 例: 条件ごとにユーザを別々のテーブルに分ける場合
- 各テーブルが表す条件の意味が完全に独立したものであれば、特に設計上の問題はない
- 両方の条件を満たす
SELECT user_name FROM A INNER JOIN B USING (user_name)
- いずれかの条件を満たす
(SELECT user_name FROM A) UNION (SELECT user_name FROM B)
重複を解消することのメリット
- 正規化・直交化のメリットを振り返りまとめる
異常を防げる
- 述語論理の天敵
- Principle of Explosion
必要なデータがどこにあるかが明確になる
- 直交化されていないDBでは、どのリレーションを対象に演算を行えば良いのか一意に定まらない
クエリの記述が宣言的になる
- 最低限1NFなら、テーブルはリレーションである
- テーブルがリレーションなら、クエリは述語論理になる
- howではなくwhatを記述できる
不要な無損失分解が必要ない
- 正規化されていない = 結合(JOIN)されている
- クエリを書くたびに無損失分解(SELECT DISTINCT サブクエリ)が必要になる
複雑な制約が必要ない
- 正規化されていないと
- 自明ではない関数従属性が残っている場合
- 更新時に関数従属性を壊さないよう制約が必要
- クエリごとのトリガーで集計を行う必要があるだろう
- 更新時に関数従属性を壊さないよう制約が必要
- 自明ではない関数従属性が残っている場合
- 直交化されていないと
- 重複データをもつ複数のテーブルの同期をとる必要がある
- FK制約は使えない
- FK側が部分集合である必要がある
- 共通でない行が互いのテーブルに含まれる場合は適用不可
アプリケーションのコードに無駄がなくなる
- DB側で重複を解消しないと、アプリケーション側で異常のチェックが必要になる
- 異常発生の予防
- 異常発生時のハンドリング
- そのテスト
- 予想以上に甚大
性能が向上する
- 重複を解消したほうが性能がよくなることも
- 参照系
- 結合は増えてしまう
- が、前述の「クエリを書くたびに無損失分解」する必要がなくなる
- 更新系
- 複雑な制約が必要なくなる
- アプリケーション側でも異常予防・ハンドリングのロジックが不要に
- 参照系
- 「性能を向上するために非正規化する」のは特殊なケース
- 一般的には重複があることによりDBの負担は飛躍的に上昇する