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:
| Kolom | Tipe | Aturan |
|---|---|---|
id | INTEGER | primary key, bernilai otomatis |
tanggal | TEXT | wajib, harus format ISO YYYY-MM-DD |
jenis | TEXT | wajib, hanya 'masuk' atau 'keluar' |
kategori | TEXT | wajib, mis. Gaji, Makan |
jumlah | REAL | wajib, harus lebih besar dari nol |
catatan | TEXT | opsional, 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:
-- 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:
- Baris
tanggal TEXT NOT NULL CHECK (tanggal IS date(tanggal)): tanggal disimpan sebagai teks ISOYYYY-MM-DD— format yang juga dipakai SQLite untuk fungsi tanggalnya, seperti dibahas pada Chapter Bekerja dengan Tanggal dan Waktu. Constraint memakai operatorIS, bukan=, karenadate()mengembalikanNULLuntuk teks yang bukan tanggal sah. Perbandingan'15-06-2025' = NULLmenghasilkanNULL, dan CHECK hanya menolak baris saat hasilnya false —NULLjustru lolos! OperatorISmembandingkan secara null-safe:'15-06-2025' IS NULLbernilai false sehingga baris ditolak. - 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. - Baris
jumlah REAL NOT NULL CHECK (jumlah > 0): jumlah selalu positif — arah uang sudah diwakilijenis, jadi tanda minus tidak punya arti sah. - Baris
catatan TEXT NOT NULL DEFAULT '': kolom boleh tidak diisi saatINSERT; SQLite mengisinya dengan string kosong sehingga kode Python tidak perlu berurusan denganNULL. 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:
"""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:
NAMA_DB = "keuangan.db": nama berkas database hidup di satu konstanta. Seluruh aplikasi — dan nanti test pada Bagian 4 — merujuk ke satu tempat ini.- Fungsi
get_connection(path=NAMA_DB): setiap koneksi baru otomatis mendapatsqlite3.Rowsebagairow_factory(Chapter Row Factory) sehingga kolom bisa diakses lewat nama, plus dua PRAGMA per koneksi:foreign_keys = ONdanbusy_timeout = 5000. Parameterpathpunya nilai bawaankeuangan.db— test nanti mengirimkan berkas sementaranya sendiri tanpa mengubah kode produksi. BERKAS_SKEMA = Path(__file__).with_name("schema.sql"): modul mencarischema.sqldi folder yang sama dengan dirinya sendiri, jadi lokasi folder kerja tidak memengaruhi pembacaan skema.- Fungsi
init_db(path): membaca teks skema lalu menjalankannya denganexecutescript().closing()daricontextlibmemastikan koneksi tertutup setelahnya. Karena skema memakaiIF 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:
python3 -c "import db; db.init_db(); print('Database keuangan.db siap.')"Output yang Dihasilkan:
Database keuangan.db siap.Strukturnya bisa diperiksa dari luar Python memakai sqlite3 CLI:
/home/z/my-project/tools/sqlite3 keuangan.db ".schema transaksi"Output yang Dihasilkan:
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:
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:
Ditolak: CHECK constraint failed: tanggal IS date(tanggal)
Ditolak: CHECK constraint failed: jenis IN ('masuk', 'keluar')
Ditolak: CHECK constraint failed: jumlah > 0
Jumlah baris: 1Ketiga 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"""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-- 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 ''
);