$lookup

Menggabungkan dokumen antarkoleksi dengan $lookup sebagai padanan JOIN di dalam pipeline.

Daftar isi

Tahap $lookup menggabungkan dokumen dari dua koleksi di dalam satu pipeline agregasi, sehingga data pendukung dari koleksi lain dapat disertakan pada hasil agregat. Mekanisme ini adalah padanan operasi JOIN pada basis data relasional, yaitu mencocokkan satu field pada koleksi asal dengan satu field pada koleksi tujuan. Topik ini membahas bentuk dasar $lookup, perapian hasil gabungan dengan $unwind, serta penghitungan dari sisi koleksi kedua melalui opsi let dan pipeline. Data contohnya berupa basis data pegawai dengan dua koleksi yang berelasi, yaitu koleksi departemen sebagai daftar induk dan koleksi pegawai yang merujuk kode departemen.

Menggabungkan dua koleksi dengan $lookup#

Tahap $lookup menerima empat kunci utama, yaitu from sebagai nama koleksi yang digabungkan, localField sebagai field pada dokumen masukan, foreignField sebagai field pencocokan pada koleksi tujuan, dan as sebagai nama field keluaran. Untuk setiap dokumen masukan, server mencari seluruh dokumen pada koleksi from yang nilai foreignField-nya sama dengan nilai localField, lalu menaruh hasil pencocokan berupa array pada field as. Array tersebut berisi salinan utuh dokumen koleksi tujuan, termasuk _id-nya. Apabila tidak ada dokumen yang cocok, array tetap dibuat tetapi berisi kosong, sehingga setiap dokumen masukan selalu menghasilkan tepat satu dokumen keluaran.

Jalankan mongosh, pilih basis data kerja, lalu isi kedua koleksi berikut. Koleksi departemen memuat empat departemen, sedangkan koleksi pegawai memuat enam pegawai yang merujuk kode departemen. Kode LEG sengaja tidak dirujuk oleh pegawai mana pun, sedangkan pegawai terakhir merujuk kode MKT yang belum terdaftar pada daftar induk. Dua keadaan tersebut dipakai untuk menguji perilaku $lookup apabila tidak ada kecocokan, baik dari arah pegawai maupun dari arah departemen.

mongosh
use pegawai
text Keluaran
switched to db pegawai
mongosh
db.departemen.insertMany([
  { kode: "IT", nama: "Teknologi Informasi", lokasi: "Gedung A" },
  { kode: "SDM", nama: "Sumber Daya Manusia", lokasi: "Gedung B" },
  { kode: "KEU", nama: "Keuangan", lokasi: "Gedung B" },
  { kode: "LEG", nama: "Hukum", lokasi: "Gedung C" }
])
text Keluaran
{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId('6ae898cb05423a4a468de666'),
    '1': ObjectId('6ae898cb05423a4a468de667'),
    '2': ObjectId('6ae898cb05423a4a468de668'),
    '3': ObjectId('6ae898cb05423a4a468de669')
  }
}
mongosh
db.pegawai.insertMany([
  { nama: "Ali Rahman", kodeDepartemen: "IT", gaji: 8000000 },
  { nama: "Budi Santoso", kodeDepartemen: "IT", gaji: 7000000 },
  { nama: "Citra Melati", kodeDepartemen: "SDM", gaji: 6500000 },
  { nama: "Dewi Lestari", kodeDepartemen: "SDM", gaji: 6000000 },
  { nama: "Eka Pratama", kodeDepartemen: "KEU", gaji: 7500000 },
  { nama: "Fitri Handayani", kodeDepartemen: "MKT", gaji: 5500000 }
])
text Keluaran
{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId('6ae898cb05423a4a468de66a'),
    '1': ObjectId('6ae898cb05423a4a468de66b'),
    '2': ObjectId('6ae898cb05423a4a468de66c'),
    '3': ObjectId('6ae898cb05423a4a468de66d'),
    '4': ObjectId('6ae898cb05423a4a468de66e'),
    '5': ObjectId('6ae898cb05423a4a468de66f')
  }
}

Pipeline berikut memperkaya setiap dokumen pegawai dengan data departemennya. Proyeksi bersarang "departemen.kode" dan "departemen.nama" dipakai agar keluarannya ringkas, sedangkan isi array aslinya adalah salinan lengkap dokumen departemen.

