Kita sudah mengenal bentuk dasar $lookup di Chapter $lookup untuk Join pada Section Relationships dan Consistency: empat field from, localField, foreignField, dan as menjalankan left outer join equality sederhana. Chapter ini mendalaminya dari sisi pipeline: bentuk let + pipeline yang membuka kondisi join bebas, bentuk gabungan yang menyatukan keduanya, cara menjaga pasangan item saat me-lookup array, faktor penentu kinerjanya, dan preview materialisasi hasil lewat $merge. Papan datanya pun bertambah: collection pelanggan dan pesanan yang menyimpan transaksi toko buku.

Rekap Cepat: Bentuk Dasar #

Tiga collection berikut menjadi basis kerja kita — perhatikan PSN-003 yang pelangganId-nya sengaja menunjuk nomor yang tidak ada, dan minat pada pelanggan yang akan menjadi bahan pipeline form:

js
use latihan_agregasi
db.dropDatabase()

Output yang Dihasilkan:

text
switched to db latihan_agregasi
{ ok: 1, dropped: 'latihan_agregasi' }
js
db.pelanggan.insertMany([
  { _id: 101, nama: "Budi", email: "[email protected]", kota: "Bandung", minat: ["Fiksi", "Nonfiksi"] },
  { _id: 102, nama: "Siti", email: "[email protected]", kota: "Jakarta", minat: ["Referensi"] },
  { _id: 103, nama: "Dewi", email: "[email protected]", kota: "Surabaya", minat: ["Fiksi"] }
])

Output yang Dihasilkan:

text
{ acknowledged: true, insertedIds: { '0': 101, '1': 102, '2': 103 } }
js
db.buku.insertMany([
  { _id: 1, sku: "FIC-001", judul: "Laut Bercerita", harga: 98000, kategori: "Fiksi", stok: 12 },
  { _id: 2, sku: "FIC-002", judul: "Bumi", harga: 89000, kategori: "Fiksi", stok: 20 },
  { _id: 3, sku: "NF-001", judul: "Filosofi Teras", harga: 89000, kategori: "Nonfiksi", stok: 8 },
  { _id: 4, sku: "NF-002", judul: "Atomic Habits", harga: 115000, kategori: "Nonfiksi", stok: 15 },
  { _id: 5, sku: "REF-001", judul: "Kamus Bahasa Indonesia", harga: 55000, kategori: "Referensi", stok: 30 }
])

Output yang Dihasilkan:

text
{
  acknowledged: true,
  insertedIds: { '0': 1, '1': 2, '2': 3, '3': 4, '4': 5 }
}
js
db.pesanan.insertMany([
  { _id: 1, noPesanan: "PSN-001", pelangganId: 101, items: [ { sku: "FIC-001", jumlah: 2 }, { sku: "NF-002", jumlah: 1 } ] },
  { _id: 2, noPesanan: "PSN-002", pelangganId: 102, items: [ { sku: "REF-001", jumlah: 3 } ] },
  { _id: 3, noPesanan: "PSN-003", pelangganId: 999, items: [ { sku: "FIC-002", jumlah: 1 } ] }
])

Output yang Dihasilkan:

text
{ acknowledged: true, insertedIds: { '0': 1, '1': 2, '2': 3 } }

Bentuk dasar bekerja seperti yang kita ingat — setiap pesanan digabung dengan pelanggannya, dan pesanan tanpa pasangan mendapat array kosong:

js
db.pesanan.aggregate([
  { $lookup: { from: "pelanggan", localField: "pelangganId", foreignField: "_id", as: "pelanggan" } },
  { $project: { _id: 0, noPesanan: 1, "pelanggan.nama": 1, "pelanggan.kota": 1 } }
])

Output yang Dihasilkan:

text
[
  {
    noPesanan: 'PSN-001',
    pelanggan: [ { nama: 'Budi', kota: 'Bandung' } ]
  },
  {
    noPesanan: 'PSN-002',
    pelanggan: [ { nama: 'Siti', kota: 'Jakarta' } ]
  },
  { noPesanan: 'PSN-003', pelanggan: [] }
]

