Materi Lengkap SQL Teknis
Materi ยท Persiapan Tes Teknis

Materi Lengkap SQL Teknis

Kamu sudah lancar SELECT, WHERE, ORDER BY. Ini mulai dari 5 hal yang masih lemah (JOIN, GROUP BY, HAVING, MAX/MIN per grup, COALESCE), lanjut ke materi lain yang berpotensi keluar di tes teknis โ€” semua dijelaskan pakai analogi, bukan istilah teknis. Setelah paham, buka file latihan soal buat praktik. Data sampel ada di panel sebelah (atau di atas kalau layar sempit).

Sebelum Mulai: Alias Tabel
Di semua contoh nanti kamu bakal sering lihat huruf tunggal kayak k.nama atau p.harga. Ini bukan singkatan aneh โ€” ini alias (nama pendek buat tabel).
Waktu nulis FROM karyawan k, huruf k setelah nama tabel itu ngasih nickname buat tabel karyawan โ€” jadi buat sisa query, k = karyawan. Sama juga d = divisi, p = produk, t = transaksi. Jadi k.nama artinya "kolom nama dari tabel karyawan", dan p.nama_produk artinya "kolom nama_produk dari tabel produk".
SELECT k.nama, d.nama_divisi
FROM karyawan k
JOIN divisi d ON k.divisi_id = d.id;
-- k.nama       = kolom "nama" dari tabel karyawan
-- d.nama_divisi = kolom "nama_divisi" dari tabel divisi
-- k.divisi_id   = kolom "divisi_id" milik karyawan
-- d.id          = kolom "id" milik divisi
Kenapa perlu alias: (1) lebih pendek diketik daripada nulis karyawan.nama berkali-kali, (2) menghindari ambigu kalau dua tabel sama-sama punya kolom bernama id atau nama, (3) bikin JOIN gampang dibaca โ€” ON k.divisi_id = d.id jelas artinya "divisi_id milik karyawan = id milik divisi".
1

JOIN

Analogi: JOIN itu kayak nyambungin KTP-mu ke data Kartu Keluarga. KTP cuma punya "nomor KK", nggak punya nama kepala keluarga. Buat tau nama kepala keluargamu, kamu harus SAMBUNGIN nomor KK di KTP-mu ke tabel Kartu Keluarga.

Di database, tabel karyawan cuma nyimpen divisi_id (angka), bukan nama divisinya. Nama divisi ada di tabel divisi yang terpisah. JOIN nyambungin dua tabel itu lewat kolom yang jadi "kunci"-nya.

karyawan
namadivisi_id
Budi2
Sari1
+
divisi
idnama_divisi
1Produksi
2Gudang
SELECT k.nama, d.nama_divisi
FROM karyawan k
JOIN divisi d ON k.divisi_id = d.id;
Cara Inget JOIN = "sambungin dua tabel pakai kolom yang cocok". ON selalu diikuti "kolom_A = kolom_B" โ€” kolom kunci di kedua tabel.
Contoh Soal
Tampilkan nama produk beserta nama divisi yang memproduksinya.
Jawaban
SELECT p.nama_produk, d.nama_divisi
FROM produk p
JOIN divisi d ON p.divisi_id = d.id;
Output
nama_produk  | nama_divisi
-------------+------------
Kursi        | Produksi
Meja         | Produksi
Lemari       | Produksi
Rak Gudang   | Gudang
Banner Promo | Marketing
Penjelasan: Pola persis sama seperti contoh karyawan-divisi di atas, cuma ganti tabel karyawan jadi produk. Kunci sambungnya tetap sama bentuknya: kolom_id di tabel kiri = id di tabel kanan.
Lanjut: LEFT JOIN โ†’
2

LEFT JOIN

Analogi: JOIN biasa itu kayak absensi rapat โ€” cuma yang HADIR yang ditulis. LEFT JOIN itu kayak daftar SEMUA karyawan, terus dicatat siapa yang hadir dan siapa yang nggak (kosong). Nggak ada yang hilang dari daftar.

JOIN biasa cuma nampilin baris yang PUNYA pasangan di tabel satunya. Kalau ada divisi yang belum punya karyawan sama sekali, divisi itu HILANG dari hasil JOIN biasa. LEFT JOIN mempertahankan SEMUA baris dari tabel kiri, walau nggak ada pasangannya (jadi NULL/kosong).

