Skip to content

Validasi Database yang Tidak Menghukum Evolusi Nilai

Adityo Guni Waluyo

Tiga kolom enum-like hanya dijaga aplikasi. CHECK constraint memindahkan garansi ke database, dan evolusi nilai jadi operasi murah.

Ringkasan

Dulu validasi enum cuma di aplikasi, jadi gampang jebol kalau ada insert manual iseng. Akhirnya ditambah tiga CHECK constraint di database biar nilai ngaco langsung ketolak, nggak pake ENUM native karena ribet kalau mau nambah nilai baru. Untuk tabel kecil validasi langsung full, kalau udah gede pake trik NOT VALID dulu biar nggak ngunci tabel.

Kolom enum yang dijaga siapa pun

Migrasi 0004 di DemandScope berawal dari temuan yang gamblang: tiga kolom string yang berperilaku seperti enum tersebar di tiga tabel, dan tidak satu pun dijaga di tingkat database. Validasinya hidup sepenuhnya di lapisan aplikasi lewat StrEnum dan Pydantic. Selama semua penulis lewat aplikasi yang sama, itu cukup. Masalahnya, database bertahan lebih lama daripada aplikasi mana pun.

Dugaan awal saya, kedisiplinan di kode sudah memadai. Dugaan itu mengabaikan penulis liar: skrip ad-hoc untuk debug, sesi psql langsung, layanan mikro masa depan yang berbagi database yang sama. Satu INSERT sampah dari sesi manual cukup untuk merusak kolom status yang diasumsikan semua pembaca selalu valid. Validasi yang hanya hidup di aplikasi pada dasarnya adalah janji yang tidak ditegakkan oleh siapa pun.

Constraint generik untuk daftar nilai

Perbaikannya adalah tiga CHECK constraint (ck_signals_signal_type, ck_ingest_runs_status, ck_users_role) yang menegakkan keanggotaan daftar nilai langsung di database. PostgreSQL mendefinisikan CHECK sebagai jenis constraint paling generik: nilai kolom harus memenuhi ekspresi Boolean [1].

Verifikasinya tidak berhenti di skema. Siklus upgrade, downgrade, lalu upgrade lagi berjalan bersih; penyisipan sampah yang disengaja ditolak di ketiga kolom; 49 tes hijau; dan pengiriman live 20 kasus tetap steady-state, 20 kali unchanged dalam dua run berturut-turut:

def upgrade():
    # Tiny pilot tables: full validation is instant.
    # Past ~1M rows: switch to NOT VALID + separate VALIDATE.
    op.create_check_constraint(
        "ck_signals_signal_type", "signals",
        "signal_type IN ('vendor_blacklist', 'suit_bankruptcy_pkpu')",
    )

Mengapa bukan ENUM native

Pertanyaan wajarnya: mengapa tidak memakai tipe ENUM bawaan. Dokumentasi PostgreSQL menjawabnya langsung: tipe enum "primarily intended for static sets of values", dan penambahan nilai baru berjalan lewat ALTER TYPE [13]. Himpunan nilai di kolom status justru bukan himpunan statis; nilai baru lahir dari kebutuhan produk, bukan dari jadwal migrasi skema.

Dunia MySQL menunjukkan insting yang sama pecah dengan cara berbeda: MySQL 5.7 mengizinkan perubahan ENUM in-place hanya dengan menambah anggota di akhir daftar tanpa mengubah ukuran penyimpanan; sisipan di tengah menomori ulang anggota existing dan memaksa salinan tabel penuh (temuan dari riset migrasi terkait sebelumnya). Motivasinya identik di kedua ekosistem: kumpulan nilai berevolusi, dan skema tidak boleh menghukum evolusi dengan operasi yang mahal. Menambah nilai ke CHECK constraint hanyalah ADD CONSTRAINT biasa, tanpa rewrite tipe dan tanpa kunci panjang.

Urutan lapisnya juga disengaja. Pesan error dari Pydantic bisa menjelaskan ke pengguna bahwa nilai tidak dikenal; pesan dari constraint database memang lebih kasar, tetapi justru itu fungsinya: ia muncul di log sebagai anomali penulis, bukan sebagai kebingungan pengguna. Dua lapis itu menangkap dua jenis masalah yang berbeda, dan hanya satu yang pantas dilihat pengguna.

Contoh evolusi yang sudah diantisipasi: tipe sinyal baru akan lahir saat sumber baru masuk pilot. Dengan CHECK, penambahannya adalah satu statement constraint baru tanpa menyentuh definisi tipe global; tidak ada tabel referensi yang harus diperbarui, tidak ada downtime migrasi tipe. Biaya evolusi turun dari operasi skema menjadi operasi konfigurasi.

Migrasi yang tidak mengunci meja kerja

Untuk tabel pilot yang kecil, validasi penuh dilakukan sekaligus saat pembuatan constraint. Commit ini mendokumentasikan aturan jelas untuk nanti: begitu tabel melewati sekitar satu juta baris, beralih ke ADD CONSTRAINT ... NOT VALID. Perintah itu melewati pemindaian tabel yang berpotensi panjang dan langsung commit; tujuannya mengurangi dampak penambahan constraint pada update konkuren [12]. Constraint baru langsung berlaku untuk INSERT dan UPDATE yang masuk, sementara baris lama menyusul.

Validasi susulan lewat VALIDATE CONSTRAINT hanya mengambil kunci SHARE UPDATE EXCLUSIVE pada tabel yang diubah, sehingga baca-tulis biasa tetap berjalan sambil baris lama diperiksa. Aturan satu juta baris di atas adalah ketentuan operasional dari commit ini sendiri, bukan benchmark eksternal; yang penting bukan angkanya, melainkan strateginya sudah tertulis sebelum tabel sempat membesar.

Keputusan yang saya ambil: kolom dengan kosakata terbatas selalu mendapat guard di dua lapis. Lapisan aplikasi (StrEnum, Pydantic) tetap validator pertama karena pesannya paling manusiawi; constraint di database adalah garis terakhir yang menjaga saat semua validator pertama dilewati. Database yang dipakai bersama tidak boleh percaya pada janji yang tidak bisa ditegakkannya sendiri.

Sumber

  1. PostgreSQL Documentation: Constraints
  2. PostgreSQL Documentation: ALTER TABLE
  3. PostgreSQL Documentation: Enumerated Types

Artikel terkait