Join manual di aplikasi bekerja, tetapi pola N+1 dan perakitan object di sisi client cepat menjadi beban. MongoDB punya jawaban server-side: stage agregasi $lookup, yang menggabungkan document dari collection lain langsung di dalam pipeline — satu perintah, hasil sudah utuh. Chapter ini adalah padanan JOIN di dunia relational: kita kuasai bentuk dasarnya, meratakannya dengan $unwind, bentuk pipeline + let untuk kondisi join bebas, lalu batasan dan performanya. Detail pipeline aggregation secara penuh menyusul di Section Aggregation — di sini kita pakai $lookup sebagai alat relasi.

Bentuk Dasar: from, localField, foreignField, as #

$lookup adalah stage pertama dari aggregate() yang benar-benar kita sambut di course ini — cukup anggap aggregate([]) sebagai wadah daftar stage untuk sekarang. Empat field mendefinisikan join equality sederhana:

FieldTugas
fromNama collection sumber data yang digabungkan
localFieldField pada document input (collection yang di-query)
foreignFieldField pada document di from yang dicocokkan
asNama field array hasil gabungan

Kita siapkan data uji — perhatikan PSN-003 yang bukuId-nya sengaja berupa ObjectId baru yang tidak ada di buku, menirkan buku yang sudah dihapus:

js
use latihan_relasi
db.pelanggan.drop()
db.buku.drop()
db.pesanan.drop()
db.pelanggan.insertMany([
  { nama: "Budi", email: "[email protected]", kota: "Bandung" },
  { nama: "Siti", email: "[email protected]", kota: "Jakarta" }
])
db.buku.insertMany([
  { sku: "BK-001", judul: "Laut Bercerita", harga: 98000 },
  { sku: "BK-002", judul: "Bumi", harga: 89000 }
])
var idBudi = db.pelanggan.findOne({ nama: "Budi" })._id
var idSiti = db.pelanggan.findOne({ nama: "Siti" })._id
var idBuku = {}
db.buku.find({}, { sku: 1 }).forEach(b => idBuku[b.sku] = b._id)
db.pesanan.insertMany([
  { noPesanan: "PSN-001", pelangganId: idBudi, bukuId: idBuku["BK-001"], jumlah: 2 },
  { noPesanan: "PSN-002", pelangganId: idSiti, bukuId: idBuku["BK-002"], jumlah: 1 },
  { noPesanan: "PSN-003", pelangganId: idSiti, bukuId: new ObjectId(), jumlah: 1 }
])

Output yang Dihasilkan:

text
switched to db latihan_relasi
true
true
true
{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId('6ad39a48506e1b89f74b64a8'),
    '1': ObjectId('6ad39a48506e1b89f74b64a9')
  }
}
{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId('6ad39a48506e1b89f74b64aa'),
    '1': ObjectId('6ad39a48506e1b89f74b64ab')
  }
}

{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId('6ad39a48506e1b89f74b64ad'),
    '1': ObjectId('6ad39a48506e1b89f74b64ae'),
    '2': ObjectId('6ad39a48506e1b89f74b64af')
  }
}

Join pertama: gabungkan setiap pesanan dengan pelanggannya:

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

Output yang Dihasilkan:

text
[
  {
    noPesanan: 'PSN-001',
    jumlah: 2,
    pelanggan: [
      {
        _id: ObjectId('6ad39a48506e1b89f74b64a8'),
        nama: 'Budi',
        email: '[email protected]',
        kota: 'Bandung'
      }
    ]
  },
  {
    noPesanan: 'PSN-002',
    jumlah: 1,
    pelanggan: [
      {
        _id: ObjectId('6ad39a48506e1b89f74b64a9'),
        nama: 'Siti',
        email: '[email protected]',
        kota: 'Jakarta'
      }
    ]
  },
  {
    noPesanan: 'PSN-003',
    jumlah: 1,
    pelanggan: [
      {
        _id: ObjectId('6ad39a48506e1b89f74b64a9'),
        nama: 'Siti',
        email: '[email protected]',
        kota: 'Jakarta'
      }
    ]
  }
]

Mari kita bedah:

  1. from: "pelanggan": data penggabung diambil dari collection pelanggan, di database yang sama.
  2. localField: "pelangganId" / foreignField: "_id": setiap pesanan dicocokkan dengan pelanggan yang _id-nya sama dengan pelangganId — padanan ON pesanan.pelangganId = pelanggan._id.
  3. as: "pelanggan": hasil pencocokan ditempelkan sebagai field array bernama pelanggan — meski cocoknya satu, bentuknya tetap array.
  4. $project: memangkas field agar hasil mudah dibaca; ini stage biasa yang berjalan setelah $lookup.

Hasilnya persis left outer join: setiap pesanan tetap muncul, dan pesanan tanpa pasangan mendapat array kosong. Justru sifat ini bisa dimanfaatkan — query berikut mencari pesanan yang referensi bukunya sudah tidak ada (array hasil kosong):

js
db.pesanan.aggregate([
  { $lookup: { from: "buku", localField: "bukuId", foreignField: "_id", as: "buku" } },
  { $match: { $expr: { $eq: [{ $size: "$buku" }, 0] } } },
  { $project: { _id: 0, noPesanan: 1, bukuId: 1 } }
])

