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:

js src/db.js — tambahKomentar
  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:

js src/server.js — POST /artikel/:id/komentar
    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:

js src/db.js — cariArtikel
  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:

  1. Alias a dan k memendekkan rujukan kolom. Kolom yang bernama sama pada kedua tabel — id, isi, created_at — diberi nama hasil yang berbeda lewat AS, karena satu baris hasil hanya boleh punya satu kunci per nama.
  2. LEFT JOIN ... ON k.artikel_id = a.id memasangkan setiap komentar ke artikelnya. LEFT berarti artikel tanpa komentar tetap muncul — kolom komentarnya berisi NULL, bukan baris yang hilang.
  3. ORDER BY k.id menjaga komentar tampil urut. Tanpa klausa ini, urutan baris dari SQL tidak dijamin, dan API yang mengacak urutan membuat pengguna kehilangan kepercayaan.
  4. filter((b) => b.komentar_id !== null) membuang baris pasangan kosong: artikel pertama tanpa komentar menghasilkan satu baris dengan seluruh kolom k bernilai NULL.
  5. map(...) merakit ulang kolom-kolom pipih menjadi array komentar bersarang 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=:

js src/db.js — daftarKomentar
  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);
  }
js src/server.js — bacaHalaman
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:

js src/db.js — hapusArtikel dan jumlahKomentar
  function hapusArtikel(id) {
    return db.prepare('DELETE FROM artikel WHERE id = ?').run(id).changes > 0;
  }
js src/db.js — jumlahKomentar
  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:

js
// 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:

text
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): 0

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