“Siapa atasan Dewi Lestari?” — pertanyaan sederhana itu tidak bisa dijawab dengan query satu tabel, tetapi jawabannya juga tidak tersimpan di tabel lain. Data atasan hidup di tabel yang sama: kolom atasan_id menunjuk ke baris karyawan lain. Pola seperti ini muncul di mana-mana — struktur karyawan, kategori produk yang punya induk kategori, folder di dalam folder, tiket yang lahir dari tiket sebelumnya. PostgreSQL tidak punya jenis join khusus bernama SELF JOIN; yang ada adalah trik membaca satu tabel dua kali lewat dua alias, lalu menggabungkan salinannya seperti dua tabel berbeda. Chapter ini membedah trik tersebut beserta pola turunannya: mencari puncak hierarki, menghitung bawahan, dan menjalin beberapa tingkat ke atas.

Satu Tabel, Dua Peran #

SELF JOIN bukan sintaks baru, melainkan pemakaian JOIN biasa di mana kedua sisi adalah tabel yang sama. Kuncinya adalah alias: satu tabel fisik dibaca sebagai dua “salinan” yang masing-masing memegang peran berbeda — salinan k berperan sebagai karyawan, salinan a berperan sebagai atasan:

Diagram di atas adalah model mentalnya: hanya ada satu tabel karyawan di disk, tetapi klausa FROM menyajikannya dua kali dengan nama panggilan berbeda, dan kondisi ON menghubungkan keduanya. Tanpa dua alias itu, PostgreSQL tidak punya cara membedakan kolom nama milik karyawan dari kolom nama milik atasan. Skema contohnya — karyawan Toko Maju Jaya dengan relasi pelaporannya:

sql buat-tabel.sql
-- Skema contoh: 6 karyawan Toko Maju Jaya, atasan_id menunjuk ke baris karyawan lain
CREATE TABLE karyawan (
    id        integer PRIMARY KEY,
    nama      text NOT NULL,
    jabatan   text NOT NULL,
    atasan_id integer REFERENCES karyawan (id)
);

INSERT INTO karyawan (id, nama, jabatan, atasan_id) VALUES
    (1, 'Budi Santoso', 'Direktur', NULL),
    (2, 'Siti Rahayu',  'Manajer',  1),
    (3, 'Andi Wijaya',  'Manajer',  1),
    (4, 'Dewi Lestari', 'Staf',     2),
    (5, 'Ratna Putri',  'Staf',     3),
    (6, 'Yusuf Hakim',  'Magang',   2);

SELECT * FROM karyawan ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 6
 id |     nama     | jabatan  | atasan_id 
----+--------------+----------+-----------
  1 | Budi Santoso | Direktur |          
  2 | Siti Rahayu  | Manajer  |         1
  3 | Andi Wijaya  | Manajer  |         1
  4 | Dewi Lestari | Staf     |         2
  5 | Ratna Putri  | Staf     |         3
  6 | Yusuf Hakim  | Magang   |         2
(6 rows)

Foreign key atasan_id integer REFERENCES karyawan (id) menunjuk ke tabel tempat kolom itu sendiri berada — self-reference, pola yang diperkenalkan di chapter Foreign Key . Perhatikan baris Budi Santoso: kolom atasan_id-nya NULL. NULL di sini bukan data yang hilang, melainkan pernyataan “tidak punya atasan” — Budi adalah orang paling atas di Toko Maju Jaya.

Join Tabel dengan Dirinya Sendiri #

Pertanyaan “siapa atasan tiap karyawan” dijawab dengan menggabungkan salinan k dan salinan a:

sql self-dasar.sql
-- SELF JOIN: tabel karyawan dibaca dua kali lewat dua alias
SELECT k.nama AS karyawan, k.jabatan, a.nama AS atasan
FROM karyawan k
JOIN karyawan a ON k.atasan_id = a.id
ORDER BY k.id;

-- LEFT JOIN dirinya sendiri: baris tanpa atasan (Budi) tetap tampil
SELECT k.nama AS karyawan, k.jabatan, a.nama AS atasan
FROM karyawan k
LEFT JOIN karyawan a ON k.atasan_id = a.id
ORDER BY k.id;

Output yang Dihasilkan:

text
   karyawan   | jabatan |    atasan    
--------------+---------+--------------
 Siti Rahayu  | Manajer | Budi Santoso
 Andi Wijaya  | Manajer | Budi Santoso
 Dewi Lestari | Staf    | Siti Rahayu
 Ratna Putri  | Staf    | Andi Wijaya
 Yusuf Hakim  | Magang  | Siti Rahayu
(5 rows)

   karyawan   | jabatan  |    atasan    
--------------+----------+--------------
 Budi Santoso | Direktur | 
 Siti Rahayu  | Manajer  | Budi Santoso
 Andi Wijaya  | Manajer  | Budi Santoso
 Dewi Lestari | Staf     | Siti Rahayu
 Ratna Putri  | Staf     | Andi Wijaya
 Yusuf Hakim  | Magang   | Siti Rahayu
