Kodeloka
  1. Kodeloka
  2. Kelas PostgreSQL
  3. Materi

8 · Praktik · Modul 11 dari 12

Pola Kueri: Window Function, CTE & Upsert

Fitur SQL yang menggantikan perulangan di aplikasi, dan cara menulisnya agar tetap cepat.

Window Function

SELECT
    pengguna_id, id, total,
    row_number() OVER (PARTITION BY pengguna_id ORDER BY total DESC) AS urutan,
    sum(total)   OVER (PARTITION BY pengguna_id) AS total_pengguna,
    total - lag(total) OVER (PARTITION BY pengguna_id ORDER BY dibuat_pada)
        AS selisih_dari_sebelumnya
FROM pesanan;
-- Total berjalan & rata-rata bergerak
SELECT tanggal, jumlah,
       sum(jumlah) OVER (ORDER BY tanggal
                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
           AS kumulatif,
       avg(jumlah) OVER (ORDER BY tanggal
                         ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
           AS rata_7_hari
FROM penjualan_harian;
💡 Mengambil N teratas per grup

Ini kebutuhan yang sangat sering muncul dan sering diselesaikan dengan perulangan di aplikasi — satu kueri per pengguna, yang berarti masalah N+1.

SELECT * FROM (
    SELECT *, row_number() OVER (PARTITION BY pengguna_id
                                 ORDER BY dibuat_pada DESC) AS rn
    FROM pesanan
) t WHERE rn <= 3;
-- 3 pesanan terbaru untuk SETIAP pengguna, dalam satu kueri.

CTE dan Jebakan Materialisasi

⚠️ Perilaku CTE berubah di PostgreSQL 12

Sebelum versi 12, CTE selalu dimaterialisasi — dihitung penuh lebih dulu, dan kondisi dari luar tidak bisa didorong masuk. Sejak versi 12, perencana boleh menyisipkannya (inline) sehingga jauh lebih efisien. Anda bisa mengendalikannya secara eksplisit:

WITH pesanan_besar AS MATERIALIZED (...)      -- paksa materialisasi
WITH pesanan_besar AS NOT MATERIALIZED (...)  -- paksa inline

Materialisasi berguna ketika CTE dipakai berkali-kali dan perhitungannya mahal; inline lebih baik untuk hampir semua kasus lain.

-- CTE rekursif: menelusuri hierarki
WITH RECURSIVE bawahan AS (
    SELECT id, nama, atasan_id, 1 AS tingkat
    FROM karyawan WHERE id = 1

    UNION ALL

    SELECT k.id, k.nama, k.atasan_id, b.tingkat + 1
    FROM karyawan k
    JOIN bawahan b ON k.atasan_id = b.id
    WHERE b.tingkat < 10       -- pengaman terhadap siklus
)
SELECT * FROM bawahan;

Upsert

-- Sisipkan, atau perbarui bila sudah ada
INSERT INTO stok (produk_id, jumlah, diubah_pada)
VALUES (1, 100, now())
ON CONFLICT (produk_id) DO UPDATE
SET jumlah = stok.jumlah + EXCLUDED.jumlah,
    diubah_pada = now()
RETURNING *;

-- Abaikan bila sudah ada (pola idempoten)
INSERT INTO peristiwa_diproses (kunci_idempotensi)
VALUES ('abc-123')
ON CONFLICT DO NOTHING
RETURNING id;
-- Bila tidak ada baris kembali, berarti sudah pernah diproses.
📌 ON CONFLICT DO NOTHING untuk idempotensi

Pola ini sangat berguna untuk consumer pesan yang harus idempoten. Karena INSERT dengan constraint unik bersifat atomik, tidak ada celah balapan seperti pada pola "cek dulu lalu sisipkan". Bila tidak ada baris yang dikembalikan, pesan itu sudah pernah diproses dan bisa langsung di-ACK.

RETURNING & LATERAL

-- Ambil data hasil operasi tanpa kueri kedua
UPDATE pesanan SET status = 'dibatalkan'
WHERE dibuat_pada < now() - interval '7 days'
  AND status = 'menunggu'
RETURNING id, pengguna_id;

-- Memindahkan baris secara atomik
WITH dihapus AS (
    DELETE FROM antrean WHERE id = 1 RETURNING *
)
INSERT INTO arsip SELECT * FROM dihapus;

-- LATERAL: subkueri kanan bisa merujuk kolom kiri
SELECT u.nama, p.id, p.total
FROM pengguna u
CROSS JOIN LATERAL (
    SELECT id, total FROM pesanan
    WHERE pengguna_id = u.id
    ORDER BY dibuat_pada DESC
    LIMIT 3
) p;

Paginasi yang Benar

🚫 OFFSET menjadi sangat lambat pada halaman jauh
-- ❌ Harus membaca dan membuang 100.000 baris dulu
SELECT * FROM pesanan ORDER BY dibuat_pada DESC
LIMIT 20 OFFSET 100000;

-- ✅ Keyset pagination: selalu cepat, berapa pun halamannya
SELECT * FROM pesanan
WHERE (dibuat_pada, id) < ('2026-07-20 10:00:00+07', 48213)
ORDER BY dibuat_pada DESC, id DESC
LIMIT 20;

Keyset pagination memakai nilai baris terakhir halaman sebelumnya sebagai titik mulai, sehingga indeks bisa langsung melompat ke sana. Kelemahannya: tidak bisa melompat ke "halaman 500" secara langsung — tetapi antarmuka "muat lebih banyak" dan gulir tak berujung justru lebih cocok dengan pola ini.