SQL

CASE式で条件分岐をSQL文に任せる

条件分岐と言えば、プログラム言語にあるif文が代表格です。ところが、SQL文の中でも条件分岐を実現できます。CASE式を使うのですが、最初に知ったときは感動しました。プログラムのif文とSQLのCASE式、使い分けることで全体的にコードをスッキリさせることができます。


CASE式とは

以下の書式で書かれた文法をCASE式と呼びます。

CASE式には単純CASE式と検索CASE式があります。

単純CASE式はその値が真であるかどうかの評価しかできませんがクエリをシンプルにできます。

検索CASE式の方が応用が効きますし、検索CASE式で単純CASE式の内容を表すことができます。

私もほとんど検索CASE式を使用していますが可読性のために単純CASE式を採用してもよいと思います。

--単純CASE式

CASE gender
WHEN '男' THEN 1
WHEN '女' THEN 2
ELSE 99
END

--検索CASE式
CASE
WHEN gender = '男' THEN 1
WHEN gender = '女' THEN 2
ELSE 99
END

CASE式を使うときには

* 条件は排他的に書く

* ELSEも書く

べきです。下記のような書き方は混乱の元なのでやめた方がよいでしょう。

-- この書き方は絶対1や2に評価されない

CASE
WHEN gender = '男' OR gender = '女' THEN 0
WHEN gender = '男' THEN 1
WHEN gender = '女' THEN 2
ELSE 99
END

-- '男'、'女'いずれにも設定されていない場合はNULLになる
-- NULLを期待する場合でも ELSE NULLを明示的に書いた方がよい
CASE
WHEN gender = '男' THEN 1
WHEN gender = '女' THEN 2
END

以下の記事が非常に分かりやすくおすすめです。上記の例も載っています。

CASE式のススメ(前編) (1/3):CodeZine(コードジン)

また、この本を読んでCASE式を使いこなせるようになりました。

達人に学ぶ SQL徹底指南書 | ミック | 工学 | Kindleストア | Amazon


CASE式の実例


フラグを付ける

既存のデータに対してCASE式を使ってフラグを設定する場合によく使います。

SELECT

id,
title,
publish_date,
CASE WHEN publish_date > CURRENT_TIMESTAMP() - INTERVAL 7 DAY THEN 1 ELSE 0 END AS is_new
FROM news
ORDER BY publish_date DESC
LIMIT 20

例えばお知らせの新しい記事に「NEW」マークをつけたい場合にこの方法を使います。日付が現在から7日以内のときは1、そうでない場合は0を設定しています。これにis_newとでも名前を付けておきます。こうすると、

<!-- 先のSQLの実行結果が$newsに入っているものとする -->

<?php foreach ($news as $id => $data): ?>
<li>
<?php echo $data['publish_date']; ?> <?php echo $data['title']; ?> <?php if ($data['is_new']): ?><span class="icon">NEW</span><?php endif; ?>
</li>
<?php endforeach; ?>

view側ではif文の条件を簡潔にできます。CASE式を使わない場合は

<?php     if ($data['publish_date']が最新から7日以内かどうか): ?>

のようなことをするか、DBからの取得結果に対して、foreachなどでフラグを設定することになるでしょう。

※CASE式にしないと絶対に効率が悪いと言っているわけではありません。

実際私も下記のようなことをすることもあります。

foreach ($news as $id => $data) {

if ($data['publish_date']が最新から7日以内) {
$data['is_new'] = 1;
} else {
$data['is_new'] = 0;
}
}

例が単純ですが、高齢者をSQL側で定義しています。

条件を組み合わせて、例えば「高齢者割引対象者」のような式も作れそうです。

SELECT

id,
name,
CASE WHEN age >= 60 THEN 1 ELSE 0 END AS senior
FROM member
LIMIT 20


階級に分ける

先の例は1or0でしたが、もちろん3段階以上の場合分けも可能です。

SELECT

CASE
WHEN score >= 80 THEN 'S'
WHEN score >= 60 THEN 'A'
WHEN score >= 40 THEN 'B'
ELSE 'C'
END
FROM exam;


