Dua chapter sebelumnya membahas fondasi columnar storage dan vectorized execution. Sekarang kita bertemu penghuninya yang paling mudah dijangkau: DuckDB, engine analitik yang berjalan di dalam proses program kita sendiri — tanpa server, tanpa instalasi terpisah, tanpa menyalin data lewat jaringan. DuckDB sering dijuluki “SQLite-nya OLAP”: satu pustaka yang di-import lalu langsung menjalankan SQL analitik berat atas berkas CSV, Parquet, bahkan database lain. Seluruh contoh di chapter ini dieksekusi nyata dengan DuckDB 1.5.6 pada dataset pesanan TokoKita — tujuh baris pesanan dengan kolom id, pelanggan, produk, kota, waktu, dan nilai.

Membaca Berkas Tanpa Proses Import #

Mulai dari read_csv_auto — satu fungsi yang mendeteksi delimiter, header, dan tipe kolom secara otomatis. Kita coba mendeskripsikan sebuah CSV pesanan tanpa satu baris CREATE TABLE pun:

python
import duckdb

con = duckdb.connect()
for baris in con.execute(
        "DESCRIBE SELECT * FROM read_csv_auto('pesanan_kecil.csv')").fetchall():
    print(baris[0], "->", baris[1])

Output yang Dihasilkan:

text
id -> BIGINT
pelanggan -> VARCHAR
produk -> VARCHAR
kota -> VARCHAR
waktu -> TIMESTAMP
nilai -> BIGINT

Kolom waktu terdeteksi sebagai TIMESTAMP dan nilai sebagai BIGINT — konversi yang biasanya harus ditulis manual saat memuat CSV. Window function juga berperilaku persis seperti di chapter Window Functions pada course Dasar SQL, sintaksnya standar semua, jadi kita langsung bahas fitur yang khas DuckDB.

ASOF JOIN: Menggabungkan Berdasarkan Waktu Terdekat #

Pertanyaan klasik TokoKita: berapa harga promo yang berlaku saat setiap pesanan masuk? Tabel harga promo hanya menyimpan waktu mulai, dan setiap pesanan harus dipasangkan dengan baris promo terakhir sebelum waktunya. ASOF JOIN mengerjakan pasangan waktu-terdekat ini dalam satu klausul:

python
con.execute("""
    CREATE TABLE harga_promo AS
    SELECT * FROM (VALUES
        (TIMESTAMP '2024-07-01 10:00:00', 12500000, 11800000),
        (TIMESTAMP '2024-07-01 10:30:00', 12500000, 12000000),
        (TIMESTAMP '2024-07-01 10:05:00',  3500000,  3299000)
    ) t(waktu_mulai, harga_normal, harga_promo)
""")
for baris in con.execute("""
    SELECT p.id, p.produk, p.waktu, h.harga_promo
    FROM read_csv_auto('pesanan_kecil.csv') p
    ASOF JOIN harga_promo h
        ON p.waktu >= h.waktu_mulai AND p.nilai = h.harga_normal
    ORDER BY p.id
""").fetchall():
    print(baris)

Output yang Dihasilkan:

text
(1, 'Laptop Pro 14', datetime.datetime(2024, 7, 1, 10, 2), 11800000)
(2, 'Smartphone X1', datetime.datetime(2024, 7, 1, 10, 11), 3299000)
(4, 'Laptop Pro 14', datetime.datetime(2024, 7, 1, 10, 39), 12000000)
(7, 'Smartphone X1', datetime.datetime(2024, 7, 1, 11, 48), 3299000)

Mari kita telaah query ASOF JOIN di atas:

  1. ASOF JOIN harga_promo h: kata kunci ASOF mengubah makna join — bukan mencari kesamaan, melainkan baris harga_promo dengan waktu_mulai terbesar yang masih tidak melebihi p.waktu.
  2. ON p.waktu >= h.waktu_mulai: ketaksamaan inilah syarat “berlaku sebelum”; ASOF JOIN hanya mengizinkan satu kondisi ketaksamaan.
  3. AND p.nilai = h.harga_normal: syarat kesamaan tambahan memastikan pesanan laptop hanya dipasangkan dengan promo laptop.
  4. Baris 3, 5, dan 6 tidak muncul: headset, mouse, dan keyboard tidak punya baris promo dengan harga yang cocok, dan ASOF JOIN bawaan membuang baris tanpa pasangan — perilaku INNER JOIN.

