Binding ke Python di Chapter sebelumnya menyelesaikan masalah “siapa yang memanggil kode kita”; sekarang kita bahas kebutuhan yang jauh lebih umum: menyimpan data secara terstruktur tanpa memasang server database. SQLite adalah database SQL penuh yang hidup di dalam satu file biasa — tanpa proses server, tanpa konfigurasi, tanpa port jaringan — dan karena itulah ia ada di mana-mana: browser menyimpan riwayat dan cookie di SQLite, Android dan iPhone menyimpan data aplikasi di SQLite, dan hampir semua aplikasi desktop yang menyimpan “sesuatu yang terstruktur” juga memakainya. Pada Chapter ini kita pelajari pola C API-nya — open, prepare, bind, step, finalize — lalu membangun wrapper RAII mini di atasnya dan menjalankannya sungguhan dengan library SQLite yang tersedia di lingkungan course.

Database Embedded vs Database Client-Server #

Perbedaan mendasarnya ada pada arsitektur proses. PostgreSQL atau MySQL berjalan sebagai server tersendiri; program Anda berbicara dengannya lewat koneksi jaringan dan kredensial, dan server itulah yang menulis ke disk. SQLite berjalan di dalam proses program Anda: fungsi C yang Anda panggil langsung membaca dan menulis file database. Tidak ada jaringan berarti tidak ada latensi per query, tidak ada kredensial yang bocor, dan deployment cuma satu file data. Harga yang dibayar juga jelas: hanya satu proses yang boleh menulis pada satu waktu, tidak ada manajemen user, dan tidak ada replikasi bawaan.

SituasiPilihan yang wajar
Aplikasi desktop/mobile menyimpan data lokalSQLite
Cache lokal, log terstruktur, file konfigurasi kompleksSQLite
Web app multi-server dengan ribuan penulis konkurenPostgreSQL/MySQL
Butuh user management, replikasi, koneksi jaringanPostgreSQL/MySQL
Analitik satu mesin atas data besar sekaliDuckDB/kolom-based, atau SQLite bila pas

Aturan praktisnya: bila data dan program hidup di mesin yang sama dan penulisnya satu proses, SQLite hampir selalu pilihan paling sederhana yang tetap memberi jaminan transaksi penuh (ACID — atomik, konsisten, terisolasi, tahan crash).

Pola C API: open, prepare, bind, step, finalize #

SQLite menawarkan C API, jadi semua pengetahuan dari Chapter C ABI dan C++ ABI berlaku: kita memanggil fungsi C polos dengan handle pointer. Urutan panggilannya selalu sama lima langkah:

cpp Pola C API SQLite (ilustrasi)
sqlite3* db = nullptr;
sqlite3_open("koperasi.db", &db);                    // 1) buka koneksi

sqlite3_stmt* stmt = nullptr;
sqlite3_prepare_v2(db,                               // 2) siapkan statement
    "SELECT nama, saldo FROM anggota WHERE saldo > ?",
    -1, &stmt, nullptr);
sqlite3_bind_int(stmt, 1, 100000);                   // 3) isi parameter '?'

while (sqlite3_step(stmt) == SQLITE_ROW) {           // 4) tarik baris demi baris
    const unsigned char* nama = sqlite3_column_text(stmt, 0);
    int saldo = sqlite3_column_int(stmt, 1);
    std::printf("%s: %d\n", nama, saldo);
}

sqlite3_finalize(stmt);                              // 5) bebaskan statement
sqlite3_close(db);                                   // 6) tutup koneksi

Cara membangunnya di mesin Anda biasa adalah satu flag linker tambahan:

bash Cara link biasa di luar harness course
g++ -std=c++23 -Wall -Wextra program.cpp -lsqlite3 -o program

Harness course ini mengompilasi contoh dengan perintah tetap tanpa flag tambahan, sehingga program teruji di bawah memuat libsqlite3.so.0 saat runtime dengan dlopen — teknik yang sudah Anda kenal dari Chapter Static dan Dynamic Libraries. Fungsi yang dipanggil dan urutannya identik dengan pola di atas; hanya cara menemukannya yang berbeda. Perhatikan juga state machine di langkah 4: sqlite3_step mengembalikan SQLITE_ROW setiap kali ada baris baru dan SQLITE_DONE saat data habis — loop while hanyalah penerjemahan langsung dari kontrak itu.