mongosh
db.pegawai.aggregate([
  { $lookup: {
      from: "departemen",
      localField: "kodeDepartemen",
      foreignField: "kode",
      as: "departemen"
  } },
  { $sort: { nama: 1 } },
  { $project: { _id: 0, nama: 1, kodeDepartemen: 1, "departemen.kode": 1, "departemen.nama": 1 } }
])
text Keluaran
[
  {
    nama: 'Ali Rahman',
    kodeDepartemen: 'IT',
    departemen: [ { kode: 'IT', nama: 'Teknologi Informasi' } ]
  },
  {
    nama: 'Budi Santoso',
    kodeDepartemen: 'IT',
    departemen: [ { kode: 'IT', nama: 'Teknologi Informasi' } ]
  },
  {
    nama: 'Citra Melati',
    kodeDepartemen: 'SDM',
    departemen: [ { kode: 'SDM', nama: 'Sumber Daya Manusia' } ]
  },
  {
    nama: 'Dewi Lestari',
    kodeDepartemen: 'SDM',
    departemen: [ { kode: 'SDM', nama: 'Sumber Daya Manusia' } ]
  },
  {
    nama: 'Eka Pratama',
    kodeDepartemen: 'KEU',
    departemen: [ { kode: 'KEU', nama: 'Keuangan' } ]
  },
  { nama: 'Fitri Handayani', kodeDepartemen: 'MKT', departemen: [] }
]

Enam dokumen masukan menghasilkan enam dokumen keluaran karena $lookup tidak menambah maupun mengurangi jumlah dokumen. Field departemen berupa array dan bukan subdokumen tunggal karena satu nilai localField dapat mencocokkan lebih dari satu dokumen tujuan. Ali Rahman hingga Eka Pratama memuat satu anggota array sesuai kode departemennya, sedangkan Fitri Handayani memuat array kosong karena kode MKT tidak ditemukan pada koleksi departemen. Array kosong semacam itu bukan error, melainkan cara $lookup menyatakan bahwa relasinya belum ada.

Merapikan hasil gabungan dengan $unwind#

Field keluaran $lookup berupa array, sedangkan banyak laporan membutuhkan satu field tunggal, misalnya nama departemen pada daftar pegawai. Bentuk $lookup yang menggabungkan localField, foreignField, dan pipeline menjawab kebutuhan tersebut. Tahap-tahap pada pipeline membentuk ulang dokumen koleksi tujuan yang telah dicocokkan, sehingga $project di dalamnya hanya memangkas field yang tidak diperlukan. Array hasilnya kemudian diubah menjadi subdokumen tunggal oleh $unwind, dan opsi preserveNullAndEmptyArrays menjaga pegawai tanpa departemen tetap muncul pada keluaran.

mongosh
db.pegawai.aggregate([
  { $lookup: {
      from: "departemen",
      localField: "kodeDepartemen",
      foreignField: "kode",
      pipeline: [ { $project: { _id: 0, nama: 1, lokasi: 1 } } ],
      as: "departemen"
  } },
  { $unwind: { path: "$departemen", preserveNullAndEmptyArrays: true } },
  { $project: { _id: 0, nama: 1, kodeDepartemen: 1, namaDepartemen: "$departemen.nama" } }
])
text Keluaran
[
  {
    nama: 'Ali Rahman',
    kodeDepartemen: 'IT',
    namaDepartemen: 'Teknologi Informasi'
  },
  {
    nama: 'Budi Santoso',
    kodeDepartemen: 'IT',
    namaDepartemen: 'Teknologi Informasi'
  },
  {
    nama: 'Citra Melati',
    kodeDepartemen: 'SDM',
    namaDepartemen: 'Sumber Daya Manusia'
  },
  {
    nama: 'Dewi Lestari',
    kodeDepartemen: 'SDM',
    namaDepartemen: 'Sumber Daya Manusia'
  },
  {
    nama: 'Eka Pratama',
    kodeDepartemen: 'KEU',
    namaDepartemen: 'Keuangan'
  },
  { nama: 'Fitri Handayani', kodeDepartemen: 'MKT' }
]

Keluarannya memuat field namaDepartemen untuk lima pegawai yang departemennya terdaftar. Field tersebut tidak ditulis sama sekali untuk Fitri Handayani karena field path $departemen.nama tidak dapat diurai apabila field departemen sudah tidak ada pada dokumen tersebut. Perilaku ini sejalan dengan sifat field pada MongoDB, yaitu field yang tidak ada diperlakukan berbeda dari field bernilai null. Apabila nilai pengganti dibutuhkan, bungkus field path tersebut dengan $ifNull agar null atau nilai bawaan tertentu selalu muncul.

Menghitung dari sisi koleksi lain#

