EF Core'da many-to-many (çoka çok) ilişki, bir türün herhangi sayıda kaydının başka (ya da aynı) türün herhangi sayıda kaydıyla ilişkili olduğu durumdur: bir Post birçok Tag taşıyabilir, bir Tag da birçok yazıda kullanılabilir. Entity Framework Core bu ilişkiyi iki tarafta da collection navigation gördüğünde konvansiyonla kurar ve aradaki join tablosunu sizin yerinize yönetir; join tablosunun adını, kolonlarını veya ek alanlarını değiştirmek için UsingEntity kullanılır.

Bu yazıda join entity'nin perde arkasında nasıl çalıştığını, konvansiyonun ürettiği şemayı, join tablosunu adlandırmayı, join entity için sınıf tanımlamayı, payload (ek veri) eklemeyi, tek yönlü ve kendini gösteren many-to-many ilişkileri ve sık yapılan hataları anlatıyoruz. Temel ilişki kavramları için EF Core ilişkiler yazısına, serinin tamamı için EF Core öğrenme rehberi sayfasına bakabilirsiniz.

Many-to-many perde arkasında nasıl çalışır?

One-to-many ve one-to-one ilişkiler tek bir foreign key ile ifade edilebilir; many-to-many edilemez. Bunun için iki tarafı birleştiren ek bir entity türü gerekir. EF buna join entity type, veritabanındaki karşılığına join table der. Join tablosunun her satırı bir foreign key çifti taşır: biri ilişkinin bir tarafındaki kaydı, diğeri öbür tarafındaki kaydı gösterir. Böylece her satır tek bir eşleşmeyi temsil eder.

Bu tabloyu üç ayrı entity ile (Post, Tag ve PostTag) eşleyip iki tane one-to-many ilişki kullanmak da mümkündür, ama bu join tablosunun asıl niyetini, yani tek bir many-to-many ilişkiyi yansıtmaz. EF'in sunduğu daha doğal yol, iki tarafa birbirini gösteren collection navigation'lar eklemektir. Belgeler bunlara skip navigation der, çünkü join entity'yi atlayarak doğrudan karşı tarafa ulaşırlar. En sade hâliyle model şöyledir:

csharp
public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
}

Bu iki sınıf için hiçbir yapılandırma yazmanız gerekmez. EF iki collection navigation'ı eşleştirir, many-to-many ilişki kurar ve join entity'yi kendisi oluşturup gizler. Belgelerin SQLite için gösterdiği örnek şema şöyledir:

sql
CREATE TABLE "PostTag" (
    "PostsId" INTEGER NOT NULL,
    "TagsId" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostsId", "TagsId"),
    CONSTRAINT "FK_PostTag_Posts_PostsId" FOREIGN KEY ("PostsId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagsId" FOREIGN KEY ("TagsId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

Konvansiyonun kuralları

Konvansiyonun kuralları: Join entity türünün adı <sol tip adı><sağ tip adı> biçimindedir, yani PostTag..., Join entity'ye her yön için bir foreign key verilir ve bunlar...

Bu şemanın her parçası bir konvansiyondan gelir ve bunları bilmek, ileride bir şeyi değiştirmeniz gerektiğinde nereye dokunacağınızı gösterir:

  • Join entity türünün adı <sol tip adı><sağ tip adı> biçimindedir, yani PostTag; join tablosu da aynı adı alır.
  • Join entity'ye her yön için bir foreign key verilir ve bunlar <navigation adı><principal key adı> biçiminde adlandırılır: PostsId ve TagsId. Tek yönlü ilişkide navigation'ı olmayan tarafın foreign key'i principal tip adıyla adlandırılır.
  • Foreign key'ler nullable değildir; join entity'ye giden iki ilişki de required'dır ve cascade delete konvansiyonu nedeniyle silmede cascade uygulanır.
  • Join entity'nin birincil anahtarı iki foreign key'den oluşan bileşik anahtardır.
  • Konvansiyonel foreign key indeksi de oluşturulur; belgelerdeki modelde yalnızca TagsId için bir indeks görülür. EF, mevcut bir indeks ya da birincil anahtar kısıtıyla zaten kapsanan kolonlar için ek indeks oluşturmaz.

Konvansiyonla kurulan ilişkiyi öğrenme amacıyla açıkça yazmak isterseniz HasMany(e => e.Tags).WithMany(e => e.Posts) yeterlidir. Belgeler daha da ayrıntılı bir UsingEntity örneği gösterir ama hemen ardından bir uyarı ekler: gerekmediği hâlde her şeyi açıkça yapılandırmaya çalışmayın; kod hızla karmaşıklaşır, hata yapmak kolaylaşır ve yine de pek çok şey konvansiyonla belirlenmeye devam eder.

Join tablosunu ve kolonlarını adlandırmak

Join tablosunun adını değiştirmek için UsingEntity'ye yalnızca bir ad vermeniz yeterlidir; şemanın geri kalanı aynı kalır:

csharp
modelBuilder.Entity<Post>()
    .HasMany(e => e.Tags)
    .WithMany(e => e.Posts)
    .UsingEntity("PostsToTagsJoinTable");

Foreign key kolonlarının adını değiştirmenin iki yolu vardır. Birincisi join entity'deki foreign key özelliklerinin adlarını değiştirmektir; ikincisi özellik adlarını konvansiyonel bırakıp yalnızca kolon adlarını eşlemektir. İkisi de aynı şemayı üretir ve tablo adı değişikliğiyle birleştirilebilir:

csharp
// 1) Foreign key özelliklerinin adını değiştirmek
modelBuilder.Entity<Post>()
    .HasMany(e => e.Tags)
    .WithMany(e => e.Posts)
    .UsingEntity(
        r => r.HasOne(typeof(Tag)).WithMany().HasForeignKey("TagForeignKey"),
        l => l.HasOne(typeof(Post)).WithMany().HasForeignKey("PostForeignKey"));

// 2) Özellik adları aynı, yalnızca kolon adları farklı
modelBuilder.Entity<Post>()
    .HasMany(e => e.Tags)
    .WithMany(e => e.Posts)
    .UsingEntity(
        j =>
        {
            j.Property("PostsId").HasColumnName("PostForeignKey");
            j.Property("TagsId").HasColumnName("TagForeignKey");
        });

Join entity için sınıf tanımlamak

Join entity için sınıf tanımlamak

Şimdiye kadar join tablosu sınıfı olmayan bir shared-type entity type'a eşlendi. Join entity'ye kodda kolayca ulaşmak, ona navigation ya da payload eklemek istediğinizde bir sınıf tanımlamak işinizi kolaylaştırır. Sınıfın adı serbesttir ama iki tarafın adlarını birleştirmek yaygındır:

csharp
public class PostTag
{
    public int PostId { get; set; }
    public int TagId { get; set; }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>();
}

PostId ve TagId konvansiyonla foreign key olarak bulunur ve join entity'nin bileşik birincil anahtarı yapılır. Özellik adları konvansiyona uymuyorsa UsingEntity<PostTag>(r => r.HasOne<Tag>().WithMany().HasForeignKey(e => e.TagId), l => l.HasOne<Post>().WithMany().HasForeignKey(e => e.PostId)) biçiminde açıkça verebilirsiniz.

Sınıf olduktan sonra Post ve Tag içine List<PostTag> PostTags gibi join entity'ye giden navigation'lar, PostTag içine de Post ve Tag reference navigation'ları ekleyebilirsiniz. Belgeler bunun iki dünyanın en iyisini sunduğunu söyler: skip navigation'larla ilişkiyi doğal biçimde kullanırsınız, join entity navigation'larıyla da gerektiğinde join satırlarının kendisine erişirsiniz. Bu navigation'lar konvansiyonla bulunduğu için UsingEntity<PostTag>() çağrısını değiştirmeniz gerekmez ve veritabanı şeması da değişmez.

Payload: join tablosunda ek veri

Join tablosu yalnızca anahtar çiftlerini değil, eşleşmeyle ilgili bilgiyi de tutabilir; örneğin etiketin yazıya ne zaman eklendiği. Belgeler buna "payload" der ve bu durumda join entity için bir sınıf tanımlamayı önerir. Payload alanlarının veritabanı tarafından üretilmesi yaygındır:

csharp
public class PostTag
{
    public int PostId { get; set; }
    public int TagId { get; set; }
    public DateTime CreatedOn { get; set; }
}

modelBuilder.Entity<Post>()
    .HasMany(e => e.Tags)
    .WithMany(e => e.Posts)
    .UsingEntity<PostTag>(
        j => j.Property(e => e.CreatedOn).HasDefaultValueSql("CURRENT_TIMESTAMP"));

Belgelerdeki SQLite örnek çıktısında "CreatedOn" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) kolonu görülür. SQL Server için belgeler HasDefaultValueSql("GETUTCDATE()") kullanmayı ve kolon tipini TEXT yerine datetime olarak düşünmeyi söyler. Varsayılan değerler ve üretilen kolonlar hakkında ayrıntı için default value ve computed column yazısına bakın.

Neden üretilen değer? Çünkü siz post.Tags.Add(tag) dediğinizde join entity'yi EF kendisi oluşturur ve payload alanını dolduramaz. Payload veritabanında üretilmiyorsa değeri sonradan yazmanız gerekir. Change tracking belgesi bunun yolunu gösterir: önce ChangeTracker.DetectChanges() çağrılarak EF'in join entity'yi oluşturması sağlanır, ardından join entity context.Set<PostTag>().FindAsync(post.Id, tag.Id) ile bulunup alanı atanır. Alternatif olarak join entity'yi kendiniz oluşturup context.Add(new PostTag { ... }) ile ekleyebilirsiniz.

İlişkiyi kodda kullanmak

Skip navigation'lar normal collection navigation gibi davranır. Bir yazıya etiket eklemek için join tablosunu düşünmeden koleksiyona eleman eklemeniz yeterlidir; EF uygun foreign key değerleriyle bir join entity örneği oluşturur ve Tag.Posts tarafındaki ters koleksiyonu da düzeltir:

csharp
var post = await context.Posts.SingleAsync(e => e.Id == 3);
var tag = await context.Tags.SingleAsync(e => e.Id == 1);

post.Tags.Add(tag);

await context.SaveChangesAsync();

Join entity sınıfınız varsa aynı ilişkiyi context.Add(new PostTag { Post = post, Tag = tag }) ya da context.Add(new PostTag { PostId = post.Id, TagId = tag.Id }) ile de kurabilirsiniz; skip navigation'lar yine doğru şekilde düzeltilir. Belgeler, EF join entity'yi perde arkasında yönettiği için many-to-many navigation'ların her iki taraftan eleman eklenip çıkarılarak doğal biçimde kullanılabildiğini vurgular. Bu sürecin change tracker açısından ayrıntısı SaveChanges yazısında, ilişkili verileri sorguyla yüklemek ise Include yazısında anlatılıyor.

Diğer varyasyonlar

Diğer varyasyonlar: Tek yönlü many-to-many, Ayrı birincil anahtarı olan join tablosu, Cascade olmadan, Kendini gösteren ve simetrik ilişki

Tek yönlü many-to-many

Navigation'ın iki tarafta da olması gerekmez. Yalnızca Post.Tags varsa EF bunun many-to-many mi yoksa one-to-many mi olduğunu bilemez; HasMany(e => e.Tags).WithMany() ile argümansız WithMany çağırarak belirtirsiniz. Navigation'ı kaldırmak şemayı değiştirmez; yalnızca navigation'ı olmayan tarafın foreign key adı belgelerdeki örnekte PostId olur.

Ayrı birincil anahtarı olan join tablosu

Şemayı siz kontrol ediyorsanız join tablosuna ayrı bir birincil anahtar kolonu eklemenin bir gerekçesi yoktur. Ama mevcut bir veritabanında böyle bir kolon varsa EF bunu da eşleyebilir: join sınıfına int Id eklemek yeterlidir, çünkü konvansiyon onu birincil anahtar olarak alır. Sınıf olmadan da j.IndexerProperty<int>("Id") ve j.HasKey("Id") ile eklenebilir.

Cascade olmadan

Konvansiyonel cascade, bir yazı ya da etiket silindiğinde ona ait join satırlarının da silinmesini sağlar; belgeler bunu değiştirmenin yararlı olacağı bir durumu hayal etmenin zor olduğunu söyler. Yine de gerekirse UsingEntity içindeki iki ilişkiye OnDelete(DeleteBehavior.Restrict) verilebilir. Silme davranışlarının tamamı cascade delete yazısında.

Kendini gösteren ve simetrik ilişki

Aynı tür iki uçta da olabilir: Person sınıfındaki Parents ve Children koleksiyonları PersonPerson adlı bir join tablosuna eşlenir. Arkadaşlık gibi simetrik ilişkiler ise zordur, çünkü aynı navigation iki ucu birden temsil edemez. Belgelerin önerdiği en iyi yol HasMany(e => e.Friends).WithMany() ile tek yönlü eşlemek ve iki kişiyi birbirinin listesine elle eklemektir.

csharp
modelBuilder.Entity<Person>()
    .HasMany(e => e.Friends)
    .WithMany();

// İki yönün de kurulması uygulamanın sorumluluğu:
ginny.Friends.Add(hermione);
hermione.Friends.Add(ginny);

Aynı join sınıfını birden çok ilişkide kullanmak

Bütün join tablolarınız aynı biçimdeyse (örneğin hepsinde CreatedOn varsa) tek bir JoinType sınıfını shared-type entity olarak birden fazla ilişkide kullanabilirsiniz. UsingEntity<JoinType>("PostTag", ...) ve UsingEntity<JoinType>("BlogAuthor", ...) gibi her ilişkiye ayrı bir tablo adı verilir. Ayrıca join entity'deki foreign key'ler birincil anahtar yerine alternatif anahtarlara da bağlanabilir; bunun için HasPrincipalKey kullanılır.

Join tablosunu doğrudan kullanmak

Many-to-many kalıbını hiç kullanmadan join tablosunu normal bir entity olarak eşleyip yalnızca iki one-to-many ilişkiyle çalışmak da mümkündür. Bu özel bir eşleme gerektirmez; one-to-many ilişkiler gibi davranır. Join satırı zengin bir iş nesnesi hâline geliyorsa bu yaklaşım daha açık olabilir.

Mevcut veritabanından scaffold

Database First çalışıyor ve mevcut bir veritabanından DbContext üretiyorsanız, belgelere göre EF Core 6 ve sonrası şemadaki bu join tablosu kalıbını tanır ve many-to-many ilişki olarak scaffold eder. Bu davranış özel bir T4 şablonuyla değiştirilebilir; ayrıntılar scaffold-dbcontext yazısında.

Sık yapılan hatalar

  • Payload alanının otomatik dolacağını sanmak. Skip navigation ile eklenen join satırında EF payload alanını dolduramaz; veritabanında varsayılan değer tanımlayın ya da join entity'yi bulup değeri kendiniz yazın.
  • Gizli join tipine bağımlı kod yazmak. Dictionary<string, object> bir uygulama ayrıntısıdır; join satırlarına erişmeniz gerekiyorsa bir sınıf tanımlayın.
  • Gereksiz yere her şeyi açıkça yapılandırmak. Konvansiyon yeterliyse uzun UsingEntity zincirleri yalnızca hata riskini artırır.
  • Simetrik ilişkide tek tarafı eklemek. Arkadaşlık gibi ilişkilerde iki kişiyi birbirinin listesine eklemezseniz ilişki tek yönlü kalır.
  • Tek yönlü navigation'ı yapılandırmadan bırakmak. Yalnızca bir tarafta collection varsa EF onu one-to-many sanar; WithMany() ekleyin.

Sık Sorulan Sorular

Join tablosu için sınıf tanımlamak zorunlu mu?

Hayır. İki tarafta collection navigation varsa EF join entity'yi kendisi oluşturur. Sınıf; join satırlarına doğrudan erişmek, payload eklemek ya da adları açıkça yönetmek istediğinizde işe yarar.

Join tablosunun adını nasıl değiştiririm?

UsingEntity("YeniAd") ile. Sınıf kullanıyorsanız UsingEntity<PostTag> ile birlikte tablo adını da verebilirsiniz; tablo adı ile kolon adlarını değiştirmek birleştirilebilir.

Etiket silinince yazılar da silinir mi?

Hayır. Konvansiyonel cascade yalnızca join tablosundaki satırları siler; yani etiket silindiğinde o etiketin yazılarla eşleşmeleri kalkar, yazıların kendisi kalır.

Join satırına ek alan eklemek için ne yapmalıyım?

Join entity için bir sınıf tanımlayın, alanı ekleyin ve ilişkiyi UsingEntity<PostTag> ile bu sınıfa bağlayın. Alan veritabanında üretilecekse HasDefaultValueSql kullanın.

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: EF Core öğrenme rehberi · EF Core ilişkiler · One-to-many ilişki · One-to-one ilişki · SaveChanges ile ilişkili veri · Scaffold ve Database First