Progress belajar
Modul 22 dari 73
0% 0/73 modul selesai
Setelah selesai, tandai modul ini agar progres kursus tetap rapi.
Progress disimpan lokal di browser ini.
SQL Dasar
untuk Backend API
Query SQL yang kamu tulis di modul ini akan menjadi fondasi repository Go untuk produk, cart, dan order skincare.
SQL sebagai Bahasa Kontrak Data
Deklaratif, bukan imperatif seperti loop JavaScript
Di Laravel, Eloquent sering membuat SQL terasa tersembunyi. Di Go, kita justru lebih dekat dengan SQL, karena repository sengaja dibuat eksplisit, mudah dibaca saat code review, dan mudah diuji.
SQL adalah bahasa deklaratif untuk membaca dan mengubah data relasional. Kamu tidak menulis langkah per langkah seperti for di JavaScript. Kamu menyatakan data seperti apa yang dibutuhkan, lalu PostgreSQL yang memilih cara eksekusinya lewat komponen bernama query planner. Ini pergeseran cara berpikir yang penting: dari “bagaimana mengambil” menjadi “apa yang diambil”.
Di Laravel, ProductVariant::where('is_active', true)->get() membungkus SQL di balik fluent API. Di Go dengan pgx, kamu menulis SELECT ... FROM product_variants WHERE is_active = true langsung, sehingga kolom yang dipilih, filter yang dipakai, dan biaya query terlihat sejak baris pertama repository, bukan tersembunyi di balik method chaining.
Ingat lompatan dari jQuery (perintahkan tiap langkah DOM) ke React (deklarasikan tampilan akhir, biar React yang mengurus cara mencapainya). SQL adalah pola yang sama untuk data. Kamu deklarasikan hasil akhir, planner PostgreSQL menentukan urutan scan, index, dan join yang paling murah.
Perintah SQL yang dikirim aplikasi ke database, misalnya membaca produk aktif, memasukkan order baru, atau menghitung total belanja seorang customer.
Kumpulan baris yang dikembalikan oleh query SELECT. Di Go, result set dibaca lewat rows.Next() lalu dipindahkan (scan) ke struct domain atau DTO response.
Mesin PostgreSQL yang menerjemahkan query deklaratif menjadi rencana eksekusi nyata (urutan scan tabel, pemakaian index, strategi join). Kamu tidak menulis rencananya, tetapi bisa melihatnya dengan EXPLAIN, yang dibahas mendalam di chapter indexing.
Katalog Produk
SELECT, WHERE, JOIN, ORDER BY, dan pagination memberi makan endpoint katalog publik.
Cart
INSERT, UPDATE, DELETE, dan constraint dipakai saat customer mengubah isi keranjang.
Order & Laporan
JOIN, GROUP BY, SUM, dan HAVING menggerakkan riwayat order dan dashboard admin.
Modul ini fokus pada satu hal: menulis SQL yang akurat dan aman, dengan tabel skincare yang sama persis seperti yang akan kita pakai di repository pgx nanti. Kita belum memanggil database dari Go di sini (itu dimulai di chapter koneksi pgx), tetapi setiap query sudah ditulis dengan placeholder $1 agar tinggal disalin ke kode Go tanpa perombakan.
Untuk rujukan resmi, modul ini selaras dengan dokumentasi PostgreSQL 18 tentang SELECT, INSERT, RETURNING, LIMIT/OFFSET, serta aggregate function dan join.
Skema Skincare untuk Latihan
Tabel kanonik yang dipakai sepanjang Roadmap 3
Sebelum query, kita kunci dulu skema. Tabel di bawah ini adalah subset kanonik online shop skincare yang akan dipakai semua chapter Roadmap 3, jadi nama tabel dan kolomnya tidak boleh berubah-ubah.
Inti penjualan skincare bukan produk, melainkan variant. Satu produk (misalnya “Gentle Cleanser”) punya banyak variant (ukuran 100ml dan 200ml, atau shade berbeda), dan justru variant inilah yang memegang harga lewat kolom price_rupiah serta punya sku unik. cart_items dan order_items mengacu ke variant_id, bukan ke produk langsung.
migrations/000002_catalog.up.sql (subset)CREATE TABLE brands ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, slug text NOT NULL UNIQUE, name text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE products ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, brand_id bigint NOT NULL REFERENCES brands(id), slug text NOT NULL UNIQUE, name text NOT NULL, status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')), created_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz ); CREATE TABLE product_variants ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, product_id bigint NOT NULL REFERENCES products(id) ON DELETE CASCADE, sku text NOT NULL UNIQUE, variant_name text NOT NULL, size_label text, shade text, price_rupiah bigint NOT NULL CHECK (price_rupiah >= 0), is_active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now() );
migrations/000005_orders.up.sql (subset)CREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL REFERENCES users(id), order_number text NOT NULL UNIQUE, status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','paid','processing','shipped','completed','cancelled','refunded')), subtotal_rupiah bigint NOT NULL CHECK (subtotal_rupiah >= 0), total_rupiah bigint NOT NULL CHECK (total_rupiah >= 0), placed_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE order_items ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE, variant_id bigint NOT NULL REFERENCES product_variants(id), product_name text NOT NULL, -- snapshot saat checkout sku text NOT NULL, -- snapshot saat checkout unit_price_rupiah bigint NOT NULL CHECK (unit_price_rupiah >= 0), quantity integer NOT NULL CHECK (quantity > 0) );
erDiagram
USERS ||--o{ ORDERS : places
BRANDS ||--o{ PRODUCTS : owns
PRODUCTS ||--o{ PRODUCT_VARIANTS : has
PRODUCT_VARIANTS ||--o{ CART_ITEMS : selected_as
CARTS ||--o{ CART_ITEMS : contains
ORDERS ||--o{ ORDER_ITEMS : contains
PRODUCT_VARIANTS ||--o{ ORDER_ITEMS : snapshot_fromGambar 1. ERD subset skincare untuk latihan SQL. Variant memegang harga; cart dan order item mengacu ke variant.
Kolom uang memakai bigint rupiah utuh: price_rupiah, subtotal_rupiah, total_rupiah, unit_price_rupiah. Jangan pernah float, numeric, atau kolom _cents. Di Go ini memetakan ke int64, sehingga harga 129.000 rupiah disimpan sebagai angka 129000, bebas dari galat pembulatan pecahan.
order_items menyalin product_name, sku, dan unit_price_rupiah saat checkout. Ini sengaja: jika harga variant naik besok, invoice kemarin tetap menampilkan harga saat dibeli. Kita akan sering mem-JOIN ke product_variants untuk catalog, tetapi laporan order membaca kolom snapshot.
Urutan Eksekusi Logis Query
Kenapa WHERE tidak bisa memakai alias dari SELECT
Satu sumber kebingungan terbesar pendatang baru SQL: query ditulis dengan urutan SELECT ... FROM ... WHERE, tetapi PostgreSQL mengevaluasinya dengan urutan logis yang sangat berbeda. Memahami urutan ini menjelaskan banyak error sekaligus.
Urutan kamu menulis klausa bukan urutan database memprosesnya. Secara logis, PostgreSQL mulai dari FROM (tentukan sumber baris), lalu menyaring dengan WHERE, mengelompokkan dengan GROUP BY, menyaring grup dengan HAVING, baru kemudian menghitung kolom SELECT, mengurutkan dengan ORDER BY, dan terakhir memotong dengan LIMIT/OFFSET.
flowchart LR FROM["FROM / JOIN<br/>kumpulkan baris sumber"] --> WHERE["WHERE<br/>saring baris"] WHERE --> GROUP["GROUP BY<br/>kelompokkan"] GROUP --> HAVING["HAVING<br/>saring grup"] HAVING --> SELECT["SELECT<br/>hitung kolom & alias"] SELECT --> ORDER["ORDER BY<br/>urutkan"] ORDER --> LIMIT["LIMIT / OFFSET<br/>potong halaman"]
Gambar 2. Urutan eksekusi logis. SELECT dievaluasi setelah WHERE dan GROUP BY, itulah sebabnya alias kolom belum tersedia di WHERE.
Konsekuensi praktis pertama: alias yang kamu definisikan di SELECT belum ada saat WHERE berjalan. Query berikut akan error karena harga belum dikenal di tahap WHERE.
salah.sql-- ERROR: column "harga" does not exist SELECT price_rupiah AS harga FROM product_variants WHERE harga <= 200000;
Solusinya: pakai ekspresi aslinya di WHERE, atau pakai alias di ORDER BY (yang memang berjalan setelah SELECT).
benar.sqlSELECT price_rupiah AS harga FROM product_variants WHERE price_rupiah <= 200000 -- ekspresi asli, bukan alias ORDER BY harga ASC; -- alias boleh di sini, ORDER BY setelah SELECT
Di JavaScript kamu menulis arr.filter(...).map(...).sort(...).slice(0, 20), dan setiap langkah jelas urutannya karena kamu sendiri yang merangkainya. SQL menyembunyikan urutan itu di balik satu pernyataan, jadi kamu harus menghafalnya: WHERE (filter) selalu sebelum SELECT (map), dan LIMIT (slice) selalu paling akhir.
Karena WHERE berjalan lebih dulu, filter yang selektif memangkas baris sebelum operasi mahal seperti ORDER BY atau aggregate. Menaruh filter yang tepat (dan, nanti, index yang tepat) di WHERE adalah tuas performa terbesar yang kamu kendalikan.
SELECT, FROM, dan WHERE
Query yang menggerakkan hampir setiap endpoint baca
SELECT adalah query paling sering muncul di backend API karena hampir semua endpoint membaca data sebelum merespons client.
Bentuk dasarnya SELECT kolom FROM tabel WHERE kondisi. Pilih kolom yang benar-benar dibutuhkan. Jangan refleks memakai SELECT * di endpoint produksi: ia menarik kolom yang tidak perlu, membuat kontrak ke struct Go rapuh, dan gampang membocorkan kolom internal.
queries/list-active-variants.sqlSELECT id, product_id, sku, variant_name, price_rupiah FROM product_variants WHERE is_active = true;
Untuk detail produk publik, kita cari dengan slug, karena slug enak dipakai di URL. id dipakai untuk relasi internal.
queries/product-detail-by-slug.sqlSELECT id, brand_id, slug, name, status, created_at FROM products WHERE slug = 'gentle-cleanser' AND status = 'active' AND deleted_at IS NULL;
SELECT * membuat API gampang membocorkan kolom internal seperti deleted_at, dan kode Go mudah rusak saat urutan atau jumlah kolom berubah (rows.Scan memetakan kolom berdasarkan posisi). Tulis kolom eksplisit, selalu.
WHERE untuk filter katalog
Filter produk skincare biasanya datang dari query parameter React app: brand, harga maksimum, dan status stok. Operator perbandingan (=, <>, <=, >=), BETWEEN, IN, dan pencocokan teks ILIKE (case-insensitive) adalah perkakas sehari-hari.
queries/variant-filter.sqlSELECT id, sku, variant_name, shade, price_rupiah FROM product_variants WHERE is_active = true AND price_rupiah BETWEEN 50000 AND 250000 AND variant_name ILIKE '%toner%';
IN cocok untuk menyaring beberapa nilai sekaligus, misalnya beberapa status order.
queries/orders-by-status.sqlSELECT id, order_number, status, total_rupiah FROM orders WHERE status IN ('paid', 'processing', 'shipped');
WHERE price_rupiah <= 250000 sama persis maksudnya dengan variants.filter(v => v.priceRupiah <= 250000) di JavaScript. Bedanya, SQL menyaring di database sebelum data dikirim lewat jaringan, sedangkan filter JS berjalan setelah seluruh data sudah ada di memori aplikasi. Untuk dataset besar, perbedaan ini menentukan apakah API cepat atau lambat.
/v1/products Daftar produk aktif dengan filter brand dan harga /v1/products/gentle-cleanser Detail produk berdasarkan slug publik NULL dan Tiga Nilai Logika
Bukan sekadar null JavaScript, ini logika tiga-nilai
NULL di SQL berarti “tidak diketahui”, dan ia mengikuti aturan logika tiga-nilai (true, false, unknown). Salah memahami ini adalah sumber bug filter yang paling sering lolos sampai produksi.
Aturan kunci: apa pun yang dibandingkan dengan NULL lewat = atau <> menghasilkan unknown, bukan true atau false. Dan WHERE hanya meloloskan baris yang kondisinya bernilai true. Maka WHERE deleted_at = NULL tidak pernah meloloskan baris apa pun, walau niatmu mencari yang belum dihapus.
null-salah-vs-benar.sql-- SALAH: = NULL menghasilkan unknown, baris tidak pernah lolos SELECT id FROM products WHERE deleted_at = NULL; -- BENAR: pakai IS NULL / IS NOT NULL SELECT id FROM products WHERE deleted_at IS NULL; -- belum dihapus SELECT id FROM products WHERE deleted_at IS NOT NULL; -- sudah soft-deleted
Di JS, value === null bekerja normal dan null == undefined bernilai true. Di SQL, value = NULL justru tidak pernah true, karena NULL berarti “tidak diketahui” dan dua hal tak-diketahui tidak bisa dibilang sama. Selalu pakai IS NULL dan IS NOT NULL, jangan = NULL.
NULL dalam aggregate dan COALESCE
COUNT(*) menghitung semua baris, tetapi COUNT(kolom) melewati baris yang kolomnya NULL. SUM dan AVG juga mengabaikan NULL. Saat kamu butuh angka 0 alih-alih NULL (misalnya total belanja customer yang belum pernah order), bungkus dengan COALESCE.
coalesce-default.sql-- COALESCE mengembalikan argumen pertama yang bukan NULL SELECT u.id, COALESCE(SUM(o.total_rupiah), 0) AS total_spent_rupiah FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id;
WHERE id NOT IN (1, 2, NULL) akan mengembalikan nol baris, karena perbandingan dengan NULL di dalam list membuat seluruh kondisi unknown. Pastikan subquery di balik NOT IN tidak pernah menghasilkan NULL, atau pakai NOT EXISTS yang lebih aman terhadap NULL.
Logika SQL dengan tiga hasil: true, false, dan unknown. WHERE dan HAVING hanya meloloskan baris yang kondisinya true. NULL dalam perbandingan menghasilkan unknown, sehingga baris tersaring keluar diam-diam.
INSERT dengan RETURNING
Dapatkan id baru tanpa query kedua
Di PostgreSQL, RETURNING sangat penting untuk backend Go, karena kita hampir selalu butuh id (atau kolom yang dibuat database) segera setelah insert, tanpa harus melakukan SELECT susulan.
Bentuk dasarnya INSERT INTO tabel (kolom) VALUES (nilai) RETURNING id. PostgreSQL mendukung RETURNING pada perintah yang memodifikasi baris (INSERT, UPDATE, DELETE), dan ini menghemat satu round-trip ke database.
queries/create-variant.sqlINSERT INTO product_variants (product_id, sku, variant_name, size_label, price_rupiah) VALUES (1, 'GC-100ML', 'Gentle Cleanser 100ml', '100ml', 129000) RETURNING id;
Untuk response 201 Created, kita sering ingin mengembalikan seluruh data baris baru, termasuk kolom yang diisi default oleh database (seperti created_at dan is_active).
queries/create-variant-full.sqlINSERT INTO product_variants (product_id, sku, variant_name, size_label, price_rupiah) VALUES (1, 'GC-200ML', 'Gentle Cleanser 200ml', '200ml', 199000) RETURNING id, sku, variant_name, price_rupiah, is_active, created_at;
ProductVariant::create($data) di Laravel otomatis mengisi atribut id model setelah insert (di balik layar Eloquent menjalankan query lagi atau memakai lastInsertId). Di Go, INSERT ... RETURNING id adalah cara eksplisit dan satu round-trip untuk mendapatkan nilai yang dibuat database, tanpa sihir.
Insert cart item
Saat customer menambahkan item ke cart, cart_items menyimpan cart_id, variant_id, dan quantity. Perhatikan: cart item tidak menyimpan harga. Harga dibaca segar dari product_variants saat checkout, supaya cart selalu memantulkan harga terkini.
queries/add-cart-item.sqlINSERT INTO cart_items (cart_id, variant_id, quantity) VALUES (10, 7, 2) RETURNING id, cart_id, variant_id, quantity, created_at;
Jika customer menambah variant yang sudah ada di cart, idealnya quantity bertambah, bukan baris baru. PostgreSQL punya INSERT ... ON CONFLICT (cart_id, variant_id) DO UPDATE SET quantity = cart_items.quantity + EXCLUDED.quantity. Pola upsert ini kita perdalam di chapter write pgx; untuk sekarang cukup tahu bahwa unique constraint (cart_id, variant_id) membuatnya mungkin.
/v1/admin/variants Admin membuat variant produk, API mengembalikan id dari PostgreSQL /v1/cart/items Customer menambah variant ke keranjang UPDATE, DELETE, dan Soft Delete
Perintah yang wajib dijaga ketat dengan WHERE
UPDATE dan DELETE terlihat sederhana, tetapi di backend nyata keduanya selalu harus dibatasi dengan WHERE yang jelas. Satu klausa yang lupa bisa mengubah seluruh tabel.
UPDATE mengubah baris yang sudah ada. Untuk admin katalog, query umum adalah mengubah harga, nama, atau status aktif variant. Gabungkan dengan RETURNING agar handler langsung punya data terbaru untuk dikirim ke client.
queries/update-variant-price.sqlUPDATE product_variants SET price_rupiah = 139000 WHERE id = 7 RETURNING id, sku, price_rupiah;
UPDATE product_variants SET price_rupiah = 139000 (tanpa WHERE) akan mengubah harga SEMUA variant di seluruh katalog. Biasakan menulis WHERE lebih dulu, baru SET. Di klien interaktif, banyak tim mengaktifkan mode “safe update” yang menolak UPDATE/DELETE tanpa kunci.
DELETE menghapus baris secara fisik. Untuk data transaksi (order, payment), ini hampir selalu salah karena riwayat audit hilang. Untuk item cart, hard delete masuk akal karena cart bersifat sementara.
queries/delete-cart-item.sqlDELETE FROM cart_items WHERE id = 15 AND cart_id = 10;
Untuk aksi milik customer seperti menghapus item cart, tambahkan AND cart_id = $2 (atau lewat join ke cart milik user yang login). Tanpa itu, customer bisa menghapus item milik orang lain hanya dengan menebak id. Ini bukan optimasi, ini kontrol akses.
Soft delete: hapus tanpa menghapus
Soft delete berarti baris tidak benar-benar dihapus. Kita isi kolom deleted_at, lalu semua query publik menambahkan filter deleted_at IS NULL. Pola ini menjaga riwayat (produk yang sudah pernah dibeli tetap bisa dirujuk order lama) sambil menyembunyikannya dari katalog.
queries/soft-delete-product.sqlUPDATE products SET deleted_at = now(), status = 'archived' WHERE id = 1 AND deleted_at IS NULL RETURNING id, status, deleted_at;
queries/public-products.sql-- Katalog publik selalu menyaring yang sudah soft-deleted SELECT id, slug, name, status FROM products WHERE deleted_at IS NULL AND status = 'active';
Laravel menyediakan trait SoftDeletes yang otomatis menambahkan WHERE deleted_at IS NULL ke setiap query Eloquent. Di Go raw SQL, tidak ada sihir otomatis: kamu menulis deleted_at IS NULL sendiri di setiap query publik. Lebih berisik, tetapi tidak ada filter tersembunyi yang mengejutkanmu saat debugging.
/v1/admin/variants/7 Admin mengubah harga atau status variant /v1/admin/products/1 Admin mengarsipkan produk (soft delete) ORDER BY, LIMIT, dan OFFSET
Pagination untuk katalog, order history, dan dashboard
Pagination adalah kebutuhan harian: product listing, order history, dan tabel admin semuanya butuh memotong hasil menjadi halaman.
ORDER BY menentukan urutan baris, LIMIT membatasi jumlah, dan OFFSET melewati sejumlah baris di awal. PostgreSQL mengingatkan bahwa tanpa ORDER BY yang deterministik, urutan baris tidak dijamin, sehingga LIMIT/OFFSET bisa memberi halaman yang acak atau tumpang tindih.
queries/products-page-1.sqlSELECT id, slug, name, created_at FROM products WHERE deleted_at IS NULL AND status = 'active' ORDER BY created_at DESC, id DESC -- tie-breaker id agar deterministik LIMIT 20 OFFSET 0;
Halaman kedua dengan per_page = 20 memakai OFFSET 20. Rumusnya offset = (page - 1) * per_page.
queries/products-page-2.sqlSELECT id, slug, name, created_at FROM products WHERE deleted_at IS NULL AND status = 'active' ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 20;
ORDER BY created_at DESC saja berbahaya: jika dua baris punya created_at sama persis (mudah terjadi pada insert batch), urutannya tidak pasti dan satu produk bisa muncul di dua halaman. Tambahkan id DESC sebagai tie-breaker, karena id selalu unik.
Sort dari harga termurah
queries/variants-cheapest-first.sqlSELECT id, sku, variant_name, price_rupiah FROM product_variants WHERE is_active = true ORDER BY price_rupiah ASC, id ASC LIMIT 12 OFFSET 0;
ORDER BY price_rupiah ASC LIMIT 12 OFFSET 0 setara dengan variants.sort((a,b)=>a.priceRupiah-b.priceRupiah).slice(0, 12). Sekali lagi bedanya lokasi: SQL mengurutkan dan memotong di database, jadi hanya 12 baris yang melintasi jaringan, bukan seluruh katalog.
OFFSET 100000 tetap memaksa PostgreSQL memindai dan membuang 100.000 baris pertama sebelum mengembalikan 20 baris. Untuk halaman dalam, biaya ini membengkak. Solusi yang lebih cepat adalah keyset pagination (WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 20), yang kita bahas tuntas di chapter indexing dan performa. Untuk sekarang, LIMIT/OFFSET sudah cukup dan paling mudah dipahami.
INNER JOIN dan LEFT JOIN
Menggabungkan beberapa tabel dalam satu response
Relasi tabel menjadi nyata saat API perlu menggabungkan data dari beberapa tabel dalam satu response, misalnya menampilkan variant beserta nama produk dan brand-nya.
INNER JOIN hanya mengembalikan baris yang punya pasangan di kedua tabel. Untuk halaman katalog yang menampilkan variant beserta produk dan brand, ini tepat: kita hanya mau variant yang memang terhubung ke produk yang ada.
queries/variants-with-product.sqlSELECT v.id AS variant_id, v.sku, v.variant_name, v.price_rupiah, p.name AS product_name, b.name AS brand_name FROM product_variants v INNER JOIN products p ON p.id = v.product_id INNER JOIN brands b ON b.id = p.brand_id WHERE v.is_active = true AND p.deleted_at IS NULL AND p.status = 'active' ORDER BY p.name ASC, v.price_rupiah ASC;
LEFT JOIN mengembalikan semua baris tabel kiri, walau tabel kanan tidak punya pasangan. Kolom dari sisi kanan akan NULL untuk baris tak berpasangan. Ini berguna untuk admin, misalnya menemukan produk yang belum punya variant agar segera dilengkapi.
queries/products-without-variants.sqlSELECT p.id AS product_id, p.name AS product_name, v.id AS variant_id FROM products p LEFT JOIN product_variants v ON v.product_id = p.id WHERE p.deleted_at IS NULL AND v.id IS NULL -- hanya produk yang TIDAK punya variant ORDER BY p.name ASC;
flowchart LR O["orders<br/>(1 baris order)"] -->|order_id| OI["order_items<br/>(banyak baris item)"] OI -->|variant_id| V["product_variants<br/>(harga, sku)"] V -->|product_id| P["products<br/>(nama, brand_id)"] P -->|brand_id| B["brands<br/>(nama brand)"]
Gambar 3. Rantai JOIN untuk menampilkan detail order lengkap: dari order ke item, ke variant, ke produk, ke brand.
Untuk menampilkan detail satu order lengkap dengan nama produk dan brand, kita rantai beberapa join. Perhatikan, untuk laporan order kita pakai kolom snapshot (oi.product_name, oi.unit_price_rupiah) agar harga historis akurat, dan join ke katalog hanya untuk data yang memang ingin “fresh”.
queries/order-detail.sqlSELECT o.order_number, o.status, oi.product_name, -- snapshot saat checkout oi.unit_price_rupiah, -- snapshot saat checkout oi.quantity, b.name AS current_brand -- data katalog terkini (boleh berbeda) FROM orders o INNER JOIN order_items oi ON oi.order_id = o.id INNER JOIN product_variants v ON v.id = oi.variant_id INNER JOIN products p ON p.id = v.product_id INNER JOIN brands b ON b.id = p.brand_id WHERE o.id = $1 ORDER BY oi.id ASC;
- Hanya baris yang punya pasangan di kedua tabel.
- Cocok untuk response publik yang butuh data lengkap.
- Variant tanpa produk tidak akan muncul.
- Semua baris tabel kiri tetap tampil; sisi kanan jadi
NULLbila tak berpasangan. - Cocok untuk dashboard admin dan mencari data yang belum lengkap.
- Filter
v.id IS NULLmengisolasi baris tak berpasangan.
Kalau dua tabel sama-sama punya kolom id atau name, query gagal tanpa kualifikasi tabel. Selalu beri prefix tabel (v.id, p.name) dan alias kolom hasil (AS variant_id, AS product_name) agar mapping ke struct Go lewat rows.Scan jelas dan tidak tertukar.
Menulis LEFT JOIN ... WHERE v.is_active = true diam-diam mengubah LEFT JOIN menjadi INNER JOIN, karena baris dengan v NULL gagal lolos v.is_active = true. Jika kamu butuh syarat pada sisi kanan tetapi tetap mempertahankan baris kiri, taruh syarat itu di klausa ON (LEFT JOIN ... ON v.product_id = p.id AND v.is_active = true), bukan di WHERE.
GROUP BY, Aggregate, dan HAVING
Mengubah banyak baris menjadi angka ringkas
Aggregate query meringkas banyak baris menjadi angka, cocok untuk dashboard, ringkasan order, dan laporan penjualan. GROUP BY mengelompokkan, HAVING menyaring grup.
Fungsi aggregate inti: COUNT (jumlah baris), SUM (penjumlahan), AVG (rata-rata), MIN/MAX (nilai ekstrem). GROUP BY membagi baris menjadi grup, lalu aggregate dihitung per grup.
queries/order-count-per-user.sqlSELECT u.id AS user_id, u.email, COUNT(o.id) AS total_orders FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id, u.email ORDER BY total_orders DESC;
Untuk total nilai belanja per customer, gunakan SUM. Karena LEFT JOIN bisa menghasilkan NULL untuk customer tanpa order, COALESCE(SUM(...), 0) mengubahnya menjadi 0.
queries/customer-spending.sqlSELECT u.id AS user_id, u.email, COUNT(o.id) AS total_orders, COALESCE(SUM(o.total_rupiah), 0) AS total_spent_rupiah FROM users u LEFT JOIN orders o ON o.user_id = u.id AND o.status IN ('paid', 'completed') -- hanya order yang benar dibayar GROUP BY u.id, u.email ORDER BY total_spent_rupiah DESC;
HAVING: menyaring setelah dikelompokkan
WHERE menyaring baris sebelum dikelompokkan; HAVING menyaring grup setelah aggregate dihitung. Untuk menemukan “customer VIP” yang sudah belanja lebih dari 1 juta rupiah, syarat itu memakai hasil SUM, sehingga harus di HAVING, bukan WHERE.
queries/vip-customers.sqlSELECT u.id, u.email, SUM(o.total_rupiah) AS total_spent_rupiah FROM users u INNER JOIN orders o ON o.user_id = u.id WHERE o.status IN ('paid', 'completed') -- saring baris dulu (sebelum grup) GROUP BY u.id, u.email HAVING SUM(o.total_rupiah) > 1000000 -- saring grup (setelah aggregate) ORDER BY total_spent_rupiah DESC;
Variant terlaris
Menggabungkan join dan aggregate untuk laporan: variant mana yang paling banyak terjual, memakai snapshot di order_items.
queries/best-selling-variants.sqlSELECT oi.sku, oi.product_name, SUM(oi.quantity) AS units_sold, SUM(oi.unit_price_rupiah * oi.quantity) AS revenue_rupiah FROM order_items oi INNER JOIN orders o ON o.id = oi.order_id WHERE o.status IN ('paid', 'completed', 'shipped') GROUP BY oi.sku, oi.product_name ORDER BY units_sold DESC LIMIT 10;
SUM(o.total_rupiah) per user mirip orders.reduce((acc, o) => acc + o.total, 0), tetapi GROUP BY u.id membuatnya seperti mengelompokkan dulu dengan Object.groupBy lalu reduce tiap grup. Bedanya, agregasi terjadi di database dekat data, sehingga hanya angka ringkas (bukan ribuan baris order) yang dikirim ke Go.
Setiap kolom di SELECT yang bukan fungsi aggregate harus muncul di GROUP BY. Menulis SELECT u.email, u.name, COUNT(o.id) ... GROUP BY u.id akan ditolak PostgreSQL kecuali u.email dan u.name ikut di GROUP BY (atau kolom-kolom itu bergantung fungsional pada primary key u.id yang sudah ada di GROUP BY). Aturan amannya: cantumkan semua kolom non-aggregate di GROUP BY.
Parameterized Query dan SQL Injection
Pemisahan perintah dan data, garis pertahanan utama
Query API tidak boleh menyisipkan input user langsung ke string SQL. Gunakan parameter agar nilai dan perintah SQL tetap terpisah. Ini bukan sekadar gaya, ini pertahanan keamanan paling fundamental terhadap SQL injection.
Di PostgreSQL, placeholder parameter ditulis berdasarkan posisi: $1, $2, $3, dan seterusnya. Ini berbeda dari MySQL/PDO yang memakai ?. Nilai yang dikirim sebagai argumen tidak pernah ditafsirkan sebagai SQL, ia selalu diperlakukan sebagai data murni.
queries/variant-search-parameterized.sqlSELECT id, sku, variant_name, price_rupiah FROM product_variants WHERE is_active = true AND variant_name ILIKE $1 AND price_rupiah <= $2 ORDER BY price_rupiah ASC, id ASC LIMIT $3 OFFSET $4;
Membuat query lewat fmt.Sprintf("... WHERE slug = '%s'", slug) membuka pintu SQL injection. Jika slug berisi ' OR '1'='1, kondisi WHERE menjadi selalu true dan seluruh tabel bocor. Lebih parah, input '; DROP TABLE products; -- bisa merusak data. Solusinya mutlak: SELALU pakai $1, $2 dan oper nilai sebagai argumen terpisah, jangan tempel ke string.
Bandingkan langsung cara berbahaya dan cara aman saat dipanggil dari Go dengan pgx. Yang aman membuat driver mengirim template dan nilai secara terpisah ke server, sehingga input tidak mungkin “keluar” dari posisinya sebagai data.
fmt.Sprintf("... WHERE slug = '%s'", slug)- Input user menyatu dengan perintah SQL.
' OR '1'='1membocorkan seluruh tabel.- Rentan, dan dilarang di repository proyek ini.
pool.QueryRow(ctx, "... WHERE slug = $1", slug)- Driver mengirim template dan nilai terpisah.
- Input selalu diperlakukan sebagai data, bukan SQL.
- Aman, dan ini standar pgx.
Query yang memisahkan template SQL dari nilai input. Nilai user dikirim sebagai argumen lewat placeholder $1, $2, bukan ditempel ke string SQL. Inilah mekanisme yang menutup celah SQL injection.
Contoh pemanggilan dari Go dengan pgxpool
Inilah preview cara query parameterized dipanggil dari Go. Kita belum membahas pgx secara penuh (itu chapter berikutnya), tetapi perhatikan tiga hal idiomatik: context.Context sebagai parameter pertama, SQL sebagai konstanta, dan nilai dioper sebagai argumen di belakang.
internal/product/repository.gopackage product import ( "context" "errors" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type Variant struct { ID int64 SKU string VariantName string PriceRupiah int64 } type Repository struct { pool *pgxpool.Pool } func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } func (r *Repository) FindVariantBySKU(ctx context.Context, sku string) (Variant, error) { const query = ` SELECT id, sku, variant_name, price_rupiah FROM product_variants WHERE sku = $1 AND is_active = true` var v Variant err := r.pool.QueryRow(ctx, query, sku).Scan( &v.ID, &v.SKU, &v.VariantName, &v.PriceRupiah, ) if errors.Is(err, pgx.ErrNoRows) { return Variant{}, ErrVariantNotFound } if err != nil { return Variant{}, err } return v, nil }
r.pool.QueryRow(...).Scan(...) mengembalikan pgx.ErrNoRows saat tidak ada baris cocok. Periksa dengan errors.Is(err, pgx.ErrNoRows) dan ubah menjadi error domain (ErrVariantNotFound) yang nanti dipetakan handler ke 404. Error lain diteruskan sebagai 500. Pola ini berulang di seluruh repository pgx.
Jika kamu terbiasa PDO MySQL atau query builder Laravel, placeholder ? terasa akrab. Di PostgreSQL native (dan pgx), gunakan $1, $2, dan seterusnya. Nomornya berbasis posisi, jadi $1 boleh dipakai berkali-kali dalam satu query untuk merujuk argumen pertama yang sama.
Peta JS Array ke Klausa SQL
Pemetaan langsung dari yang sudah kamu kuasai
Karena kamu kuat di JavaScript, cara tercepat menjinakkan SQL adalah memetakannya ke array method yang sudah kamu pakai setiap hari. Hampir setiap klausa SQL punya padanan langsung.
| JavaScript array | Klausa SQL | Contoh skincare |
|---|---|---|
.filter(v => …) | WHERE | WHERE price_rupiah <= 250000 |
.map(v => v.x) | SELECT kolom | SELECT sku, price_rupiah |
.sort((a,b)=>…) | ORDER BY | ORDER BY price_rupiah ASC |
.slice(0, 20) | LIMIT / OFFSET | LIMIT 20 OFFSET 0 |
.reduce((a,o)=>a+o.total,0) | SUM() | SUM(total_rupiah) |
.length | COUNT(*) | COUNT(*) AS total |
Object.groupBy(…) | GROUP BY | GROUP BY user_id |
.find(v => v.id===id) | WHERE + LIMIT 1 | WHERE id = $1 LIMIT 1 |
| gabung 2 array berdasar key | JOIN … ON | JOIN products p ON p.id = v.product_id |
Gambar 4. Peta padanan array method JavaScript ke klausa SQL.
Yang membuat SQL bukan sekadar “array method di database” adalah dua hal. Pertama, urutan eksekusi logis tetap (WHERE selalu sebelum SELECT), tidak sebebas merangkai method. Kedua, dan ini pembeda nyata, SQL mengeksekusi semuanya dekat data lalu hanya mengirim hasil akhir lewat jaringan.
Godaan pemula adalah SELECT * FROM orders lalu memfilter di Go dengan slice operation. Untuk 10 baris tidak masalah, tetapi untuk 10 juta baris ini menarik seluruh tabel ke memori aplikasi (mahal di RAM dan jaringan). Biarkan database melakukan WHERE, JOIN, dan SUM; Go hanya menerima hasil yang sudah ringkas. Aturan praktis: dorong pekerjaan data ke tempat data tinggal.
Untuk yang datang dari Laravel, peta serupa berlaku: where() jadi WHERE, select() jadi SELECT, orderBy() jadi ORDER BY, limit()/offset() jadi LIMIT/OFFSET, join() jadi JOIN, groupBy() jadi GROUP BY, dan sum()/count() jadi aggregate. Di Go kita menulis SQL-nya langsung, jadi tidak ada lapisan builder di antaranya.
Hands-on Query API Skincare
Tiga query yang siap dibawa ke repository
Latihan ini menyusun tiga query nyata yang langsung bisa kamu pindahkan ke repository produk, cart, dan order di chapter pgx. Semuanya sudah memakai placeholder $1 dan kolom kanonik.
Ambil variant aktif beserta nama produk dan brand, urut terbaru, dengan pagination parameterized.
Masukkan cart_id, variant_id, dan quantity, lalu ambil baris baru dengan RETURNING.
Gabungkan orders dan order_items, hitung total item per order, dengan paginasi.
hands-on/01-list-catalog.sqlSELECT v.id AS variant_id, v.sku, v.variant_name, v.price_rupiah, p.name AS product_name, b.name AS brand_name FROM product_variants v INNER JOIN products p ON p.id = v.product_id INNER JOIN brands b ON b.id = p.brand_id WHERE v.is_active = true AND p.deleted_at IS NULL AND p.status = 'active' ORDER BY p.created_at DESC, v.id DESC LIMIT $1 OFFSET $2;
hands-on/02-add-cart-item.sqlINSERT INTO cart_items (cart_id, variant_id, quantity) VALUES ($1, $2, $3) RETURNING id, cart_id, variant_id, quantity, created_at;
hands-on/03-order-history.sqlSELECT o.id AS order_id, o.order_number, o.status, o.total_rupiah, o.placed_at, COUNT(oi.id) AS line_count, SUM(oi.quantity) AS total_items FROM orders o INNER JOIN order_items oi ON oi.order_id = o.id WHERE o.user_id = $1 GROUP BY o.id, o.order_number, o.status, o.total_rupiah, o.placed_at ORDER BY o.placed_at DESC, o.id DESC LIMIT $2 OFFSET $3;
Checklist sebelum query masuk repository
- Semua input user memakai
$1,$2, bukan string concatenation. SELECTmengambil kolom eksplisit, bukan*.UPDATEdanDELETEselalu punyaWHEREyang membatasi baris, plus kunci pemilik.- Filter nullable memakai
IS NULL/IS NOT NULL, bukan= NULL. - Pagination memakai
ORDER BYdeterministik dengan tie-breakerid. - Kolom hasil
JOINdiberi alias agar mudah di-scan ke struct Go. - Kolom non-aggregate di query
GROUP BYikut dicantumkan diGROUP BY.
flowchart LR
HTTP["HTTP Handler (chi)"] --> DTO["Parse query params"]
DTO --> SVC["Service: validasi bisnis"]
SVC --> REPO["Repository: SQL + args $1..$n"]
REPO --> PG[("PostgreSQL")]
PG --> REPO
REPO --> SVC
SVC --> HTTPGambar 5. Posisi SQL dalam alur request API Go. SQL hidup di lapisan repository, dipanggil dengan argumen parameterized.
/v1/products Memakai 01-list-catalog.sql /v1/cart/items Memakai 02-add-cart-item.sql /v1/orders Memakai 03-order-history.sql Ringkasan & Poin Penting
Fondasi sebelum pgxpool, transaksi, dan repository
SQL dasar di modul ini adalah fondasi sebelum kita menyentuh pgxpool, transaksi, dan repository pattern di chapter-chapter berikutnya Roadmap 3.
Yang Wajib Menempel
- SQL itu deklaratif: nyatakan apa yang dibutuhkan, planner PostgreSQL urus caranya.
- Urutan eksekusi logis (
FROM,WHERE,GROUP BY,HAVING,SELECT,ORDER BY,LIMIT) menjelaskan kenapa aliasSELECTtak bisa dipakai diWHERE. NULLitu “tidak diketahui”: pakaiIS NULL/IS NOT NULL, jangan= NULL; bungkus aggregate denganCOALESCE.INSERT ... RETURNING idadalah pola PostgreSQL satu round-trip yang penting untuk Go.UPDATEdanDELETEwajib dibatasiWHEREplus kunci pemilik; soft delete memakaideleted_at IS NULL.ORDER BY+LIMIT/OFFSETmembentuk pagination, selalu dengan tie-breakerid; keyset menyusul di chapter indexing.INNER JOINmengambil pasangan lengkap,LEFT JOINmenjaga baris kiri; syarat sisi kanan taruh diON, bukanWHERE.GROUP BYdenganCOUNT/SUMdan saringanHAVINGmenggerakkan laporan dan dashboard.- Parameter
$1,$2(bukan?MySQL) memisahkan data dari perintah dan menutup celah SQL injection.
Sudah dikuasai
Membaca, menulis, menggabung, dan meringkas data skincare dengan SQL parameterized yang aman.
Berikutnya: pgx
Query ini dipanggil dari Go lewat pgxpool, QueryRow, Query, Exec, dengan context.Context.
Lalu: transaksi & index
Checkout multi-tabel pakai transaksi; pagination cepat dan EXPLAIN dibahas di chapter indexing.
Di chapter koneksi dan query pgx, query yang sudah kamu tulis di sini akan dieksekusi dari Go: pool.Query(ctx, sql, args...) untuk banyak baris, pool.QueryRow(...) untuk satu baris, dan pool.Exec(...) untuk write. Kolom dan placeholder $1 yang sudah rapi membuat perpindahannya nyaris tanpa friksi.
Progress disimpan lokal di browser ini.