(6 rows)

Query pertama menghasilkan lima baris; query kedua enam baris. Bedah perannya:

  1. FROM karyawan k JOIN karyawan a — tabel yang sama muncul dua kali dengan dua alias. Salinan k menjadi sisi kiri, salinan a sisi kanan; dari sudut pandang mesin, keduanya adalah dua tabel hasil yang berbeda.
  2. ON k.atasan_id = a.id — kondisi penggabungannya memasangkan setiap baris karyawan dengan baris atasan yang ditunjuk atasan_id. Misalnya baris Dewi (atasan_id = 2) menemukan salinan a dengan id = 2, yaitu Siti Rahayu.
  3. Query pertama: 5 baris — Budi Santoso tidak muncul karena NULL = a.id tidak pernah benar, sama seperti perilaku INNER JOIN pada umumnya (dibahas di chapter INNER JOIN ).
  4. Query kedua: 6 baris — LEFT JOIN mempertahankan seluruh salinan kiri; Budi tampil dengan kolom atasan kosong karena NULL penambal, persis mekanisme chapter LEFT JOIN .

Tanpa Alias, PostgreSQL Menolak #

Nama tabel tidak bisa dipakai dua kali tanpa pembeda:

sql tanpa-alias.sql
-- JOIN tanpa alias: PostgreSQL menolak nama tabel yang sama dua kali
SELECT karyawan.nama
FROM karyawan
JOIN karyawan ON karyawan.atasan_id = karyawan.id;

Output yang Dihasilkan:

text
psql:tanpa-alias.sql:4: ERROR:  table name "karyawan" specified more than once

Parser berhenti dengan table name "karyawan" specified more than once — nama tabel, seperti alias, harus unik dalam satu klausa FROM. Error ini adalah kabar baik: SELF JOIN yang ditulis lalai gagal keras di muka, bukan menghasilkan data salah secara diam-diam. Bila Anda menulis dua alias tetapi lupa mengkualifikasi kolom — misalnya SELECT nama FROM karyawan k JOIN karyawan a ON ... — PostgreSQL menjawab dengan error column reference "nama" is ambiguous, karena nama kini dimiliki oleh dua salinan. Aturan praktisnya: pada SELF JOIN, setiap kolom SELALU dikualifikasi dengan alias.

Mencari Puncak Hierarki dengan Self Anti-Join #

Gabungkan SELF JOIN dengan pola anti-join dari chapter LEFT JOIN , dan Anda mendapatkan cara menemukan baris yang pasangannya tidak ada — di sini: karyawan yang tidak punya atasan:

-- Self anti-join: baris yang atasan-nya tidak ada = puncak hierarki
SELECT k.id, k.nama, k.jabatan
FROM karyawan k
LEFT JOIN karyawan a ON k.atasan_id = a.id
WHERE a.id IS NULL
ORDER BY k.id;

-- Berapa bawahan langsung tiap atasan
SELECT a.nama AS atasan, count(*) AS jumlah_bawahan
FROM karyawan k
JOIN karyawan a ON k.atasan_id = a.id
GROUP BY a.id, a.nama
ORDER BY a.id;

Output yang Dihasilkan:

text
 id |     nama     | jabatan  
----+--------------+----------
  1 | Budi Santoso | Direktur
(1 row)

    atasan    | jumlah_bawahan 
--------------+----------------
 Budi Santoso |              2
 Siti Rahayu  |              2
 Andi Wijaya  |              1
(3 rows)

Query pertama mengembalikan satu baris: Budi Santoso, sang Direktur. Mekanismenya dua tahap: LEFT self-join mempertahankan semua karyawan sambil menambal NULL bagi yang tak punya pasangan, lalu WHERE a.id IS NULL menyaring yang tertambal — satu-satunya sumber NULL di a.id (sebuah primary key) adalah absennya atasan. Pola yang sama juga menemukan “yatim” relasi: baris yang atasan_id-nya menunjuk ke id yang sudah dihapus akan lolos saringan ini karena pasangannya memang tidak ditemukan — berguna untuk audit data hierarki.

Query kedua membalik arah pertanyaan: berapa bawahan langsung tiap atasan. Salinan k kini menjadi tabel anak (yang punya atasan_id), a menjadi tabel induk, lalu GROUP BY a.id, a.nama merangkum jumlah bawahan per atasan — Budi 2, Siti 2, Andi 1. Ini pola standar laporan “jumlah anak per induk” yang dibahas tuntas di section Agregasi Data .

Dua Tingkat ke Atas #

Satu self-join menjawab satu tingkat relasi. Untuk “atasan dari atasan”, tambahkan self-join kedua yang menggantung pada alias pertama:

-- Dua SELF JOIN berantai: karyawan -> atasan -> atasan dari atasan
SELECT k.nama AS karyawan,
       a.nama AS atasan,
       aa.nama AS atasan_dari_atasan
