پیاده‌سازی رابطه‌های یک‌به‌یک، یک‌به‌چند و چندبه‌چند در EF Core

در این مقاله، الگوهای اصلی رابطه‌ها در EF Core (۱:۱، ۱:N، N:N) را با مدل دامنه، Fluent API، نکات ظریف (Required/Optional، حذف آبشاری، کلید جایگزین/Shared PK) و نمونه‌های Query، Seeding، Migration و تست مرور می‌کنیم.

۱) مفاهیم پایه

  • Principal / Dependent: موجودیت اصلی و وابسته؛ کلید خارجی (FK) در وابسته است.
  • Required vs Optional: نال نبودن FK → رابطه الزامی؛ نال بودن FK → اختیاری.
  • DeleteBehavior: رفتار حذف را با Cascade، Restrict یا NoAction تنظیم کنید.
  • Shadow FK: اگر FK را تعریف نکنید، EF Core ستون سایه‌ای ایجاد می‌کند؛ شفاف‌سازی با property صریح بهتر است.
  • Unique/Alternate Key: برای 1:1 واقعی باید یکتایی را تضمین کنید (Unique Index یا Shared PK).

۲) رابطه یک‌به‌یک (1:1)

سناریو: هر User دقیقاً یک Profile دارد.

مدل دامنه

public class User
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public Profile? Profile { get; set; } // 1:1
}

public class Profile
{
public int Id { get; set; }
public string? Bio { get; set; }

```
// FK یکتا به User (الگوی رایج)
public int UserId { get; set; }
public User User { get; set; } = default!;
```

} 

Fluent API

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<User>(e =>
    {
        e.HasOne(u => u.Profile)
         .WithOne(p => p.User)
         .HasForeignKey<Profile>(p => p.UserId)
         .OnDelete(DeleteBehavior.Cascade); // یا Restrict/NoAction
    });

```
modelBuilder.Entity<Profile>()
    .HasIndex(p => p.UserId)
    .IsUnique(); // تضمین 1:1
```

} 

Shared Primary Key (گزینه جایگزین):

می‌توانید Profile.Id را به‌عنوان FK به User.Id نگه‌دارید:

modelBuilder.Entity<Profile>()
    .HasOne(p => p.User)
    .WithOne(u => u.Profile)
    .HasForeignKey<Profile>(p => p.Id); // Shared PK

Query نمونه

var user = new User { Name = "Alice", Profile = new Profile { Bio = "C# Dev" } };
ctx.Add(user);
await ctx.SaveChangesAsync();

var loaded = await ctx.Users.Include(u => u.Profile).FirstAsync(); 

۳) رابطه یک‌به‌چند (1:N)

سناریو: هر Blog چند Post دارد.

مدل دامنه

public class Blog
{
    public int Id { get; set; }
    public required string Title { get; set; }
    public List<Post> Posts { get; set; } = new();
}

public class Post
{
public int Id { get; set; }
public required string Title { get; set; }

```
public int BlogId { get; set; }           // Required
public Blog Blog { get; set; } = default!;
```

} 

Fluent API

modelBuilder.Entity<Blog>(e =>
{
    e.HasMany(b => b.Posts)
     .WithOne(p => p.Blog)
     .HasForeignKey(p => p.BlogId)
     .IsRequired()                          // .IsRequired(false) برای Optional
     .OnDelete(DeleteBehavior.Cascade);     // یا Restrict/NoAction
});

Queryهای متداول

// بارگذاری همراه
var blogs = await ctx.Blogs
    .Include(b => b.Posts)
    .ToListAsync();

// گرفتن عنوان بلاگ هر پست
var recent = await ctx.Posts
.Where(p => p.Title.Contains("EF"))
.Select(p => new { p.Id, BlogTitle = p.Blog.Title })
.ToListAsync(); 

۴) رابطه چندبه‌چند (N:N)

حالت ساده (Skip Navigations)

بدون کلاس Join صریح؛ EF Core جدول میانی با کلید مرکب می‌سازد.