JOIN biasa
Divisi tanpa karyawan โ†’ hilang dari hasil
LEFT JOIN
Divisi tanpa karyawan โ†’ tetap muncul, kolom karyawan-nya kosong (NULL)
SELECT d.nama_divisi, k.nama
FROM divisi d
LEFT JOIN karyawan k ON k.divisi_id = d.id;
Cara Inget Kalau soal bilang "termasuk yang belum punya" atau "yang belum pernah" โ†’ itu sinyal wajib pakai LEFT JOIN, bukan JOIN biasa.
Contoh Soal
Tampilkan semua produk, termasuk produk yang belum pernah ada transaksinya sama sekali.
Jawaban
SELECT p.nama_produk, t.id AS id_transaksi
FROM produk p
LEFT JOIN transaksi t ON t.produk_id = p.id;
Output
nama_produk  | id_transaksi
-------------+-------------
Kursi        | 1
Kursi        | 2
Kursi        | 6
Meja         | NULL
Lemari       | 3
Lemari       | 7
Rak Gudang   | 4
Rak Gudang   | 5
Rak Gudang   | 8
Banner Promo | NULL

(Meja dan Banner Promo tetap muncul dengan id_transaksi NULL, meski belum pernah transaksi)
Penjelasan: Kata "termasuk yang belum pernah" langsung nandain ini harus LEFT JOIN. Produk tanpa transaksi tetap muncul di hasil, dengan kolom id_transaksi kosong (NULL) karena memang tidak ada pasangannya di tabel transaksi.
Lanjut: GROUP BY โ†’
3

GROUP BY

Analogi: Bayangin kamu punya tumpukan struk belanja, dan disuruh hitung total belanja PER TOKO. Langkah pertama pasti kamu pisah-pisah dulu struknya jadi beberapa tumpukan kecil per toko. Itulah GROUP BY โ€” mengelompokkan baris jadi tumpukan-tumpukan kecil sebelum dihitung.

Setelah dikelompokkan, kamu pakai fungsi "penghitung" (disebut fungsi agregasi) buat tiap tumpukan: SUM (jumlahkan), COUNT (hitung banyaknya), AVG (rata-rata), MAX/MIN (paling besar/kecil).

Sebelum (baris mentah)
divisigaji
Gudang4jt
Gudang5jt
Produksi6jt
โ†’
Setelah GROUP BY + SUM
divisitotal_gaji
Gudang9jt
Produksi6jt
SELECT d.nama_divisi, SUM(k.gaji) AS total_gaji
FROM divisi d
JOIN karyawan k ON k.divisi_id = d.id
GROUP BY d.nama_divisi;
Cara Inget Aturan besi: setiap kolom di SELECT yang BUKAN hasil SUM/COUNT/AVG/dll, WAJIB ikut ditulis di GROUP BY. Kalau lupa, query error (di PostgreSQL) atau hasilnya ngaco (di MySQL).
Contoh Soal
Hitung rata-rata gaji karyawan di tiap divisi.
Jawaban
SELECT d.nama_divisi, AVG(k.gaji) AS rata_rata_gaji
FROM divisi d
JOIN karyawan k ON k.divisi_id = d.id
GROUP BY d.nama_divisi;
Output
nama_divisi | rata_rata_gaji
------------+---------------
Produksi    | 5500000
Gudang      | 5333333
Marketing   | 5500000
Penjelasan: Pola sama persis dengan contoh SUM tadi, cuma fungsinya diganti AVG. Selama kolom non-agregasi (nama_divisi) tetap ada di GROUP BY, tinggal ganti-ganti fungsi agregasinya sesuai kebutuhan (SUM, AVG, COUNT, MAX, MIN).
Lanjut: HAVING vs WHERE โ†’
4

HAVING vs WHERE

Analogi: Balik ke contoh struk belanja tadi. WHERE itu kayak kamu MEMBUANG struk yang bukan bulan ini SEBELUM mulai mengelompokkan. HAVING itu kamu kelompokkan dulu semua per toko, hitung totalnya, BARU buang tumpukan yang totalnya kurang dari 100rb.

Intinya cuma soal KAPAN penyaringan terjadi: WHERE menyaring baris SEBELUM dikelompokkan. HAVING menyaring hasil SETELAH dikelompokkan dan dihitung.

