10 分間コーディングで 30 分かかった SQL を読み下す
再帰 CTE の入門が済んだところで、10 分間コーディングで 30 分かかった・・・ - ぐるぐる〜を読んでみましょう!
読み方
上の CTE (Input) から下に向かって読んできます。
Input
Input は簡単。
Input(id, numPlayers, deck) AS ( SELECT 1, 3, '123123123' UNION ALL SELECT 2, 4, '123123123' UNION ALL SELECT 3, 6, '012345012345012345' UNION ALL SELECT 4, 4, '111122223333' UNION ALL SELECT 5, 1, '012345012345012345' UNION ALL SELECT 6, 6, '01234' UNION ALL SELECT 7, 2, '' UNION ALL SELECT 8, 0, '' UNION ALL SELECT 9, 0, '123' )
id がついているので、複数の入力を処理できるようになっている。
Cards
Cards は Input.deck で与えられた文字列を一文字毎の行に分割する CTE で、同時に各カードに連番を付けていく。
Cards(id, i, card, deck) AS ( SELECT id , 0 , LEFT(deck, 1) , SUBSTRING(deck, 2, LEN(deck)) FROM Input WHERE deck <> '' UNION ALL SELECT id , i + 1 , LEFT(deck, 1) , SUBSTRING(deck, 2, LEN(deck)) FROM Cards WHERE deck <> '' )
Cards は再帰 CTE になっていて、deck の先頭一文字を card とし、残りを次の deck として分割していき、deck が空になったら再帰を終了する。
終了条件を UNION ALL より上の SELECT にも記述しているため、deck がはじめから空の場合には結果は空となる。
どのように処理が行われるのかが分かりにくい場合は、CTE を SELECT してみるといい。
-- 略 , Deal(id, player, cards) AS ( SELECT id , player , LEFT(cards, (SELECT MIN(LEN(cards)) FROM Deal_ C WHERE P.id = C.id)) FROM Deal_ P ) --SELECT * FROM Deal ORDER BY id, player SELECT * FROM Cards ORDER BY id, i
このように、元の SELECT をコメントアウトしておき、表示したい CTE の SELECT に置き換える。
Players
Players はプレイヤーの人数から、各プレイヤーを識別するための数値を 1 から連番で振っていく。
Players(id, player, n, total) AS ( SELECT id , 1 , numPlayers , numPlayers FROM Input WHERE numPlayers <> 0 UNION ALL SELECT id , player + 1 , n - 1 , total FROM Players WHERE n <> 1 )
Players も Cards と同様、再帰 CTE となっている。
Cards の場合は文字列を先頭から取っていって縮めていったが、Players では n を減らしていく。
そして、n が 1 になった時点で再帰が終了する。
また、Input の numPlayers が 0 の場合は Players は空になる。
Cards の連番 (i) は 0 からはじまるのに、Players の連番 (player) は 1 からはじまている点に注意。
player の最大値は total、つまり Input.numPlayers と同じになり、Cards.i の最大値は Input.numPlayers - 1 と同じになる。
これはちょうど、Java 等の配列のサイズと添え字の関係と同じになっている。
DealSrc
DealSrc は Players と Cards を結合して、手札算出の元にする。
DealSrc(id, i, player, card, total) AS ( SELECT Player.id , COALESCE(Card.i + 1, Player.player) , Player.player , COALESCE(Card.card, '') , Player.total FROM Players Player LEFT OUTER JOIN Cards Card ON Player.id = Card.id AND Player.player = (Card.i % Player.total + 1) )
DealSrc では再帰 CTE ではなく、通常の CTE となっている。
ただし、JOIN が少し複雑になっているので、少し詳しく見ていくことにする。
まずは Players と Cards を、Players を左側にして外部結合している。
結合条件の Player.id と Card.id が等しい部分はいいだろう。
問題は、Player.player = (Card.i % Player.total + 1) の部分だ。
Card.i は 0 からはじまる連番なので、Player.player と直接 = で結合することはできない (1 ずれる)。
そのため、+ 1 することが必要となる。
また、1 人のプレーヤーに 2 枚以上のカードを配る場合、Cards.i が Players.player を突き抜けてしまうので、プレーヤーの数で剰余を取っている。
total を Players に持たせずに、(SELECT MAX(player) FROM Players C WHERE Player.id = C.id) とすることもできるが、Players に持たせておくと読みやすくなる。
カードが足りずにプレーヤーに 1 枚も配れないような場合でも、プレイヤーの行は欲しいため、INNER JOIN ではなく LEFT OUTER JOIN にしている。
そして SELECT 句では、Cards 由来のカラムに COALESCE をかませることで NULL を回避している。
Concated
Concated は DealSrc の結果を id 毎、player 毎にカードを連結した文字列を生成する。
Concated(id, i, player, cards) AS ( SELECT id , i , player , CAST(card AS varchar(max)) FROM DealSrc WHERE i = 1 UNION ALL SELECT Concated.id , Concated.i + Src.total , Concated.player , Concated.cards + Src.card FROM Concated INNER JOIN DealSrc Src ON Concated.id = Src.id AND Concated.player = Src.player WHERE Concated.i + Src.total = Src.i )
Concated では再帰 CTE を使用して、文字列を連結している。
これまでに再帰 CTE は十分説明していると思うので、詳細は追わない。
一つだけ注意が必要なのは、再帰 CTE は処理の途中段階も保持しているということ。
これは重要なので、理解してから次に進むこと。
下の SQL を実行してみるのもいいだろう。
SELECT * FROM Concated ORDER BY id, i, player
Deal_
Deal_ は Concated から必要なものだけを取ってくる。
Deal_(id, player, cards) AS ( SELECT id , player , cards FROM Concated P WHERE LEN(cards) = (SELECT MAX(LEN(cards)) FROM Concated C WHERE P.id = C.id AND P.player = C.player) )
Deal_ は再帰ではない普通の CTE で、Concated の中から、id と player が同じで cards が一番長い行のみを取ってくる。
上で言ったように、再帰 CTE は途中段階も保持しているため、結果のみを取り出す SQL も書く必要がある。
複数行を単一の行にまとめ上げるため、再帰 CTE (この例では Concated) と、必要な行のみを抽出する SQL (この例では Deal_) は、集約を行っているとも言える。
その最たる例が、上のエントリで紹介した総積を求める SQL だ。
また、必要な行のみを抽出する方法には他にもいくつも方法がある。
例えば、NOT EXISTS を使った方法、ALL を使った方法、TOP 1 と ORDER BY を使った方法等が考えられる。
さらに、cards の長さを取らずに、再帰 CTE 側で終了データに終了フラグを立てておき、抽出側は単純な WHERE 句にするといった方法もある。
Deal
Deal は、手札の長さを揃える。
Deal(id, player, cards) AS ( SELECT id , player , LEFT(cards, (SELECT MIN(LEN(cards)) FROM Deal_ C WHERE P.id = C.id)) FROM Deal_ P )
Deal も再帰ではない普通の CTE で、Deal_ を元にして全プレーヤーの手札を一番少ないプレーヤーと同じになるように調整する。
これで、Deal を SELECT するだけで結果が取得できる。
全体
一応全体を載っけておきますね。
WITH Input(id, numPlayers, deck) AS ( SELECT 1, 3, '123123123' UNION ALL SELECT 2, 4, '123123123' UNION ALL SELECT 3, 6, '012345012345012345' UNION ALL SELECT 4, 4, '111122223333' UNION ALL SELECT 5, 1, '012345012345012345' UNION ALL SELECT 6, 6, '01234' UNION ALL SELECT 7, 2, '' UNION ALL SELECT 8, 0, '' UNION ALL SELECT 9, 0, '123' ) , Cards(id, i, card, deck) AS ( SELECT id , 0 , LEFT(deck, 1) , SUBSTRING(deck, 2, LEN(deck)) FROM Input WHERE deck <> '' UNION ALL SELECT id , i + 1 , LEFT(deck, 1) , SUBSTRING(deck, 2, LEN(deck)) FROM Cards WHERE deck <> '' ) , Players(id, player, n, total) AS ( SELECT id , 1 , numPlayers , numPlayers FROM Input WHERE numPlayers <> 0 UNION ALL SELECT id , player + 1 , n - 1 , total FROM Players WHERE n <> 1 ) , DealSrc(id, i, player, card, total) AS ( SELECT Player.id , COALESCE(Card.i + 1, Player.player) , Player.player , COALESCE(Card.card, '') , Player.total FROM Players Player LEFT OUTER JOIN Cards Card ON Player.id = Card.id AND Player.player = (Card.i % Player.total + 1) ) , Concated(id, i, player, cards) AS ( SELECT id , i , player , CAST(card AS varchar(max)) FROM DealSrc UNION ALL SELECT Concated.id , Concated.i + Src.total , Concated.player , Concated.cards + Src.card FROM Concated INNER JOIN DealSrc Src ON Concated.id = Src.id AND Concated.player = Src.player WHERE Concated.i + Src.total = Src.i ) , Deal_(id, player, cards) AS ( SELECT id , player , cards FROM Concated P WHERE LEN(cards) = (SELECT MAX(LEN(cards)) FROM Concated C WHERE P.id = C.id AND P.player = C.player) ) , Deal(id, player, cards) AS ( SELECT id , player , LEFT(cards, (SELECT MIN(LEN(cards)) FROM Deal_ C WHERE P.id = C.id)) FROM Deal_ P ) SELECT * FROM Deal ORDER BY id, player
もう読めるはず!