Memodelkan banyak-ke-banyak

Membangun relasi banyak-ke-banyak dengan pola array of ObjectId dan dua arah query-nya.

Daftar isi

Relasi banyak-ke-banyak muncul ketika entitas pada dua koleksi saling berkaitan tanpa arah tunggal, misalnya mahasiswa yang mengambil banyak mata kuliah dan mata kuliah yang diambil banyak mahasiswa. Pola yang lazim pada MongoDB adalah menyimpan array of ObjectId pada satu sisi saja, kemudian membaca relasinya ke dua arah, yaitu membaca array untuk arah maju dan mencocokkan elemen array untuk arah balik. Topik ini membangun relasi tersebut pada basis data kampus, membahas query dua arahnya, indeks multikey yang dibutuhkan, serta pembaruan keanggotaannya.

Menyimpan keanggotaan sebagai array of ObjectId#

Keanggotaan disimpan pada sisi yang daftar relasinya terbatas, yaitu dokumen mahasiswa memuat daftar mata kuliah yang diambil. Jumlah mata kuliah yang diambil satu mahasiswa terbatas oleh aturan akademik, sehingga array pada dokumen mahasiswa tidak akan tumbuh tanpa kendali. Sebaliknya, jumlah peserta satu mata kuliah bisa ratusan orang dan tidak layak ditanam pada dokumen mata kuliah.

mongosh
use kampus
text Keluaran
switched to db kampus
mongosh
db.mata_kuliah.insertMany([
  { _id: "TSA201", nama: "Statistika Terapan", sks: 3 },
  { _id: "TSA301", nama: "Analisis Regresi", sks: 3 },
  { _id: "PST101", nama: "Pengantar Sains Data", sks: 2 }
])
text Keluaran
{
  acknowledged: true,
  insertedIds: { '0': 'TSA201', '1': 'TSA301', '2': 'PST101' }
}
mongosh
db.mahasiswa.insertMany([
  { _id: "10115001", nama: "Budi Santoso", mataKuliahId: [ "TSA201", "TSA301" ] },
  { _id: "10115002", nama: "Citra Melati", mataKuliahId: [ "TSA201", "PST101" ] },
  { _id: "10115003", nama: "Dewi Lestari", mataKuliahId: [ "PST101" ] },
  { _id: "10115004", nama: "Eka Pratama", mataKuliahId: [ "TSA301" ] }
])
text Keluaran
{
  acknowledged: true,
  insertedIds: {
    '0': '10115001',
    '1': '10115002',
    '2': '10115003',
    '3': '10115004'
  }
}

Nilai _id mata kuliah diisi kode mata kuliah agar array keanggotaannya mudah dibaca, dan ObjectId menggantikan kode tersebut tanpa mengubah polanya. Tidak ada salinan daftar peserta pada dokumen mata kuliah, sehingga keanggotaan hanya disimpan di satu tempat dan setiap perubahan cukup ditulis sekali. Sisi penyimpan array dipilih berdasarkan pertumbuhan per dokumen, yaitu sisi yang jumlah relasinya paling terkendali. Mahasiswa yang mengambil sepuluh mata kuliah masih normal, sedangkan mata kuliah yang menampung ribuan peserta tidak.

Setiap koleksi tetap menyimpan field milik entitasnya sendiri tanpa terpengaruh relasi. Dokumen mata kuliah tetap membawa sks dan nama lengkapnya, sedangkan dokumen mahasiswa tetap membawa nama. Relasi hanya menambah satu field bertipe array pada sisi yang menyimpan keanggotaan, dan field lainnya tetap dapat diindeks serta diquery sebagaimana koleksi biasa.

Query arah maju ke koleksi mata kuliah#

Arah maju menjawab pertanyaan mata kuliah apa saja yang diambil seorang mahasiswa. Array pada dokumen mahasiswa dibaca terlebih dahulu, kemudian diterjemahkan menjadi satu query dengan operator $in ke koleksi mata_kuliah.

