MongoDB'de iki koleksiyonu birleştirmek için aggregation pipeline içindeki $lookup aşaması kullanılır. Bu aşama, aynı veritabanındaki başka bir koleksiyona sol dış birleştirme (left outer join) yapar: her girdi belgesine, yabancı koleksiyonda eşleşen belgeleri içeren yeni bir dizi alanı ekler ve belgeyi bir sonraki aşamaya iletir. Girdi belgesi eşleşme bulunamasa bile elenmez; yalnızca eklenen dizi boş kalır.

Bu yazıda $lookup aşamasının iki ana biçimini (tek eşitlik koşulu ve let ile pipeline kullanan alt sorgu), sonucu düzleştirmek için $unwind aşamasını, indeks ve performans kurallarını ve sık yapılan hataları kendi küçük örneklerimizle anlatıyoruz. Pipeline kavramına yeniyseniz önce aggregation pipeline rehberini okumanızı öneririz.

$lookup ne yapar, ne yapmaz?

$lookup SQL'deki JOIN'e en yakın araçtır, ancak sonucu satır çoğaltarak değil dizi alanı ekleyerek verir. Örneğin bir sipariş belgesine müşteri bilgisini bağladığınızda sipariş belgesi tek kalır, içine musteri adlı bir dizi eklenir. SQL tarzı düz satırlar istiyorsanız bu diziyi $unwind ile açarsınız. Terim karşılıklarının tamamı için SQL ve MongoDB karşılaştırma yazısına bakabilirsiniz.

Önemli sınırlar şunlardır: from ile belirtilen koleksiyon aynı veritabanında olmalıdır. İki koleksiyonun belgelerini alt alta eklemek (birleşim, UNION ALL) istiyorsanız doğru araç $lookup değil $unionWith aşamasıdır. Ayrıca $lookup içindeki alt pipeline, $out ya da $merge aşaması içeremez.

Söz dizimi ve alanlar

Söz dizimi ve alanlar: from, localField, foreignField, Hayır

$lookup bir belge alır. Hangi alanların zorunlu olduğu, hangi biçimi kullandığınıza bağlıdır:

AlanZorunlu muGörevi
fromEvetAynı veritabanındaki yabancı koleksiyonun adı
localFieldpipeline yoksa evetGirdi belgelerinde eşleştirilecek alan
foreignFieldpipeline yoksa evetYabancı belgelerde eşleştirilecek alan
letHayırpipeline içinde kullanılacak değişkenler
pipelinelocalField ve foreignField yoksa evetYabancı koleksiyonda çalışacak alt pipeline
asEvetEşleşen belgelerin yazılacağı yeni dizi alanının adı

as ile verdiğiniz ad girdi belgesinde zaten varsa, mevcut alanın üzerine yazılır. Bu yüzden belgede zaten musteri diye bir alan varsa ve as: "musteri" derseniz eski değeri kaybedersiniz; çakışmayan bir ad seçin.

Örnek veri

Aşağıdaki örneklerde iki küçük koleksiyon kullanıyoruz: musteriler ve siparisler. Siparişler, müşteriye musteriId alanıyla referans veriyor. Bu, veri modelleme yazısında anlatılan referans (referencing) yaklaşımıdır.

javascript
db.musteriler.insertMany( [
   { _id: 1, ad: "Ayşe", sehir: "İzmir" },
   { _id: 2, ad: "Mehmet", sehir: "Ankara" }
] )

db.siparisler.insertMany( [
   { _id: 101, musteriId: 1, tutar: 250, urunler: [ "kalem", "defter" ] },
   { _id: 102, musteriId: 1, tutar: 90, urunler: [ "silgi" ] },
   { _id: 103, musteriId: 2, tutar: 400, urunler: [ ] },
   { _id: 104, tutar: 35, urunler: [ "kalem" ] }
] )