public class Student
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public ICollection<Course> Courses { get; set; } = new HashSet<Course>();
}

public class Course
{
public int Id { get; set; }
public required string Title { get; set; }
public ICollection\ Students { get; set; } = new HashSet\();
}

modelBuilder.Entity\()
.HasMany(s => s.Courses)
.WithMany(c => c.Students)
.UsingEntity(j =>
{
j.ToTable("StudentCourses"); // جدول میانی
j.HasIndex("CoursesId");
j.HasIndex("StudentsId");
}); 

حالت پیشرفته (Join Entity با ستون‌های اضافه)

public class Enrollment
{
    public int StudentId { get; set; }
    public int CourseId  { get; set; }
    public DateTime RegisteredAt { get; set; } = DateTime.UtcNow;
    public int? Grade { get; set; }

```
public Student Student { get; set; } = default!;
public Course  Course  { get; set; } = default!;
```

}

modelBuilder.Entity\(e =>
{
e.HasKey(x => new { x.StudentId, x.CourseId }); // PK مرکب

```
e.HasOne(x => x.Student)
 .WithMany(s => s.Enrollments)
 .HasForeignKey(x => x.StudentId)
 .OnDelete(DeleteBehavior.Cascade);

e.HasOne(x => x.Course)
 .WithMany(c => c.Enrollments)
 .HasForeignKey(x => x.CourseId)
 .OnDelete(DeleteBehavior.Cascade);

e.Property(x => x.RegisteredAt)
 .HasDefaultValueSql("GETUTCDATE()"); // SQL Server
```

});

// مدل‌های والد:
public class Student
{
public int Id { get; set; }
public required string Name { get; set; }
public ICollection\ Enrollments { get; set; } = new HashSet\();
}
public class Course
{
public int Id { get; set; }
public required string Title { get; set; }
public ICollection\ Enrollments { get; set; } = new HashSet\();
} 

Query نمونه

// ثبت نام دانشجو در یک درس
ctx.Add(new Enrollment { StudentId = 1, CourseId = 10, Grade = 18 });
await ctx.SaveChangesAsync();

// لیست درس‌های یک دانشجو همراه با نمره
var std = await ctx.Students
.Where(s => s.Id == 1)
.Select(s => new {
s.Name,
Courses = s.Enrollments.Select(e => new { e.Course.Title, e.Grade })
})
.FirstAsync(); 

۵) Migration و Seeding

DbContext نمونه (تجمیع پیکربندی‌ها)

public class AppDbContext : DbContext
{
    public DbSet<User> Users => Set<User>();
    public DbSet<Profile> Profiles => Set<Profile>();
    public DbSet<Blog> Blogs => Set<Blog>();
    public DbSet<Post> Posts => Set<Post>();
    public DbSet<Student> Students => Set<Student>();
    public DbSet<Course> Courses => Set<Course>();
    public DbSet<Enrollment> Enrollments => Set<Enrollment>();

```
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) {}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // 1:1
    modelBuilder.Entity<User>(e =>
    {
        e.HasOne(u => u.Profile)
         .WithOne(p => p.User)
         .HasForeignKey<Profile>(p => p.UserId)
         .OnDelete(DeleteBehavior.Cascade);
    });
    modelBuilder.Entity<Profile>().HasIndex(p => p.UserId).IsUnique();

    // 1:N
    modelBuilder.Entity<Blog>(e =>
    {
        e.HasMany(b => b.Posts)
         .WithOne(p => p.Blog)
         .HasForeignKey(p => p.BlogId)
         .IsRequired()
         .OnDelete(DeleteBehavior.Cascade);
    });

    // N:N (Join Entity)
    modelBuilder.Entity<Enrollment>(e =>
    {
        e.HasKey(x => new { x.StudentId, x.CourseId });
        e.Property(x => x.RegisteredAt).HasDefaultValueSql("GETUTCDATE()");
        e.HasOne(x => x.Student).WithMany(s => s.Enrollments).HasForeignKey(x => x.StudentId);
        e.HasOne(x => x.Course).WithMany(c => c.Enrollments).HasForeignKey(x => x.CourseId);
    });

    // Seed
    modelBuilder.Entity<Blog>().HasData(new Blog { Id = 1, Title = "EF Core Tips" });
    modelBuilder.Entity<Post>().HasData(
        new Post { Id = 1, Title = "1:N Deep Dive", BlogId = 1 },
        new Post { Id = 2, Title = "DeleteBehavior Guide", BlogId = 1 }
    );
}
```

} 

