Rekursi di MySQL diperkenalkan pada chapter RECURSIVE CTE: sebuah CTE membaca hasilnya sendiri lewat tiga bagian — anchor sebagai titik awal, bagian rekursif sebagai mesin penghasil baris baru, dan terminator sebagai rem pengereman. Pola dasar itu kita bawa ke pemakaian yang jauh lebih kaya pada chapter ini. Kita menelusuri hierarki organisasi sambil menghitung kedalaman, merangkai jalur atasan-bawahan menjadi satu string, membangkitkan kalender bulanan dengan segala jebakan tanggalnya, membangkitkan deret Fibonacci, lalu membalik arah penelusuran dari daun pohon naik ke akar. Chapter ditutup dengan pengaturan batas kedalaman dan pertanyaan arsitektural: kapan rekursi sebaiknya tetap di SQL, dan kapan lebih baik dipindahkan ke kode aplikasi.

Menyiapkan Tabel Latihan #

Struktur organisasi toko kita tersimpan dalam satu tabel pegawai yang merujuk dirinya sendiri — kolom atasan_id pada tiap baris menunjuk ke id atasan di tabel yang sama. Ratna berdiri di puncak tanpa atasan, dan Zahra menjadi ujung rantai terdalam:

sql
DROP DATABASE IF EXISTS belajar_adv;
CREATE DATABASE belajar_adv;
USE belajar_adv;

Output yang Dihasilkan:

text
Query OK, 0 rows affected, 1 warning (0.00 sec)

Query OK, 1 row affected (0.00 sec)

Database changed
sql
CREATE TABLE pegawai (
  id INT PRIMARY KEY,
  nama VARCHAR(50) NOT NULL,
  atasan_id INT NULL
);

INSERT INTO pegawai (id, nama, atasan_id) VALUES
  (1, 'Ratna',  NULL),
  (2, 'Hendra', 1),
  (3, 'Maya',   1),
  (4, 'Budi',   2),
  (5, 'Siti',   2),
  (6, 'Andi',   3),
  (7, 'Dewi',   3),
  (8, 'Rina',   3),
  (9, 'Zahra',  6);

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Query OK, 9 rows affected (0.00 sec)
Records: 9  Duplicates: 0  Warnings: 0

Rekap struktur dalam satu kalimat contoh: WITH RECURSIVE deret AS (SELECT 1 AS n UNION ALL SELECT n + 1 FROM deret WHERE n < 5) SELECT * FROM deret membangkitkan 1 sampai 5 — anchor SELECT 1, bagian rekursif n + 1, terminator n < 5. Seluruh contoh chapter ini hanyalah pengisian ulang tiga bagian tersebut dengan isi yang berbeda.

Hierarki dengan Penghitung Kedalaman #

Penelusuran dari akar ke bawah menjadi jauh lebih berguna bila setiap hasil membawa nomor tingkatnya. Penghitung itu lahir dari satu baris ekspresi di bagian rekursif — tingkat + 1:

sql
WITH RECURSIVE hierarki AS (
  SELECT id, nama, atasan_id, 1 AS tingkat
  FROM pegawai
  WHERE atasan_id IS NULL
  UNION ALL
  SELECT p.id, p.nama, p.atasan_id, h.tingkat + 1
  FROM pegawai p JOIN hierarki h ON p.atasan_id = h.id
)
SELECT tingkat, id, nama, atasan_id
FROM hierarki
ORDER BY tingkat, nama;

Output yang Dihasilkan:

text
+---------+------+--------+-----------+
| tingkat | id   | nama   | atasan_id |
+---------+------+--------+-----------+
|       1 |    1 | Ratna  |      NULL |
|       2 |    2 | Hendra |         1 |
|       2 |    3 | Maya   |         1 |
|       3 |    6 | Andi   |         3 |
|       3 |    4 | Budi   |         2 |
|       3 |    7 | Dewi   |         3 |
|       3 |    8 | Rina   |         3 |
|       3 |    5 | Siti   |         2 |
|       4 |    9 | Zahra  |         6 |
+---------+------+--------+-----------+
9 rows in set (0.00 sec)

Bedah tiga bagiannya: anchor WHERE atasan_id IS NULL memilih Ratna sebagai baris tingkat 1 — satu-satunya pegawai tanpa atasan. Bagian rekursif JOIN hierarki h ON p.atasan_id = h.id mencari pegawai yang atasannya berada di generasi sebelumnya: iterasi pertama mengangkat Hendra dan Maya ke tingkat 2, iterasi kedua mengangkat lima pegawai ke tingkat 3, iterasi ketiga mengangkat Zahra ke tingkat 4. Ekspresi h.tingkat + 1 mengalir menurun bersama baris sehingga setiap anak tahu kedalamannya. Terminatornya alami — begitu generasi terakhir tidak punya bawahan, JOIN gagal menghasilkan baris dan rekursi berhenti sendiri.