Pakai WHERE kalau...
Syaratnya soal data MENTAH sebelum dikelompokkan (misal: tanggal, nama, status)
Pakai HAVING kalau...
Syaratnya soal HASIL agregasi (SUM, COUNT, AVG, MAX, MIN)
SELECT d.nama_divisi, SUM(k.gaji) AS total_gaji
FROM divisi d
JOIN karyawan k ON k.divisi_id = d.id
GROUP BY d.nama_divisi
HAVING SUM(k.gaji) > 10000000;
Cara Inget Satu kalimat: "WHERE duluan, HAVING belakangan." Urutan proses SQL selalu: FROM/JOIN โ†’ WHERE โ†’ GROUP BY โ†’ HAVING โ†’ SELECT.
Contoh Soal
Dari transaksi yang terjadi bulan Januari 2026 saja, tampilkan produk dengan total qty terjual lebih dari 5.
Jawaban
SELECT p.nama_produk, SUM(t.qty) AS total_qty
FROM produk p
JOIN transaksi t ON t.produk_id = p.id
WHERE t.tgl_transaksi >= '2026-01-01' AND t.tgl_transaksi < '2026-02-01'
GROUP BY p.nama_produk
HAVING SUM(t.qty) > 5;
Output
nama_produk | total_qty
------------+----------
Kursi       | 8

(Kursi terjual 3+5=8 unit di Januari 2026; Lemari cuma 1 unit, tidak lolos syarat >5)
Penjelasan: Ada dua syarat penyaringan di sini. "Bulan Januari 2026" itu syarat data mentah (tanggal transaksi) โ†’ masuk WHERE. "Total qty lebih dari 5" itu syarat hasil SUM โ†’ masuk HAVING. Dua-duanya dipakai bareng, tinggal ingat urutan: WHERE dulu, baru HAVING.
Lanjut: MAX/MIN per grup โ†’
5

MAX / MIN per grup

Analogi: Ini beda dari "cari nilai tertinggi" biasa. Ini kayak nyari "siswa juara 1 DI SETIAP KELAS" โ€” bukan juara 1 se-sekolah. Jadi kamu harus bandingin tiap siswa dengan nilai tertinggi DI KELASNYA SENDIRI, bukan nilai tertinggi keseluruhan.

Polanya: cari MAX per grup dulu pakai subquery yang "terhubung" ke baris luar (disebut correlated subquery) โ€” subquery itu dijalankan ulang untuk tiap baris, seolah nanya "berapa gaji tertinggi di divisi yang SAMA dengan baris ini?"

SELECT k.nama, k.divisi_id, k.gaji
FROM karyawan k
WHERE k.gaji = (
  SELECT MAX(k2.gaji)
  FROM karyawan k2
  WHERE k2.divisi_id = k.divisi_id
);
Cara Inget Lihat baris WHERE k2.divisi_id = k.divisi_id โ€” ini kuncinya. Subquery "dikunci" ke divisi baris yang sedang dicek, jadi MAX-nya cuma dihitung dalam divisi itu saja, bukan semua karyawan.
Contoh Soal
Tampilkan karyawan dengan gaji TERENDAH di setiap divisi.
Jawaban
SELECT k.nama, k.divisi_id, k.gaji
FROM karyawan k
WHERE k.gaji = (
  SELECT MIN(k2.gaji)
  FROM karyawan k2
  WHERE k2.divisi_id = k.divisi_id
);
Output
nama | divisi_id | gaji
-----+-----------+--------
Budi | 1         | 5000000
Andi | 2         | 4500000
Dewi | 2         | 4500000
Lina | 3         | 5500000

(Andi dan Dewi sama-sama gaji terendah di Gudang, 4.500.000 โ€” keduanya tampil)
Penjelasan: Polanya identik dengan contoh gaji tertinggi di atas โ€” tinggal ganti MAX jadi MIN. Ini nunjukkin begitu kamu paham satu pola correlated subquery, semua variasi "tertinggi/terendah per grup" tinggal ganti fungsi agregasinya saja.
Lanjut: COALESCE โ†’
6

COALESCE

Analogi: COALESCE itu kayak nanya ke temanmu, "Kamu punya pulpen nggak? Kalau nggak punya, aku pinjemin punyaku." COALESCE ngecek satu-satu nilai yang kamu kasih, dan pakai nilai PERTAMA yang nggak kosong (NULL).

Paling sering dipakai bareng LEFT JOIN: kalau LEFT JOIN bikin suatu kolom jadi NULL (karena nggak ada pasangannya), COALESCE ganti NULL itu jadi nilai default biar rapi ditampilkan.