Dikkat ederseniz 104 numaralı siparişte musteriId alanı yok. Bu belgeyi bilerek ekledik; aşağıda eksik alanların nasıl davrandığını göstereceğiz.

Tek eşitlik koşuluyla join

Tek eşitlik koşuluyla join: Eksik alan tuzağı, localField bir dizi olduğunda

En sık kullanılan biçim, girdi belgesindeki bir alanı yabancı belgedeki bir alanla eşitlik üzerinden eşleştirmektir:

javascript
db.siparisler.aggregate( [
   { $match: { tutar: { $gte: 50 } } },
   { $lookup: {
        from: "musteriler",
        localField: "musteriId",
        foreignField: "_id",
        as: "musteri"
   } }
] )

Bu sorgu önce tutarı 50 ve üzerindeki siparişleri seçer, sonra her birine musteri adlı bir dizi ekler. 101 ve 102 numaralı siparişlerin dizisinde Ayşe'nin belgesi, 103 numaralının dizisinde Mehmet'in belgesi bulunur. $match aşamasını $lookup önüne koymamız bilinçli bir tercihtir: join'e giren belge sayısı ne kadar azsa iş o kadar hafifler.

Eksik alan tuzağı

Belgelere göre girdi belgesinde localField yoksa, $lookup bu alanı eşleştirme sırasında null kabul eder. Aynı şekilde yabancı belgede foreignField yoksa o belge de null değerle eşleştirilir. Sonuç olarak musteriId alanı olmayan bir sipariş, yabancı koleksiyonda o alanı taşımayan (veya alanı null olan) belgelerle eşleşebilir. Yukarıdaki örnekte foreignField olarak her belgede bulunan _id kullandığımız için sorun çıkmaz; fakat isteğe bağlı bir alan üzerinden join yapıyorsanız önce $match ile alanın varlığını kontrol etmek (örneğin musteriId: { $exists: true }) beklenmedik eşleşmeleri önler.

localField bir dizi olduğunda

localField bir dizi ise, dizinin elemanlarını tek değerli bir foreignField ile eşleştirmek için önce $unwind yapmanız gerekmez. Örneğin bir sipariş belgesinde urunIdleri: [ 5, 8 ] gibi bir dizi varsa ve urunler koleksiyonunda _id alanıyla eşleştirirseniz, $lookup dizideki her değer için eşleşen ürünleri bulur ve hepsini as dizisine koyar.

$unwind ile sonucu düzleştirmek

$unwind, bir dizi alanını açarak dizinin her elemanı için ayrı bir belge üretir. Her çıkış belgesi, dizi alanının yerine o tek elemanı taşıyan girdi belgesinin kopyasıdır. Bire bir ilişkilerde (her siparişin tek müşterisi olması gibi) $lookup sonrası dizi yerine doğrudan gömülü belge görmek için sık kullanılır.

javascript
db.siparisler.aggregate( [
   { $lookup: {
        from: "musteriler",
        localField: "musteriId",
        foreignField: "_id",
        as: "musteri"
   } },
   { $unwind: { path: "$musteri", preserveNullAndEmptyArrays: true } },
   { $project: { _id: 1, tutar: 1, musteriAdi: "$musteri.ad" } }
] )

Burada preserveNullAndEmptyArrays: true kritik bir ayardır. Varsayılan değer false olduğunda $unwind, alan eksikse, null ise ya da boş diziyse o belge için hiç çıktı üretmez. Yani müşterisi bulunamayan siparişler sessizce kaybolur ve sol dış birleştirme fiilen iç birleştirmeye dönüşür. Ayarı true yaptığınızda bu belgeler korunur: alan eksikse veya boş diziyse çıkışta o alan yer almaz, değer null ise null olarak kalır.

$unwind için iki yazım vardır. Seçenek gerekmiyorsa kısa biçim yeterlidir: { $unwind: "$urunler" }. Seçenek vereceğiniz zaman belge biçimini kullanırsınız. Alan yolunu her iki biçimde de başında dolar işaretiyle ve tırnak içinde yazmanız gerekir. includeArrayIndex seçeneği, elemanın dizideki sırasını yeni bir alana yazar; bu alanın adı dolar işaretiyle başlayamaz.

