Skema database hampir tidak pernah selesai pada hari pertama: kolom baru dibutuhkan, constraint berubah, dan desain awal yang terbukti keliru harus diperbaiki tanpa membuang data. Masalahnya, ALTER TABLE pada SQLite hanya mampu sebagian kecil perubahan — dan menyunting berkas database lewat editor teks akan merusaknya. Bab ini membangun pola migrasi bernomor yang tercatat rapi, lalu menerapkan resep resmi SQLite untuk perubahan yang tidak bisa dilakukan ALTER TABLE.
Batas ALTER TABLE #
Sebagai pengingat materi chapter Mengubah Struktur Tabel, kemampuan ALTER TABLE di SQLite ringkasnya begini:
| Perubahan | ALTER TABLE |
|---|---|
| Menambah kolom baru | bisa — ADD COLUMN |
| Mengganti nama tabel atau kolom | bisa — RENAME |
| Menghapus kolom | bisa — DROP COLUMN (sejak 3.35) |
| Mengubah tipe kolom | tidak bisa |
| Menambah atau menghapus constraint | tidak bisa |
| Mengubah urutan kolom | tidak bisa |
Perubahan di bawah garis “tidak bisa” memerlukan pembangunan ulang tabel — dan agar itu dilakukan dengan aman serta terdokumentasi, kita butuh sistem, bukan improvisasi.
Pola Migrasi Bernomor #
Idenya sederhana: setiap perubahan skema adalah satu berkas .sql bernomor urut, dijalankan sekali saja, dan tercatat di tabel khusus schema_migrations. Kita buat dua berkas pertama untuk database toko:
-- migrasi/001_create_toko.sql
CREATE TABLE pelanggan (
id INTEGER PRIMARY KEY,
nama TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE pesanan (
id INTEGER PRIMARY KEY,
pelanggan_id INTEGER NOT NULL REFERENCES pelanggan(id),
total INTEGER NOT NULL CHECK (total >= 0)
);-- migrasi/002_add_kolom_kota.sql
ALTER TABLE pelanggan ADD COLUMN kota TEXT;Lalu runner-nya, migrasi.sh, yang membaca berkas secara urut dan mencatat yang sudah diterapkan:
#!/bin/bash
DB=toko.db
SQLITE=sqlite3
$SQLITE $DB "CREATE TABLE IF NOT EXISTS schema_migrations (
nama TEXT PRIMARY KEY,
dijalankan_pada TEXT NOT NULL
);"
for berkas in migrasi/*.sql; do
nama=$(basename "$berkas")
sudah=$($SQLITE $DB "SELECT count(*) FROM schema_migrations WHERE nama = '$nama';")
if [ "$sudah" = "0" ]; then
$SQLITE $DB < "$berkas" &&
$SQLITE $DB "INSERT INTO schema_migrations (nama, dijalankan_pada)
VALUES ('$nama', strftime('%Y-%m-%d %H:%M','now'));"
echo "diterapkan: $nama"
else
echo "dilewati : $nama"
fi
doneMari kita bedah inti runner-nya:
CREATE TABLE IF NOT EXISTS schema_migrations: tabel catatan dibuat sekali;IF NOT EXISTSmembuat perintah ini aman diulang.for berkas in migrasi/*.sql: glob shell mengurutkan berkas menaik secara alfabetis — nomor tiga digit menjaga010tetap setelah009.sudah=$(... count(*) ...): pemeriksaan apakah nama berkas sudah tercatat; hasilnya string0atau1.$SQLITE $DB < "$berkas" &&: berkas migrasi dieksekusi;&&memastikan pencatatan hanya terjadi bila eksekusi berhasil.
Jalankan dua kali berturut-turut:
./migrasi.shOutput yang Dihasilkan:
diterapkan: 001_create_toko.sql
diterapkan: 002_add_kolom_kota.sqldilewati : 001_create_toko.sql
dilewati : 002_add_kolom_kota.sqlEksekusi kedua tidak mengulang apa pun — itulah idempotensi yang dibutuhkan agar runner aman dijalankan kapan saja. Riwayatnya bisa diperiksa langsung:
sqlite3 toko.db "SELECT nama FROM schema_migrations ORDER BY nama;"Output yang Dihasilkan:
001_create_toko.sql
002_add_kolom_kota.sqlMigrasi yang Gagal Harus Berhenti #
Tanpa pengawasan, sqlite3 CLI melanjutkan baris berikutnya meski ada yang error. Sebagai contoh, berkas 003_gagal_contoh.sql:
CREATE TABLE uji (a);
INSERT INTO tabel_tidak_ada VALUES (1);
CREATE TABLE tidak_tereksekusi (b);Eksekusi tanpa dan dengan --bail pada berkas uji uji-bail.db:
sqlite3 uji-bail.db < migrasi/003_gagal_contoh.sql; echo "exit: $?"
sqlite3 uji-bail.db "SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name;"Output yang Dihasilkan:
Parse error near line 2: no such table: tabel_tidak_ada
exit: 1
tidak_tereksekusi
ujirm uji-bail.db
sqlite3 --bail uji-bail.db < migrasi/003_gagal_contoh.sql; echo "exit: $?"
sqlite3 uji-bail.db "SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name;"Output yang Dihasilkan:
Parse error near line 2: no such table: tabel_tidak_ada
exit: 1
ujiTanpa --bail, tabel tidak_tereksekusi tetap dibuat setelah error — setengah migrasi masuk diam-diam, terlihat dari daftar tabel di sqlite_schema. Dengan --bail, eksekusi berhenti di baris error sehingga hanya uji yang sempat dibuat dan kerusakan mudah diaudit. Praktik terbaiknya menggabungkan keduanya: bungkus isi berkas migrasi dengan BEGIN TRANSACTION; … COMMIT; — transaksi yang belum di-commit otomatis di-rollback saat koneksi menutup — dan jalankan runner dengan --bail.
Resep Resmi untuk Perubahan Besar #
Untuk perubahan yang di luar jangkauan ALTER TABLE, dokumentasi SQLite (sqlite.org/lang_altertable) memberikan resep pembangunan ulang tabel yang sering disebut prosedur 12 langkah — kita rangkum intinya:
PRAGMA foreign_keys = OFF— dan ini harus di luar transaksi.BEGIN TRANSACTION.- Catat dulu bentuk semua index, trigger, dan view yang menempel pada tabel lama.
- Buat tabel baru dengan skema yang diinginkan.
- Salin data dari tabel lama ke tabel baru.
DROP TABLEtabel lama.ALTER TABLE ... RENAMEtabel baru ke nama lama.- Buat ulang index, trigger, dan view yang dicatat di langkah 3.
PRAGMA foreign_key_checkuntuk memastikan relasi tetap utuh.PRAGMA foreign_keys = ON.COMMIT.
Kita praktikkan untuk kasus klasik: kolom harga ternyata bertipe TEXT sejak awal proyek, dan harus menjadi INTEGER. Skema awalnya di berkas produk.db:
CREATE TABLE produk (
id INTEGER PRIMARY KEY,
nama TEXT NOT NULL,
harga TEXT NOT NULL
);
INSERT INTO produk (nama, harga) VALUES ('Buku Tulis', '3500'), ('Pensil 2B', '2500');
CREATE INDEX idx_produk_nama ON produk(nama);Berikut resepnya dijalankan utuh — perhatikan CAST di langkah penyalinan data:
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE produk_baru (
id INTEGER PRIMARY KEY,
nama TEXT NOT NULL,
harga INTEGER NOT NULL CHECK (harga >= 0)
);
INSERT INTO produk_baru (id, nama, harga)
SELECT id, nama, CAST(harga AS INTEGER) FROM produk;
DROP TABLE produk;
ALTER TABLE produk_baru RENAME TO produk;
CREATE INDEX idx_produk_nama ON produk(nama);
PRAGMA foreign_key_check;
PRAGMA foreign_keys = ON;
COMMIT;Tabel hasilnya kini menyimpan harga sebagai integer dengan CHECK yang mengikat:
sqlite3 produk.db "SELECT nama, harga, typeof(harga) FROM produk ORDER BY id;"
sqlite3 produk.db "PRAGMA integrity_check;"Output yang Dihasilkan:
Buku Tulis|3500|integer
Pensil 2B|2500|integerokSkema tersimpan juga berubah wujud — .schema produk menampilkan CREATE TABLE IF NOT EXISTS "produk" dengan nama tabel dalam tanda kutip: penanda bahwa teks CREATE TABLE pernah ditulis ulang oleh ALTER TABLE ... RENAME:
sqlite3 produk.db ".schema produk"Output yang Dihasilkan:
CREATE TABLE IF NOT EXISTS "produk" (
id INTEGER PRIMARY KEY,
nama TEXT NOT NULL,
harga INTEGER NOT NULL CHECK (harga >= 0)
);
CREATE INDEX idx_produk_nama ON produk(nama);Menguji Migrasi dan Larangan Menyentuh sqlite_schema #
Migrasi yang menyangkut tabel besar sebaiknya diuji dulu di salinan: cp toko.db uji-migrasi.db, jalankan runner terhadap salinan, lalu PRAGMA integrity_check dan bandingkan jumlah baris tabel yang tersentuh. Baru setelah itu jalankan pada database asli.