FROM karyawan k
LEFT JOIN karyawan a  ON k.atasan_id  = a.id
LEFT JOIN karyawan aa ON a.atasan_id  = aa.id
ORDER BY k.id;

Output yang Dihasilkan:

text
   karyawan   |    atasan    | atasan_dari_atasan 
--------------+--------------+--------------------
 Budi Santoso |              | 
 Siti Rahayu  | Budi Santoso | 
 Andi Wijaya  | Budi Santoso | 
 Dewi Lestari | Siti Rahayu  | Budi Santoso
 Ratna Putri  | Andi Wijaya  | Budi Santoso
 Yusuf Hakim  | Siti Rahayu  | Budi Santoso
(6 rows)

Kini tabel yang sama dibaca tiga kali: k (karyawan), a (atasan), aa (atasan dari atasan). Yusuf Hakim menemukan Siti Rahayu sebagai atasan dan Budi Santoso sebagai atasan kedua; Siti dan Andi menemukan Budi sebagai atasan pertama dan NULL sebagai atasan kedua karena Budi memang tidak punya atasan. Kedua join memakai LEFT agar baris dengan mata rantai terputus (Budi sendiri) tetap tampil. Pola ini bekerja untuk hierarki dangkal yang diketahui tingkatnya, tetapi melelahkan untuk kedalaman tak tentu — kategori produk bertingkat tujuh, misalnya. Untuk itu PostgreSQL menyediakan recursive CTE yang berjalan menelusuri hierarki sampai habis; alat itu dibahas di section Subquery dan CTE .

SELF JOIN memperlihatkan bahwa “tabel” dalam klausa FROM sebenarnya adalah sumber baris yang bisa disalin dan diberi peran. Dua alias saja sudah cukup untuk hierarki satu tingkat. Bab berikutnya menghadapi masalah yang lebih umum lagi: menggabungkan banyak tabel berbeda sekaligus dalam satu rantai — pelanggan ke pesanan, pesanan ke rincian, rincian ke produk — dengan jenis join yang berbeda-beda di tiap mata rantainya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

atasan-dari-atasan.sql

Unduh berkas
sql
-- Dua SELF JOIN berantai: karyawan -> atasan -> atasan dari atasan
SELECT k.nama AS karyawan,
       a.nama AS atasan,
       aa.nama AS atasan_dari_atasan
FROM karyawan k
LEFT JOIN karyawan a  ON k.atasan_id  = a.id
LEFT JOIN karyawan aa ON a.atasan_id  = aa.id
ORDER BY k.id;

buat-tabel.sql

Unduh berkas
sql
-- Skema contoh: 6 karyawan Toko Maju Jaya, atasan_id menunjuk ke baris karyawan lain
CREATE TABLE karyawan (
    id        integer PRIMARY KEY,
    nama      text NOT NULL,
    jabatan   text NOT NULL,
    atasan_id integer REFERENCES karyawan (id)
);

INSERT INTO karyawan (id, nama, jabatan, atasan_id) VALUES
    (1, 'Budi Santoso', 'Direktur', NULL),
    (2, 'Siti Rahayu',  'Manajer',  1),
    (3, 'Andi Wijaya',  'Manajer',  1),
    (4, 'Dewi Lestari', 'Staf',     2),
    (5, 'Ratna Putri',  'Staf',     3),
    (6, 'Yusuf Hakim',  'Magang',   2);

SELECT * FROM karyawan ORDER BY id;

puncak-hierarki.sql

Unduh berkas
sql
-- Self anti-join: baris yang atasan-nya tidak ada = puncak hierarki
SELECT k.id, k.nama, k.jabatan
FROM karyawan k
LEFT JOIN karyawan a ON k.atasan_id = a.id
WHERE a.id IS NULL
ORDER BY k.id;

-- Berapa bawahan langsung tiap atasan
SELECT a.nama AS atasan, count(*) AS jumlah_bawahan
FROM karyawan k
JOIN karyawan a ON k.atasan_id = a.id
GROUP BY a.id, a.nama
ORDER BY a.id;

self-dasar.sql

Unduh berkas
sql
-- SELF JOIN: tabel karyawan dibaca dua kali lewat dua alias
SELECT k.nama AS karyawan, k.jabatan, a.nama AS atasan
FROM karyawan k
JOIN karyawan a ON k.atasan_id = a.id
ORDER BY k.id;

-- LEFT JOIN dirinya sendiri: baris tanpa atasan (Budi) tetap tampil
SELECT k.nama AS karyawan, k.jabatan, a.nama AS atasan
FROM karyawan k
LEFT JOIN karyawan a ON k.atasan_id = a.id
ORDER BY k.id;

tanpa-alias.sql

Unduh berkas
sql
-- JOIN tanpa alias: PostgreSQL menolak nama tabel yang sama dua kali
SELECT karyawan.nama
FROM karyawan
JOIN karyawan ON karyawan.atasan_id = karyawan.id;