Output yang Dihasilkan:

text
[ { noPesanan: 'PSN-003', bukuId: ObjectId('6ad39a48506e1b89f74b64ac') } ]

Ekspresi { $size: "$buku" } menghitung isi array hasil join; $match dengan $expr menyaring yang jumlahnya nol. PSN-003 terdeteksi sebagai pesanan dengan referensi menggantung — laporan yang lazim dibutuhkan tim data untuk membereskan data lama.

Meratakkan Hasil dengan $unwind #

Field as yang selalu berbentuk array membuat hasil join perlu diratakan sebelum dipakai sebagai object biasa. Stage $unwind memecah satu document menjadi satu document per elemen array:

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

Output yang Dihasilkan:

text
[
  {
    noPesanan: 'PSN-001',
    jumlah: 2,
    judul: 'Laut Bercerita',
    total: 196000
  },
  { noPesanan: 'PSN-002', jumlah: 1, judul: 'Bumi', total: 89000 }
]

Setelah $unwind, field buku bukan lagi array melainkan object, sehingga "$buku.judul" merujuk field di dalamnya. Ekspresi $multiply menghitung jumlah × harga di sisi server — contoh kecil kalkulasi yang akan banyak kita pakai di Section Aggregation.

Bentuk Pipeline dan let #

Bentuk dasar hanya mampu equality match pada satu pasang field. Ketika kondisi join lebih bebas — misalnya menggabungkan hanya buku dengan harga tertentu, atau membandingkan dua field — $lookup menerima let + pipeline: variabel dari sisi lokal, dan sub-pipeline yang dijalankan pada collection from:

js
db.pesanan.aggregate([
  {
    $lookup: {
      from: "buku",
      let: { dibeli: "$bukuId" },
      pipeline: [
        { $match: { $expr: { $eq: ["$_id", "$$dibeli"] }, harga: { $lte: 90000 } } },
        { $project: { _id: 0, judul: 1, harga: 1 } }
      ],
      as: "buku"
    }
  },
  { $project: { _id: 0, noPesanan: 1, buku: 1 } }
])

Output yang Dihasilkan:

text
[
  { noPesanan: 'PSN-001', buku: [] },
  { noPesanan: 'PSN-002', buku: [ { judul: 'Bumi', harga: 89000 } ] },
  { noPesanan: 'PSN-003', buku: [] }
]

Bedah bentuknya:

  1. let: { dibeli: "$bukuId" }: nilai bukuId dari setiap pesanan diikat ke variabel $$dibeli.
  2. pipeline: [...]: daftar stage yang dijalankan pada collection buku — di sinilah kondisi join berada.
  3. $match dengan $expr: $_id dari tiap buku dibandingkan dengan $$dibeli; syarat harga: { $lte: 90000 } menyaring sisi buku sekaligus — hal yang mustahil pada bentuk dasar.
  4. $project di dalam pipeline: memangkas field sebelum hasil ditempelkan ke as.

Hasilnya: PSN-001 (buku 98000) mendapat array kosong karena tersaring harga, PSN-002 mendapat bukunya, dan PSN-003 tetap kosong karena referensinya memang tidak ada. Bentuk let + pipeline inilah yang dipakai untuk multiple join conditions dan subquery berkorelasi.

Batasan dan Performa #

AspekKeterangan
Satu databasefrom harus collection di database yang sama dengan collection lokal
Bentuk hasilselalu array pada as; semantik left outer join
Kondisi joinbentuk dasar hanya equality; kondisi lain lewat let + pipeline
Collection shardedfrom yang sharded didukung sejak MongoDB 5.1 — pada 9.0 bukan lagi batasan
Performasangat bergantung pada index di foreignField; join tanpa index pada collection besar mahal

Dua catatan praktis menutup bagian ini. Pertama, karena pencocokan mengarah ke foreignField, index pada field itu (misalnya _id sudah otomatis berindex) adalah penentu utama kecepatan $lookup. Kedua, subquery berkorelasi dijalankan per document input — pada collection besar, dokumentasi resmi menyarankan mempertimbangkan model embedded agar kebutuhan $lookup menyusut, alih-alih menjadikan setiap query sebagai join.

Rangkuman #

  • $lookup = left outer join server-side: from/localField/foreignField/as, hasil selalu array pada as.
  • Document tanpa pasangan tetap muncul dengan array kosong — pola $size: 0 mendeteksi referensi menggantung.
  • $unwind meratakan array hasil menjadi object; tanpa preserveNullAndEmptyArrays, baris tanpa pasangan hilang.
  • let + pipeline membuka kondisi join bebas: filter sisi foreign, beberapa kondisi, proyeksi sebelum ditempel.
  • Index di foreignField menentukan performa; collection from yang sharded sah sejak 5.1.

$lookup menyatukan data saat dibaca. Pertanyaan berikutnya berpindah ke penulisan: bagaimana menjaga konsistensi ketika data terkait harus berubah bersamaan — itulah wilayah atomicity dan transaksi di sisa Section ini.