Dizi olmayan bir değerle karşılaşıldığında davranış şöyledir: değer eksik, null veya boş dizi değilse $unwind değeri olduğu gibi tek bir belge olarak verir. includeArrayIndex kullanılıyorsa bu durumda indeks null olur.

let ve pipeline ile alt sorgu

Eşitlikten fazlasına ihtiyaç duyduğunuzda (birden fazla koşul, aralık karşılaştırması, yabancı belgeleri sıralama ya da sınırlama) pipeline biçimine geçersiniz. Alt pipeline girdi belgesinin alanlarına doğrudan erişemez; bunun için alanları let ile değişkene atar, pipeline içinde $$degisken biçiminde kullanırsınız. Bu değişkenler pipeline içindeki iç içe $lookup aşamalarından da okunabilir.

javascript
db.musteriler.aggregate( [
   { $lookup: {
        from: "siparisler",
        let: { mid: "$_id" },
        pipeline: [
           { $match: { $expr: { $and: [
                { $eq: [ "$musteriId", "$$mid" ] },
                { $gte: [ "$tutar", 100 ] }
           ] } } },
           { $sort: { tutar: -1 } },
           { $limit: 3 },
           { $project: { _id: 0, tutar: 1 } }
        ],
        as: "buyukSiparisler"
   } }
] )

Bu sorgu her müşteri için yalnızca tutarı 100 ve üzerindeki siparişlerden en büyük üçünü getirir. Girdi koleksiyonundaki alana başvurduğu için buna ilişkili (correlated) alt sorgu denir. Alt pipeline içindeki $match aşaması değişkenlere yalnızca $expr içinden erişebilir; $sort ya da $project gibi diğer aşamalar için $expr gerekmez.

Kısa söz dizimi (MongoDB 5.0 ve sonrası)

MongoDB 5.0 ile localField ve foreignField alanlarını pipeline ile birlikte kullanabilirsiniz. Bu durumda eşitlik eşleşmesi pipeline çalışmadan önce yapılır ve aynı eşitliği $expr içinde tekrar yazmanız gerekmez:

javascript
db.musteriler.aggregate( [
   { $lookup: {
        from: "siparisler",
        localField: "_id",
        foreignField: "musteriId",
        pipeline: [ { $match: { tutar: { $gte: 100 } } } ],
        as: "buyukSiparisler"
   } }
] )

Bu sürüm, bir önceki örneğin ilk iki koşulunu daha okunur biçimde ifade eder. Değişkene ihtiyaç duymadığımız için let yazmadık ve sabit bir değerle karşılaştırdığımız için $match içinde normal sorgu söz dizimini kullanabildik.

İlişkisiz (uncorrelated) alt sorgu

Alt pipeline girdi belgesine hiç başvurmuyorsa her girdi belgesi için aynı sonucu üretir. Belgelere göre MongoDB bu durumda alt sorguyu bir kez çalıştırıp sonucu önbelleğe alabilir. İstisna: alt pipeline içinde $sample aşaması, $sampleRate ya da $rand operatörü varsa MongoDB 5.0'dan beri alt sorgu her seferinde yeniden çalıştırılır.

Join sonucunu ana belgeyle birleştirmek

Bazen join sonucunu ayrı bir dizi olarak değil, ana belgenin alanları olarak görmek istersiniz. Bunun için $replaceRoot ile $mergeObjects birlikte kullanılabilir. $arrayElemAt ile dizinin ilk elemanını alıp sipariş belgesiyle birleştiriyoruz:

javascript
db.siparisler.aggregate( [
   { $lookup: { from: "musteriler", localField: "musteriId", foreignField: "_id", as: "musteri" } },
   { $replaceRoot: { newRoot: {
        $mergeObjects: [ { $arrayElemAt: [ "$musteri", 0 ] }, "$$ROOT" ]
   } } },
   { $unset: "musteri" }
] )