区分ごとの集計を一発で出す

memberテーブルのgenderフィールドに

男性:1、女性:2

で登録されているとします。

男性、女性それぞれの人数はwhere句を2回使えば求められます。

SELECT COUNT(*) FROM member WHERE gender = 1;

SELECT COUNT(*) FROM member WHERE gender = 2;

ただ、GROUP BYの方がスマートでしょう。

SELECT gender, COUNT(gender) FROM member GROUP BY gender;

実は、CASE式を使っても求められます。

CASE文で条件に一致するレコードを1,しないレコードを0と評価しておいてそれらを足し合わせています。

SELECT

SUM(CASE WHEN gender = 1 THEN 1 ELSE 0 END) AS male_count,
SUM(CASE WHEN gender = 2 THEN 1 ELSE 0 END) AS female_count
FROM member

この例だとGROUP BYの方がSQLのコード的には読みやすいですが、

区分が増えたり、条件が複雑になったときには使えることがあります。

この手法の肝は、CASE式の評価を集計しているところです。

覚えておいて損はありません。


指定の商品全てが含まれた購入情報を求める


  • 購入情報 sales

  • 購入明細情報 sales_item(sales_item.sales_id = sales.idでリレーション)

  • 商品情報 item(sales_item.sales_id = sales.idでリレーション)

という、買い物かごにありがちなテーブル構成を想定します。

下図の購入明細情報から、商品ID1と2全てが含まれている購入情報を求めたいときはどうすればよいでしょう?

つまり購入情報ID110と111を出したい(112や113は全てではないので除外)場合です。

id
sales_id
item_id

1
110
1

2
110
2

3
111
1

4
111
2

5
111
3

6
112
1

7
113
2

8
114
3

CASE式とHAVING句を使って求めることができます。

購入情報を出したいので、購入情報idでグループ化します。

集約の中身をCASE式で評価します。

CASE式での評価方法ですが、商品ID1,2が含まれているレコードは1、含まれていないレコードは0を割り当てます。

CASE式の評価合計 = 商品の点数(ここでは2種類の商品IDなので「2」)となる購入情報を求められます。

SELECT sales_id FROM sales_item GROUP BY sales_id HAVING SUM(CASE WHEN item_id IN (1, 2) THEN 1 ELSE 0 END) = 2

もちろん全てではなくても応用できます。

購入明細情報から、商品ID6〜10の商品の中から1種類だけ購入されている購入情報を求める

SELECT sales_id FROM sales_item GROUP BY sales_id HAVING SUM(CASE WHEN item_id IN (6, 7, 8, 9, 10) THEN 1 ELSE 0 END) = 1

購入明細情報から、商品ID11〜20の商品の中から3種類以上含まれている購入情報を求める

SELECT sales_id FROM sales_item GROUP BY sales_id HAVING SUM(CASE WHEN item_id >= 11 OR item_id <= 20 THEN 1 ELSE 0 END) >= 3


順序を変更する

ORDER句にCASE式を指定することで、順序を自在にコントロールできます。

SELECT

*
FROM recipe
ORDER BY
CASE
WHEN category_id = 1 THEN 2
WHEN category_id = 2 THEN 1
WHEN category_id = 3 THEN 3
WHEN category_id = 4 THEN 5
WHEN category_id = 5 THEN 4
ELSE category_id
END;

式の内容そのままですが、実際の表示順は以下になります。

「CASE」〜「END」までが数値として評価されます。

category_idが1だったら2と評価され、2だったら1と評価され、・・・

最終的に評価された数値で並べ替えています。

カテゴリーID
実際の表示順

1
2

2
1

3
3

4
5

5
4

その他
カテゴリーIDと同じ

レシピをカテゴリー順に表示させる仕様になっていて、例えば

1:和食

2:洋食

3:中華

...

と割り当てていたとしましょう。「洋食を一番上に持ってきてくれ」と急に言われた場合なんかには使えます。


数値を入れ替える

ID
カテゴリーID

1
1

2
1