mongosh
const mhs = db.mahasiswa.findOne({ _id: "10115001" })
db.mata_kuliah.find({ _id: { $in: mhs.mataKuliahId } }, { _id: 1, nama: 1 })
text Keluaran
[
  { _id: 'TSA201', nama: 'Statistika Terapan' },
  { _id: 'TSA301', nama: 'Analisis Regresi' }
]

Arah maju tidak membutuhkan pencocokan terhadap seluruh koleksi mahasiswa, karena daftarnya sudah tersimpan pada dokumen yang bersangkutan. Biaya arah maju berupa satu pembacaan dokumen mahasiswa dan satu query berindeks pada _id koleksi mata_kuliah, sehingga query ini tetap murah pada koleksi yang besar.

Proyeksi pada query menjaga hasilnya ringkas, tetapi bentuk query-nya tidak berubah apabila proyeksi dilepas. Apabila hanya daftar kodenya yang dibutuhkan, field mataKuliahId juga dapat dibaca langsung dari dokumen mahasiswa tanpa query kedua. Arah maju inilah yang paling murah pada pola ini, dan halaman kartu rencana studi satu mahasiswa biasanya cukup dengan satu pembacaan dokumen mahasiswa diikuti satu query $in. Query $in yang sama juga berlaku apabila keanggotaannya berupa array ObjectId, sehingga pola arah maju tidak terikat pada pengenal natural, yaitu pengenal yang bermakna bagi manusia seperti kode TSA201.

Query arah balik ke koleksi mahasiswa#

Arah balik menjawab pertanyaan siapa saja peserta suatu mata kuliah. Query ini tidak membaca array secara eksplisit, melainkan mencocokkan nilai tunggal terhadap field array, dan MongoDB menyatakan cocok apabila salah satu elemen array sama dengan syaratnya.

mongosh
db.mahasiswa.find({ mataKuliahId: "TSA201" }, { _id: 1, nama: 1 })
text Keluaran
[
  { _id: '10115001', nama: 'Budi Santoso' },
  { _id: '10115002', nama: 'Citra Melati' }
]

Query mataKuliahId: "TSA201" mengembalikan seluruh mahasiswa yang array keanggotaannya memuat kode tersebut. Bentuk pencocokan elemen array semacam ini sama dengan query array biasa, sehingga arah balik tidak memerlukan struktur tambahan apa pun pada schema. Pencocokan ini juga bekerja dengan baik pada array pengenal natural maupun array ObjectId, sehingga penggantian pengenal tidak mengubah pola querynya.

Pencocokan elemen array dapat dikombinasikan dengan syarat lain pada dokumen yang sama, misalnya syarat pada field nama untuk menyaring pesertanya. Kombinasi semacam itu tetap berupa satu query, dan indeks multikey yang sama tetap dimanfaatkan selama rencana eksekusi yang dipilih masih mencakup syarat pada array.

Indeks pada field array disebut indeks multikey (multikey index) karena setiap elemen array diindeks sebagai kunci tersendiri. Indeks ini yang membuat arah balik tetap murah pada koleksi besar.

mongosh
db.mahasiswa.createIndex({ mataKuliahId: 1 })
text Keluaran
mataKuliahId_1

Memperbarui keanggotaan dan menggabungkan data#

Keanggotaan baru ditambahkan dengan $push dan keanggotaan yang dihapus ditarik dengan $pull, keduanya pada satu dokumen mahasiswa saja.

