Aplikasi keuangan dimulai dari satu pertanyaan: data apa yang boleh masuk, dan data apa yang harus ditolak? Tanggal 15-06-2025, jenis transfer, atau jumlah -5000 semuanya bisa menempel di variabel Python tanpa komplain — dan begitu tersimpan, laporan kita menjadi bohong. Pada bagian ini kita membangun dua fondasi yang dipakai seluruh bagian berikutnya: satu tabel transaksi yang menolak data tidak sah lewat CHECK constraint, serta satu modul db.py yang menjadi satu-satunya pintu masuk ke database.

Bentuk Data Transaksi #

Satu transaksi — entah pemasukan atau pengeluaran — kita wakili dengan satu baris pada satu tabel. Berikut aturan tiap kolomnya:

KolomTipeAturan
idINTEGERprimary key, bernilai otomatis
tanggalTEXTwajib, harus format ISO YYYY-MM-DD
jenisTEXTwajib, hanya 'masuk' atau 'keluar'
kategoriTEXTwajib, mis. Gaji, Makan
jumlahREALwajib, harus lebih besar dari nol
catatanTEXTopsional, otomatis string kosong

Pilihan satu tabel untuk dua jenis arus uang disengaja: pemasukan dan pengeluaran memiliki bentuk yang persis sama, hanya berbeda arah. Kolom jenis memilah keduanya, dan query pada Bagian 3 tinggal memfilter kolom tersebut. Skala data pribadi juga tidak menuntut tabel kategori terpisah — teks bebas sudah cukup, sementara desain multi-tabel yang dinormalisasi dibahas pada project chapter Proyek: Katalog Perpustakaan. Berikut isi schema.sql:

sql
-- Skema Pencatat Keuangan Pribadi.
-- Satu tabel transaksi menampung pemasukan dan pengeluaran sekaligus.

CREATE TABLE IF NOT EXISTS transaksi (
    id       INTEGER PRIMARY KEY,
    tanggal  TEXT NOT NULL CHECK (tanggal IS date(tanggal)),
    jenis    TEXT NOT NULL CHECK (jenis IN ('masuk', 'keluar')),
    kategori TEXT NOT NULL,
    jumlah   REAL NOT NULL CHECK (jumlah > 0),
    catatan  TEXT NOT NULL DEFAULT ''
);

Mari kita bedah aturan terpentingnya satu per satu:

  1. Baris tanggal TEXT NOT NULL CHECK (tanggal IS date(tanggal)): tanggal disimpan sebagai teks ISO YYYY-MM-DD — format yang juga dipakai SQLite untuk fungsi tanggalnya, seperti dibahas pada Chapter Bekerja dengan Tanggal dan Waktu. Constraint memakai operator IS, bukan =, karena date() mengembalikan NULL untuk teks yang bukan tanggal sah. Perbandingan '15-06-2025' = NULL menghasilkan NULL, dan CHECK hanya menolak baris saat hasilnya false — NULL justru lolos! Operator IS membandingkan secara null-safe: '15-06-2025' IS NULL bernilai false sehingga baris ditolak.
  2. Baris jenis TEXT NOT NULL CHECK (jenis IN ('masuk', 'keluar')): daftar nilai sah dinyatakan di satu tempat. Nilai seperti 'transfer' atau 'pemasukan' ditolak sebelum sempat tersimpan.
  3. Baris jumlah REAL NOT NULL CHECK (jumlah > 0): jumlah selalu positif — arah uang sudah diwakili jenis, jadi tanda minus tidak punya arti sah.
  4. Baris catatan TEXT NOT NULL DEFAULT '': kolom boleh tidak diisi saat INSERT; SQLite mengisinya dengan string kosong sehingga kode Python tidak perlu berurusan dengan NULL.
  5. CREATE TABLE IF NOT EXISTS: skema aman dijalankan berulang — program memanggilnya setiap mulai, dan pemanggilan kedua tidak menimbulkan error.

Modul db.py #

Proyek ini menerapkan pola dari Chapter Pola Akses Data: seluruh pembukaan koneksi dan eksekusi SQL dikumpulkan di satu modul, sehingga program utama tidak pernah menyentuh SQL. Berikut isi lengkap db.py untuk tahap ini:

python
"""Modul akses data Pencatat Keuangan Pribadi.

Satu-satunya tempat yang membuka koneksi database dan menjalankan SQL;
modul lain cukup memanggil fungsi-fungsi di bawah.
"""

import sqlite3
from contextlib import closing
from pathlib import Path

NAMA_DB = "keuangan.db"
BERKAS_SKEMA = Path(__file__).with_name("schema.sql")


def get_connection(path=NAMA_DB):
    """Buka koneksi baru dengan pengaturan seragam untuk seluruh aplikasi."""
    con = sqlite3.connect(path)
    con.row_factory = sqlite3.Row
    con.execute("PRAGMA foreign_keys = ON")
    con.execute("PRAGMA busy_timeout = 5000")
    return con


def init_db(path=NAMA_DB):
    """Jalankan schema.sql; aman dipanggil berulang karena memakai IF NOT EXISTS."""
    skema = BERKAS_SKEMA.read_text(encoding="utf-8")
    with closing(get_connection(path)) as con:
        con.executescript(skema)