3
1

4
3

5
2

6
5

7
5

8
5

9
4

10
4

11
4

12
4

13
4

14
4

15
4

16
4

17
4

18
4

19
4

20
4

例えば上記のようなテーブル(itemsテーブルとします。)でカテゴリーIDの1と4を入れ替えたい場合があります。

UPDATE items SET category = 4 WHERE category = 1;

UPDATE items SET category = 1 WHERE category = 4;

とやっては詰んでしまいます。(結果もとの4は4のまま。)

UPDATE items SET category = -1 WHERE category = 1;

UPDATE items SET category = 1 WHERE category = 4;
UPDATE items SET category = 1 WHERE category = -1;

存在しないカテゴリーIDでいったんアップロードしておくとうまくいきます。

ただし、クエリを3回実行させていますし、上記でいえば-1が存在しないことを確認しておかないといけません。

CASE式を使えば、1クエリで完結します。

UPDATE items SET category = CASE WHEN category = 1 THEN 4 WHEN category = 4 THEN 1 ELSE category

WHERE category IN (1, 4);

UPDATEする値をCASE式で評価しています。

「ELSE category」もしくはWHERE句を忘れないようにしましょう。

そうしないと、1と4以外全部NULLになってしまいます。

私は不用意な全件書き換えはしたくないのでWHERE句はつけますし、ELSEもいつも明示的に書く派なので両方記述しています。


住所フィールドに都道府県名が含まれていたときに、バラして都道府県番号に変換する

ID(id)
名前(name)
住所(address)

1
田中
大阪府大阪市〜

2
鈴木
東京都港区〜

3
佐藤
兵庫県神戸市〜

4
吉田
北海道札幌市〜

ID(id)
名前(name)
都道府県(prefecture)
住所(address)

1
田中
27
大阪市〜

2
鈴木
13
港区〜

3
佐藤
28
神戸市〜

4
吉田
1
札幌市〜

上のテーブルを下のテーブルのような形にしたいときもUPDATE文の中でCASE式を使えば1クエリで可能です。

都道府県に対応する都道府県番号をWHEN句の1つ1つに割り当てていった上で評価します。

仮に住所が含まれていない場合はprefectureフィールドはNULLのままです。

同様にaddressフィールドからは都道府県名を取り除きます。

REPLACE関数(MySQLで操作)など、文字列を置換(都道府県名を空文字に置換)することで実現できます。

WHERE句がなくても実行結果は同じですが、安全配慮の癖づけとして入れています。

また、addressフィールドのELSE句は必ずそのままの値を設定します。

そうしないと、都道府県が含まれていない住所が全て消えてしまいます。

(下記はWHERE句を設定しているのでその事態は免れますが。。)

余談ですが、47行の編集が大変に見える場合は


  • 連番を作成する手法

  • 矩形編集

を覚えておくことをおすすめします。思ったより大変ではありません。

UPDATE member SET