Tanpa COALESCE
namatotal_qty
Budi50
SariNULL
โ†’
Dengan COALESCE(x, 0)
namatotal_qty
Budi50
Sari0
SELECT k.nama, COALESCE(SUM(t.qty), 0) AS total_qty
FROM karyawan k
LEFT JOIN transaksi t ON t.karyawan_id = k.id
GROUP BY k.nama;
Cara Inget Baca COALESCE(a, b) sebagai: "pakai a, kalau a kosong pakai b." Bisa dirantai lebih panjang: COALESCE(a, b, c) = coba a, kalau kosong coba b, kalau kosong juga pakai c.
Contoh Soal
Tampilkan semua produk beserta total qty terjualnya. Produk yang belum pernah terjual harus tampil angka 0, bukan kosong.
Jawaban
SELECT p.nama_produk, COALESCE(SUM(t.qty), 0) AS total_qty
FROM produk p
LEFT JOIN transaksi t ON t.produk_id = p.id
GROUP BY p.nama_produk;
Output
nama_produk  | total_qty
-------------+----------
Kursi        | 10
Meja         | 0
Lemari       | 5
Rak Gudang   | 24
Banner Promo | 0
Penjelasan: Sama persis dengan contoh karyawan tadi, cuma konteksnya diganti produk. LEFT JOIN jaga semua produk tetap tampil, COALESCE ganti hasil SUM yang NULL (produk tanpa transaksi) jadi 0.
7

Konsep NULL

Analogi: NULL itu bukan "nol" dan bukan "kosong string" โ€” NULL itu artinya "nggak ada nilainya sama sekali, nggak diketahui." Kayak kolom "tanggal resign" buat karyawan yang masih aktif kerja โ€” bukan diisi 0 atau kosong, tapi memang belum ada nilainya.

Aturan paling penting: NULL nggak bisa dibandingkan pakai = atau !=. WHERE kolom = NULL SELALU salah, walau kolomnya memang NULL. Harus pakai IS NULL atau IS NOT NULL. NULL juga "menular" โ€” angka berapa pun ditambah/dikali NULL, hasilnya NULL.

Salah
WHERE tgl_resign = NULL โ€” ini tidak pernah cocok, walau kolomnya NULL
Benar
WHERE tgl_resign IS NULL โ€” ini yang benar untuk cek NULL
SELECT nama
FROM karyawan
WHERE divisi_id IS NULL;
Cara Inget NULL = "tidak diketahui", bukan angka 0 atau string kosong. Selalu pakai IS NULL / IS NOT NULL, jangan pernah = NULL.
Contoh Soal
Tampilkan gaji rata-rata SEMUA karyawan, termasuk yang belum ditentukan divisinya โ€” kali ini fokusnya ke kolom gaji, bukan divisi_id.
Jawaban
SELECT AVG(gaji) AS rata_rata_gaji
FROM karyawan;
Output
rata_rata_gaji
---------------
5214285.71

(AVG menghitung SEMUA baris termasuk Tono yang divisi_id-nya NULL โ€” NULL cuma masalah kalau dipakai di ekspresi matematika/perbandingan LANGSUNG pada kolom itu sendiri, bukan pada kolom lain seperti gaji di sini)
Penjelasan: Ini contoh kalau NULL di SATU kolom (divisi_id) tidak mengganggu perhitungan di KOLOM LAIN (gaji) โ€” Tono tetap ikut dihitung AVG(gaji) meski divisi_id-nya NULL. NULL cuma "menular" kalau NULL itu sendiri yang dipakai langsung dalam operasi (misal SUM(divisi_id) baru akan terpengaruh).
Lanjut: RIGHT & FULL JOIN โ†’
8

RIGHT & FULL JOIN

Analogi: Kalau LEFT JOIN itu "pertahankan semua dari tabel KIRI", RIGHT JOIN kebalikannya: "pertahankan semua dari tabel KANAN". FULL JOIN itu gabungan dua-duanya: "pertahankan semua dari kiri DAN kanan, cocokkan yang bisa dicocokkan."

Di praktiknya, RIGHT JOIN jarang dipakai โ€” orang lebih suka tukar urutan tabel dan pakai LEFT JOIN, karena lebih gampang dibaca. Tapi FULL JOIN penting dipahami: dipakai kalau kamu mau lihat data yang "tidak nyambung" dari DUA sisi sekaligus.

