Web Artisan
Beranda

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.

Roadmap 3 · PostgreSQL dan pgx

SQL Dasar
untuk Backend API

Query SQL yang kamu tulis di modul ini akan menjadi fondasi repository Go untuk produk, cart, dan order skincare.

PostgreSQL 18Bahasa: Go 1.26~80 menit baca
01

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”.

🌉Jembatan: dari Eloquent ke SQL eksplisit

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.

🌉Jembatan: deklaratif seperti React, bukan imperatif seperti jQuery

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.

query

Perintah SQL yang dikirim aplikasi ke database, misalnya membaca produk aktif, memasukkan order baru, atau menghitung total belanja seorang customer.

result set

Kumpulan baris yang dikembalikan oleh query SELECT. Di Go, result set dibaca lewat rows.Next() lalu dipindahkan (scan) ke struct domain atau DTO response.

query planner

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.

02

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_from

Gambar 1. ERD subset skincare untuk latihan SQL. Variant memegang harga; cart dan order item mengacu ke variant.

💰Uang selalu integer rupiah, bukan float

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.

🧭Snapshot di order_items

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.

03

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.sql
SELECT 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
🌉Jembatan: rantai array method JS punya urutan eksplisit

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.

💡Kenapa ini penting untuk performa

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.

04

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.sql
SELECT 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.sql
SELECT id, brand_id, slug, name, status, created_at FROM products WHERE slug = 'gentle-cleanser' AND status = 'active' AND deleted_at IS NULL;
⚠️Jebakan: SELECT * terasa praktis, tapi rapuh

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.sql
SELECT 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.sql
SELECT id, order_number, status, total_rupiah FROM orders WHERE status IN ('paid', 'processing', 'shipped');
🌉Jembatan: WHERE adalah Array.filter()

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.

GET /v1/products Daftar produk aktif dengan filter brand dan harga
GET /v1/products/gentle-cleanser Detail produk berdasarkan slug publik
05

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
🌉Jembatan: NULL bukan undefined JS dan bukan null PHP

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;
⚠️Jebakan: NOT IN dengan NULL

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.

three-valued logic

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.

06

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.sql
INSERT 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.sql
INSERT 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;
🌉Jembatan: dari Eloquent create() ke INSERT RETURNING

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.sql
INSERT INTO cart_items (cart_id, variant_id, quantity) VALUES (10, 7, 2) RETURNING id, cart_id, variant_id, quantity, created_at;
💡Upsert untuk cart yang sama

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.

POST /v1/admin/variants Admin membuat variant produk, API mengembalikan id dari PostgreSQL
POST /v1/cart/items Customer menambah variant ke keranjang
07

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.sql
UPDATE product_variants SET price_rupiah = 139000 WHERE id = 7 RETURNING id, sku, price_rupiah;
🚨Jebakan besar: UPDATE atau DELETE tanpa WHERE

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.sql
DELETE FROM cart_items WHERE id = 15 AND cart_id = 10;
💡Selalu sertakan pemilik baris di WHERE

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.sql
UPDATE 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';
🌉Jembatan: SoftDeletes trait Laravel

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.

PATCH /v1/admin/variants/7 Admin mengubah harga atau status variant
DELETE /v1/admin/products/1 Admin mengarsipkan produk (soft delete)
08

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.sql
SELECT 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.sql
SELECT 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;
💡Selalu tambahkan tie-breaker unik

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.sql
SELECT id, sku, variant_name, price_rupiah FROM product_variants WHERE is_active = true ORDER BY price_rupiah ASC, id ASC LIMIT 12 OFFSET 0;
🌉Jembatan: ini sort().slice() di JavaScript

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.

⚠️Jebakan: OFFSET besar makin lambat

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.

09

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.sql
SELECT 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.sql
SELECT 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.sql
SELECT 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;
INNER JOIN
  • Hanya baris yang punya pasangan di kedua tabel.
  • Cocok untuk response publik yang butuh data lengkap.
  • Variant tanpa produk tidak akan muncul.
LEFT JOIN
  • Semua baris tabel kiri tetap tampil; sisi kanan jadi NULL bila tak berpasangan.
  • Cocok untuk dashboard admin dan mencari data yang belum lengkap.
  • Filter v.id IS NULL mengisolasi baris tak berpasangan.