فرمان‌های Migration (CLI)

dotnet ef migrations add Init_Relationships
dotnet ef database update

۶) تست رابطه‌ها (SQLite In-Memory برای قیود relational)

برای تست صحیح کلیدهای خارجی و یکتایی، از SQLite In-Memory استفاده کنید (InMemoryProvider قیود relational را اعمال نمی‌کند).

using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Xunit;

public class RelationshipTests
{
private static AppDbContext CreateContext()
{
var conn = new SqliteConnection("DataSource=\:memory:");
conn.Open();
var options = new DbContextOptionsBuilder\()
.UseSqlite(conn)
.Options;
var ctx = new AppDbContext(options);
ctx.Database.EnsureCreated();
return ctx;
}

```
[Fact]
public void OneToOne_Unique_IsEnforced()
{
    using var ctx = CreateContext();
    var u1 = new User { Name = "Alice" };
    ctx.Users.Add(u1);
    ctx.SaveChanges();

    ctx.Profiles.Add(new Profile { UserId = u1.Id, Bio = "B1" });
    ctx.SaveChanges();

    // تلاش برای ایجاد پروفایل دوم برای همان کاربر باید خطا دهد (Unique)
    ctx.Profiles.Add(new Profile { UserId = u1.Id, Bio = "B2" });
    Assert.ThrowsAny<DbUpdateException>(() => ctx.SaveChanges());
}

[Fact]
public void OneToMany_Cascade_Delete_Posts()
{
    using var ctx = CreateContext();
    var blog = new Blog { Title = "Test" };
    blog.Posts.Add(new Post { Title = "P1" });
    blog.Posts.Add(new Post { Title = "P2" });
    ctx.Add(blog);
    ctx.SaveChanges();

    ctx.Remove(blog);
    ctx.SaveChanges();

    Assert.Empty(ctx.Posts.ToList());
}
```

} 

۷) خطاهای متداول و راه‌حل

  • Unable to determine the relationship represented by navigation… → ناوبری‌ها مبهم‌اند؛ HasOne/WithOne یا HasMany/WithOne را صریح و FK را مشخص کنید.
  • Multiple cascade paths → چند مسیر حذف آبشاری؛ روی یکی از روابط DeleteBehavior.Restrict یا NoAction تنظیم کنید.
  • The property ‘…’ is part of a key and so cannot be modified → از تغییر کلید اصلی/مرکب اجتناب کنید؛ رکورد جدید بسازید یا طراحی را بازنگری کنید.

۸) جمع‌بندی (Cheat Sheet سریع)

  • 1:1 → HasOne().WithOne().HasForeignKey<TDependent>(...) + Unique Index یا Shared PK
  • 1:N → HasMany().WithOne().HasForeignKey(...) (+ IsRequired(true/false))
  • N:N ساده → HasMany().WithMany().UsingEntity(...) (Skip Navigations)
  • N:N با اطلاعات اضافه → Join Entity + HasKey(Composite)
  • رفتار حذف → OnDelete(Cascade|Restrict|NoAction)
  • خوانایی و کارایی → FK صریح، Include هوشمند، Projection با Select

نکته عملکردی:

  • برای لیست‌های بزرگ از Projection استفاده کنید (به‌جای Include همه ناوبری‌ها).
  • ایندکس روی FKها و ستون‌های Join (به‌ویژه در N:N) را فراموش نکنید.
  • برای تست قیود relational از SQLite In-Memory استفاده کنید.