SELECT d.nama_divisi, k.nama
FROM divisi d
FULL JOIN karyawan k ON k.divisi_id = d.id;
-- MySQL tidak punya FULL JOIN langsung; PostgreSQL punya.
Cara Inget LEFT = utamakan kiri. RIGHT = utamakan kanan. FULL = utamakan dua-duanya. INNER (JOIN biasa) = cuma yang cocok dua-duanya.
Contoh Soal
Kamu mau audit data: cari SEMUA divisi tanpa karyawan, DAN semua karyawan yang divisi_id-nya salah/tidak ada di tabel divisi (data rusak), dalam satu hasil.
Jawaban
SELECT d.nama_divisi, k.nama
FROM divisi d
FULL JOIN karyawan k ON k.divisi_id = d.id
WHERE d.id IS NULL OR k.id IS NULL;
Output
nama_divisi | nama
------------+------
Riset       | NULL
NULL        | Tono

('Riset' muncul karena tidak ada karyawannya; 'Tono' muncul karena divisi_id-nya NULL)
Penjelasan: FULL JOIN menjaga baris dari kedua tabel meski tidak nyambung. Filter d.id IS NULL OR k.id IS NULL menyisakan cuma baris yang "pincang" โ€” karyawan tanpa divisi valid, atau divisi tanpa karyawan.
Lanjut: CASE WHEN โ†’
9

CASE WHEN

Analogi: CASE WHEN itu if/else-nya SQL. Kayak satpam yang ngecek satu-satu kondisi: "kalau tamu bawa surat undangan, masuk VIP. Kalau nggak bawa tapi terdaftar, masuk biasa. Selain itu, tolak."

Dipakai buat ubah data mentah jadi label/kategori yang gampang dibaca. Kondisi dicek DARI ATAS KE BAWAH, berhenti di kondisi pertama yang benar.

SELECT nama_produk,
  CASE
    WHEN stok = 0 THEN 'Habis'
    WHEN stok < 10 THEN 'Menipis'
    ELSE 'Aman'
  END AS status_stok
FROM produk;
Cara Inget CASE WHEN kondisi1 THEN hasil1 WHEN kondisi2 THEN hasil2 ELSE hasil_default END โ€” persis if/elseif/else, cuma sintaksnya beda.
Contoh Soal
Tampilkan nama karyawan dan kategori gaji: "Tinggi" (di atas 8 juta), "Menengah" (4-8 juta), "Standar" (di bawah 4 juta).
Jawaban
SELECT nama,
  CASE
    WHEN gaji > 8000000 THEN 'Tinggi'
    WHEN gaji >= 4000000 THEN 'Menengah'
    ELSE 'Standar'
  END AS kategori_gaji
FROM karyawan;
Output
nama | kategori_gaji
-----+--------------
Budi | Menengah
Sari | Menengah
Andi | Menengah
Dewi | Menengah
Rudi | Menengah
Lina | Menengah
Tono | Menengah

(semua karyawan di data sampel ini gajinya masih antara 4-8 juta, jadi semua kena "Menengah" โ€” belum ada yang tembus di atas 8 juta)
Penjelasan: Urutan kondisi penting โ€” dicek dari atas dulu. Karyawan gaji 9 juta kena kondisi pertama ("Tinggi") dan berhenti di situ, tidak lanjut dicek ke kondisi berikutnya.
Lanjut: Subquery IN/EXISTS โ†’
10

Subquery IN / EXISTS / NOT EXISTS

Analogi: IN itu kayak nanya "namamu ada di daftar tamu ini nggak?" โ€” cocokkan ke SATU DAFTAR nilai. EXISTS itu beda: cuma nanya "ada nggak SATU AJA baris yang cocok?" โ€” nggak peduli isinya, cuma peduli ada/tidaknya.

IN dipakai kalau subquery-nya hasilkan daftar nilai sederhana (misal daftar id). EXISTS/NOT EXISTS dipakai kalau kamu cuma perlu tau "ada/tidak ada data terkait" โ€” biasanya lebih cepat untuk data besar karena EXISTS berhenti begitu nemu satu kecocokan.

SELECT nama_produk
FROM produk p
WHERE EXISTS (
  SELECT 1 FROM transaksi t WHERE t.produk_id = p.id
);
Cara Inget IN = "cocok ke salah satu nilai di daftar". EXISTS = "ada minimal satu baris terkait, ya/tidak". NOT EXISTS = kebalikannya, sering gantiin pola LEFT JOIN + IS NULL.
Contoh Soal
Tampilkan nama karyawan yang divisinya termasuk "Produksi" atau "Gudang" โ€” pakai subquery IN.
Jawaban
SELECT nama
FROM karyawan
WHERE divisi_id IN (
  SELECT id FROM divisi WHERE nama_divisi IN ('Produksi', 'Gudang')
);
Output
nama
----
Budi
Sari
Andi
Dewi
Rudi