mongosh
db.mahasiswa.updateOne({ _id: "10115003" }, { $push: { mataKuliahId: "TSA301" } })
db.mahasiswa.find({ mataKuliahId: "TSA301" }, { _id: 1, nama: 1 })
text Keluaran
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
[
  { _id: '10115001', nama: 'Budi Santoso' },
  { _id: '10115003', nama: 'Dewi Lestari' },
  { _id: '10115004', nama: 'Eka Pratama' }
]
mongosh
db.mahasiswa.updateOne({ _id: "10115004" }, { $pull: { mataKuliahId: "TSA301" } })
db.mahasiswa.find({ mataKuliahId: "TSA301" }, { _id: 1, nama: 1 })
text Keluaran
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
[
  { _id: '10115001', nama: 'Budi Santoso' },
  { _id: '10115003', nama: 'Dewi Lestari' }
]

Setiap perubahan keanggotaan berupa satu operasi pada satu dokumen, sehingga tidak ada pembaruan ganda yang harus dijaga sinkron. Query pembanding setelah setiap $push dan $pull menunjukkan daftar peserta yang bergeser, sehingga efek perubahannya dapat diverifikasi tanpa membaca dokumen satu per satu. Apabila data mata kuliah dibutuhkan utuh bersama daftar keanggotaannya, tahap $lookup mengubah array rujukan menjadi daftar dokumen lengkap di dalam server.

mongosh
db.mahasiswa.aggregate([
  { $match: { _id: "10115001" } },
  { $lookup: {
    from: "mata_kuliah",
    localField: "mataKuliahId",
    foreignField: "_id",
    as: "mataKuliah"
  } },
  { $project: { _id: 0, nama: 1, "mataKuliah.nama": 1 } }
])
text Keluaran
[
  {
    nama: 'Budi Santoso',
    mataKuliah: [ { nama: 'Statistika Terapan' }, { nama: 'Analisis Regresi' } ]
  }
]

Pipeline tersebut membaca array mataKuliahId milik Budi Santoso, mengambil dokumen mata kuliah yang cocok, lalu menggabungkannya sebagai field mataKuliah. Hasilnya sudah berupa daftar mata kuliah lengkap tanpa langkah kedua di sisi aplikasi.

Kesalahan umum#

Kesalahan pertama adalah menyalin daftar keanggotaan ke kedua sisi dengan niat mempercepat query. Dokumen mata kuliah yang memuat array mahasiswaId berdampingan dengan array mataKuliahId pada mahasiswa menimbulkan dua sumber kebenaran, dan setiap penambahan peserta menjadi dua pembaruan yang bisa berjalan tidak sinkron. Salah satu array pun cepat usang, sehingga query dua arah dapat menghasilkan jawaban yang berbeda tanpa pesan error apa pun.

Kesalahan kedua adalah meninggalkan arah balik tanpa indeks. Query mataKuliahId: "TSA201" tanpa indeks multikey menyapu seluruh koleksi mahasiswa, dan biayanya muncul justru ketika koleksi sudah besar sehingga sulit dilacak. Buat indeksnya pada saat pola query diperkenalkan. Perlu diingat pula bahwa setiap elemen array menambah satu kunci pada indeks, sehingga array yang panjang membuat indeksnya ikut membesar.

Kesalahan ketiga adalah menerapkan pola ini pada relasi yang sisi arraynya tidak terbatas. Riwayat kehadiran per mahasiswa yang terus bertambah setiap pertemuan tidak layak menjadi elemen array pada dokumen mahasiswa. Data seperti itu dipindahkan ke koleksi sendiri yang setiap dokumennya memuat mahasiswaId, sehingga pertumbuhannya berupa jumlah dokumen, bukan ukuran satu dokumen.

Ringkasan#

  • Keanggotaan banyak-ke-banyak disimpan sebagai array of ObjectId pada satu sisi dengan cardinality yang terkendali.
  • Arah maju membaca array lalu menerjemahkannya dengan $in, sedangkan arah balik mencocokkan elemen array dengan syarat nilai tunggal.
  • Indeks pada field array bersifat multikey dan menjadi syarat murahnya query arah balik pada koleksi besar.
  • $push dan $pull memperbarui keanggotaan pada satu dokumen, dan $lookup mengubah array rujukan menjadi daftar dokumen utuh di dalam server.

Referensi#