Bentuk Pipeline: let, $expr, dan Kondisi Bebas #

Bentuk dasar hanya mampu equality satu pasang field. Ketika kondisi join lebih bebas, $lookup menerima let + pipeline: let mengikat nilai dari sisi lokal menjadi variabel, dan pipeline berisi sub-pipeline yang dijalankan pada collection from untuk setiap document input. Contoh klasiknya di toko buku: rekomendasi — gabungkan tiap pelanggan dengan buku yang kategorinya termasuk dalam minatnya:

js
db.pelanggan.aggregate([
  { $lookup: {
      from: "buku",
      let: { minatPelanggan: "$minat" },
      pipeline: [
        { $match: { $expr: { $in: ["$kategori", "$$minatPelanggan"] } } },
        { $project: { _id: 0, judul: 1, kategori: 1 } }
      ],
      as: "rekomendasi"
  } },
  { $project: { _id: 0, nama: 1, rekomendasi: 1 } }
])

Output yang Dihasilkan:

text
[
  {
    nama: 'Budi',
    rekomendasi: [
      { judul: 'Laut Bercerita', kategori: 'Fiksi' },
      { judul: 'Bumi', kategori: 'Fiksi' },
      { judul: 'Filosofi Teras', kategori: 'Nonfiksi' },
      { judul: 'Atomic Habits', kategori: 'Nonfiksi' }
    ]
  },
  {
    nama: 'Siti',
    rekomendasi: [ { judul: 'Kamus Bahasa Indonesia', kategori: 'Referensi' } ]
  },
  {
    nama: 'Dewi',
    rekomendasi: [
      { judul: 'Laut Bercerita', kategori: 'Fiksi' },
      { judul: 'Bumi', kategori: 'Fiksi' }
    ]
  }
]

Mari kita bedah anatomi bentuk pipeline ini:

  1. let: { minatPelanggan: "$minat" }: array minat dari tiap pelanggan diikat ke variabel $$minatPelanggan.
  2. pipeline: [...]: daftar tahap yang dijalankan pada collection buku; di sinilah kondisi join hidup.
  3. $match dengan $expr: karena $$minatPelanggan adalah variabel, perbandingannya harus lewat $expr — di sini $in menguji apakah kategori buku termuat dalam array minat pelanggan. Kondisi semacam ini mustahil dinyatakan di bentuk dasar.
  4. $project di dalam sub-pipeline: memangkas field sebelum hasil ditempelkan, sehingga array rekomendasi langsung ramping.

Budi mendapat empat buku (minatnya dua kategori), Siti satu, Dewi dua. Sub-pipeline menerima seluruh peralatan pipeline biasa — $match tambahan, $sort, $limit — sehingga bentuk ini juga menjadi rumah bagi join non-equality seperti rentang tanggal atau perbandingan numerik.

Bentuk Gabungan dan Lookup Banyak Nilai #

Sejak MongoDB 5.0, kedua bentuk bisa dipadukan: localField/foreignField menangani pencocokan equality, sementara pipeline menyempurnakan hasilnya. Bonus penting dari bentuk gabungan: localField boleh berupa array, dan MongoDB mencocokkan tiap elemennya terhadap foreignField — inilah pola lookup-many untuk relasi many-to-many. Field items.sku pada pesanan adalah array di dalam array of documents:

js
db.pesanan.aggregate([
  { $lookup: {
      from: "buku",
      localField: "items.sku",
      foreignField: "sku",
      pipeline: [ { $project: { _id: 0, sku: 1, judul: 1, harga: 1 } } ],
      as: "buku"
  } },
  { $project: { _id: 0, noPesanan: 1, "buku.judul": 1 } }
])

Output yang Dihasilkan:

text
[
  {
    noPesanan: 'PSN-001',
    buku: [ { judul: 'Laut Bercerita' }, { judul: 'Atomic Habits' } ]
  },
  { noPesanan: 'PSN-002', buku: [ { judul: 'Kamus Bahasa Indonesia' } ] },
  { noPesanan: 'PSN-003', buku: [ { judul: 'Bumi' } ] }
]