Mari kita bedah perannya satu per satu:

  1. NAMA_DB = "keuangan.db": nama berkas database hidup di satu konstanta. Seluruh aplikasi — dan nanti test pada Bagian 4 — merujuk ke satu tempat ini.
  2. Fungsi get_connection(path=NAMA_DB): setiap koneksi baru otomatis mendapat sqlite3.Row sebagai row_factory (Chapter Row Factory) sehingga kolom bisa diakses lewat nama, plus dua PRAGMA per koneksi: foreign_keys = ON dan busy_timeout = 5000. Parameter path punya nilai bawaan keuangan.db — test nanti mengirimkan berkas sementaranya sendiri tanpa mengubah kode produksi.
  3. BERKAS_SKEMA = Path(__file__).with_name("schema.sql"): modul mencari schema.sql di folder yang sama dengan dirinya sendiri, jadi lokasi folder kerja tidak memengaruhi pembacaan skema.
  4. Fungsi init_db(path): membaca teks skema lalu menjalankannya dengan executescript(). closing() dari contextlib memastikan koneksi tertutup setelahnya. Karena skema memakai IF NOT EXISTS, fungsi ini aman dipanggil setiap kali program dimulai.

Menguji Skema dan Koneksi #

Saatnya membuktikan bahwa skema benar-benar menolak data rusak. Dari folder yang berisi schema.sql dan db.py, buat database lalu periksa struktur tabelnya:

bash
python3 -c "import db; db.init_db(); print('Database keuangan.db siap.')"

Output yang Dihasilkan:

text
Database keuangan.db siap.

Strukturnya bisa diperiksa dari luar Python memakai sqlite3 CLI:

bash
/home/z/my-project/tools/sqlite3 keuangan.db ".schema transaksi"

Output yang Dihasilkan:

text
CREATE TABLE transaksi (
    id       INTEGER PRIMARY KEY,
    tanggal  TEXT NOT NULL CHECK (tanggal IS date(tanggal)),
    jenis    TEXT NOT NULL CHECK (jenis IN ('masuk', 'keluar')),
    kategori TEXT NOT NULL,
    jumlah   REAL NOT NULL CHECK (jumlah > 0),
    catatan  TEXT NOT NULL DEFAULT ''
);

Perhatikan bahwa .schema menampilkan CREATE TABLE transaksi tanpa IF NOT EXISTS — SQLite menyimpan pernyataan aslinya dalam sqlite_master dalam bentuk yang sudah dinormalisasi. Sekarang kita ajukan tiga baris sampah plus satu baris sah:

bash
python3 -c "
import sqlite3
import db

con = db.get_connection('keuangan.db')
uji = [
    ('15-06-2025', 'masuk', 'Gaji', 5500000),
    ('2025-06-15', 'transfer', 'Lain-lain', 10000),
    ('2025-06-15', 'keluar', 'Makan', -5000),
]
for baris in uji:
    try:
        con.execute('INSERT INTO transaksi (tanggal, jenis, kategori, jumlah) VALUES (?, ?, ?, ?)', baris)
    except sqlite3.IntegrityError as e:
        print('Ditolak:', e)
con.execute(\"INSERT INTO transaksi (tanggal, jenis, kategori, jumlah, catatan) VALUES ('2025-06-01', 'masuk', 'Gaji', 5500000, 'Gaji bulanan')\")
print('Jumlah baris:', con.execute('SELECT COUNT(*) FROM transaksi').fetchone()[0])
con.close()
"

Output yang Dihasilkan:

text
Ditolak: CHECK constraint failed: tanggal IS date(tanggal)
Ditolak: CHECK constraint failed: jenis IN ('masuk', 'keluar')
Ditolak: CHECK constraint failed: jumlah > 0
Jumlah baris: 1

Ketiga baris rusak ditolak dengan pesan yang menyebut constraint yang dilanggar, sementara baris keempat yang sah tersimpan — kolom catatan yang tidak diisi otomatis menjadi string kosong berkat DEFAULT ''. Karena baris uji tersebut tidak kita butuhkan, hapus berkas ini dulu sebelum melanjutkan: rm keuangan.db — Bagian 2 akan membuatnya kembali lewat program utama.

Fondasi kini berdiri: tabel yang menolak data sampah dan satu modul koneksi yang siap dipakai. Seluruh berkas tahap ini tersedia pada bagian Berkas Sumber di bawah halaman. Bagian 2 menambahkan program utamanya: menu interaktif yang mencatat transaksi lewat fungsi-fungsi db.py.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

db.py

Unduh berkas
python
"""Modul akses data Pencatat Keuangan Pribadi.

Satu-satunya tempat yang membuka koneksi database dan menjalankan SQL;
modul lain cukup memanggil fungsi-fungsi di bawah.
"""

import sqlite3
from contextlib import closing
from pathlib import Path

NAMA_DB = "keuangan.db"
BERKAS_SKEMA = Path(__file__).with_name("schema.sql")


def get_connection(path=NAMA_DB):
    """Buka koneksi baru dengan pengaturan seragam untuk seluruh aplikasi."""
    con = sqlite3.connect(path)
    con.row_factory = sqlite3.Row
    con.execute("PRAGMA foreign_keys = ON")
    con.execute("PRAGMA busy_timeout = 5000")
    return con


def init_db(path=NAMA_DB):
    """Jalankan schema.sql; aman dipanggil berulang karena memakai IF NOT EXISTS."""
    skema = BERKAS_SKEMA.read_text(encoding="utf-8")
    with closing(get_connection(path)) as con:
        con.executescript(skema)

schema.sql

Unduh berkas
sql
-- Skema Pencatat Keuangan Pribadi.
-- Satu tabel transaksi menampung pemasukan dan pengeluaran sekaligus.

CREATE TABLE IF NOT EXISTS transaksi (
    id       INTEGER PRIMARY KEY,
    tanggal  TEXT NOT NULL CHECK (tanggal IS date(tanggal)),
    jenis    TEXT NOT NULL CHECK (jenis IN ('masuk', 'keluar')),
    kategori TEXT NOT NULL,
    jumlah   REAL NOT NULL CHECK (jumlah > 0),
    catatan  TEXT NOT NULL DEFAULT ''
);