LoginSignup
1
0

私なりのSQLの書き方、コード整理の仕方

Last updated at Posted at 2023-09-16

面白い記事があったので、
https://qiita.com/zackey2/items/883616dff71e51bf6563#comment-bf5fbfb1f782dce723d5

私なりにどういう風にSQLを書いているかを整理ついでに書いていこうと思います。
(今後も加筆修正はすると思います。)
コーディングスタイルというより、こんなコードの整理術ができるよとかのほうが近いです。

修正履歴

2023-09-17 初版
2023-09-18 in()の追加

注意事項

分析用SQLで書いている中で身に着けた整理術なので、
我流だったり、お行儀が悪いSQLも多々あります。
ここのSQL記載を参考にしてレビューアから怒られても、私は責任は取りません。
自己責任でお願いします。
特に★がついている奴は怒られると思います。

あと、いくつかの書き方はパフォーマンス懸念が発生する可能性のものがあります。
使用する場合は実際の条件で試してみて、問題ないことを確認してみてください。

動作検証環境

PostgreSQL 9.5.24, compiled by Visual C++ build 1800, 64-bit
(あれ……だいぶ古いな)

coalesce、Greatest、leastはすごい便利

正直、coalesceの有用性に気づいたとき、with句を初めて知った際と同じくらい興奮しました。
例えば、こんな感じのテーブルがあるとします。
(ユーザーと……なんかの購入額ですかね?)
image.png
なにかしらの事情があって、特例金額があるとします。
Aさんは6,000円、Bさんは3,000円でいいことになりました。

with tb_1 as (
        select * 
        from (  values ('A', 5000) , ('B', 4000)
                     , ('C', 3000) , ('D', NULL)
              ) as t (user_name, amount)
), tokurei as (
        select  * 
        from (values ('A', 6000), ('B', 3000)) as t (user_name, amount)
)
        select
          tb_1.user_name
          , coalesce(tokurei.amount, tb_1.amount) as last_amount
        from
          tb_1 
          left join tokurei as tokurei 
            on tb_1.user_name = tokurei.user_name

image.png
これ、すごくないです?
正直、私、coalesceを知ったとき、nullを変換するだけの関数か~~とか思ってましたよ!!
上記のことができるということはこんなことまでできるってことなんですよ!!!

例えば、特例的な金額2つがAさん、Bさん、Cさんに発生していて
その中で特例金額がある人は小さいほうの特例金額を、ない人は通常金額を出力みたいな
「それ……SQLでやるんです??」みたいな要件に対して、

with tb_1 as (
        select * 
        from (  values ('A', 5000) , ('B', 4000)
                      , ('C', 3000) , ('D', NULL),('E',1000) ) as t (user_name, amount)
), tokurei_1 as (
      -- 特例事情Aでの金額
      select  *
        from (values ('A', 4000), ('B', 1500)) as t (user_name, amount)
), tokurei_2 as (
      -- 特例事情Bでの金額
      select  * 
        from (values ('A', 3000), ('B', 3000),('C', 6000)) as t (user_name, amount)
)
select
  tb_1.user_name
  , coalesce(least(tok_1.amount
                    , tok_2.amount)
	         , tb_1.amount) as last_amount 
from  tb_1 
  left join tokurei_1 as tok_1 
                 on tb_1.user_name = tok_1.user_name 
  left join tokurei_2 as tok_2 
                 on tb_1.user_name = tok_2.user_name

image.png
ほら、抽出できちゃう!
最後のクエリのとこだけ抜粋すると、

select
  tb_1.user_name
  -- この部分だけで出力内容の制御ができちゃう!!°˖✧◝(⁰▿⁰)◜✧˖°
  , coalesce(least(tok_1.amount
                   , tok_2.amount)
	         , tb_1.amount) as last_amount 
from  tb_1 
  left join tokurei_1 as tok_1 
                 on tb_1.user_name = tok_1.user_name 
  left join tokurei_2 as tok_2 
                 on tb_1.user_name = tok_2.user_name

それだけで、今回みたいな要件が出力できるのはすごいよね。実際……
という「SQLって、仕様書と対応させようとするの簡単なんだ!!」と感動した部分でした。

あと、これは一般的な需要がないでしょうけど
自宅(code_home)、勤務先(code_work)、実家(code_parent)をそれぞれ登録できる場合に
自宅→勤務先→実家の順で、最初に登録されている地区で集計するみたいなこともできたりします。
image.png

with tb_1 as ( 
  select * 
  from
    ( 
      values ('A', 'α地区', NULL,NULL)
           , ('B', 'β地区', NULL,NULL)
           , ('C', NULL, 'α地区', NULL)
           , ('D', NULL, 'β地区', 'γ地区')
           , ('E', NULL, NULL, 'β地区')
    ) as t(user_name, code_home , code_work, code_parent)
) 

