気がつけば5月も終わりですね。 十分休暇は取れたのですが、外出自粛の影響でどこにも行けなかったのでリフレッシュはあまりできなかった1ヶ月でした^^;
5月頭あたりはそれなりの頻度で記事を挙げていましたが、後半だれました…
さて、今回はタイトル通りです。
MySQLは8.0からCTE(Common table expression 共通テーブル式)をサポートするようになりました。 CTEというのは名前付きの一時的な結果セットで、これを使うと例えば同じサブクエリを複数の個所で使用しているSQLの可読性を向上させることができます。
また、再帰CTEというものも存在しこちらを使うとシーケンスを簡単に生成できたり、入れ子構造をSQLで比較的簡単に処理できます。 8.0.19で再帰CTEにLIMIT句も利用できるようにさらに使い勝手がよくなりました。
今回はそんなCTEについて使い方を含めて覚えるために練習します。 特に再帰CTEは原理をちゃんと理解しないと複雑なクエリが読めないので中心的に練習しようと思います。
通常CTE
まずは通常のCTEから。
worldデータベースを例にCTEを使ってみます。 まずは次のようなクエリを考えてみます。
SELECT * FROM (SELECT CountryCode, SUM(Population) sum_population FROM city GROUP BY CountryCode) tmp WHERE sum_population > 100000000;
このクエリをCTEを用いて書き換えると次のようになります。
WITH test_cte AS ( SELECT CountryCode, SUM(Population) sum_population FROM city GROUP BY CountryCode) SELECT * FROM test_cte WHERE sum_population > 100000000;
まずWITH cte名 AS(対象となるSQL)でサブセットを定義しておいて、そのあとSELECT文でそのサブセットを参照する形になります。 そのため、次のように同じサブセットを複数回参照するクエリも書けます。
WITH test_cte AS ( SELECT CountryCode, SUM(Population) sum_population FROM city GROUP BY CountryCode) SELECT CountryCode, sum_population/(SELECT avg(sum_population) FROM test_cte) * 100 avg_rate FROM test_cte WHERE sum_population > 100000000;
これをCTEを使わずに表現すると次のようになります。
SELECT CountryCode, sum_population/(SELECT avg(sum_population) FROM (SELECT CountryCode, SUM(Population) sum_population FROM city GROUP BY CountryCode) t1) * 100 avg_rate FROM (SELECT CountryCode, SUM(Population) sum_population FROM city GROUP BY CountryCode) t2 WHERE sum_population > 100000000;
非常に解読しにくいクエリの誕生です。 派生テーブルにはエイリアスをつけなくてはいけないのでt1, t2のように毎度テーブル名をつけてあげる必要があります。
少なくとも僕はこんなクエリ見たくないですね^^;
このようにサブクエリを複数回参照する必要がある場合でも、CTEを使うことで非常にすっきりした表現にまとめることができます。
再帰CTE
通常のCTEはシンプルに使えますが、一方で派生テーブルの代わり程度のことしかできません。
CTEが真価を発揮するのはそれを再帰的に使うことです。 これを再帰CTE(Recursive CTE)と呼びます。
再帰CTEを使うと例えばシーケンスの生成などを簡単に行うことができます。
試しに1から10のシーケンスを再帰CTEで生成してみます。
WITH RECURSIVE num AS( SELECT 1 AS n UNION ALL SELECT n + 1 FROM num WHERE n < 10) SELECT * FROM num;
先ほどと違いRECURSIVEというキーワードが追加されています。 このキーワードにより再帰CTEであることを宣言しています。
結果は次のようになります。
+------+ | n | +------+ | 1 | | 2 | | 3 | | 4 | | 5 | | 6 | | 7 | | 8 | | 9 | | 10 | +------+ 10 rows in set (0.00 sec)
まず、SELECT 1が実行されます。
そのあとWHERE句の条件を満たしている間、UNION ALL以降のクエリが実行されます。
1 < 10なのでSELECT n(=1) + 1 FROM num
が実行され、2 < 10なのでSELECT n(=2) + 1 FROM num
が実行され…というように条件を満たす間クエリが繰り返し実行されます。
同様に奇数だけを取り出したい場合は次のようなクエリでできます。
WITH RECURSIVE odd_num AS( SELECT 1 AS n UNION ALL SELECT n + 2 FROM odd_num WHERE n < 19 )SELECT * FROM odd_num;
結果は次のようになります。
+------+ | n | +------+ | 1 | | 3 | | 5 | | 7 | | 9 | | 11 | | 13 | | 15 | | 17 | | 19 | +------+ 10 rows in set (0.00 sec)
数値に限らず日付にも適用できます。
次の例では5月の日付一覧を生成しています。
WITH RECURSIVE May AS( SELECT DATE('2020-05-01') AS day UNION ALL SELECT DATE_ADD(day, INTERVAL 1 DAY) FROM May WHERE day < '2020-05-31') SELECT * FROM May;
結果は次の通りです。
+------------+ | day | +------------+ | 2020-05-01 | | 2020-05-02 | | 2020-05-03 | : : | 2020-05-31 | +------------+ 31 rows in set (0.00 sec)
これを使って5月の月曜日の日付だけ出力するということも簡単にできます。
WITH RECURSIVE May AS( SELECT DATE('2020-05-01') AS day UNION ALL SELECT DATE_ADD(day, INTERVAL 1 DAY) FROM May WHERE day < '2020-05-31') SELECT day FROM May WHERE WEEKDAY(day) = 0; +------------+ | day | +------------+ | 2020-05-04 | | 2020-05-11 | | 2020-05-18 | | 2020-05-25 | +------------+ 4 rows in set (0.00 sec)
階乗を求める
再帰というと最もよく上がる例として階乗を求めるなんて問題があると思います。
自然数nの階乗は次の式で求められます。
なおと定義されています。
再帰を取り扱っているプログラミングの入門書などではおそらくほとんどのケースで階乗を再帰を使って求めるなんて問題が出てきます。
例えばPythonで階乗を求める関数は次のように書けます。(nは自然数であることを前提とする)
def calc_fract(n): if n == 0 : return 1 else : return n * calc_fract(n - 1)
それでは、これを再帰CTEを使って求めてみましょう。
WITH RECURSIVE fract(n, f) AS( SELECT 1, 1 UNION ALL SELECT n + 1, f * (n + 1) FROM fract LIMIT 10 )SELECT * FROM fract; +------+---------+ | n | f | +------+---------+ | 1 | 1 | | 2 | 2 | | 3 | 6 | | 4 | 24 | | 5 | 120 | | 6 | 720 | | 7 | 5040 | | 8 | 40320 | | 9 | 362880 | | 10 | 3628800 | +------+---------+
1列だけでは階乗を求めることができないので、補助のための列(n)があることに注意する必要があります。
これで10までの階乗を求めることができました。なお、SELECT 1, 1
の代わりにSELECT 0, 1
としても問題ありません。
⚠️ 再帰CTEにLIMIT句を使えるのはMySQL8.0.19からです。それ以前のバージョンではシンタックスエラーとなるので適当にWHERE n < N
とする必要があります。
フィボナッチ数を求める
もう一つ再帰に関して有名な問題としてフィボナッチ数列を求めるという問題があります。
フィボナッチ数列は次の漸化式で定義されます。
ここで
は2以上の整数です
フィボナッチ数もプログラミング言語で再帰を使って表現できます。(同様にnは自然数であるとする)
def calc_fibonacci(n): if n == 0 OR n == 1: return 1 else: return calc_fibonacci(n - 1) + calc_fibonacci(c -2)
⚠️ 上記はあくまで再帰の例示のためのものでパフォーマンスは非常に悪いです。メモ化やWhileで実装したほうが圧倒的に高速です。
WITH RECURSIVE fibo(n, n_1, n_2) AS( SELECT 0, 1, 1 UNION ALL SELECT n + 1, n_2, n_1 + n_2 FROM fibo LIMIT 10 )SELECT * FROM fibo; +------+------+------+ | n | n_1 | n_2 | +------+------+------+ | 0 | 1 | 1 | | 1 | 1 | 2 | | 2 | 2 | 3 | | 3 | 3 | 5 | | 4 | 5 | 8 | | 5 | 8 | 13 | | 6 | 13 | 21 | | 7 | 21 | 34 | | 8 | 34 | 55 | | 9 | 55 | 89 | +------+------+------+
n番目のフィボナッチ数がn_2となります。n,n_1は補助のため必要な列です。
再帰数の限界を把握する
再帰プログラムを書くときに気を付けるべきポイントはそのプログラムが必ず終了するという保証ができることです。
例えば上記の階乗を求めるプログラムで誤って次のように書いてしまうと問題が発生してしまいます。
def calc_fract(n): return n * fract(n -1)
この状態で例えばcalc_fract(2)を求めると何が起こるでしょうか。 結果は次のようになります。
Traceback (most recent call last): File "<stdin>", line 1, in <module> File "<stdin>", line 2, in calc_fract File "<stdin>", line 2, in calc_fract File "<stdin>", line 2, in calc_fract [Previous line repeated 996 more times] RecursionError: maximum recursion depth exceeded
僕の環境(Windows 10 Home, Anaconda Python 3.7.6)では次のようなエラーが出ました。 おそらくC言語などではセグメンテーション違反が発生することでしょう。(もしかしたらスタックオーバーフローが発生するかも)
直接の原因は終了条件を記述していないため無限ループが発生したことです。
MySQLの場合も同様に終了条件(WHERE句)を記述する必要があります。
WITH RECURSIVE numbers AS( SELECT 1 AS n UNION ALL SELECT n + 1 FROM numbers )SELECT * FROM numbers; ERROR 3636 (HY000): Recursive query aborted after 1001 iterations. Try increasing @@cte_max_recursion_depth to a larger value.
WHERE句に何も書いていないため、numbersが何度も参照されてしまいます。上記エラーはメッセージ通り再帰呼び出し回数がcte_max_recursion_depth
というその回数の上限を制御するシステム変数に達してしまったため生じたものです。
この値はデフォルトでは1000にセットされており、最大値は4294967295です。 セッションごとに変更が可能です。
mysql80-root@localhost:[(none)]>SELECT @@cte_max_recursion_depth; +---------------------------+ | @@cte_max_recursion_depth | +---------------------------+ | 1000 | +---------------------------+ 1 row in set (0.00 sec)
当たり前ですが、この値を増やすと再帰呼び出し回数が増えるため、処理当たりの実行時間は大きくなります。
セッションごとに再帰CTEを用いたクエリの実行時間を制御したい場合はmax_execution_time
変数を使います。(単位はミリ秒、デフォルトは0で実行時間による制御をおこなわない)
次の例はmax_execution_timeによってクエリが制限されている様子です。
mysql80-root@localhost:[(none)]>SET cte_max_recursion_depth = 4294967295; Query OK, 0 rows affected (0.00 sec) mysql80-root@localhost:[(none)]>SET max_execution_time = 1000; Query OK, 0 rows affected (0.00 sec) mysql80-root@localhost:[(none)]>WITH RECURSIVE numbers AS( -> SELECT 1 AS n -> UNION ALL -> SELECT n + 1 -> FROM numbers -> )SELECT * FROM numbers; ERROR 3024 (HY000): Query execution was interrupted, maximum statement execution time exceeded
システム変数を使わず、クエリ内で直接指定することも可能です。 (アスタリスクが被るため見やすさのためnと記述しています)
mysql80-root@localhost:[(none)]>SET max_execution_time = 0; Query OK, 0 rows affected (0.00 sec) mysql80-root@localhost:[(none)]>WITH RECURSIVE numbers AS( -> SELECT 1 AS n -> UNION ALL -> SELECT n + 1 -> FROM numbers -> )SELECT /*+ MAX_EXECUTION_TIME(1000) */ n FROM numbers; ERROR 3024 (HY000): Query execution was interrupted, maximum statement execution time exceeded
その他に上の例にもあるようにMySQL8.0.19からはLIMIT句によって制御することも可能です。
mysql80-root@localhost:[(none)]>WITH RECURSIVE numbers AS( -> SELECT 1 AS n -> UNION ALL -> SELECT n + 1 -> FROM numbers LIMIT 10000 -> )SELECT * FROM numbers ORDER BY n DESC LIMIT 1; +-------+ | n | +-------+ | 10000 | +-------+ 1 row in set (0.01 sec)
再帰CTEに関するLIMITはあくまでCTE内に記述することに注意してください。
とにかく再帰CTEを使う際はその再帰がちゃんと終了するものか、再帰の呼び出し回数、再帰クエリにかかる時間などを考慮する必要があります。
再帰CTEを使ってナイーブツリーを処理する
ナイーブツリー(素朴な木)というものをご存じでしょうか?
名著SQLアンチパターンの第2章で紹介されているアンチパターンです。 これはリレーショナルデータベースで階層構造を表現しようとするものです。
上記書籍内で紹介されている例を拝借させていただきます。
CREATE TABLE comments( comment_id SERIAL PRIMARY KEY, parent_id BIGINT UNSIGNED, commenter CHAR(20) NOT NULL, comment TEXT NOT NULL, FOREIGN KEY `fk_parent_id` (parent_id) REFERENCES comments(comment_id) );
mysql80-root@localhost:[sql_anti_pattern]>SELECT * FROM comments; +------------+-----------+-----------+--------------------------------------------------------------------------+ | comment_id | parent_id | commenter | comment | +------------+-----------+-----------+--------------------------------------------------------------------------+ | 1 | NULL | Fran | このバグの原因は何かな? | | 2 | 1 | Ollie | ヌルポインターのせいじゃないかな? | | 3 | 2 | Fran | そうじゃないよ。それは確認済みだ。 | | 4 | 1 | Kukla | 無効な入力を調べてみたら? | | 5 | 4 | Ollie | そうか、バグの原因はそれだな。 | | 6 | 4 | Fran | よし、じゃあチェック機能を追加してもらえるかな? | | 7 | 6 | Kukla | 了解。修正したよ。 | +------------+-----------+-----------+--------------------------------------------------------------------------+ 7 rows in set (0.00 sec)
このテーブルからコメントの階層を表示するためにはJOINを行う必要があります。
mysql80-root@localhost:[sql_anti_pattern]>SELECT c1.commenter, c1.comment, c2.commenter, c2.comment -> FROM comments c1 LEFT JOIN comments c2 -> ON c2.parent_id = c1.comment_id; +-----------+--------------------------------------------------------------------------+-----------+--------------------------------------------------------------------------+ | commenter | comment | commenter | comment | +-----------+--------------------------------------------------------------------------+-----------+--------------------------------------------------------------------------+ | Fran | このバグの原因は何かな? | Ollie | ヌルポインターのせいじゃないかな? | | Fran | このバグの原因は何かな? | Kukla | 無効な入力を調べてみたら? | | Ollie | ヌルポインターのせいじゃないかな? | Fran | そうじゃないよ。それは確認済みだ。 | | Fran | そうじゃないよ。それは確認済みだ。 | NULL | NULL | | Kukla | 無効な入力を調べてみたら? | Ollie | そうか、バグの原因はそれだな。 | | Kukla | 無効な入力を調べてみたら? | Fran | よし、じゃあチェック機能を追加してもらえるかな? | | Ollie | そうか、バグの原因はそれだな。 | NULL | NULL | | Fran | よし、じゃあチェック機能を追加してもらえるかな? | Kukla | 了解。修正したよ。 | | Kukla | 了解。修正したよ。 | NULL | NULL | +-----------+--------------------------------------------------------------------------+-----------+--------------------------------------------------------------------------+ 9 rows in set (0.01 sec)
このクエリの悪い点は階層が深くなるにつれてJOINを繰り返す必要がある点です。
SELECT c1.commenter, c1.comment, c2.commenter, c2.comment, c3.commenter, c3.comment, c4.commenter, c4.comment FROM comments c1 LEFT JOIN comments c2 ON c2.parent_id = c1.comment_id LEFT JOIN comments c3 ON c3.parent_id = c2.comment_id LEFT JOIN comments c4 ON c4.parent_id = c3.comment_id; +-----------+--------------------------------------------------------------------------+-----------+--------------------------------------------------------------------------+-----------+--------------------------------------------------------------------------+-----------+-----------------------------+ | commenter | comment | commenter | comment | commenter | comment | commenter | comment | +-----------+--------------------------------------------------------------------------+-----------+--------------------------------------------------------------------------+-----------+--------------------------------------------------------------------------+-----------+-----------------------------+ | Fran | このバグの原因は何かな? | Ollie | ヌルポインターのせいじゃないかな? | Fran | そうじゃないよ。それは確認済みだ。 | NULL | NULL | | Fran | このバグの原因は何かな? | Kukla | 無効な入力を調べてみたら? | Ollie | そうか、バグの原因はそれだな。 | NULL | NULL | | Fran | このバグの原因は何かな? | Kukla | 無効な入力を調べてみたら? | Fran | よし、じゃあチェック機能を追加してもらえるかな? | Kukla | 了解。修正したよ。 | | Ollie | ヌルポインターのせいじゃないかな? | Fran | そうじゃないよ。それは確認済みだ。 | NULL | NULL | NULL | NULL | | Fran | そうじゃないよ。それは確認済みだ。 | NULL | NULL | NULL | NULL | NULL | NULL | | Kukla | 無効な入力を調べてみたら? | Ollie | そうか、バグの原因はそれだな。 | NULL | NULL | NULL | NULL | | Kukla | 無効な入力を調べてみたら? | Fran | よし、じゃあチェック機能を追加してもらえるかな? | Kukla | 了解。修正したよ。 | NULL | NULL | | Ollie | そうか、バグの原因はそれだな。 | NULL | NULL | NULL | NULL | NULL | NULL | | Fran | よし、じゃあチェック機能を追加してもらえるかな? | Kukla | 了解。修正したよ。 | NULL | NULL | NULL | NULL | | Kukla | 了解。修正したよ。 | NULL | NULL | NULL | NULL | NULL | NULL | +-----------+--------------------------------------------------------------------------+-----------+--------------------------------------------------------------------------+-----------+--------------------------------------------------------------------------+-----------+-----------------------------+
さらに悪いことに最も階層が深くなる行以外は不要なNULLが表示されます。
再帰CTEを使うとこれをすっきり表現できます。
WITH RECURSIVE c(comment_id, level, commenter_chain, comment_chain) AS( SELECT comment_id, 0, CAST(commenter AS CHAR(100)), CAST(comment AS CHAR(100)) FROM comments WHERE parent_id IS NULL UNION ALL SELECT c2.comment_id, level + 1, CONCAT(c1.commenter_chain, ' -> ', c2.commenter), CONCAT(c1.comment_chain, ' -> ', c2.comment) FROM c c1 JOIN comments c2 ON c1.comment_id = c2.parent_id )SELECT * FROM c ORDER BY comment_chain; +------------+-------+--------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | comment_id | level | commenter_chain | comment_chain | +------------+-------+--------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | 1 | 0 | Fran | このバグの原因は何かな? | | 2 | 1 | Fran -> Ollie | このバグの原因は何かな? -> ヌルポインターのせいじゃないかな? | | 3 | 2 | Fran -> Ollie -> Fran | このバグの原因は何かな? -> ヌルポインターのせいじゃないかな? -> そうじゃないよ。それは確認済み だ。 | | 4 | 1 | Fran -> Kukla | このバグの原因は何かな? -> 無効な入力を調べてみたら? | | 5 | 2 | Fran -> Kukla -> Ollie | このバグの原因は何かな? -> 無効な入力を調べてみたら? -> そうか、バグの原因はそれだな。 | | 6 | 2 | Fran -> Kukla -> Fran | このバグの原因は何かな? -> 無効な入力を調べてみたら? -> よし、じゃあチェック機能を追加してもら えるかな? | | 7 | 3 | Fran -> Kukla -> Fran -> Kukla | このバグの原因は何かな? -> 無効な入力を調べてみたら? -> よし、じゃあチェック機能を追加してもら えるかな? -> 了解。修正したよ。 | +------------+-------+--------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
なぜ、CAST関数が必要なのかというと、非再帰部分(UNION句前のSELECT部分)を実行した時点でcommenter_chainやcomment_chainの型が決まってしまうためです。
こちらの記事を参考にしています。
こちらの記事では非常に面白い例を使って再帰CTEのサンプルをいくつか挙げています。
この方法のいいところは階層が増えたとしてもクエリを変更する必要がないところです。
mysql80-root@localhost:[sql_anti_pattern]>INSERT INTO comments VALUES (8, 7, 'Fran', 'ありがとう!'); Query OK, 1 row affected (0.01 sec) mysql80-root@localhost:[sql_anti_pattern]>WITH RECURSIVE c(comment_id, level, commenter_chain, comment_chain) AS( -> SELECT comment_id, 0, CAST(commenter AS CHAR(100)), CAST(comment AS CHAR(100)) -> FROM comments -> WHERE parent_id IS NULL -> UNION ALL -> SELECT c2.comment_id, level + 1, CONCAT(c1.commenter_chain, ' -> ', c2.commenter), CONCAT(c1.comment_chain, ' -> ', c2.comment) -> FROM c c1 JOIN comments c2 ON c1.comment_id = c2.parent_id -> )SELECT * FROM c ORDER BY comment_chain; +------------+-------+----------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | comment_id | level | commenter_chain | comment_chain | +------------+-------+----------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | 1 | 0 | Fran | このバグの原因は何かな? | | 2 | 1 | Fran -> Ollie | このバグの原因は何かな? -> ヌルポインターのせいじゃないかな? | | 3 | 2 | Fran -> Ollie -> Fran | このバグの原因は何かな? -> ヌルポインターのせいじゃないかな? -> そうじゃないよ。それは確認済みだ。 | | 4 | 1 | Fran -> Kukla | このバグの原因は何かな? -> 無効な入力を調べてみたら? | | 5 | 2 | Fran -> Kukla -> Ollie | このバグの原因は何かな? -> 無効な入力を調べてみたら? -> そうか、バグの原因はそれだな。 | | 6 | 2 | Fran -> Kukla -> Fran | このバグの原因は何かな? -> 無効な入力を調べてみたら? -> よし、じゃあチェック機能を追加してもらえるかな? | | 7 | 3 | Fran -> Kukla -> Fran -> Kukla | このバグの原因は何かな? -> 無効な入力を調べてみたら? -> よし、じゃあチェック機能を追加してもらえるかな? -> 了解。修正したよ。 | | 8 | 4 | Fran -> Kukla -> Fran -> Kukla -> Fran | このバグの原因は何かな? -> 無効な入力を調べてみたら? -> よし、じゃあチェック機能を追加してもらえるかな? -> 了解。修正したよ。 -> ありがとう! | +------------+-------+----------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ 8 rows in set (0.00 sec)
コメントをすべて列挙すると大変なのでcomment_idのみ表示してもいいかもしれませんね。
mysql80-root@localhost:[sql_anti_pattern]>WITH RECURSIVE c(comment_id, level, commenter_chain, comment_id_chain) AS( -> SELECT comment_id, 0, CAST(commenter AS CHAR(100)), CAST(comment_id AS CHAR(100)) -> FROM comments -> WHERE parent_id IS NULL -> UNION ALL -> SELECT c2.comment_id, level + 1, CONCAT(c1.commenter_chain, ' -> ', c2.commenter), CONCAT(c1.comment_id_chain, ' -> ', c2.comment_id) -> FROM c c1 JOIN comments c2 ON c1.comment_id = c2.parent_id -> )SELECT commenter_chain, comment_id_chain FROM c ORDER BY comment_id_chain; +----------------------------------------+-----------------------+ | commenter_chain | comment_id_chain | +----------------------------------------+-----------------------+ | Fran | 1 | | Fran -> Ollie | 1 -> 2 | | Fran -> Ollie -> Fran | 1 -> 2 -> 3 | | Fran -> Kukla | 1 -> 4 | | Fran -> Kukla -> Ollie | 1 -> 4 -> 5 | | Fran -> Kukla -> Fran | 1 -> 4 -> 6 | | Fran -> Kukla -> Fran -> Kukla | 1 -> 4 -> 6 -> 7 | | Fran -> Kukla -> Fran -> Kukla -> Fran | 1 -> 4 -> 6 -> 7 -> 8 | +----------------------------------------+-----------------------+ 8 rows in set (0.00 sec)
他にもゲームにおけるミッションなどがありますが、特定のミッションを達成しないと出現しないミッションを実現するために階層構造を利用できます。(こちらを参照) このようなケースでも再帰CTEを使うことで、階層構造を処理することができます。
ということで、今回はCTEおもに再帰CTEを練習するためにいくつかの例を考えてみました。