(Lina di Marketing dan Tono tanpa divisi tidak ikut)
Penjelasan: Subquery dalam mencari id divisi Produksi dan Gudang dulu, hasilnya jadi "daftar id" yang dicocokkan ke divisi_id tiap karyawan pakai IN. Ini alternatif dari JOIN untuk kasus sederhana seperti ini.
Lanjut: UNION โ†’
11

UNION

Analogi: UNION itu kayak nggabungin dua daftar undangan jadi satu daftar tamu, terus buang nama yang dobel. UNION ALL sama, tapi nama yang dobel tetap dibiarkan muncul dua kali.

Dipakai buat menggabungkan HASIL dari dua query yang jumlah dan tipe kolomnya sama, jadi satu tabel hasil. Beda dengan JOIN yang nyambungin tabel ke SAMPING (nambah kolom), UNION nyambungin ke BAWAH (nambah baris).

SELECT nama AS nama_orang, 'Karyawan' AS tipe FROM karyawan
UNION
SELECT nama_produk AS nama_orang, 'Produk' AS tipe FROM produk;
Cara Inget UNION = gabung ke bawah + buang duplikat. UNION ALL = gabung ke bawah + duplikat tetap ada (lebih cepat, karena nggak perlu cek duplikat).
Contoh Soal
Buat satu daftar nama semua "pihak terkait perusahaan": gabungkan nama karyawan dan nama divisi jadi satu kolom, tanpa duplikat.
Jawaban
SELECT nama FROM karyawan
UNION
SELECT nama_divisi FROM divisi;
Output
nama
---------
Andi
Budi
Dewi
Gudang
Lina
Marketing
Produksi
Riset
Rudi
Sari
Tono

(11 baris: 7 nama karyawan + 4 nama divisi, tanpa ada yang dobel)
Penjelasan: Dua SELECT ini masing-masing hasilkan satu kolom teks, jumlah kolomnya sama (satu-satu), jadi bisa di-UNION. Kalau jumlah kolom beda, SQL akan error.
Lanjut: Window Function โ†’
12

Window Function (RANK, ROW_NUMBER)

Analogi: Kalau correlated subquery itu kayak nanya "siapa juara 1 di tiap kelas" satu-satu, window function itu kayak nyuruh SEMUA murid berbaris dan dikasih nomor urut ranking langsung, per kelas, dalam satu langkah.

Window function ini cara MODERN buat gantiin correlated subquery di soal "top-N per grup" โ€” lebih ringkas dan lebih cepat di database besar. PARTITION BY itu "per grup apa", ORDER BY di dalam OVER() itu "diurutkan berdasar apa".

SELECT nama, divisi_id, gaji,
  RANK() OVER (PARTITION BY divisi_id ORDER BY gaji DESC) AS ranking
FROM karyawan;
Cara Inget PARTITION BY = "kelompokkan per apa" (mirip GROUP BY, tapi baris aslinya tetap utuh, cuma ditambah kolom ranking). Karyawan gaji tertinggi di divisinya dapat ranking = 1.
Contoh Soal
Tampilkan hanya karyawan dengan gaji tertinggi (ranking 1) di tiap divisi, pakai window function.
Jawaban
SELECT * FROM (
  SELECT nama, divisi_id, gaji,
    RANK() OVER (PARTITION BY divisi_id ORDER BY gaji DESC) AS ranking
  FROM karyawan
) sub
WHERE ranking = 1;
Output
nama | divisi_id | gaji    | ranking
-----+-----------+---------+--------
Sari | 1         | 6000000 | 1
Rudi | 2         | 7000000 | 1
Lina | 3         | 5500000 | 1

(hasilnya sama persis dengan correlated subquery MAX di Materi 5, cuma teknik berbeda)
Penjelasan: Window function nggak bisa langsung difilter di WHERE (karena dihitung belakangan), jadi query-nya "dibungkus" jadi subquery dulu, baru difilter ranking = 1 di luar. Ini alternatif yang lebih modern dari correlated subquery MAX/MIN yang dipelajari di Materi 5.
Lanjut: Normalisasi Database โ†’
13

Normalisasi Database