prefecture =
CASE
WHEN address LIKE '%北海道%' THEN 1
WHEN address LIKE '%青森県%' THEN 2
WHEN address LIKE '%岩手県%' THEN 3
WHEN address LIKE '%宮城県%' THEN 4
WHEN address LIKE '%秋田県%' THEN 5
WHEN address LIKE '%山形県%' THEN 6
WHEN address LIKE '%福島県%' THEN 7
WHEN address LIKE '%茨城県%' THEN 8
WHEN address LIKE '%栃木県%' THEN 9
WHEN address LIKE '%群馬県%' THEN 10
WHEN address LIKE '%埼玉県%' THEN 11
WHEN address LIKE '%千葉県%' THEN 12
WHEN address LIKE '%東京都%' THEN 13
WHEN address LIKE '%神奈川県%' THEN 14
WHEN address LIKE '%新潟県%' THEN 15
WHEN address LIKE '%富山県%' THEN 16
WHEN address LIKE '%石川県%' THEN 17
WHEN address LIKE '%福井県%' THEN 18
WHEN address LIKE '%山梨県%' THEN 19
WHEN address LIKE '%長野県%' THEN 20
WHEN address LIKE '%岐阜県%' THEN 21
WHEN address LIKE '%静岡県%' THEN 22
WHEN address LIKE '%愛知県%' THEN 23
WHEN address LIKE '%三重県%' THEN 24
WHEN address LIKE '%滋賀県%' THEN 25
WHEN address LIKE '%京都府%' THEN 26
WHEN address LIKE '%大阪府%' THEN 27
WHEN address LIKE '%兵庫県%' THEN 28
WHEN address LIKE '%奈良県%' THEN 29
WHEN address LIKE '%和歌山県%' THEN 30
WHEN address LIKE '%鳥取県%' THEN 31
WHEN address LIKE '%島根県%' THEN 32
WHEN address LIKE '%岡山県%' THEN 33
WHEN address LIKE '%広島県%' THEN 34
WHEN address LIKE '%山口県%' THEN 35
WHEN address LIKE '%徳島県%' THEN 36
WHEN address LIKE '%香川県%' THEN 37
WHEN address LIKE '%愛媛県%' THEN 38
WHEN address LIKE '%高知県%' THEN 39
WHEN address LIKE '%福岡県%' THEN 40
WHEN address LIKE '%佐賀県%' THEN 41
WHEN address LIKE '%長崎県%' THEN 42
WHEN address LIKE '%熊本県%' THEN 43
WHEN address LIKE '%大分県%' THEN 44
WHEN address LIKE '%宮崎県%' THEN 45
WHEN address LIKE '%鹿児島県%' THEN 46
WHEN address LIKE '%沖縄県%' THEN 47
ELSE NULL
END,
address =
CASE
WHEN address LIKE '%北海道%' THEN REPLACE(address, '北海道', '')
WHEN address LIKE '%青森県%' THEN REPLACE(address, '青森県', '')
WHEN address LIKE '%岩手県%' THEN REPLACE(address, '岩手県', '')
WHEN address LIKE '%宮城県%' THEN REPLACE(address, '宮城県', '')
WHEN address LIKE '%秋田県%' THEN REPLACE(address, '秋田県', '')
WHEN address LIKE '%山形県%' THEN REPLACE(address, '山形県', '')
WHEN address LIKE '%福島県%' THEN REPLACE(address, '福島県', '')
WHEN address LIKE '%茨城県%' THEN REPLACE(address, '茨城県', '')
WHEN address LIKE '%栃木県%' THEN REPLACE(address, '栃木県', '')
WHEN address LIKE '%群馬県%' THEN REPLACE(address, '群馬県', '')
WHEN address LIKE '%埼玉県%' THEN REPLACE(address, '埼玉県', '')
WHEN address LIKE '%千葉県%' THEN REPLACE(address, '千葉県', '')
WHEN address LIKE '%東京都%' THEN REPLACE(address, '東京都', '')
WHEN address LIKE '%神奈川県%' THEN REPLACE(address, '神奈川県', '')
WHEN address LIKE '%新潟県%' THEN REPLACE(address, '新潟県', '')
WHEN address LIKE '%富山県%' THEN REPLACE(address, '富山県', '')
WHEN address LIKE '%石川県%' THEN REPLACE(address, '石川県', '')
WHEN address LIKE '%福井県%' THEN REPLACE(address, '福井県', '')
WHEN address LIKE '%山梨県%' THEN REPLACE(address, '山梨県', '')
WHEN address LIKE '%長野県%' THEN REPLACE(address, '長野県', '')
WHEN address LIKE '%岐阜県%' THEN REPLACE(address, '岐阜県', '')
WHEN address LIKE '%静岡県%' THEN REPLACE(address, '静岡県', '')
WHEN address LIKE '%愛知県%' THEN REPLACE(address, '愛知県', '')
WHEN address LIKE '%三重県%' THEN REPLACE(address, '三重県', '')
WHEN address LIKE '%滋賀県%' THEN REPLACE(address, '滋賀県', '')
WHEN address LIKE '%京都府%' THEN REPLACE(address, '京都府', '')
WHEN address LIKE '%大阪府%' THEN REPLACE(address, '大阪府', '')
WHEN address LIKE '%兵庫県%' THEN REPLACE(address, '兵庫県', '')
WHEN address LIKE '%奈良県%' THEN REPLACE(address, '奈良県', '')
WHEN address LIKE '%和歌山県%' THEN REPLACE(address, '和歌山県', '')
WHEN address LIKE '%鳥取県%' THEN REPLACE(address, '鳥取県', '')
WHEN address LIKE '%島根県%' THEN REPLACE(address, '島根県', '')
WHEN address LIKE '%岡山県%' THEN REPLACE(address, '岡山県', '')
WHEN address LIKE '%広島県%' THEN REPLACE(address, '広島県', '')
WHEN address LIKE '%山口県%' THEN REPLACE(address, '山口県', '')
WHEN address LIKE '%徳島県%' THEN REPLACE(address, '徳島県', '')
WHEN address LIKE '%香川県%' THEN REPLACE(address, '香川県', '')
WHEN address LIKE '%愛媛県%' THEN REPLACE(address, '愛媛県', '')
WHEN address LIKE '%高知県%' THEN REPLACE(address, '高知県', '')
WHEN address LIKE '%福岡県%' THEN REPLACE(address, '福岡県', '')
WHEN address LIKE '%佐賀県%' THEN REPLACE(address, '佐賀県', '')
WHEN address LIKE '%長崎県%' THEN REPLACE(address, '長崎県', '')
WHEN address LIKE '%熊本県%' THEN REPLACE(address, '熊本県', '')
WHEN address LIKE '%大分県%' THEN REPLACE(address, '大分県', '')
WHEN address LIKE '%宮崎県%' THEN REPLACE(address, '宮崎県', '')
WHEN address LIKE '%鹿児島県%' THEN REPLACE(address, '鹿児島県', '')
WHEN address LIKE '%沖縄県%' THEN REPLACE(address, '沖縄県', '')
ELSE address
END
WHERE
address LIKE '%北海道%' OR
address LIKE '%青森県%' OR
address LIKE '%岩手県%' OR
address LIKE '%宮城県%' OR
address LIKE '%秋田県%' OR
address LIKE '%山形県%' OR
address LIKE '%福島県%' OR
address LIKE '%茨城県%' OR
address LIKE '%栃木県%' OR
address LIKE '%群馬県%' OR
address LIKE '%埼玉県%' OR
address LIKE '%千葉県%' OR
address LIKE '%東京都%' OR
address LIKE '%神奈川県%' OR
address LIKE '%新潟県%' OR
address LIKE '%富山県%' OR
address LIKE '%石川県%' OR
address LIKE '%福井県%' OR
address LIKE '%山梨県%' OR
address LIKE '%長野県%' OR
address LIKE '%岐阜県%' OR
address LIKE '%静岡県%' OR
address LIKE '%愛知県%' OR
address LIKE '%三重県%' OR
address LIKE '%滋賀県%' OR
address LIKE '%京都府%' OR
address LIKE '%大阪府%' OR
address LIKE '%兵庫県%' OR
address LIKE '%奈良県%' OR
address LIKE '%和歌山県%' OR
address LIKE '%鳥取県%' OR
address LIKE '%島根県%' OR
address LIKE '%岡山県%' OR
address LIKE '%広島県%' OR
address LIKE '%山口県%' OR
address LIKE '%徳島県%' OR
address LIKE '%香川県%' OR
address LIKE '%愛媛県%' OR
address LIKE '%高知県%' OR
address LIKE '%福岡県%' OR
address LIKE '%佐賀県%' OR
address LIKE '%長崎県%' OR
address LIKE '%熊本県%' OR
address LIKE '%大分県%' OR
address LIKE '%宮崎県%' OR
address LIKE '%鹿児島県%' OR
address LIKE '%沖縄県%';