Kolom tingkat bukan hiasan. Nomor tingkat menjadi dasar filter “hanya dua tingkat ke bawah” (WHERE tingkat <= 2), indentasi tampilan berlapis, dan deteksi struktur yang tidak sengaja terlalu dalam — pohon dengan tingkat > 8 pada organisasi lima lapis pasti ada kesalahan data.

Merangkai Jalur dari Akar ke Setiap Pegawai #

Penelusuran tingkat menghasilkan baris terpisah; sering kali yang kita butuhkan adalah jalur lengkap sebagai satu string — misalnya Ratna > Maya > Andi > Zahra — untuk ditampilkan di breadcrumb aplikasi. Rekursi merangkainya dengan CONCAT di bagian rekursif:

sql
WITH RECURSIVE jalur AS (
  SELECT id, nama, CAST(nama AS CHAR(200)) AS path
  FROM pegawai
  WHERE atasan_id IS NULL
  UNION ALL
  SELECT p.id, p.nama, CONCAT(j.path, ' > ', p.nama)
  FROM pegawai p JOIN jalur j ON p.atasan_id = j.id
)
SELECT id, nama, path
FROM jalur
ORDER BY id;

Output yang Dihasilkan:

text
+------+--------+-----------------------------+
| id   | nama   | path                        |
+------+--------+-----------------------------+
|    1 | Ratna  | Ratna                       |
|    2 | Hendra | Ratna > Hendra              |
|    3 | Maya   | Ratna > Maya                |
|    4 | Budi   | Ratna > Hendra > Budi       |
|    5 | Siti   | Ratna > Hendra > Siti       |
|    6 | Andi   | Ratna > Maya > Andi         |
|    7 | Dewi   | Ratna > Maya > Dewi         |
|    8 | Rina   | Ratna > Maya > Rina         |
|    9 | Zahra  | Ratna > Maya > Andi > Zahra |
+------+--------+-----------------------------+
9 rows in set (0.00 sec)

Bagian pentingnya ada pada anchor: CAST(nama AS CHAR(200)). Tipe kolom hasil CTE ditetapkan oleh anchor, dan nama bertipe VARCHAR(50) terlalu pendek untuk menampung jalur terpanjang; tanpa CAST, string path akan terpotong diam-diam saat melebihi 50 karakter. Bagian rekursif CONCAT(j.path, ' > ', p.nama) menempelkan nama anak di belakang jalur induknya — path Ratna Ratna melahirkan Ratna > Hendra, yang kemudian melahirkan Ratna > Hendra > Budi. Jalur terpanjang milik Zahra: Ratna > Maya > Andi > Zahra, empat tingkat dalam satu string 26 karakter. Query dengan filter WHERE path LIKE 'Ratna > Maya%' kemudian bisa mengambil seluruh cabang Maya tanpa penelusuran ulang.

Membangkitkan Rentang Tanggal: Level Bulanan #

Pembangkitan tanggal harian dengan DATE_ADD(t, INTERVAL 1 DAY) sudah kita kuasai. Level berikutnya adalah rentang bulanan — kerangka laporan bulanan — dan di sinilah tanggal menyimpan dua jebakan sekaligus. Kita mulai dari versi polos:

sql
WITH RECURSIVE bulan AS (
  SELECT DATE '2026-01-31' AS t
  UNION ALL
  SELECT DATE_ADD(t, INTERVAL 1 MONTH) FROM bulan WHERE t < DATE '2026-07-01'
)
SELECT t FROM bulan ORDER BY t;

Output yang Dihasilkan:

text
+------------+
| t          |
+------------+
| 2026-01-31 |
| 2026-02-28 |
| 2026-03-28 |
| 2026-04-28 |
| 2026-05-28 |
| 2026-06-28 |
| 2026-07-28 |
+------------+
7 rows in set (0.00 sec)

Dua kejutan muncul di hasil. Pertama, baris terakhir adalah 2026-07-28 padahal kita ingin berhenti di bulan Juni — terminator t < '2026-07-01' diperiksa pada baris yang sedang dibaca, bukan pada baris yang akan dihasilkan, sehingga 2026-06-28 yang lolos uji ikut melahirkan satu baris lagi. Kedua, setelah 2026-02-28, deret melanjutkan ke 2026-03-28 alih-alih akhir Maret — DATE_ADD menambah satu bulan penuh dari tanggal yang ada; karena 29-31 Februari tidak ada, +1 MONTH dari 31 Januari mendarat di 28 Februari, dan dari 28 Februari mendarat di 28 Maret.