select
  coalesce(code_home , code_work, code_parent) as code
  , string_agg(user_name , '@'::text) as users
from
  tb_1 
group by 
  coalesce(code_home 
           , code_work
           , code_parent)

image.png
正直、この書き方は業務では書きたくないです。
DBの設計ミスった感あるもの(´・ω・`)

★in()による抽出条件(多分、怒られるやつ)

多分、通常パターンの方がいいやつです。
これをレビューに出したら怒られる覚悟をした方がいいやつです。
こんなテーブルがあるとします。
正規化もなにもないテーブルですね。
image.png
要件としては
「引数(prm)の値が'ALL'の場合は全件出力、それ以外の場合はcol_aの値でフィルターする」といったものです。
それでは、一般的なパターンです。

with tb_1 as ( 
		  select  * 
		  from
		    ( values ('ミカン', 'ブドウ', 'イチゴ')
		           , ('オレンジ', 'グレープ', 'ストロベリー')
		           , ('不知火', '巨峰', 'あまおう')
		    ) as t(col_A, col_B, col_C))
, prm as ( 
	select * from (values ( 'ALL' ,  '')) as t (filter_A , filter_ALL)  
)
select tb_1.* 
   from tb_1 , prm 
   where           -- ALLの場合に全件出力
         case when prm.filter_A = 'ALL' then 1 = 1
                   -- col_aをフィルタリングする。
		      else prm.filter_A in (tb_1.col_A  )
		  end 

これはcase式でわかりやすいですね。

これをB列にも適用する必要が増えました。
まあ、同じようにかくだけではあるのですが、行数が膨らんでしまいます。

with tb_1 as ( 
		  select  * 
		  from
		    ( values ('ミカン', 'ブドウ', 'イチゴ')
		                , ('オレンジ', 'グレープ', 'ストロベリー')
		                , ('不知火', '巨峰', 'あまおう')
		    ) as t(col_A, col_B, col_C)
		)
, prm as ( 
	select * from (values ( 'ALL' , 'ALL', '')) as t (filter_A , filter_B,filter_ALL)  
)
select tb_1.* 
   from tb_1 , prm 
   where 
         ( case when prm.filter_A = 'ALL' then 1 = 1 
		        else prm.filter_A in (tb_1.col_A  )
		    end ) 
		  AND ( case when prm.filter_B = 'ALL' then 1 = 1 
		             else prm.filter_B in (tb_1.col_B  )
		        end )
		  AND ( case when prm.filter_ALL = 'ALL' then 1 = 1 
		             else prm.filter_ALL in (tb_1.col_A, tb_1.col_B, tb_1.col_C )
		        end )

全てALLならば全件出力できました。
image.png

では、filter_ALLを巨峰に変えてみます。

with tb_1 as ( 
		  select  * 
		  from
		    ( values ('ミカン', 'ブドウ', 'イチゴ')
		                , ('オレンジ', 'グレープ', 'ストロベリー')
		                , ('不知火', '巨峰', 'あまおう')
		    ) as t(col_A, col_B, col_C)
		)
, prm as ( 
	select * from (values ( 'ALL' , 'ALL', '巨峰')) as t (filter_A , filter_B,filter_ALL)  
)
select tb_1.* 
   from tb_1 , prm 
   where 
              prm.filter_A     in (tb_1.col_A  , 'ALL')
		  AND prm.filter_B     in (tb_1.col_B , 'ALL' )
		  AND prm.filter_ALL   in (tb_1.col_A , tb_1.col_B , tb_1.col_C ,'ALL' )

image.png
巨峰が含まれているレコードのみが出力されるようになりました。。
このように、inの中に複数項目を入れることで、その複数項目を条件に抽出することが
することができます。

マジックナンバーはwith句で分離しよう

※パフォーマンス劣化を起こす場合があるので、実際の要件に沿ってテストは実施してください。
例えば、こんなユーザーの名前を管理しているテーブルがあるとします。
image.png
はい、全角スペースや半角スペースの混在しています。
(わかりやすさのために、半角スペースは'△'で表現しています。)

こんなの「姓と名前に分割してください。」とか言われたら発狂したくなります。

with tb_1 as ( 
    select *  from (values ( '山田△太郎') , ('川田 花子') ,( '緑川薫') ) as t (user_name)
 )
 
    select 
         (case when user_name like '%'||'△'||'%'  then split_part(user_name,'△',1) 
               when user_name like '%'||' '||'%'  then split_part(user_name,' ',1) 
               else user_name
          end ) as first_name
        , (case when user_name like '%'||'△'||'%'  then split_part(user_name,'△',2) 
                when user_name like '%'||' '||'%'  then split_part(user_name,' ',2) 
                else ''
          end ) as last_name
    from tb_1

めんどくさい!!!
直書き箇所が4か所もあるんですよ!!
区切り文字の追加や修正するたびにミスしてないか怖くなります。
(……'△'が('ω')みたいでかわいいな……)

SQLなんですから、joinで書いた方が楽です。

with tb_1 as ( 
    select *  from (values ( '山田△太郎') , ('川田 花子') ,( '緑川薫') ) as t (user_name)
), delimiter as ( 
    select *  from (values ( '△') , (' ')  ) as t (delimiter)
 ), position as (
    -- 氏名の出力する位置を設定
 	select * from (values (1,2)) as t (first_name, last_name)
 )
 
    select 
        coalesce( split_part(user_name, delimiter , p.first_name ) 
                  , user_name ) as first_name
        ,coalesce( split_part(user_name, delimiter , p.last_name) 
                   , '' ) as last_name
    from tb_1 
	cross join position as p
    left join delimiter as d 
			  on tb_1.user_name like '%'||d.delimiter ||'%'  

image.png
はい、マジックナンバーはすべて分離できました!!( ´∀`)bグッ!
これで、区切り文字が追加になっても問題なく追加しやすいでしょう。