Pesanan 4 masuk pukul 10:39 dan promo laptop kedua mulai 10:30, maka harga promonya 12.000.000; pesanan 1 masuk 10:02 sehingga masih memakai promo pertama (11.800.000). Tanpa ASOF JOIN, pola ini biasanya ditulis dengan subquery korelasi atau window function berlapis — lebih lambat dan lebih sulit dibaca.

LIST dan STRUCT: Hasil Agregasi Bersarang #

DuckDB punya tipe LIST (daftar) dan STRUCT (rekaman berkunci) sehingga agregasi dapat mengembalikan struktur bersarang dalam satu baris — bentuk yang akrab bagi siapa pun yang bekerja dengan JSON:

python
for baris in con.execute("""
    SELECT pelanggan,
           list(produk ORDER BY waktu) AS daftar_produk,
           {'jumlah': count(*), 'total': SUM(nilai)} AS ringkasan
    FROM read_csv_auto('pesanan_kecil.csv')
    GROUP BY pelanggan
    ORDER BY pelanggan
""").fetchall():
    print(baris)

Output yang Dihasilkan:

text
('Budi', ['Laptop Pro 14', 'Headset ANC', 'Keyboard Mekanik'], {'jumlah': 3, 'total': 14200000})
('Dewi', ['Laptop Pro 14', 'Smartphone X1'], {'jumlah': 2, 'total': 16000000})
('Siti', ['Smartphone X1', 'Mouse Ergonomis'], {'jumlah': 2, 'total': 3680000})

Satu baris per pelanggan berisi daftar produk terurut waktu plus ringkasan jumlah dan total — hasil yang di engine lain menuntut string_agg lalu parsing ulang di aplikasi.

ATTACH: Satu Koneksi, Banyak Database #

DuckDB juga bisa memasang database lain ke koneksi saat ini lewat ATTACH, lalu menjahit data dari berkas Parquet dan database tersebut dalam satu query. Pembersihan berkas di awal membuat skrip aman dijalankan ulang:

python
import os

for f in ("promo.duckdb", "ringkasan_pelanggan.parquet"):
    if os.path.exists(f):
        os.remove(f)

con.execute("COPY (SELECT pelanggan, count(*) AS jumlah, SUM(nilai) AS total "
            "FROM read_csv_auto('pesanan_kecil.csv') GROUP BY pelanggan) "
            "TO 'ringkasan_pelanggan.parquet' (FORMAT parquet)")
con.execute("ATTACH 'promo.duckdb' AS promo_db")
con.execute("CREATE TABLE promo_db.promo_aktif AS SELECT * FROM harga_promo")
for baris in con.execute("""
    SELECT r.pelanggan, r.jumlah, p.harga_promo
    FROM read_parquet('ringkasan_pelanggan.parquet') r
    JOIN promo_db.promo_aktif p ON r.total >= p.harga_promo * 3
    ORDER BY r.pelanggan, p.harga_promo
""").fetchall():
    print(baris)

Output yang Dihasilkan:

text
('Budi', 3, 3299000)
('Dewi', 2, 3299000)

Hasil agregasi di Parquet digabungkan dengan tabel di promo.duckdb melalui satu SELECT — hanya Budi dan Dewi yang total belanjanya melebihi tiga kali harga promo smartphone. Pola COPY untuk menulis hasil antara, lalu read_parquet untuk membacanya kembali, juga menjadi cara kerja standar pipeline batch berbasis DuckDB: setiap tahap menyimpan hasilnya sebagai berkas kolom, bukan menyimpannya di memori. Kapabilitas ATTACH ini kita perdalam di chapter Federated Query hingga ke database SQLite.

DuckDB menguasai satu mesin dengan sangat baik. Ketika data tersebar di banyak mesin dan banyak sumber, kita naik kelas ke Trino.