Bentuk $lookup kedua bekerja dari sisi koleksi induk, yaitu departemen yang ingin dihitung jumlah pegawainya. Pada bentuk ini pencocokan ditulis sepenuhnya di dalam pipeline melalui variabel let dan operator $expr, karena nilai yang dicocokkan berasal dari dokumen induk yang sedang diproses. Variabel let menyimpan field koleksi induk, dan $$kode pada tahap $match membaca variabel tersebut untuk menyaring dokumen koleksi pegawai. Dua tanda dolar menandakan variabel, sedangkan satu tanda dolar tetap berarti field dokumen.

mongosh
db.departemen.aggregate([
  { $lookup: {
      from: "pegawai",
      let: { kode: "$kode" },
      pipeline: [
        { $match: { $expr: { $eq: ["$kodeDepartemen", "$$kode"] } } },
        { $count: "jumlah" }
      ],
      as: "ringkasan"
  } },
  { $unwind: { path: "$ringkasan", preserveNullAndEmptyArrays: true } },
  { $project: { _id: 0, kode: 1, jumlahPegawai: { $ifNull: ["$ringkasan.jumlah", 0] } } },
  { $sort: { kode: 1 } }
])
text Keluaran
[
  { kode: 'IT', jumlahPegawai: 2 },
  { kode: 'KEU', jumlahPegawai: 1 },
  { kode: 'LEG', jumlahPegawai: 0 },
  { kode: 'SDM', jumlahPegawai: 2 }
]

Sub-pipeline pada contoh ini diakhiri $count, sehingga hasilnya berupa satu dokumen jumlah atau array kosong apabila tidak ada pegawai yang cocok. $unwind dengan preserveNullAndEmptyArrays mengubah array berisi dokumen menjadi subdokumen, sedangkan $ifNull mengganti field yang tidak ada dengan angka 0. Departemen Hukum dengan kode LEG menampilkan jumlahPegawai: 0 berkat perlakuan tersebut, dan departemen lainnya menampilkan jumlah sesuai data tanamannya. Pola semacam ini menggantikan correlated subquery pada SQL, yaitu subquery yang membaca nilai baris luarnya untuk setiap perhitungan.

Kesalahan umum#

Kesalahan yang paling sering pada $lookup adalah salah menulis nama field pencocokan, dan server tidak menolak kesalahan ini. Pipeline berikut memakai foreignField: "nama" padahal yang dimaksud adalah kode, sehingga tidak ada dokumen departemen yang nilainya sama dengan kode pegawai.

mongosh
db.pegawai.aggregate([
  { $lookup: { from: "departemen", localField: "kodeDepartemen", foreignField: "nama", as: "hasil" } },
  { $project: { _id: 0, nama: 1, hasil: 1 } },
  { $limit: 1 }
])
text Keluaran
[ { nama: 'Ali Rahman', hasil: [] } ]

Keluarannya array kosong tanpa satu pun pesan error, sehingga pipeline tampak berhasil padahal relasinya tidak terbentuk. Perbaikannya adalah memastikan localField dan foreignField menunjuk pasangan field yang memang saling merujuk, lalu memeriksa isi array hasil $lookup sebelum tahap-tahap berikutnya ditulis. Syarat lain yang perlu dijaga adalah koleksi pada from wajib berada di basis data yang sama dengan koleksi asalnya. Koleksi yang tidak ada maupun nama yang salah eja diperlakukan sebagai koleksi kosong, sehingga gejalanya sama dengan kesalahan penulisan field, yaitu array kosong tanpa peringatan.

Gunakan $limit kecil atau $count untuk memeriksa kecocokan pada koleksi yang besar, karena array kosong pada sebagian dokumen mudah terlewat bila keluarannya panjang. Indeks pada field foreignField juga membantu kecepatan pencocokan $lookup pada koleksi yang besar. Biaya kedua upaya tersebut kecil, sedangkan relasi yang salah baru terasa pada laporan yang sudah dipakai.

Ringkasan#

  • $lookup mencocokkan localField dengan foreignField pada koleksi from, lalu menaruh salinan dokumen yang cocok berupa array pada field as.
  • Setiap dokumen masukan selalu menghasilkan satu dokumen keluaran, dan array kosong menyatakan relasi tanpa kecocokan.
  • Kombinasi pipeline, $unwind, dan $ifNull merapikan hasil gabungan menjadi satu field tunggal atau nilai bawaan.
  • Bentuk let beserta $expr menggantikan correlated subquery SQL untuk menghitung sesuatu dari sisi koleksi kedua.

Referensi#