item1, item2, item3...のようなテーブル(列持ちを行持ちに変換する)

売上テーブル(sales)

ID(id)
商品1(item1)
金額1(price1)
商品2(item2)
金額2(price2)
商品3(item3)
金額3(price3)

1
1
3000
2
1500
3
1000

2
4
1000
3
1000

3
2
2000

4
5
1000
1
500
3
5000

商品を複数フィールドに登録でき、それぞれの商品に対する売上も同様に複数フィールドを持ちます。

「列持ち」と言われている構成で、実務では度々出てきます。

列持ち構成で集計をしたいときに困ったことになります。

例えば上記のようなテーブルで各商品ごとの金額合計や商品が出てきた回数を出したいときはどうしましょう?

列ごとに集計してホスト言語側でループを回して合計する、という方法も考えられます。

その場合は上記ならば3回SQLを発行することになり、さらにホスト言語側のコードも増えます。

CASE式を使って、列持ちを行持ちに変換することで1SQLで解決できます。

変換のためには、別にピボットテーブルを用意します。

連番を格納しただけのテーブルです。

※今回は3までしか必要ないですが、使い回しができるように適当な数まで作っています。

CREATE TABLE pivot (

seq INT NOT NULL AUTO_INCREMENT COMMENT '連番',
-- ---------------------------------------------------------
PRIMARY KEY(seq)
) COMMENT = 'ピボットテーブル';