実際、マジックナンバーは最初にwith句でくくった方がグッと楽になります。
SQLを開いたときに、マジックナンバーに使いたい内容も一目で確認もできますし……

Cross joinも便利なんじゃよ……(覚えなくていいとかいわれるけど……)

※パフォーマンス劣化を起こす場合があるので、実際の要件に沿ってテストは実施してください。
例えば、こんな感じでユーザーと購入額がまとめてあるようなテーブルがあるとします。
image.png
合計金額が3000円を超えている人を出したい場合ってありますよね。
(あるかな……ないかも……)

select user_id   
  from tb_1 
group by user_id 
having sum(purchase_amount)  >= 3000

havingの説明にも出る感じの基本形ですね。

……でもよく考えてください。
キャンペーンっていくつもある可能性がありますよね。
もちろんコレくらいならコメントでも残せます。

select user_id   
  from tb_1 
group by user_id 
having sum(purchase_amount)  >= 3000    -- キャンペーンA
       OR sum(purchase_amount) >= 1500  -- キャンペーンB

それを直書きで書こうモノなら、今後の修正時に影響が出る可能性があります。
数個所に書こうモノなら、今回の条件では
置換は効かないしコメントを検索するしかありません。
「テヘペロ、コメント……書き忘れてた」なんてこともあり得ます!
数字で書いたら、エディタの補完機能も使えません!!!!!!

同じく、with句にまとめちゃいましょう!!!

……条件が合計金額だけなので結合条件がありませんね。同じようにはjoinできないですね。
そんなの知るか!くっつけたいんじゃ!!!とそこでCross joinです。

with tb_1 as (
	-- 使用明細(ユーザー、購入額)
	select  * 
	  from
	     (  values ('A', 1500) , ('A', 2000)
	             , ('B', 1000) , ('B', 1000)
		  ) as t (user_id, purchase_amount)
), campaign_A as (
    -- キャンペーンAの対象開始金額
	select 3000 as start_campaign
), campaign_B as (
    -- キャンペーンBの対象開始金額
	select 1500 as start_campaign
)
select user_id   
  from tb_1 
           , campaign_A as c_A -- Cross joinの省略形
		   , campaign_B as c_B
group by user_id 
having sum(purchase_amount)  >= MAX(c_A.start_campaign)
             OR sum(purchase_amount)  >= MAX(c_B.start_campaign)