Analogi: Normalisasi itu kayak beres-beres lemari โ€” jangan simpan barang yang sama di banyak tempat sekaligus. Kalau nama divisi ditulis ULANG di setiap baris karyawan (bukan cuma disimpan sekali di tabel divisi), begitu nama divisi berubah, kamu harus edit RATUSAN baris. Itu masalah yang dihindari normalisasi.

Ini yang bikin skema kita punya tabel divisi terpisah dari karyawan โ€” bukan cuma gaya, tapi prinsip: setiap data cuma disimpan di SATU tempat, tabel lain cuma nyimpen REFERENSI (foreign key) ke situ. Level normalisasi (1NF, 2NF, 3NF) itu aturan bertahap buat mastiin ini, tapi intinya cukup diingat sebagai satu prinsip: hindari data yang berulang/redundan.

Tidak Normal
Tabel karyawan punya kolom "nama_divisi" langsung (teks diulang di tiap baris)
Normal
Tabel karyawan cuma punya "divisi_id", nama divisi disimpan sekali di tabel divisi
Cara Inget Kalau ditanya soal normalisasi/flowchart di interview: jawab pakai skema yang sudah kamu pahami ini sebagai contoh nyata โ€” tabel karyawan-divisi kita SUDAH ternormalisasi karena nama divisi cuma ada sekali di satu tabel.
Contoh Soal (Konsep, Bukan Query)
Kenapa harga produk disimpan di tabel produk, bukan ditulis ulang di setiap baris transaksi?
Jawaban
Karena kalau harga berubah, cukup update SATU baris di tabel produk โ€” semua transaksi yang mereferensikan produk itu otomatis "ikut" nilai terbaru lewat JOIN, tanpa perlu edit ribuan baris transaksi lama satu-satu. Ini contoh nyata kenapa normalisasi penting: mengurangi duplikasi data dan risiko data jadi tidak konsisten.
Lanjut: Query Gabungan โ†’
14

Query Gabungan โ€” Naik Bertahap

Analogi: Di tes beneran, soal jarang minta satu konsep doang, tapi juga jarang langsung minta semua sekaligus. Biasanya soal-soal disusun makin lama makin berat โ€” kayak naik tangga, bukan lompat ke lantai atas langsung. Di bawah ini 5 soal yang SALING NAMBAH dari soal sebelumnya, dari 2 konsep sampai 6 konsep.

Perhatikan tiap soal cuma nambah SATU potongan baru dari query sebelumnya โ€” bukan bikin ulang dari nol. Cara mikir kayak gini yang dipakai kalau ketemu soal panjang di tes: bangun dikit-dikit, jangan langsung nulis query final.

Level 1 โ€” 2 Konsep (JOIN + GROUP BY)
Tampilkan tiap divisi beserta jumlah karyawannya.
Jawaban
SELECT d.nama_divisi, COUNT(k.id) AS jumlah_karyawan
FROM divisi d
JOIN karyawan k ON k.divisi_id = d.id
GROUP BY d.nama_divisi;
Output
nama_divisi | jumlah_karyawan
------------+----------------
Produksi    | 2
Gudang      | 3
Marketing   | 1

('Riset' belum muncul karena JOIN biasa masih membuang divisi tanpa karyawan)
Konsep dipakai: JOIN + GROUP BY/COUNT. Ini fondasinya โ€” semua level berikutnya nambah dari query ini.
Level 2 โ€” 3 Konsep (+ HAVING)
Dari data yang sama, tampilkan cuma divisi yang jumlah karyawannya lebih dari 2 orang.
Jawaban
SELECT d.nama_divisi, COUNT(k.id) AS jumlah_karyawan
FROM divisi d
JOIN karyawan k ON k.divisi_id = d.id
GROUP BY d.nama_divisi
HAVING COUNT(k.id) > 2;
Output
nama_divisi | jumlah_karyawan
------------+----------------
Gudang      | 3

(Produksi cuma 2 karyawan, Marketing cuma 1 โ€” keduanya tidak lolos syarat >2)
Apa yang nambah: cuma satu baris โ€” HAVING COUNT(k.id) > 2 ditempel di bawah GROUP BY. Query lainnya persis sama dengan Level 1.
Level 3 โ€” 4 Konsep (+ LEFT JOIN + COALESCE)
Sekarang direktur mau divisi yang BELUM punya karyawan juga tetap tampil (jumlah karyawan = 0) โ€” bukan cuma divisi yang sudah ramai. Syarat "lebih dari 2 karyawan" tadi dihapus dulu.
Jawaban
SELECT d.nama_divisi, COALESCE(COUNT(k.id), 0) AS jumlah_karyawan
FROM divisi d
LEFT JOIN karyawan k ON k.divisi_id = d.id
GROUP BY d.nama_divisi;
Output
nama_divisi | jumlah_karyawan
------------+----------------
Produksi    | 2
Gudang      | 3
Marketing   | 1
Riset       | 0