INSERT INTO pivot (seq) VALUES (1);
INSERT INTO pivot (seq) VALUES (2);
INSERT INTO pivot (seq) VALUES (3);
INSERT INTO pivot (seq) VALUES (4);
INSERT INTO pivot (seq) VALUES (5);
INSERT INTO pivot (seq) VALUES (6);
INSERT INTO pivot (seq) VALUES (7);
INSERT INTO pivot (seq) VALUES (8);
INSERT INTO pivot (seq) VALUES (9);
INSERT INTO pivot (seq) VALUES (10);

ピボットテーブル(pivot)

seq

1

2

3

4

5

...

10

列持ちテーブルとピボットテーブルをクロス結合します。

クロス結合しただけだと、以下のような結果になります。

ID(id)
商品1(item1)
金額1(price1)
商品2(item2)
金額2(price2)
商品3(item3)
金額3(price3)
seq

1
1
3000
2
1500
3
1000
1

1
1
3000
2
1500
3
1000
2

1
1
3000
2
1500
3
1000
3

2
4
1000
3
1000

1

2
4
1000
3
1000

2

2
4
1000
3
1000

3

3
2
2000

1

3
2
2000

2

3
2
2000

3

4
5
1000
1
500
3
5000
1

4
5
1000
1
500
3
5000
2

4
5
1000
1
500
3
5000
3

上記結果の「seq」の値によってCASE式で対象の商品番号、売上を選ぶイメージです。

例えば一番上の行はseqが1なので、商品1と売上1を集計対象にします。

今までの話を踏まえると下記の回答になります。

SELECT

CASE pivot.seq
WHEN 1 THEN sales.item1
WHEN 2 THEN sales.item2
WHEN 3 THEN sales.item3
END AS fund,
COUNT(CASE pivot.seq WHEN 1 THEN sales.item1 WHEN 2 THEN sales.item2 WHEN 3 THEN sales.item3 END) AS count,
SUM(CASE pivot.seq WHEN 1 THEN sales.price1 WHEN 2 THEN sales.price2 WHEN 3 THEN sales.price3 END) AS price
FROM
sales
CROSS JOIN
pivot
ON pivot.seq <= 3
GROUP BY
CASE pivot.seq
WHEN 1 THEN sales.item1
WHEN 2 THEN sales.item2
WHEN 3 THEN sales.item3
END

今回は例示しませんが、逆に行持ちから列持ちへの変換も可能です。


注意事項

CASE式を使えると楽しくなってきますが、何でもかんでもCASE式で処理するのはやめましょう。使い方を誤ると、逆にSQL文が解読不能になってしまいます。また、SQL文はプログラム言語と比べてデバッグが難しいです。当然プログラム側で処理を書いた方がシンプルになる場合もあるわけで、ケースバイケースで使い分けるべきと思います。