Klausa FROM selama ini kita isi nama tabel. Padahal MySQL hanya menuntut satu hal dari klausa ini: sumber berbentuk tabel. Hasil sebuah query SELECT pun berbentuk tabel — punya kolom, punya baris — sehingga query pun boleh diletakkan tepat di tempat nama tabel biasanya ditulis. Bentuk ini disebut derived table: tabel turunan yang hidup hanya selama query luar berjalan. Pada chapter ini kita membuktikan kewajiban alias derived table lewat error nyata, lalu membangun pola klasik yang paling sering dipakai di laporan nyata: agregat dulu di dalam subquery, join belakangan untuk melengkapi nama, dan terakhir memfilter hasil agregat yang mustahil disaring WHERE biasa.
Menyiapkan Empat Tabel Latihan #
Kita pakai struktur dan data yang sama dengan chapter Subquery dalam WHERE: empat tabel toko daring di database belajar_sub, lengkap dengan pesanan tunai tanpa pelanggan terdaftar (pelanggan bernilai NULL) yang akan memperkaya pembahasan kita:
DROP DATABASE IF EXISTS belajar_sub;
CREATE DATABASE belajar_sub;
USE belajar_sub;Output yang Dihasilkan:
Query OK, 0 rows affected, 1 warning (0.00 sec)
Query OK, 1 row affected (0.00 sec)
Database changedCREATE TABLE produk (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(50) NOT NULL,
kategori VARCHAR(30) NOT NULL,
harga DECIMAL(10,2) NOT NULL
);
CREATE TABLE pelanggan (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(50) NOT NULL
);
CREATE TABLE pesanan (
id INT AUTO_INCREMENT PRIMARY KEY,
tanggal DATE NOT NULL,
pelanggan INT NULL
);
CREATE TABLE detail_pesanan (
pesanan_id INT NOT NULL,
produk_id INT NOT NULL,
jumlah INT NOT NULL
);
INSERT INTO produk (nama, kategori, harga) VALUES
('Kopi Gayo', 'kopi', 55000.00),
('Kopi Toraja', 'kopi', 62000.00),
('Teh Melati', 'teh', 25000.00),
('Teh Hijau', 'teh', 30000.00),
('Gula Aren', 'pemanis', 15000.00),
('Susu Kental', 'pemanis', 18000.00);
INSERT INTO pelanggan (nama) VALUES
('Budi'), ('Siti'), ('Rina'), ('Andi'), ('Dewi');
INSERT INTO pesanan (tanggal, pelanggan) VALUES
('2026-01-05', 1),
('2026-01-06', 2),
('2026-01-08', 1),
('2026-01-10', 3),
('2026-01-12', 2),
('2026-01-15', NULL);
INSERT INTO detail_pesanan (pesanan_id, produk_id, jumlah) VALUES
(1, 1, 2),
(1, 5, 1),
(2, 3, 3),
(3, 3, 1),
(4, 2, 2),
(4, 3, 2),
(5, 1, 1),
(5, 5, 4),
(6, 2, 1);Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)
Query OK, 0 rows affected (0.00 sec)
Query OK, 0 rows affected (0.00 sec)
Query OK, 0 rows affected (0.00 sec)
Query OK, 6 rows affected (0.00 sec)
Records: 6 Duplicates: 0 Warnings: 0
Query OK, 5 rows affected (0.00 sec)
Records: 5 Duplicates: 0 Warnings: 0
Query OK, 6 rows affected (0.00 sec)
Records: 6 Duplicates: 0 Warnings: 0
Query OK, 9 rows affected (0.00 sec)
Records: 9 Duplicates: 0 Warnings: 0Total belanja per pelanggan bila dihitung manual: Budi 150000, Siti 190000, Rina 174000, dan pesanan tunai 62000 — angka-angka ini akan kita jumpai lagi sebagai pembanding hasil query.
Derived Table Wajib Punya Alias #
Tabel biasa punya nama sejak diciptakan lewat CREATE TABLE. Derived table lahir tanpa nama, padahal query luar butuh memanggilnya — misalnya saat menulis kondisi join atau memilih kolomnya. MySQL karenanya mewajibkan kita memberi alias. Mari kita buktikan error yang muncul bila alias dilupakan:
-- @error
SELECT * FROM (SELECT pelanggan, COUNT(*) AS jumlah_pesanan FROM pesanan GROUP BY pelanggan);Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)
ERROR 1248 (42000): Every derived table must have its own alias
ByeError 1248 (42000): Every derived table must have its own alias singkat dan tegas: setiap derived table harus punya aliasnya sendiri. Penambahannya hanya sebutir kata — AS diikuti nama:
SELECT * FROM (SELECT pelanggan, COUNT(*) AS jumlah_pesanan FROM pesanan GROUP BY pelanggan) AS rekap ORDER BY pelanggan;Output yang Dihasilkan:
+-----------+----------------+
| pelanggan | jumlah_pesanan |
+-----------+----------------+
| NULL | 1 |
| 1 | 2 |
| 2 | 2 |
| 3 | 1 |
+-----------+----------------+
4 rows in set (0.00 sec)Kini query berjalan dan menghasilkan empat baris. Mari kita bedah lapisan-lapisannya:
- Query dalam kurung —
SELECT pelanggan, COUNT(*) ... GROUP BY pelangganmembaca tabelpesanan, mengelompokkan per pelanggan, dan menghasilkan tabel dua kolom:pelanggandanjumlah_pesanan. - Alias
AS rekap— derived table itu kini bisa dipanggil dengan namarekapoleh query luar, sama seperti nama tabel biasa. SELECT * ... ORDER BY pelanggan— query luar membaca seluruh kolomrekapdan mengurutkan berdasarkanpelanggan; nilaiNULLtampil paling awal karena pengurutan menaik MySQL menempatkanNULLsebelum nilai lain.- Baris
NULLberjumlah 1 — pesanan tunai membentuk kelompokNULLsendiri, perilakuGROUP BYyang sudah kita kenal di chapterGROUP BY.
Pola Klasik: Agregat Dulu, Join Belakangan #
Pola yang paling sering muncul di laporan nyata: subquery menghitung agregat per kelompok, query luar bergabung dengan tabel lain untuk melengkapi kolom yang tidak bisa dihitung — misalnya nama pelanggan. Laporan “total belanja per pelanggan” adalah contoh sempurnanya:
SELECT pelanggan.nama, rekap.total_belanja
FROM pelanggan
JOIN (
SELECT pesanan.pelanggan, SUM(detail_pesanan.jumlah * produk.harga) AS total_belanja
FROM pesanan
JOIN detail_pesanan ON detail_pesanan.pesanan_id = pesanan.id
JOIN produk ON produk.id = detail_pesanan.produk_id
GROUP BY pesanan.pelanggan
) AS rekap ON rekap.pelanggan = pelanggan.id
ORDER BY rekap.total_belanja DESC;Output yang Dihasilkan:
+------+---------------+
| nama | total_belanja |
+------+---------------+
| Siti | 190000.00 |
| Rina | 174000.00 |
| Budi | 150000.00 |
+------+---------------+
3 rows in set (0.00 sec)Siti tampil paling atas dengan total 190000, disusul Rina 174000 dan Budi 150000 — persis hitungan manual kita. Bedah baris-per-barisnya:
- Baris 4-8 (subquery) — mengambil nilai transaksi dengan menggabungkan tiga tabel (
pesanan,detail_pesanan,produk) lalu menjumlahkanjumlah * hargaper pelanggan. Hasilnya tabel kecilrekapdengan dua kolom:pelanggandantotal_belanja. AS rekap ON rekap.pelanggan = pelanggan.id— query luar meng-join tabelpelanggandenganrekapberdasarkan id, satu-satunya jalan mendapatkan kolomnamayang tidak dimilikirekap.ORDER BY rekap.total_belanja DESC— pengurutan menurun total belanja; kolom hasil join boleh dipakai langsung seperti biasa.
Perhatikan bahwa pesanan tunai (kelompok NULL sebesar 62000) tidak muncul di hasil. Inner join mencocokkan rekap.pelanggan dengan pelanggan.id, dan NULL tidak pernah cocok dengan nilai apa pun — pesanan tanpa pelanggan terdaftar kembali tersaring, kali ini karena join, bukan karena WHERE. Bila laporan kita perlu memuatnya, join harus dibalik menjadi LEFT JOIN dari rekap — arah join menentukan siapa yang bertahan, sebagaimana kita latih di section joins.
Memfilter Hasil Agregat #
Ada satu hal yang WHERE biasa tidak bisa lakukan: menyaring berdasarkan nilai yang baru ada setelah agregasi. Kondisi WHERE rata_harga > 20000 tidak dapat ditulis pada query yang sama dengan GROUP BY, karena WHERE dievaluasi sebelum pengelompokan berjalan — persis alasan klausa HAVING diciptakan di chapter HAVING. Derived table menawarkan jalan kedua: agregasi selesai di subquery, hasilnya menjadi kolom biasa, lalu WHERE query luar bebas menyaringnya:
SELECT r.kategori, r.rata_harga FROM (SELECT kategori, AVG(harga) AS rata_harga FROM produk GROUP BY kategori) AS r WHERE r.rata_harga > 20000 ORDER BY r.kategori;Output yang Dihasilkan:
+----------+--------------+
| kategori | rata_harga |
+----------+--------------+
| kopi | 58500.000000 |
| teh | 27500.000000 |
+----------+--------------+
2 rows in set (0.00 sec)Dua kategori lolos: kopi (rata-rata 58500) dan teh (rata-rata 27500), sementara pemanis (16500) tersaring. Untuk pembandingan, versi HAVING menghasilkan baris yang sama:
SELECT kategori, AVG(harga) AS rata_harga FROM produk GROUP BY kategori HAVING AVG(harga) > 20000 ORDER BY kategori;Output yang Dihasilkan:
+----------+--------------+
| kategori | rata_harga |
+----------+--------------+
| kopi | 58500.000000 |
| teh | 27500.000000 |
+----------+--------------+
2 rows in set (0.00 sec)Kapan derived table lebih pas daripada HAVING? Ketika hasil agregatnya ingin dipakai lebih lanjut — di-join ke tabel lain, disaring berlapis, atau diurutkan dengan ekspresi tambahan — derived table mengemas agregasi sebagai tabel yang bisa terus diolah, sedangkan HAVING menempel pada satu query. Untuk sekadar satu kondisi sederhana seperti di atas, HAVING tetap pilihan paling ringkas.
Membersihkan Database Latihan #
Rangkaian latihan selesai dan database latihan kita jatuhkan kembali:
DROP DATABASE belajar_sub;Output yang Dihasilkan:
Query OK, 4 rows affected (0.00 sec)Referensi #
- Derived Tables — MySQL 8.4 Reference Manual
- JOIN Clause — MySQL 8.4 Reference Manual
- SELECT Statement — MySQL 8.4 Reference Manual
Derived table memperkaya sumber data di FROM, tetapi subquery juga bisa menyelinap jauh lebih dekat ke mata pembaca: di daftar SELECT, sebagai satu kolom hasil yang dihitung untuk setiap baris. Chapter Subquery dalam SELECT berikutnya membedah kolom kalkulasi ini, termasuk hubungan tak terduganya dengan subquery berkorelasi.