Skip to content

Satu Tabel untuk Banyak Tipe Organisasi

Adityo Guni Waluyo

Satu tabel organisasi dengan kolom nullable per tipe lebih murah dirawat daripada satu tabel per tipe, asal tiga konsekuensinya diterima sadar.

Ringkasan

Saya membuat portal layanan publik. Tabel organizations memuat semua kolom yang dimiliki tipe mana pun. Saya pilih satu tabel generik dengan kolom tambahan nullable.

Saya masih ingat deploy yang memaksa keputusan itu diambil. Portal layanan publik yang saya kerjakan sudah punya beberapa jenis lembaga, dan malam itu datang satu jenis baru yang butuh kolom sendiri: masa jabatan pengurus dan nomor urut tampilan. Kolomnya belum ada di tabel mana pun, dan pilihan yang tersedia hanya dua, membuat tabel baru untuk tipe itu atau melebarkan tabel yang sudah ada.

Tebakan pertama saya adalah satu tabel per tipe. Ini tampak rapi di awal: setiap tipe punya kolomnya sendiri, tidak ada nilai kosong. Yang saya tidak perhitungkan adalah daftar lembaga hampir selalu dibutuhkan sebagai satu list, dan begitu tipe bertambah, query list berubah menjadi rangkaian UNION atau join bertingkat. Satu halaman daftar yang tadinya satu query jadi lima.

Skema Satu Tabel untuk Semua Tipe

Keputusan akhirnya jatuh ke satu tabel generik. Tabel organizations memuat semua kolom yang dimiliki tipe mana pun, dan kolom yang hanya berlaku untuk sebagian tipe dibiarkan nullable:

CREATE TABLE organizations (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  category_id BIGINT UNSIGNED NOT NULL,
  nama VARCHAR(200) NOT NULL,
  nama_panjang VARCHAR(500),
  deskripsi TEXT,
  kontak VARCHAR(100),
  alamat TEXT,
  masa_bakti VARCHAR(20),
  sk_habis TINYINT(1) NOT NULL DEFAULT 0,
  tipe_cabor VARCHAR(30),
  no_urut INT,
  status ENUM('AKTIF','NONAKTIF') NOT NULL DEFAULT 'AKTIF',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  CONSTRAINT fk_organizations_category FOREIGN KEY (category_id) REFERENCES categories(id)
);

Pola ini punya nama: Single Table Inheritance, satu tabel dengan kolom untuk seluruh field dari berbagai kelas, dan alasan utamanya memang jumlah join yang tetap kecil [4]. Karena tipe tidak lagi ditentukan nama tabel, tipe diikat lewat relasi ke tabel kategori, sementara status yang kosakatanya benar-benar tertutup cukup dipegang ENUM yang nilainya dipatok saat pembuatan tabel [3].

Anggota pengurus masuk ke tabel anak, bukan kolom JSON di baris organisasi:

CREATE TABLE organization_members (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  organization_id BIGINT UNSIGNED NOT NULL,
  jabatan VARCHAR(100) NOT NULL,
  nama VARCHAR(200) NOT NULL,
  urutan INT NOT NULL DEFAULT 0,
  CONSTRAINT fk_organization_members_org FOREIGN KEY (organization_id)
    REFERENCES organizations(id) ON DELETE CASCADE
);

Urutan migrasi turun juga harus diperhatikan: organization_members dihapus lebih dulu, baru organizations, karena arah referensinya tidak boleh dibalik.

Tiga Konsekuensi yang Harus Diterima Sadar

Validasi pindah ke aplikasi. Database hanya tahu kolom nullable; ia tidak tahu bahwa kolom masa jabatan tidak relevan untuk lembaga yang tidak punya kepengurusan berkala. Pemeriksaan per tipe menjadi tanggung jawab service, termasuk menolak kombinasi kolom yang tidak masuk akal untuk tipe tertentu.

Aksi referensial harus ditulis, bukan diasumsikan. Aksi referensial yang tidak ditulis eksplisit berarti NO ACTION, dan RESTRICT berperilaku sama di InnoDB, yaitu menolak penghapusan induk selama masih ada anak [1]. Tanpa ON DELETE CASCADE pada tabel anggota, tombol hapus organisasi akan gagal dengan error foreign key constraint, bukan membersihkan anggotanya. Satu detail yang sering bikin kaget: aksi CASCADE tidak mengaktifkan trigger, jadi logika apa pun yang dipasang di trigger tabel anak tidak akan berjalan pada hapus organisasi [1].

Seed harus idempoten. Migrasi yang menyiapkan daftar kategori awal saya tulis sebagai satu INSERT banyak baris dengan ON DUPLICATE KEY UPDATE, supaya deploy ulang, environment kedua, atau perbaikan manual tidak berhenti di duplicate key [2]. Nilai affected-rows-nya sekaligus jadi bukti cepat di log migrasi: satu berarti baris baru, dua berarti baris lama ikut diperbarui [2].

Dua hal yang saya rapikan setelah tabelnya jalan: penulisan organisasi beserta penggantian daftar anggotanya saya bungkus dalam satu transaksi, supaya organisasi tidak pernah tersimpan tanpa pengurus ketika salah satu insert anggota gagal. Lalu daftar publik difilter hanya status aktif, dan respons hapus serta update dibuat seragam 404 untuk id yang tidak ada. Keseragaman itu sejalan dengan sifat metode HTTP itu sendiri, PUT dan DELETE idempoten sehingga permintaan berulang atas id yang sama tidak seharusnya menghasilkan efek samping berbeda [5].

Aturan Memilih Bentuk Tabel

Aturan yang saya pakai sekarang sederhana: hitung dulu berapa tipe yang berbagi sekitar 80 persen kolom. Kalau mayoritas berbagi, satu tabel generik dengan kolom tambahan nullable adalah pilihan yang lebih murah untuk dirawat, terutama karena daftar gabungan tetap satu query [4]. Kalau tiap tipe hampir tidak punya kolom yang sama, tabel terpisah justru lebih jujur.

Syarat kedua: kolom nullable hanya boleh dipertahankan selama kosakata tipe masih tumbuh. Begitu daftar tipe berhenti bertambah dan satu kolom hanya terpakai oleh satu tipe, itu tanda kolom tersebut layak dipindah ke tabelnya sendiri, bukan dibiarkan menggantung di tabel bersama selamanya.

Sumber:

  1. MySQL 8.4 Reference Manual: FOREIGN KEY Constraints
  2. MySQL 8.4 Reference Manual: INSERT ... ON DUPLICATE KEY UPDATE
  3. MySQL 8.4 Reference Manual: ENUM Type
  4. Martin Fowler: Single Table Inheritance
  5. RFC 9110: HTTP Semantics

Artikel terkait