Artikel sudah bisa ditulis, diubah, dan dihapus — tetapi blog tanpa tanggapan pembaca hanya monolog. Pada bagian ini kita mengaktifkan tabel komentar yang sejak Bagian 1 sudah menunggu dengan foreign key dan indeksnya. Pekerjaan kita ada empat: menyisipkan komentar lewat endpoint bersarang, menyatukan dua tabel dengan LEFT JOIN, membagi komentar ke halaman-halaman, dan membiarkan database merawat relasi saat artikel dihapus.
Menyisipkan Komentar dengan Foreign Key #
Dari sisi database, komentar hanyalah baris dengan artikel_id. Fungsi penyisipannya sejajar dengan tambahArtikel:
function tambahKomentar(artikelId, nama, isi) {
const hasil = db
.prepare(
'INSERT INTO komentar (artikel_id, nama, isi, created_at) VALUES (?, ?, ?, ?)'
)
.run(artikelId, nama, isi, waktu());
return db
.prepare(
'SELECT id, artikel_id, nama, isi, created_at FROM komentar WHERE id = ?'
)
.get(Number(hasil.lastInsertRowid));
}Yang menarik justru di sisi handler. Komentar pada artikel yang tidak ada harus ditolak, dan penolakan itu datang dari database sendiri — FOREIGN KEY constraint failed. Handler cukup menerjemahkan error tersebut:
if (req.method === 'POST') {
const body = await bacaJson(req);
const galat = wajibString(body, 'nama') || wajibString(body, 'isi');
if (galat) return kirim(res, 400, { galat });
try {
return kirim(res, 201, db.tambahKomentar(id, body.nama, body.isi));
} catch (e) {
if (String(e.message).includes('FOREIGN KEY')) {
return kirim(res, 404, { galat: 'Artikel tidak ditemukan' });
}
throw e;
}
}Validasi nama dan isi berjalan lebih dulu, sehingga 400 tetap berarti “permintaan salah” dan 404 berarti “objek rujukan tidak ada”. Error selain pelanggaran foreign key dilempar kembali dengan throw e agar tangkapan terakhir di buatServer yang menanganinya sebagai 500. Batas antara “divalidasi aplikasi” dan “dijaga database” menjadi nyata di sini: keduanya bekerja pada lapisan berbeda dan saling melengkapi.
Menyatukan Artikel dan Komentar dengan LEFT JOIN #
Endpoint GET /artikel/:id menampilkan artikel beserta seluruh komentarnya. Kita bisa mengambilnya dengan dua query terpisah, tetapi satu query LEFT JOIN memungkinkan keduanya diambil sekaligus — lalu kita rakit ulang bentuknya di JavaScript:
function cariArtikel(id) {
const baris = db
.prepare(
`SELECT a.id, a.judul, a.isi, a.created_at,
k.id AS komentar_id, k.nama AS nama_komentar,
k.isi AS isi_komentar, k.created_at AS waktu_komentar
FROM artikel a
LEFT JOIN komentar k ON k.artikel_id = a.id
WHERE a.id = ?
ORDER BY k.id`
)
.all(id);
if (baris.length === 0) return undefined;
return {
id: baris[0].id,
judul: baris[0].judul,
isi: baris[0].isi,
created_at: baris[0].created_at,
komentar: baris
.filter((b) => b.komentar_id !== null)
.map((b) => ({
id: b.komentar_id,
nama: b.nama_komentar,
isi: b.isi_komentar,
created_at: b.waktu_komentar,
})),
};
}Mari kita bedah query dan perakitannya:
- Alias
adankmemendekkan rujukan kolom. Kolom yang bernama sama pada kedua tabel —id,isi,created_at— diberi nama hasil yang berbeda lewatAS, karena satu baris hasil hanya boleh punya satu kunci per nama. LEFT JOIN ... ON k.artikel_id = a.idmemasangkan setiap komentar ke artikelnya.LEFTberarti artikel tanpa komentar tetap muncul — kolom komentarnya berisiNULL, bukan baris yang hilang.ORDER BY k.idmenjaga komentar tampil urut. Tanpa klausa ini, urutan baris dari SQL tidak dijamin, dan API yang mengacak urutan membuat pengguna kehilangan kepercayaan.filter((b) => b.komentar_id !== null)membuang baris pasangan kosong: artikel pertama tanpa komentar menghasilkan satu baris dengan seluruh kolomkbernilaiNULL.map(...)merakit ulang kolom-kolom pipih menjadi arraykomentarbersarang di dalam object artikel — bentuk yang enak dikonsumsi klien.
Pagination dengan LIMIT dan OFFSET #
Artikel populer bisa menampung ratusan komentar; mengirim semuanya dalam satu respons memboroskan jaringan. Endpoint GET /artikel/:id/komentar membagi hasil lewat parameter ?halaman= dan ?batas=:
function daftarKomentar(artikelId, { halaman = 1, batas = 5 } = {}) {
const ofset = (halaman - 1) * batas;
return db
.prepare(
`SELECT id, nama, isi, created_at FROM komentar
WHERE artikel_id = ?
ORDER BY id
LIMIT ? OFFSET ?`
)
.all(artikelId, batas, ofset);
}function bacaHalaman(url) {
const halaman = Number(url.searchParams.get('halaman') ?? '1');
const batas = Number(url.searchParams.get('batas') ?? '5');
if (
!Number.isInteger(halaman) ||
halaman < 1 ||
!Number.isInteger(batas) ||
batas < 1 ||
batas > BATAS_MAKS
) {
return null;
}
return { halaman, batas };
}Perhitungannya ringkas: ofset = (halaman - 1) * batas, sehingga halaman 1 dengan batas 2 melewati 0 baris dan halaman 2 melewati 2 baris. Batas atas dan bawah angka diperiksa bacaHalaman dengan Number.isInteger — nilai seperti x, 0, atau 2.5 ditolak 400. Kedua nilai tetap dikirim sebagai parameter terikat LIMIT ? OFFSET ?; SQLite menerima binding pada klausa tersebut sama seperti pada WHERE.
Penghapusan Berantai dengan CASCADE #
Terakhir, kita biarkan database merawat relasi saat artikel dihapus. Karena skema menetapkan ON DELETE CASCADE, satu DELETE pada artikel cukup:
function hapusArtikel(id) {
return db.prepare('DELETE FROM artikel WHERE id = ?').run(id).changes > 0;
} function jumlahKomentar(artikelId) {
return db
.prepare('SELECT COUNT(*) AS jumlah FROM komentar WHERE artikel_id = ?')
.get(artikelId).jumlah;
}Handler DELETE /artikel/:id tidak berubah dari Bagian 2 — perbedaannya kini terasa: setelah artikel dihapus, komentarnya benar-benar tidak lagi ada. jumlahKomentar di atas membantu membuktikannya dari luar HTTP; di Bagian 4, klaim ini kita kunci menjadi test otomatis.
Mencoba Sesi Komentar #
Berkas coba-komentar.mjs menjalankan satu sesi lengkap: menanam tiga komentar, melihat JOIN bekerja, membelah halaman, menabrak foreign key, lalu menghapus artikel dan menghitung sisa komentarnya:
// Sesi HTTP untuk komentar: relasi, pagination, foreign key, dan CASCADE.
import { buatDb, WAKTU_DEMO } from '../../src/db.js';
import { buatServer } from '../../src/server.js';
const db = buatDb(':memory:', { waktu: () => WAKTU_DEMO });
const server = buatServer(db);
server.listen(0, '127.0.0.1', async () => {
const url = `http://127.0.0.1:${server.address().port}`;
async function panggil(metode, path, body) {
const r = await fetch(url + path, {
method: metode,
headers: body !== undefined ? { 'content-type': 'application/json' } : {},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const teks = await r.text();
console.log(
`${metode} ${path} -> ${r.status}${teks === '' ? '' : ' ' + teks}`
);
}
await panggil('POST', '/artikel', {
judul: 'Belajar node:sqlite',
isi: 'SQLite kini dibawa langsung oleh Node.js.',
});
await panggil('POST', '/artikel/1/komentar', {
nama: 'Siti',
isi: 'Komentar pertama.',
});
await panggil('POST', '/artikel/1/komentar', {
nama: 'Dewi',
isi: 'Komentar kedua.',
});
await panggil('POST', '/artikel/1/komentar', {
nama: 'Budi',
isi: 'Komentar ketiga.',
});
await panggil('GET', '/artikel/1');
await panggil('GET', '/artikel/1/komentar?halaman=2&batas=2');
await panggil('POST', '/artikel/999/komentar', {
nama: 'Budi',
isi: 'Komentar pada artikel yang tidak ada.',
});
await panggil('DELETE', '/komentar/2');
await panggil('GET', '/artikel/1');
await panggil('DELETE', '/artikel/1');
console.log(
'komentar tersisa milik artikel 1 (efek CASCADE):',
db.jumlahKomentar(1)
);
server.closeAllConnections();
server.close(() => db.tutup());
});Output yang Dihasilkan:
POST /artikel -> 201 {"id":1,"judul":"Belajar node:sqlite","isi":"SQLite kini dibawa langsung oleh Node.js.","created_at":"2026-01-15T08:00:00.000Z"}
POST /artikel/1/komentar -> 201 {"id":1,"artikel_id":1,"nama":"Siti","isi":"Komentar pertama.","created_at":"2026-01-15T08:00:00.000Z"}
POST /artikel/1/komentar -> 201 {"id":2,"artikel_id":1,"nama":"Dewi","isi":"Komentar kedua.","created_at":"2026-01-15T08:00:00.000Z"}
POST /artikel/1/komentar -> 201 {"id":3,"artikel_id":1,"nama":"Budi","isi":"Komentar ketiga.","created_at":"2026-01-15T08:00:00.000Z"}
GET /artikel/1 -> 200 {"id":1,"judul":"Belajar node:sqlite","isi":"SQLite kini dibawa langsung oleh Node.js.","created_at":"2026-01-15T08:00:00.000Z","komentar":[{"id":1,"nama":"Siti","isi":"Komentar pertama.","created_at":"2026-01-15T08:00:00.000Z"},{"id":2,"nama":"Dewi","isi":"Komentar kedua.","created_at":"2026-01-15T08:00:00.000Z"},{"id":3,"nama":"Budi","isi":"Komentar ketiga.","created_at":"2026-01-15T08:00:00.000Z"}]}
GET /artikel/1/komentar?halaman=2&batas=2 -> 200 [{"id":3,"nama":"Budi","isi":"Komentar ketiga.","created_at":"2026-01-15T08:00:00.000Z"}]
POST /artikel/999/komentar -> 404 {"galat":"Artikel tidak ditemukan"}
DELETE /komentar/2 -> 204
GET /artikel/1 -> 200 {"id":1,"judul":"Belajar node:sqlite","isi":"SQLite kini dibawa langsung oleh Node.js.","created_at":"2026-01-15T08:00:00.000Z","komentar":[{"id":1,"nama":"Siti","isi":"Komentar pertama.","created_at":"2026-01-15T08:00:00.000Z"},{"id":3,"nama":"Budi","isi":"Komentar ketiga.","created_at":"2026-01-15T08:00:00.000Z"}]}
DELETE /artikel/1 -> 204
komentar tersisa milik artikel 1 (efek CASCADE): 0Baca baris-baris hasilnya dengan tenang: GET /artikel/1 memperlihatkan komentar tersusun di dalam field komentar, halaman 2 dengan batas 2 hanya berisi komentar ketiga, percobaan berkomentar pada artikel 999 ditolak 404, dan setelah artikel dihapus, jumlahKomentar melaporkan 0 — bukti langsung CASCADE bekerja.
Relasi, JOIN, pagination, dan penghapusan berantai kini berjalan sebagai satu sistem. Satu pertanyaan tersisa: bagaimana kita memastikan semuanya tetap benar besok, lusa, dan setiap kali kode diubah? Jawabannya adalah rangkaian test otomatis — dan itu tepatnya isi Bagian 4.
Referensi resmi: node:sqlite dan SQLite foreign keys .