image.png
はい、両方のキャンペーン対象者が抽出できました!!( ´∀`)bグッ!
(……たぶん、普通のお作法的に考えると、副問い合わせを書くのかな……)

 campaign_A as (
    -- キャンペーンAの対象開始金額
	select 3000 as start_campaign
), campaign_B as (
    -- キャンペーンBの対象開始金額
	select 1500 as start_campaign
)
select user_id   
  from tb_1 
group by user_id 
having sum(purchase_amount)  >= (select * from campaign_A)        -- キャンペーンA
       OR sum(purchase_amount)  >= (select * from campaign_B)  -- キャンペーンB

ただ、私個人はCross joinの方を多用することが多いですね。
(だって、使う場所全部でselectを複数回書くの面倒だし……)

複数個所で使う条件はwith句でつくる!

※パフォーマンス劣化を起こす場合があるので、実際の要件に沿ってテストは実施してください。
case式は便利ですけど、偶にめんどうを作る原因にもなってしまいます!!
例えば、こんなテーブルがあって、
「松」、「竹」では誕生日にプレゼントがあるみたいです。(お寿司屋さんとかですかね?)
image.png
SQLで書くとこんな感じですかね。

with tb_1 as ( 
  select * 
  from
    ( values ('A', '梅', '2023-12-16')
           , ('B', '竹', '2023-12-16')
           , ('C', '竹', '2023-09-17')
           , ('D', '松', '2023-12-16')
           , ('E', '松', '2023-09-17')
    ) as t(user_name, plan, birthday))

select
   * 
   , case 
         when plan = '竹' and to_char(now(),'yyyy-mm-dd') = birthday then 'プレゼントがあるよ' 
         when plan = '松' and to_char(now(),'yyyy-mm-dd') = birthday then 'とても豪華なプレゼントがあるよ' 
     end as message
   , case 
          when plan = '竹' and to_char(now(),'yyyy-mm-dd') = birthday then '割引券' 
          when plan = '松' and to_char(now(),'yyyy-mm-dd') = birthday then '特上割引券' 
     end as present

from
  tb_1

また、同じです。
birthdayの判定が複数個所に書いてあります。
これでは、プランを出力するだけのSQLだと思っている人が「なんでto_char??」となってしまいます。
誕生日の判定をwith句にまとめてみましょう!

with tb_1 as ( 
  select * 
  from
    ( values ('A', '梅', '2023-12-16')
           , ('B', '竹', '2023-12-16')
           , ('C', '竹', '2023-09-17')
           , ('D', '松', '2023-12-16')
           , ('E', '松', '2023-09-17')
    ) as t(user_name, plan, birthday)
) , tmp as (    
      select * 
        -- 誕生日プレゼントがあるかどうかの判定フラグを追加
            , to_char(now(),'yyyy-mm-dd') = birthday as birthday_flag
      from tb_1

) 
select
   * 
   , case 
         when plan = '竹' and birthday_flag then 'プレゼントがあるよ' 
         when plan = '松' and birthday_flag then 'とても豪華なプレゼントがあるよ' 
     end as message
   , case 
          when plan = '竹' and birthday_flag then '割引券' 
          when plan = '松' and birthday_flag then '特上割引券' 
     end as present

from
  tmp

これで誕生日判定箇所が一か所に集まりました!
読んだ人も誕生かどうかでプレゼントがあるんだなとわかってくれることでしょう!
こんな感じで、with句で呼び出す際に、複数の箇所で使う判定条件は追加してしまいましょう!!!

でもcase式を直書きで書くのもめんどくさいですね。
文言修正するのに、SQLをいじりたくないですし……これもwith句に書いちゃいましょう!

with tb_1 as ( 
  select
    * 
  from
    (values ('A', '梅', '2023-12-16')
          , ('B', '竹', '2023-12-16')
          , ('C', '竹', '2023-09-17')
          , ('D', '松', '2023-12-16')
          , ('E', '松', '2023-09-17')
    ) as t(user_name, plan, birthday)
) 
, tmp as ( 
  select
    *
	-- 誕生日プレゼントがあるかどうかの判定フラグを追加
    , to_char(now(), 'yyyy-mm-dd') = birthday as birthday_flag 
  from
    tb_1
) 
, birthday_present as ( 
  select * 
  from ( 
          values ('竹', 'プレゼントがあるよ!', '割引券')
               , ('松', 'とても豪華なプレゼントがあるよ!', '特上割引券')
    ) as t(plan, message, present)
) 
, message as ( 
  select * 
  from
    (values ('いつもご利用ありがとうございます!')) as message(message)
)

select
  tmp.*
  , (m.message || coalesce(birthday.message, '')) as message
  , birthday.present as birthday_present 
from
  tmp 
  left join message as m 
                 on 1 = 1
  left join birthday_present birthday 
                on tmp.birthday_flag 
                     AND tmp.plan = birthday.plan

image.png
これで抽出SQLからはcase式がなくなりました!
定型文的な文章を入れてもそんなに複雑ではなくなりましたね!!

with句の行が増えているので、行が増えていますが、
実際はwith句ではなく、

  1. メッセージテーブルに格納する
  2. on条件に誕生日の判定処理を記載する

とかすると実際に行数も減って、ソースもだいぶ圧縮できます。
(逆に圧縮しすぎて何を出しているか忘れてしまいそう……(;^ω^))

select
  tb_1.*
  , (m.message || coalesce(birthday.message, '')) as message
  , birthday.present as birthday_present 
from
  tb_1 as tb_1
  left join message as m 
                 on 1 = 1
  left join birthday_present birthday 
                on to_char(now(), 'yyyy-mm-dd') = birthday 
                     AND tb_1.plan = birthday.plan

(messageのCTEテーブルですか………なんで、さっき力説していた、cross joinじゃなくてleft joinの1=1とかいう意味不明な結合条件かですって??
 いや、なんかjoinのあとにon条件がないとテーブルごとのインデントの区切りがなくて収まり悪くって……♪~<(゚ε゚;)>)

##あとがき
なんか他にもSQL書いてるときに気にしていることある気はするけど、なんか思い出せない。
思い出せたら加筆します。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0