$mergeObjects aynı adlı alanlarda listedeki son belgenin değerini korur ve null girdileri yok sayar. Sıralamada $$ROOT (sipariş belgesinin kendisi) sonda olduğu için iki belgede de bulunan _id alanı siparişin değerini taşır. Sırayı ters çevirirseniz sipariş numarası yerine müşteri numarasını görürsünüz; bu, en sık karşılaşılan hatalardan biridir. Son aşamadaki $unset ile artık gereksiz olan musteri dizisini çıkarıyoruz; bu aşamanın ayrıntıları $addFields, $project ve $unset yazısında var.

İndeks ve performans

İndeks ve performans: Tek eşitlik koşulu, İlişkisiz alt sorgu, İlişkili alt sorgu

$lookup performansı, işlemin türüne ve yabancı alanın indekslenip indekslenmediğine bağlıdır. Belgelerdeki kurallar özetle şunlardır:

İşlem türüNe yapmalı
Tek eşitlik koşuluYabancı koleksiyonda foreignField üzerinde indeks olmalı; yoksa performans büyük olasılıkla zayıf kalır
İlişkisiz alt sorguİç pipeline yabancı koleksiyonun bir indeksini kullanabilmeli; sonuç bir kez hesaplanıp önbelleğe alınabilir
İlişkili alt sorguforeignField üzerinde indeks olmalı; join'e giren belge sayısını önceki $match ile azaltın

Yukarıdaki örneklerde siparisler koleksiyonunu müşteriden siparişe doğru sorguladığımız için siparisler üzerinde { musteriId: 1 } indeksi gerekir. İndeks oluşturma için MongoDB index oluşturma yazısına, planın indeks kullanıp kullanmadığını görmek için explain() rehberine bakın.

Alt pipeline içinde $expr kullandığınızda indeks kullanımının ek sınırları vardır. $expr içindeki $eq, $lt, $lte, $gt ve $gte karşılaştırmaları from koleksiyonundaki bir indeksi kullanabilir; ancak yalnızca alan ile sabit arasındaki karşılaştırmalarda, yani let değişkeni sabit bir değere çözümlendiğinde. Değişken boş veya eksik bir değere çözümlenirse indeks kullanılmaz. Multikey, partial ve sparse indeksler de bu durumda kullanılmaz.

Belgeler, slot tabanlı sorgu motoru $lookup için kullanıldığında explain çıktısında EQ_LOOKUP aşamasının görüneceğini belirtir. Bu ifade eşitlik tabanlı join anlamına gelir ve planı yorumlarken işinize yarar.

Boyut ve bellek sınırları

Aggregation sonucunda dönen her belge 16 mebibayt BSON belge boyutu sınırına tabidir. Bir müşterinin binlerce siparişini tek bir as dizisine topladığınızda bu sınıra yaklaşabilirsiniz; böyle durumlarda alt pipeline içinde $limit kullanmak, yalnızca gereken alanları $project ile seçmek veya sonucu $unwind ile açmak gerekir. MongoDB 9.0 ile tek bir sorgu işleminin kullanabileceği toplam bellek de varsayılan olarak sınırlandı ve sürüm notlarında $lookup aşaması bellek ölçümünün raporlandığı aşamalar arasında sayılıyor. Ayrıntılar için MongoDB 9.0 yenilikleri yazısına bakın.

