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 mengirimtitleberisix' 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 });
});
dbadalah koneksi ke database. Bentuknya mengikuti better-sqlite3, library SQLite untuk Node.js. Cara membuka koneksinya ada di pelajaran Koneksi Database di track Node.js.db.prepare(sql)menyiapkan satu query..all()menjalankannya dan mengembalikan semua baris sebagai array of object.- Kolomnya disebut satu per satu, bukan
SELECT *. DenganSELECT *,cost_priceikut terkirim ke semua orang. { data: books, total: books.length }membungkus daftar dalamdataplus jumlahnya, seperti di pelajaran Pagination. Di sinitotalberisi8.
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 });
});
?adalah placeholder: kotak kosong di SQL yang diisi nilai. Nilainya dikirim lewat.get(...), terpisah dari teks SQL-nya..get()mengembalikan baris pertama yang cocok, atauundefinedkalau gak ada.GET /books/2dijawab200dengan Bumi Manusia.GET /books/99dapatundefined, jadi dijawab404.
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
- Menyambung input ke SQL.
"... WHERE title = '" + title + "'"membuka SQL injection, begitu juga template literal`... '${title}'`. Pakai?dan kirim nilainya lewat.all(...)atau.get(...). SELECT *di endpoint publik. Semua kolom terkirim, termasukcost_priceataupassword_hash. Sebut kolomnya satu per satu.- Lupa 404.
.get()yang gak ketemu mengembalikanundefined, danres.json({ data: undefined })dijawab200dengan{}. Cek dulu, lalures.status(404). - Query di dalam loop. Satu query per baris = N+1. Pakai JOIN, atau
with(...)di Laravel. - Daftar tanpa batas. 100.000 baris sekaligus menghabiskan memory dan bandwidth. Pakai pagination.
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.