('Riset' sekarang ikut tampil dengan 0 karyawan, beda dari Level 1-2 yang membuangnya)
Apa yang nambah: JOIN diganti LEFT JOIN supaya divisi kosong tetap tampil. COALESCE ditambahkan di luar COUNT sebagai jaga-jaga โ€” meski sebenarnya COUNT(k.id) sendiri sudah otomatis menghasilkan 0 (bukan NULL) untuk divisi kosong, karena COUNT selalu menghitung baris, bukan menjumlahkan nilai.
Level 4 โ€” 5 Konsep (+ tabel ketiga + SUM)
Tambahkan juga total qty transaksi seluruh karyawan di tiap divisi. Divisi/karyawan yang belum pernah transaksi tetap tampil dengan total qty = 0.
Jawaban
SELECT
  d.nama_divisi,
  COUNT(DISTINCT k.id) AS jumlah_karyawan,
  COALESCE(SUM(t.qty), 0) AS total_qty
FROM divisi d
LEFT JOIN karyawan k ON k.divisi_id = d.id
LEFT JOIN transaksi t ON t.karyawan_id = k.id
GROUP BY d.nama_divisi;
Output
nama_divisi | jumlah_karyawan | total_qty
------------+-----------------+----------
Produksi    | 2               | 13
Gudang      | 3               | 26
Marketing   | 1               | 0
Riset       | 0               | 0

(Marketing punya 1 karyawan tapi belum pernah transaksi, jadi total_qty tetap 0)
Apa yang nambah: satu LEFT JOIN lagi ke tabel transaksi, plus SUM(t.qty) yang sekarang wajib dibungkus COALESCE (karena SUM, beda dari COUNT, betulan menghasilkan NULL kalau tidak ada baris untuk dijumlahkan). Perhatikan COUNT(k.id) berubah jadi COUNT(DISTINCT k.id) โ€” karena dua LEFT JOIN sekaligus bikin baris karyawan "berlipat" ganda per transaksinya, DISTINCT mencegah karyawan kehitung berkali-kali.
Level 5 โ€” 6+ Konsep (+ CASE WHEN + ORDER BY + LIMIT)
Terakhir, tambahkan label "Produktif" (total qty di atas 20) atau "Kurang Produktif", lalu tampilkan cuma 3 divisi dengan total qty tertinggi.
Jawaban
SELECT
  d.nama_divisi,
  COUNT(DISTINCT k.id) AS jumlah_karyawan,
  COALESCE(SUM(t.qty), 0) AS total_qty,
  CASE
    WHEN COALESCE(SUM(t.qty), 0) > 20 THEN 'Produktif'
    ELSE 'Kurang Produktif'
  END AS label
FROM divisi d
LEFT JOIN karyawan k ON k.divisi_id = d.id
LEFT JOIN transaksi t ON t.karyawan_id = k.id
GROUP BY d.nama_divisi
ORDER BY total_qty DESC
LIMIT 3;
Output
nama_divisi | jumlah_karyawan | total_qty | label
------------+-----------------+-----------+-----------------
Gudang      | 3               | 26        | Produktif
Produksi    | 2               | 13        | Kurang Produktif
Marketing   | 1               | 0         | Kurang Produktif

('Riset' tidak masuk 3 besar karena total_qty-nya paling kecil, kalah dari Marketing di urutan asli)
Apa yang nambah: CASE WHEN dipasang di SELECT (pakai ekspresi COALESCE yang sama biar konsisten), lalu ORDER BY + LIMIT ditempel di paling bawah query. Query dari Level 4 sama sekali tidak diubah strukturnya โ€” cuma ditambah di ujung.
Cara Inget Query panjang bukan hafalan satu blok utuh. Itu hasil NAMBAH satu potongan demi satu potongan ke query yang lebih sederhana. Kalau bingung di tes, mulai dari versi paling sederhana dulu (kayak Level 1), baru tambahkan syarat satu-satu sampai memenuhi semua permintaan soal.