Query Database dari API — API

API yang nyata menyimpan dan mengambil data dari database, bukan hardcoded. Pola umum: Route → Handler → Query → Response app.get("/products", (req, res) => {…

Sampai pelajaran CRUD, buku Toko Buku disimpan di array. Array itu hidup di memori server: server di-restart, buku yang baru ditambah hilang. API sungguhan menyimpan datanya di database, lalu tiap request menanyakannya lewat query, perintah dalam bahasa SQL. Bayangin gudang Toko Buku. Pembeli gak boleh masuk gudang. Pelayan (API) mengisi formulir ke petugas gudang (database): "ambil buku berjudul [ ______ ]". Petugas membaca isi kotak itu sebagai judul, apa pun tulisannya, lalu membawa buku yang cocok.

🧪 Tebakan cepat: endpoint GET /books?title=... menyusun SQL-nya dengan menyambung teks: "... WHERE title = '" + title + "'". Seseorang mengirim title berisi x' OR '1'='1. Berapa buku yang keluar: 0, 1, atau semua 8? Tebak dulu, jawabannya ada di bagian "Placeholder, bukan sambung teks".

Tabel books di database

Mulai pelajaran ini, buku Toko Buku tinggal di tabel SQLite bernama books, 8 baris. Nama penulisnya di tabel authors. Cara membuat tabel dan menulis SQL dibahas di track SQL; di sini kita fokus ke API-nya.

id title author_id price stock cost_price
1 Laskar Pelangi 1 89000 12 61000
2 Bumi Manusia 2 125000 0 90000
3 Cantik Itu Luka 3 110000 5 78000
4 Ronggeng Dukuh Paruk 4 95000 3 66000
5 Sang Pemimpi 1 79000 8 55000
6 Anak Semua Bangsa 2 120000 2 86000
7 Lelaki Harimau 3 98000 0 64000
8 Edensor 1 85000 6 52000

cost_price adalah harga modal. Kolomnya ada di database, tapi API gak pernah mengirimnya.

Route, handler, query, response

Polanya selalu sama: request masuk ke route, handler menyusun query, database menjawab dengan baris-baris, handler mengirim JSON.

app.get("/books", (req, res) => {
  const books = db
    .prepare("SELECT id, title, author_id, price, stock FROM books")
    .all();
  res.json({ data: books, total: books.length });
});

Satu buku: .get() dan 404

app.get("/books/:id", (req, res) => {
  const book = db
    .prepare("SELECT id, title, author_id, price, stock FROM books WHERE id = ?")
    .get(Number(req.params.id));
  if (!book) {
    return res.status(404).json({ error: "Book not found" });
  }
  res.json({ data: book });
});

Placeholder, bukan sambung teks

Endpoint pencarian judul, ditulis dua cara:

// BAHAYA: input client disambung jadi bagian dari SQL
const sql = "SELECT id, title FROM books WHERE title = '" + req.query.title + "'";
const found = db.prepare(sql).all();

// AMAN: teks SQL-nya tetap, nilainya dikirim terpisah
const safe = db.prepare("SELECT id, title FROM books WHERE title = ?").all(req.query.title);

Kirim GET /books?title=x' OR '1'='1:

Cara Yang sampai ke database Buku yang keluar
Sambung teks ... WHERE title = 'x' OR '1'='1' 8, semuanya
Placeholder ... WHERE title = ? dengan nilai x' OR '1'='1 0

Tanda kutip di input menutup teks SQL-nya, lalu OR '1'='1' jadi syarat baru yang selalu benar. Semua 8 buku keluar. Itu jawaban tebakannya. Ini namanya SQL injection: input client berubah jadi perintah. Dengan placeholder, database membaca nilainya utuh sebagai judul, persis petugas gudang tadi, dan gak ada buku berjudul x' OR '1'='1. Pembahasan lengkapnya ada di pelajaran SQL Injection Prevention di track Security.

Placeholder cuma bisa mengisi nilai: judul, angka, id. Nama kolom dan kata ASC/DESC gak bisa. Cara menanganinya ada di pelajaran Filter & Sort.

N+1: satu query per baris

Halaman daftar butuh nama penulis tiap buku. Cara yang kelihatan wajar:

const books = db.prepare("SELECT id, title, author_id FROM books").all();
for (const book of books) {
  const author = db.prepare("SELECT name FROM authors WHERE id = ?").get(book.author_id);
  book.author = author.name;
}

Satu query untuk daftar, lalu satu query lagi untuk tiap buku: 1 + 8 = 9 query. Ini masalah N+1 (N = jumlah baris). Dengan 1.000 buku jadi 1.001 query, dan tiap query butuh waktu bolak-balik ke database. Satu JOIN mengambil semuanya sekaligus:

const books = db.prepare(`
  SELECT books.id, books.title, authors.name AS author
  FROM books JOIN authors ON authors.id = books.author_id
`).all();

Satu query, berapa pun jumlah bukunya. Detail N+1 ada di pelajaran N+1 Problem & Eager Loading di track Database.

Jangan ambil semua baris sekaligus

Query tanpa batas aman untuk 8 buku. Untuk tabel siswa berisi 100.000 baris, endpoint yang sama memuat 100.000 object ke memori server dan mengirim sekitar 10 MB JSON di tiap request (kalau satu baris kira-kira 100 byte). Masalahnya: gak ada pagination. Endpoint daftar selalu dibatasi, misalnya LIMIT 20 OFFSET 0 di SQL, seperti di pelajaran Pagination.

📌 Versi Laravel: Eloquent

Di Laravel (track Laravel), query ditulis lewat Eloquent, dan placeholder-nya dipasang otomatis:

better-sqlite3 Eloquent
db.prepare("SELECT * FROM siswa").all() Siswa::all()
db.prepare("... WHERE id = ?").get(id) Siswa::find($id)
JOIN untuk relasi Siswa::with('kelas')->get()
LIMIT 15 OFFSET 0 Siswa::paginate(15)

Jebakan N+1-nya sama:

$siswa = Siswa::all();
foreach ($siswa as $s) {
    echo $s->kelas->nama; // satu query lagi untuk tiap siswa
}

Tiap $s->kelas menjalankan query baru: 100 siswa jadi 101 query. Siswa::with('kelas')->get() (eager loading) memuat semua kelas dalam satu query tambahan, jadi totalnya 2 query.

⚠️ Jebakan yang sering ketemu

TL;DR: Endpoint API menanyakan data ke database: route, handler, query, response. db.prepare(sql).all() mengembalikan semua baris, .get() satu baris atau undefined (jawab 404). Daftar dijawab { data, total } dengan kolom yang disebut satu per satu, bukan SELECT *. Input client selalu lewat placeholder ?, gak pernah disambung ke teks SQL: x' OR '1'='1 yang disambung mengeluarkan semua 8 buku, lewat placeholder 0 buku. Query di dalam loop adalah N+1 (1 + 8 = 9 query); JOIN, atau Siswa::with('kelas')->get() di Laravel, memangkasnya. Daftar besar butuh pagination: Siswa::all() untuk 100.000 baris itu masalah. Di Eloquent, Siswa::all() mengambil semua baris.

Yang akan kamu pelajari