Dua perbaikan membersihkan keduanya sekaligus: penghitung iterasi sebagai terminator yang eksak, dan LAST_DAY untuk meluruskan kembali ke akhir bulan:

sql
WITH RECURSIVE bulan AS (
  SELECT DATE '2026-01-31' AS t, 1 AS n
  UNION ALL
  SELECT LAST_DAY(DATE_ADD(t, INTERVAL 1 MONTH)), n + 1 FROM bulan WHERE n < 6
)
SELECT n, t FROM bulan ORDER BY n;

Output yang Dihasilkan:

text
+------+------------+
| n    | t          |
+------+------------+
|    1 | 2026-01-31 |
|    2 | 2026-02-28 |
|    3 | 2026-03-31 |
|    4 | 2026-04-30 |
|    5 | 2026-05-31 |
|    6 | 2026-06-30 |
+------+------------+
6 rows in set (0.00 sec)

Kolom bantu n menjadi terminator sejati: WHERE n < 6 menghasilkan tepat enam baris tanpa perhitungan tanggal rentan. Ekspresi LAST_DAY(DATE_ADD(t, INTERVAL 1 MONTH)) menggeser satu bulan lalu menjatuhkan tanggal ke hari terakhir bulan itu — hasilnya deret bersih 01-31, 02-28, 03-31, 04-30, 05-31, 06-30. Bila kerangka laporan Anda butuh awal bulan, cukup ganti jangkar menjadi DATE '2026-01-01' dan hapus LAST_DAY. Intervalnya sendiri adalah literal yang bebas diganti: INTERVAL 3 MONTH melompat per kuartal, INTERVAL 1 WEEK per minggu.

Deret Fibonacci: Dua Kolom yang Bergeser #

Fibonacci adalah latihan klasik rekursi: setiap suku adalah jumlah dua suku sebelumnya. Kuncinya di MySQL: bagian rekursif tidak boleh memakai agregat atau subquery terhadap CTE, sehingga kedua suku berjalan harus digendong sebagai dua kolom yang bergeser:

sql
WITH RECURSIVE fib AS (
  SELECT 0 AS suku, 1 AS berikutnya
  UNION ALL
  SELECT berikutnya, suku + berikutnya FROM fib WHERE berikutnya < 50
)
SELECT suku FROM fib;

Output yang Dihasilkan:

text
+------+
| suku |
+------+
|    0 |
|    1 |
|    1 |
|    2 |
|    3 |
|    5 |
|    8 |
|   13 |
|   21 |
|   34 |
+------+
10 rows in set (0.00 sec)

Anchor memuat pasangan awal (0, 1). Bagian rekursif menghasilkan pasangan baru: kolom suku diisi nilai berikutnya lama, dan kolom berikutnya diisi suku + berikutnya — penjumlahan dua suku berturut. Setiap iterasi menggeser jendela dua kolom itu satu langkah ke kanan: (0,1) → (1,1) → (1,2) → (2,3) → (3,5) dan seterusnya. Terminator berikutnya < 50 berhenti sebelum pasangan (34, 55) melahirkan suku di atas 50, sehingga kolom suku mencetak sepuluh angka: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34. Pola “gendong nilai di kolom bantu” ini berlaku untuk semua deret bergantung-dua-nilai — misalnya deret Lucas atau perhitungan saldo berbunga majemuk.

Menelusuri dari Daun ke Akar #

Semua penelusuran sejauh ini berjalan menurun: dari akar ke daun. Arah sebaliknya sama pentingnya — layar “rantai persetujuan” dokumen menelusuri dari pengaju naik sampai direktur. Kunci pembalikan arah ada pada kondisi join: bagian rekursif mencari atasan dari baris hasil sebelumnya, bukan bawahannya:

sql
WITH RECURSIVE naik AS (
  SELECT id, nama, atasan_id, 0 AS langkah
  FROM pegawai
  WHERE id = 9
  UNION ALL
  SELECT p.id, p.nama, p.atasan_id, n.langkah + 1
  FROM pegawai p JOIN naik n ON p.id = n.atasan_id
)
SELECT langkah, id, nama, atasan_id
FROM naik
ORDER BY langkah;

Output yang Dihasilkan:

text
+---------+------+-------+-----------+
| langkah | id   | nama  | atasan_id |
+---------+------+-------+-----------+
|       0 |    9 | Zahra |         6 |
|       1 |    6 | Andi  |         3 |
|       2 |    3 | Maya  |         1 |
|       3 |    1 | Ratna |      NULL |
+---------+------+-------+-----------+
4 rows in set (0.00 sec)

Perhatikan satu perbedaan huruf yang mengubah segalanya. Penelusuran menurun memakai ON p.atasan_id = h.id — “cari pegawai yang atasannya adalah baris sebelumnya”. Penelusuran menaik memakai ON p.id = n.atasan_id — “cari pegawai yang merupakan atasan dari baris sebelumnya”. Anchor berupa Zahra (id 9) di langkah 0; iterasi pertama membaca atasan_id 6 milik Zahra lalu mengangkat Andi ke langkah 1; dari Andi naik ke Maya di langkah 2; dari Maya ke Ratna di langkah 3. Baris Ratna memiliki atasan_id NULL, sehingga p.id = NULL tidak pernah cocok dan rekursi berhenti — rem alami pohon kembali bekerja. Hasil empat barisnya persis rantai breadcrumb Zahra dalam urutan baca yang alami.

Batas Kedalaman dan Pengaturannya #

Rekursi yang salah menulis terminator tidak berputar selamanya. MySQL membatasi jumlah iterasi lewat variabel cte_max_recursion_depth, dan nilainya kita cek langsung:

sql
SELECT @@cte_max_recursion_depth;

Output yang Dihasilkan:

text
+---------------------------+
| @@cte_max_recursion_depth |
+---------------------------+
|                      1000 |
+---------------------------+
1 row in set (0.00 sec)

Bawaan 1000 iterasi cukup untuk hampir semua kasus nyata. Chapter RECURSIVE CTE sudah membuktikan perilakunya: menurunkan batas ke 5 lalu menjalankan deret yang butuh 9 iterasi berakhir dengan ERROR 3636 — rekursi dihentikan paksa tepat setelah melewati batas. Batas itu diatur per sesi dengan SET SESSION cte_max_recursion_depth = N dan kembali ke bawaan begitu sesi ditutup. Praktik yang sehat: naikkan batas hanya untuk query kalender tahunan (365-366 iterasi masih jauh di bawah 1000), dan perlakukan kebutuhan ribuan iterasi sebagai sinyal bahwa logika sebaiknya dipindah ke sisi aplikasi.

Rekursi di SQL atau di Aplikasi? #

Hampir semua bahasa pemrograman umum bisa melakukan rekursi — Python, PHP, dan JavaScript punya fungsi yang memanggil dirinya sendiri. Pertanyaan arsitekturalnya bukan “mana yang bisa”, melainkan “mana yang tepat”. Tabel berikut merangkum pertimbangannya:

AspekWITH RECURSIVE di SQLRekursi di kode aplikasi
Round-trip jaringansatu query utuhsatu query per tingkat (atau lebih)
Data berpindahtidak — semua diolah di serversetiap tingkat lewat jaringan
Logika per baristerbatas SQL (tanpa agregat/subquery di bagian rekursif)bebas: fungsi, kondisi kompleks, pustaka eksternal
Rem pengamancte_max_recursion_depthkode kita sendiri
Kegunaan terbaikhierarki, kalender, deret, agregasi langsung di SQLaturan bisnis per-node, panggilan API per tingkat, transaksi per langkah

Aturan praktisnya sederhana. Bila hasil akhir tetap berupa data SQL — pohon pegawai, rentang tanggal, rekap per tingkat — rekursi sebaiknya tetap di server: satu round-trip dan nol marshalling data. Bila tiap langkah membutuhkan keputusan yang bukan SQL — memanggil layanan eksternal, validasi lintas sistem, atau kontrol transaksi per langkah — pindahkan ke aplikasi dan gunakan query datar per tingkat.

Membersihkan Database Latihan #

Rangkaian latihan selesai dan database latihan kita jatuhkan kembali:

sql
DROP DATABASE belajar_adv;

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)

Referensi #

Rekursi kini bekerja dua arah dengan penghitung tingkat, jalur string, kalender bulanan, dan deret matematis. Bab berikutnya mundur satu langkah untuk membedah fondasi yang selama ini kita perlakukan sebagai keterbatasan: derived table — subquery di klausa FROM — mulai dari aturan alias wajib, alasan kolom terhitung bisa difilter di WHERE, sampai bukti bahwa derived table tidak boleh berkorelasi dengan tabel di sekitarnya.