Wrapper RAII Mini yang Teruji #

Polanya jelas, tetapi lima langkah manual berarti lima peluang lupa finalize atau close ketika exception terjadi. Versi RAII berikut memindahkan setiap pembersihan ke destructor, dan program ini benar-benar kita kompilasi dan jalankan:

cpp
// sqlite-mini.cpp — wrapper RAII mini di atas C API SQLite sungguhan.
// Harness course mengompilasi contoh tanpa -lsqlite3, sehingga library
// dimuat saat runtime dengan dlopen; urutan fungsi open/prepare/bind/
// step/finalize-nya persis sama dengan pemakaian biasa.
#include <dlfcn.h>
#include <sqlite3.h>

#include <cstdio>
#include <stdexcept>
#include <string>

// Kumpulan pointer fungsi yang diambil dari libsqlite3.so.0 saat runtime.
struct Api {
    decltype(&sqlite3_open)        open;
    decltype(&sqlite3_close)       close;
    decltype(&sqlite3_prepare_v2)  prepare;
    decltype(&sqlite3_bind_int)    bind_int;
    decltype(&sqlite3_bind_text)   bind_text;
    decltype(&sqlite3_step)        step;
    decltype(&sqlite3_column_int)  column_int;
    decltype(&sqlite3_column_text) column_text;
    decltype(&sqlite3_finalize)    finalize;
    decltype(&sqlite3_errmsg)      errmsg;
};

Api muatSqlite() {
    void* lib = dlopen("libsqlite3.so.0", RTLD_NOW);
    if (!lib) throw std::runtime_error(dlerror());
    Api api{};
    api.open        = reinterpret_cast<decltype(api.open)>(dlsym(lib, "sqlite3_open"));
    api.close       = reinterpret_cast<decltype(api.close)>(dlsym(lib, "sqlite3_close"));
    api.prepare     = reinterpret_cast<decltype(api.prepare)>(dlsym(lib, "sqlite3_prepare_v2"));
    api.bind_int    = reinterpret_cast<decltype(api.bind_int)>(dlsym(lib, "sqlite3_bind_int"));
    api.bind_text   = reinterpret_cast<decltype(api.bind_text)>(dlsym(lib, "sqlite3_bind_text"));
    api.step        = reinterpret_cast<decltype(api.step)>(dlsym(lib, "sqlite3_step"));
    api.column_int  = reinterpret_cast<decltype(api.column_int)>(dlsym(lib, "sqlite3_column_int"));
    api.column_text = reinterpret_cast<decltype(api.column_text)>(dlsym(lib, "sqlite3_column_text"));
    api.finalize    = reinterpret_cast<decltype(api.finalize)>(dlsym(lib, "sqlite3_finalize"));
    api.errmsg      = reinterpret_cast<decltype(api.errmsg)>(dlsym(lib, "sqlite3_errmsg"));
    return api;
}

// RAII untuk koneksi: destructor memanggil sqlite3_close secara otomatis.
class Database {
public:
    Database(const Api& api, const char* path) : api_(&api) {
        if (api.open(path, &db_) != SQLITE_OK)
            throw std::runtime_error("sqlite3_open gagal");
    }
    ~Database() { api_->close(db_); }
    Database(const Database&) = delete;
    Database& operator=(const Database&) = delete;
    sqlite3* get() const { return db_; }
    const Api& api() const { return *api_; }

private:
    const Api* api_;
    sqlite3* db_ = nullptr;
};

// RAII untuk prepared statement: destructor memanggil sqlite3_finalize.
class Statement {
public:
    Statement(const Database& db, const char* sql) : api_(&db.api()) {
        if (api_->prepare(db.get(), sql, -1, &stmt_, nullptr) != SQLITE_OK)
            throw std::runtime_error(api_->errmsg(db.get()));
    }
    ~Statement() { api_->finalize(stmt_); }
    Statement(const Statement&) = delete;
    Statement& operator=(const Statement&) = delete;

