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:

PerubahanALTER TABLE
Menambah kolom barubisa — ADD COLUMN
Mengganti nama tabel atau kolombisa — RENAME
Menghapus kolombisa — DROP COLUMN (sejak 3.35)
Mengubah tipe kolomtidak bisa
Menambah atau menghapus constrainttidak bisa
Mengubah urutan kolomtidak 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:

sql
-- 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)
);
sql
-- 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:

bash
#!/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
done

Mari kita bedah inti runner-nya:

  1. CREATE TABLE IF NOT EXISTS schema_migrations: tabel catatan dibuat sekali; IF NOT EXISTS membuat perintah ini aman diulang.
  2. for berkas in migrasi/*.sql: glob shell mengurutkan berkas menaik secara alfabetis — nomor tiga digit menjaga 010 tetap setelah 009.
  3. sudah=$(... count(*) ...): pemeriksaan apakah nama berkas sudah tercatat; hasilnya string 0 atau 1.
  4. $SQLITE $DB < "$berkas" &&: berkas migrasi dieksekusi; && memastikan pencatatan hanya terjadi bila eksekusi berhasil.

Jalankan dua kali berturut-turut:

bash
./migrasi.sh

Output yang Dihasilkan:

text
diterapkan: 001_create_toko.sql
diterapkan: 002_add_kolom_kota.sql
text
dilewati  : 001_create_toko.sql
dilewati  : 002_add_kolom_kota.sql

Eksekusi kedua tidak mengulang apa pun — itulah idempotensi yang dibutuhkan agar runner aman dijalankan kapan saja. Riwayatnya bisa diperiksa langsung:

bash
sqlite3 toko.db "SELECT nama FROM schema_migrations ORDER BY nama;"

Output yang Dihasilkan:

text
001_create_toko.sql
002_add_kolom_kota.sql

Migrasi yang Gagal Harus Berhenti #

Tanpa pengawasan, sqlite3 CLI melanjutkan baris berikutnya meski ada yang error. Sebagai contoh, berkas 003_gagal_contoh.sql:

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:

bash
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:

text
Parse error near line 2: no such table: tabel_tidak_ada
exit: 1
tidak_tereksekusi
uji
bash
rm 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:

text
Parse error near line 2: no such table: tabel_tidak_ada
exit: 1
uji

Tanpa --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:

  1. PRAGMA foreign_keys = OFF — dan ini harus di luar transaksi.
  2. BEGIN TRANSACTION.
  3. Catat dulu bentuk semua index, trigger, dan view yang menempel pada tabel lama.
  4. Buat tabel baru dengan skema yang diinginkan.
  5. Salin data dari tabel lama ke tabel baru.
  6. DROP TABLE tabel lama.
  7. ALTER TABLE ... RENAME tabel baru ke nama lama.
  8. Buat ulang index, trigger, dan view yang dicatat di langkah 3.
  9. PRAGMA foreign_key_check untuk memastikan relasi tetap utuh.
  10. PRAGMA foreign_keys = ON.
  11. COMMIT.

Kita praktikkan untuk kasus klasik: kolom harga ternyata bertipe TEXT sejak awal proyek, dan harus menjadi INTEGER. Skema awalnya di berkas produk.db:

sql
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:

sql
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:

bash
sqlite3 produk.db "SELECT nama, harga, typeof(harga) FROM produk ORDER BY id;"
sqlite3 produk.db "PRAGMA integrity_check;"

Output yang Dihasilkan:

text
Buku Tulis|3500|integer
Pensil 2B|2500|integer
text
ok

Skema 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:

bash
sqlite3 produk.db ".schema produk"

Output yang Dihasilkan:

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