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:
| Field | Tugas |
|---|---|
from | Nama collection sumber data yang digabungkan |
localField | Field pada document input (collection yang di-query) |
foreignField | Field pada document di from yang dicocokkan |
as | Nama 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:
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:
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:
db.pesanan.aggregate([
{
$lookup: {
from: "pelanggan",
localField: "pelangganId",
foreignField: "_id",
as: "pelanggan"
}
},
{ $project: { _id: 0, noPesanan: 1, jumlah: 1, pelanggan: 1 } }
])Output yang Dihasilkan:
[
{
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:
from: "pelanggan": data penggabung diambil dari collectionpelanggan, di database yang sama.localField: "pelangganId"/foreignField: "_id": setiap pesanan dicocokkan dengan pelanggan yang_id-nya sama denganpelangganId— padananON pesanan.pelangganId = pelanggan._id.as: "pelanggan": hasil pencocokan ditempelkan sebagai field array bernamapelanggan— meski cocoknya satu, bentuknya tetap array.$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):
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:
[ { 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:
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:
[
{
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:
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:
[
{ noPesanan: 'PSN-001', buku: [] },
{ noPesanan: 'PSN-002', buku: [ { judul: 'Bumi', harga: 89000 } ] },
{ noPesanan: 'PSN-003', buku: [] }
]Bedah bentuknya:
let: { dibeli: "$bukuId" }: nilaibukuIddari setiap pesanan diikat ke variabel$$dibeli.pipeline: [...]: daftar stage yang dijalankan pada collectionbuku— di sinilah kondisi join berada.$matchdengan$expr:$_iddari tiap buku dibandingkan dengan$$dibeli; syaratharga: { $lte: 90000 }menyaring sisi buku sekaligus — hal yang mustahil pada bentuk dasar.$projectdi dalam pipeline: memangkas field sebelum hasil ditempelkan keas.
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 #
| Aspek | Keterangan |
|---|---|
| Satu database | from harus collection di database yang sama dengan collection lokal |
| Bentuk hasil | selalu array pada as; semantik left outer join |
| Kondisi join | bentuk dasar hanya equality; kondisi lain lewat let + pipeline |
| Collection sharded | from yang sharded didukung sejak MongoDB 5.1 — pada 9.0 bukan lagi batasan |
| Performa | sangat 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 padaas.- Document tanpa pasangan tetap muncul dengan array kosong — pola
$size: 0mendeteksi referensi menggantung. $unwindmeratakan array hasil menjadi object; tanpapreserveNullAndEmptyArrays, baris tanpa pasangan hilang.let+pipelinemembuka kondisi join bebas: filter sisi foreign, beberapa kondisi, proyeksi sebelum ditempel.- Index di
foreignFieldmenentukan performa; collectionfromyang 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.