items.sku pada PSN-001 bernilai ["FIC-001", "NF-002"], dan kedua nilai itu masing-masing dicocokkan ke sku di collection buku — hasilnya dua buku dalam satu array. Tahap $project di sub-pipeline menyusul pencocokan, bukan menggantikannya.

Menjaga Pasangan Item: $unwind Sebelum $lookup #

Pola lookup-many di atas punya satu kelemahan: pasangan antara item dan bukunya hilang. Array buku memuat judul yang cocok, tetapi kita kehilangan jawaban untuk “jumlah berapa yang dibeli untuk judul ini”. Solusinya membalik urutan kerja — pecah dulu items dengan $unwind, baru lookup pada nilai scalar:

js
db.pesanan.aggregate([
  { $unwind: "$items" },
  { $lookup: { from: "buku", localField: "items.sku", foreignField: "sku", as: "buku" } },
  { $unwind: "$buku" },
  { $project: {
      _id: 0,
      noPesanan: 1,
      judul: "$buku.judul",
      jumlah: "$items.jumlah",
      subtotal: { $multiply: ["$items.jumlah", "$buku.harga"] }
  } }
])

Output yang Dihasilkan:

text
[
  {
    noPesanan: 'PSN-001',
    judul: 'Laut Bercerita',
    jumlah: 2,
    subtotal: 196000
  },
  {
    noPesanan: 'PSN-001',
    judul: 'Atomic Habits',
    jumlah: 1,
    subtotal: 115000
  },
  {
    noPesanan: 'PSN-002',
    judul: 'Kamus Bahasa Indonesia',
    jumlah: 3,
    subtotal: 165000
  },
  { noPesanan: 'PSN-003', judul: 'Bumi', jumlah: 1, subtotal: 89000 }
]

Urutan kerjanya: $unwind: "$items" memecah tiap pesanan menjadi satu document per item, sehingga items.sku kini satu nilai; $lookup menempelkan satu buku; $unwind: "$buku" meratakan array hasil (yang berisi tepat satu elemen) menjadi object; $project merakit baris rincian lengkap dengan subtotal. Inilah bentuk yang tepat setiap kali baris hasil perlu membawa data dari kedua sisi join secara berpasangan.

Kinerja: Index pada foreignField #

$lookup adalah operasi paling mahal di pipeline, dan penentu utama kecepatannya ada di sisi from. Pencocokan mengarah ke foreignField, jadi index pada field itu mengubah karakter join sepenuhnya. Kita buat index pada sku lalu periksa rencana eksekusinya:

js
db.buku.createIndex({ sku: 1 })
db.buku.getIndexes()

Output yang Dihasilkan:

text
sku_1
[
  {
    v: 2,
    key: { _id: 1 },
    name: '_id_',
    collation: { locale: 'simple' }
  },
  {
    v: 2,
    key: { sku: 1 },
    name: 'sku_1',
    collation: { locale: 'simple' }
  }
]
js
db.pesanan.explain().aggregate([
  { $lookup: { from: "buku", localField: "items.sku", foreignField: "sku", as: "buku" } }
]).queryPlanner.winningPlan.queryPlan

Output yang Dihasilkan:

text
{
  stage: 'EQ_LOOKUP',
  planNodeId: 4,
  foreignCollection: 'latihan_agregasi.buku',
  localField: 'items.sku',
  foreignField: 'sku',
  asField: 'buku',
  strategy: 'IndexedLoopJoin',
  inputStages: [
    {
      stage: 'COLLSCAN',
      planNodeId: 1,
      filter: {},
      nss: 'latihan_agregasi.pesanan',
      direction: 'forward'
    },
    {
      stage: 'FETCH',
      planNodeId: 3,
      nss: 'latihan_agregasi.buku',
      inputStage: {
        stage: 'IXSCAN',
        planNodeId: 2,
        nss: 'latihan_agregasi.buku',
        keyPattern: { sku: 1 },
        indexName: 'sku_1',
        isMultiKey: false,
        multiKeyPaths: { sku: [] },
        isUnique: false,
        isSparse: false,
        isPartial: false,
        indexVersion: 2,
        direction: 'forward',
        indexBounds: {}
      }
    }
  }
}

