Migrasi Down-First: Kolom Aditif dan FK yang Ditunda
Merancang migrasi MySQL dari arah baliknya: kolom aditif, down sebagai cermin, dan FK yang sengaja ditunda.
Ringkasan
Nambah kolom di entities sama categories dibikin aditif aja biar migrasi aman dan rollback gampang. Foreign key type_id sengaja ditunda karena tabel masternya belum ada biar nggak trigger copy berat di MySQL. Kolom deleted_at dibikin TIMESTAMP NULL biar nggak kena magic timestamp dan down migration tinggal drop balik.
Saya sedang menatap layar, kursor berkedip di baris pertama file 000064_slice_b_entity_columns.up.sql. Ini untuk modul layanan KotaPortal, aplikasi yang menangani pariwisata dan ekonomi kreatif daerah. Di depan saya ada blok ALTER TABLE yang siap dieksekusi. Momen begini emang bikin deg-degan.
Dugaan awal saya dulu, migrasi database itu menakutkan karena kita ngubah data yang udah ada. Logikanya, kalo kita menambah kolom type_id, langkah paling teliti ya langsung pasang foreign key ke tabel facility_types sekarang juga. Toh, biar relational integrity-nya langsung aman, kan?
Ternyata nggak. Saya justru sengaja menunda foreign key tersebut. Komentar di dalam file migrasi itu dengan jelas menyatakan bahwa tabel master facility_types baru akan mendarat di slice berikutnya. Lebih dari itu, seluruh migrasi ini dirancang agar setiap pernyataannya murni aditif. File down migration-nya hanyalah cerminan terbalik yang menjatuhkan kolom dan indeks yang sama persis. Nggak ada penulisan ulang data di mana pun.
Kenapa Pendekatan Ini Masuk Akal
Pendekatan migrasi down first ini punya alasan teknis yang kuat. Di InnoDB MySQL 5.7, operasi "ADD COLUMN" berjalan in-place dan ngizinkan DML paralel [1]. Penambahan indeks juga demikian. Sebaliknya, menjatuhkan indeks hanya mengubah metadata. Namun, menjatuhkan kolom tetap membutuhkan rebuild tabel, dan menambahkan constraint foreign key hanya bisa in-place kalo foreign_key_checks dimatikan; jika tidak, database akan maksa operasi copy yang mahal [1].
Kalau semua perubahan di fase up sifatnya aditif, rollback di fase down tinggal membuang kolom dan indeks yang sama, tanpa data lama yang ikut berubah.
Ada detail kecil di kolom deleted_at pada tabel categories. Saya mendeklarasikannya sebagai TIMESTAMP NULL DEFAULT NULL. Ini bukan kebetulan. Praktik umumnya, jika atribut NULL nggak dinyatakan secara eksplisit dan explicit_defaults_for_timestamp dalam keadaan mati, memberikan nilai NULL ke kolom TIMESTAMP itu bisa menginisialisasinya ke timestamp saat ini [2]. Dengan mendeklarasikannya sebagai NULL, kolom ini bebas dari magic tersebut dan benar-benar berfungsi sebagai flag data biasa.
Pola ini sejalan dengan kontrak golang-migrate yang mewajibkan pasangan file up dan down per versi, di mana versi naik diterapkan secara menaik dan turun secara menurun [3]. Alat semacam ini menggantungkan urutan eksekusi pada pasangan file itu, jadi saya harus mikirin skenario kegagalan dari awal. Prinsip yang sama juga muncul dalam konvensi desain API: nilai default hanya boleh berada di field opsional, dan pembaca nggak boleh berasumsi sebuah field ada tanpa default dari sisi server [4]. Logika ini persis sama dengan kolom database yang kita tambahkan ke tabel yang sedang live. Kita nggak bisa maksa aplikasi buat langsung baca kolom baru tanpa ngasih nilai default yang aman, atau menjadikannya nullable.
-- up migration
ALTER TABLE entities
ADD COLUMN contact_person VARCHAR(100) NULL,
ADD COLUMN rating_enabled TINYINT(1) NOT NULL DEFAULT 1,
ADD COLUMN rental_enabled TINYINT(1) NOT NULL DEFAULT 0,
ADD COLUMN type_id BIGINT UNSIGNED NULL,
ADD KEY idx_type_id (type_id);
ALTER TABLE categories
ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL,
ADD KEY idx_deleted_at (deleted_at);
-- down migration (cerminan terbalik)
ALTER TABLE categories
DROP KEY idx_deleted_at,
DROP COLUMN deleted_at;
ALTER TABLE entities
DROP KEY idx_type_id,
DROP COLUMN type_id,
DROP COLUMN rental_enabled,
DROP COLUMN rating_enabled,
DROP COLUMN contact_person;
Saya memilih pendekatan ini karena ketenangan pikiran saat rollback jauh lebih berharga daripada kepuasan semu memiliki relasi database yang sempurna di awal. Kalo tabel master belum siap, memaksakan foreign key cuma akan mengundang operasi copy yang nggak perlu. Kadang, langkah paling aman ya dengan tidak melakukan apa-apa yang belum benar-benar diperlukan.
Sources: [1] https://dev.mysql.com/doc/refman/5.7/en/innodb-online-ddl-operations.html [2] https://dev.mysql.com/doc/refman/5.7/en/timestamp-initialization.html [3] https://github.com/golang-migrate/migrate/blob/master/MIGRATIONS.md [4] https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md