⚠️Jebakan: nama kolom ambigu

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.

⚠️Jebakan: LEFT JOIN lalu memfilter sisi kanan di WHERE

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.

10

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.sql
SELECT 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.sql
SELECT 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.sql
SELECT 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.sql
SELECT 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;
🌉Jembatan: GROUP BY + SUM adalah reduce yang dikelompokkan

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.

⚠️Jebakan GROUP BY: kolom non-aggregate wajib ikut

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.

11

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.sql
SELECT 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;
🚨Bahaya nyata: jangan pernah merangkai SQL dari string input

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.

BERBAHAYA: string concatenation
  • fmt.Sprintf("... WHERE slug = '%s'", slug)
  • Input user menyatu dengan perintah SQL.
  • ' OR '1'='1 membocorkan seluruh tabel.
  • Rentan, dan dilarang di repository proyek ini.
AMAN: parameter $1
  • 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.
parameterized query

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.go
package 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 }
💡Idiom Go: cek ErrNoRows terpisah

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.

🌉Jembatan: bukan tanda tanya MySQL

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.

12

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 arrayKlausa SQLContoh skincare
.filter(v => …)WHEREWHERE price_rupiah <= 250000
.map(v => v.x)SELECT kolomSELECT sku, price_rupiah
.sort((a,b)=>…)ORDER BYORDER BY price_rupiah ASC
.slice(0, 20)LIMIT / OFFSETLIMIT 20 OFFSET 0
.reduce((a,o)=>a+o.total,0)SUM()SUM(total_rupiah)
.lengthCOUNT(*)COUNT(*) AS total
Object.groupBy(…)GROUP BYGROUP BY user_id
.find(v => v.id===id)WHERE + LIMIT 1WHERE id = $1 LIMIT 1
gabung 2 array berdasar keyJOIN … ONJOIN 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.

🌉Jembatan: kenapa tidak ambil semua lalu .filter() di Go?

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.

📌Padanan Laravel query builder

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.

13

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.

Query listing katalog

Ambil variant aktif beserta nama produk dan brand, urut terbaru, dengan pagination parameterized.

Query tambah cart item

Masukkan cart_id, variant_id, dan quantity, lalu ambil baris baru dengan RETURNING.

Query order history

Gabungkan orders dan order_items, hitung total item per order, dengan paginasi.

hands-on/01-list-catalog.sql
SELECT 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.sql
INSERT 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.sql
SELECT 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.
  • SELECT mengambil kolom eksplisit, bukan *.
  • UPDATE dan DELETE selalu punya WHERE yang membatasi baris, plus kunci pemilik.
  • Filter nullable memakai IS NULL / IS NOT NULL, bukan = NULL.
  • Pagination memakai ORDER BY deterministik dengan tie-breaker id.
  • Kolom hasil JOIN diberi alias agar mudah di-scan ke struct Go.
  • Kolom non-aggregate di query GROUP BY ikut dicantumkan di GROUP 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 --> HTTP

Gambar 5. Posisi SQL dalam alur request API Go. SQL hidup di lapisan repository, dipanggil dengan argumen parameterized.

GET /v1/products Memakai 01-list-catalog.sql
POST /v1/cart/items Memakai 02-add-cart-item.sql
GET /v1/orders Memakai 03-order-history.sql
14

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 alias SELECT tak bisa dipakai di WHERE.
  • NULL itu “tidak diketahui”: pakai IS NULL / IS NOT NULL, jangan = NULL; bungkus aggregate dengan COALESCE.
  • INSERT ... RETURNING id adalah pola PostgreSQL satu round-trip yang penting untuk Go.
  • UPDATE dan DELETE wajib dibatasi WHERE plus kunci pemilik; soft delete memakai deleted_at IS NULL.
  • ORDER BY + LIMIT/OFFSET membentuk pagination, selalu dengan tie-breaker id; keyset menyusul di chapter indexing.
  • INNER JOIN mengambil pasangan lengkap, LEFT JOIN menjaga baris kiri; syarat sisi kanan taruh di ON, bukan WHERE.
  • GROUP BY dengan COUNT/SUM dan saringan HAVING menggerakkan 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.

🚀Langkah berikutnya

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.