Rencana eksekusi ini mengungkap dua fakta penting:

  1. strategy: 'IndexedLoopJoin' dengan IXSCAN pada sku_1: bentuk localField/foreignField dieksekusi sebagai loop join berindex — untuk tiap pesanan, buku yang cocok dicari lewat index, bukan dengan memindai collection buku dari awal.
  2. Bentuk let + $expr murni tidak menikmati strategi ini: sub-pipeline-nya dieksekusi per document input tanpa pakta join berindex. Bila collection from besar, letakkan syarat yang bisa diindex (misalnya $match biasa pada kategori) di awal sub-pipeline, dan gunakan bentuk gabungan localField/foreignField + pipeline setiap kali pencocokannya memang equality.

Dua pertimbangan praktis menutup bagian kinerja. Pertama, sub-pipeline dijalankan sekali per document input — pada collection input besar, $match di awal pipeline utama mengendalikan jumlah eksekusi sub-pipeline. Kedua, from harus berada di database yang sama dengan collection lokal; from yang sharded sendiri sudah didukung sejak MongoDB 5.1.

Materialisasi Hasil dengan $merge #

Hasil join biasanya dikonsumsi sekali lalu hilang. Ketika laporan gabungan dipakai berulang kali — dibaca dashboard, diquery aplikasi lain — menulisnya ke collection tersendiri lebih efisien. Tahap $merge menutup pipeline dengan menuliskan hasil ke collection target:

js
db.pelanggan.aggregate([
  { $lookup: {
      from: "buku",
      let: { minatPelanggan: "$minat" },
      pipeline: [
        { $match: { $expr: { $in: ["$kategori", "$$minatPelanggan"] } } },
        { $project: { _id: 0, judul: 1 } }
      ],
      as: "rekomendasi"
  } },
  { $merge: { into: "rekomendasi_pelanggan" } }
])
db.rekomendasi_pelanggan.find({}, { _id: 0, nama: 1, rekomendasi: 1 })

Output yang Dihasilkan:

text
[
  {
    nama: 'Budi',
    rekomendasi: [
      { judul: 'Laut Bercerita' },
      { judul: 'Bumi' },
      { judul: 'Filosofi Teras' },
      { judul: 'Atomic Habits' }
    ]
  },
  { nama: 'Siti', rekomendasi: [ { judul: 'Kamus Bahasa Indonesia' } ] },
  {
    nama: 'Dewi',
    rekomendasi: [ { judul: 'Laut Bercerita' }, { judul: 'Bumi' } ]
  }
]

Pipeline dengan $merge tidak mengembalikan document — perhatikan bahwa tidak ada keluaran dari pernyataan aggregate() itu sendiri — sehingga find() pada baris berikutnya menjadi bukti bahwa tiga document hasil join kini tersimpan permanen di collection rekomendasi_pelanggan. Inilah pintu masuk pola computed pattern yang kita bahas di Section Data Modeling: hitung sekali, simpan, baca berkali-kali. Opsi lengkap $merge — mode penyisipan vs penggantian, $out sebagai alternatif — menunggu di Chapter $merge dan $out.

Rangkuman #

  • Bentuk dasar from/localField/foreignField/as menangani equality join; let + pipeline + $expr membuka kondisi bebas seperti $in dan perbandingan non-equality.
  • Bentuk gabungan (5.0+) memadukan keduanya; localField berupa array memicu lookup-many — tiap elemen dicocokkan.
  • $unwind sebelum $lookup menjaga pasangan item–buku; $unwind setelahnya meratakan array hasil menjadi object.
  • Index pada foreignField membuat bentuk dasar/gabungan dieksekusi sebagai IndexedLoopJoin; bentuk let murni tidak — saring sub-pipelinenya sedini mungkin.
  • $merge menutup pipeline dengan menuliskan hasil join ke collection target — dasar materialisasi laporan.