    void bind(int indeks, int nilai) { api_->bind_int(stmt_, indeks, nilai); }
    void bind(int indeks, const std::string& teks) {
        api_->bind_text(stmt_, indeks, teks.c_str(), -1, SQLITE_TRANSIENT);
    }
    bool step() { return api_->step(stmt_) == SQLITE_ROW; }  // true = ada baris
    int columnInt(int kolom) const { return api_->column_int(stmt_, kolom); }
    std::string columnText(int kolom) const {
        return reinterpret_cast<const char*>(api_->column_text(stmt_, kolom));
    }

private:
    const Api* api_;
    sqlite3_stmt* stmt_ = nullptr;
};

int main() {
    Api api = muatSqlite();
    Database db(api, ":memory:");  // database di RAM, hilang saat program usai

    {   // DDL: jalankan sekali sampai SQLITE_DONE; tidak menghasilkan baris
        Statement ddl(db, "CREATE TABLE kota (id INTEGER PRIMARY KEY, nama TEXT, populasi INTEGER)");
        ddl.step();
    }

    const char* sqlInsert = "INSERT INTO kota (nama, populasi) VALUES (?, ?)";
    const char* nama[] = {"Jakarta", "Bandung", "Surabaya"};
    const int populasi[] = {10600000, 2500000, 2900000};
    for (int i = 0; i < 3; ++i) {
        Statement ins(db, sqlInsert);
        ins.bind(1, std::string(nama[i]));
        ins.bind(2, populasi[i]);
        ins.step();
    }
    std::printf("3 baris dimasukkan ke tabel kota\n\n");

    Statement query(db, "SELECT id, nama, populasi FROM kota ORDER BY id");
    std::printf("id | nama     | populasi\n");
    while (query.step()) {
        std::printf("%d | %-8s | %d\n", query.columnInt(0),
                    query.columnText(1).c_str(), query.columnInt(2));
    }
    return 0;
}

Output yang Dihasilkan:

text
3 baris dimasukkan ke tabel kota

id | nama     | populasi
1 | Jakarta  | 10600000
2 | Bandung  | 2500000
3 | Surabaya | 2900000

Bedah bagian pentingnya:

  1. Baris 12-24 (struct Api) — setiap pointer fungsi mengikuti tipe persis dari sqlite3.h lewat decltype(&sqlite3_...), sehingga salah tanda tangan tertangkap compiler, bukan saat runtime.
  2. Baris 27-41 (muatSqlite) — dlopen memuat library, dlsym mengambil alamat tiap fungsi; pada proyek biasa tahap ini digantikan oleh -lsqlite3 saat link.
  3. Baris 44-65 (class Database) — RAII untuk koneksi: constructor membuka, destructor memanggil sqlite3_close; copy di-delete karena menyalin handle berarti dua destructor untuk satu koneksi (double close).
  4. Baris 68-104 (class Statement) — RAII untuk prepared statement: prepare di constructor, finalize di destructor; dua overload bind menyembunyikan perbedaan sqlite3_bind_int dan sqlite3_bind_text.
  5. Baris 110-113 (Database db(api, ":memory:")) — path khusus :memory: membuat database di RAM: cepat, tanpa file sisa, ideal untuk contoh dan unit test.
  6. Baris 131-137 (loop insert) — satu prepared statement dipersiapkan per baris di contoh ini; pada data besar, siapkan sekali lalu panggil sqlite3_reset di antara baris agar kompilasi SQL tidak diulang.
  7. Baris 141-147 (loop query) — step() membungkus state machine: true berarti SQLITE_ROW dan kolom boleh dibaca; SQLITE_TRANSIENT pada bind_text memberi tahu SQLite untuk menyalin string kita segera, sehingga aman meski buffer aslinya berubah.

Dengan wrapper ini, program C++ Anda punya penyimpanan relasional penuh tanpa operasi apa pun di luar prosesnya sendiri — data di tangan, tanpa server. Namun tidak semua data hidup di mesin sendiri; sering kali program justru harus berbicara dengan dunia luar, dan itulah topik Chapter berikutnya: socket dan jaringan.

Referensi: An Introduction To The SQLite C/C++ Interface , SQLite Documentation .