Diğer davranışlar ve kısıtlar

  • MongoDB 5.1'den beri from alanında sharded bir koleksiyon belirtebilirsiniz. MongoDB 8.0'dan beri sharded koleksiyonu hedefleyen $lookup bir transaction içinde de kullanılabilir.
  • Birden fazla view içeren bir aggregation (örneğin $lookup ile) yapıyorsanız view'ların collation ayarları aynı olmalıdır.
  • MongoDB 8.0'dan beri alt pipeline'daki ad alanları doğrulanır: alt pipeline koleksiyon gerektirmeyen bir aşamayla (örneğin $documents) başlıyorsa from alanını yazmamalısınız; $match gibi bir koleksiyon aşamasıyla başlıyorsa from zorunludur.
  • Atlas Search kullanıyorsanız $search veya $searchMeta aşaması alt pipeline'ın ilk aşaması olabilir; bu yalnızca Atlas'a özgü bir özelliktir.
  • Şifreli koleksiyonlarda şifreli bir alanı localField veya foreignField olarak kullanamazsınız.

$lookup biçimlerinin karşılaştırması

BiçimNe zamanDikkat
localField + foreignFieldBasit eşitlik joinforeignField indeksli olmalı
localField + foreignField + pipelineEşitlik artı ek filtre, sıralama veya limitMongoDB 5.0 ve sonrası
let + pipelineBirden fazla koşul veya eşit olmayan karşılaştırma$match içinde $expr gerekir
pipeline (girdiye başvurmadan)Her belgeye aynı sonucu eklemekSonuç önbelleğe alınabilir

Sık yapılan hatalar

  • Varsayılan $unwind ile belge kaybetmek: eşleşmeyen belgeleri korumak istiyorsanız preserveNullAndEmptyArrays: true verin.
  • foreignField üzerinde indeks olmaması: büyük koleksiyonlarda join'i en çok yavaşlatan nedendir.
  • Alt pipeline'da $expr olmadan değişken kullanmak: $match aşamasında $$degisken yalnızca $expr içinde anlam taşır.
  • Farklı veritabanına join denemek: from yalnızca aynı veritabanındaki koleksiyonu kabul eder.
  • Her okumada $lookup: aynı veriyi sürekli birleştiriyorsanız şemayı yeniden düşünün. Belgelerdeki anti-desen sayfası, birlikte okunan verinin birlikte saklanmasını ve gerekirse alt küme (subset) desenini önerir.
  • as adının mevcut alanla çakışması: mevcut değerin üzerine yazılır.

Sık Sorulan Sorular

$lookup iç birleştirme (inner join) yapabilir mi?

Doğrudan değil; $lookup her zaman sol dış birleştirme yapar. İç birleştirme etkisi için ardından varsayılan ayarlarla $unwind uygulayabilir veya { $match: { musteri: { $ne: [] } } } gibi bir filtreyle boş dizili belgeleri eleyebilirsiniz.

Join için gömme mi yoksa $lookup mu seçmeliyim?

Belgeler, sık birlikte okunan verinin aynı belgede saklanmasını temel ilke olarak koyar ve çok sayıda $lookup işlemini bir anti-desen olarak tanımlar. Seyrek kullanılan raporlar için $lookup uygundur; her sayfa açılışında çalışan sorgular içinse embedding ve referencing karşılaştırmasını okuyun.

$lookup sonucunu kalıcı hale getirebilir miyim?

Evet. Pipeline'ın sonuna $merge veya $out ekleyerek join sonucunu bir koleksiyona yazabilirsiniz. Bu yaklaşım materialized view yazısında ayrıntılı anlatılıyor. Yalnız $lookup aşamasının kendi alt pipeline'ı içinde bu iki aşama kullanılamaz.

Birden fazla koleksiyonu tek sorguda birleştirebilir miyim?

Evet, pipeline'a ardışık birden fazla $lookup aşaması ekleyebilir ya da bir $lookup alt pipeline'ının içine başka bir $lookup koyabilirsiniz. Her ek join'in maliyetini explain çıktısıyla ölçün ve gruplama gerekiyorsa sonucu $group aşamasıyla özetleyin.

Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.

İlgili içerikler: MongoDB öğrenme rehberi · Aggregation pipeline · $addFields ve $project · Veri modelleme · SQL karşılaştırması · Index oluşturma