مقالات › EF Core و دیتابیس

روابط در EF Core؛ یک‌به‌یک، یک‌به‌چند و چندبه‌چند

روابط را در Entity Framework Core درست مدل کنید: Navigation Property، کلید خارجی، پیکربندی با Fluent API، رفتار حذف، جدول واسط و بارگذاری داده‌های مرتبط.

داده‌های واقعی به هم متصل‌اند: مشتری‌ها سفارش دارند، سفارش‌ها آیتم دارند و پست‌ها تگ. Entity Framework Core این اتصال‌ها را به‌صورت رابطه (Relationship) مدل می‌کند و آن‌ها را به کلید خارجی، Join و Constraint در دیتابیس تبدیل می‌کند. درست انجام دادن این کار مهم است؛ یک رفتار حذف اشتباه یا یک ایندکس جاافتاده می‌تواند داده را خراب یا همه‌ی کوئری‌ها را کند کند.

اصطلاحات

  • موجودیت اصلی (Principal) — «والد» (مثلاً Blog).
  • موجودیت وابسته (Dependent) — «فرزند» که کلید خارجی را نگه می‌دارد (مثلاً Post).
  • کلید خارجی (FK) — Propertyی در موجودیت وابسته که کلید والد را ذخیره می‌کند (BlogId).
  • Navigation Property — Propertyی که به موجودیت یا کالکشن مرتبط اشاره می‌کند (post.Blog، blog.Posts).

رابطه‌ی یک‌به‌چند

رایج‌ترین رابطه. هر وبلاگ پست‌های زیادی دارد و هر پست متعلق به یک وبلاگ است.

public class Blog
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public List<Post> Posts { get; set; } = [];
}

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

    public int BlogId { get; set; }          // FK
    public Blog Blog { get; set; } = null!;  // Navigation ارجاعی
}

EF Core این رابطه را با قرارداد تشخیص می‌دهد. صریح نوشتن با Fluent API منظور را روشن‌تر می‌کند:

modelBuilder.Entity<Post>()
    .HasOne(p => p.Blog)
    .WithMany(b => b.Posts)
    .HasForeignKey(p => p.BlogId)
    .OnDelete(DeleteBehavior.Cascade);

الزامی یا اختیاری

کلید خارجی غیر Nullable (int BlogId) رابطه را الزامی می‌کند: پست بدون وبلاگ وجود ندارد. کلید خارجی Nullable (int? CategoryId) آن را اختیاری می‌کند.

نکته: همیشه Property کلید خارجی را صریح تعریف کنید. بدون آن EF Core یک کلید خارجی «سایه (Shadow)» می‌سازد که کار می‌کند اما کوئری‌نویسی و نگاشت به DTO را دشوارتر می‌کند.

رابطه‌ی یک‌به‌یک

هر کاربر حداکثر یک پروفایل دارد:

public class User
{
    public int Id { get; set; }
    public string Email { get; set; } = "";
    public UserProfile? Profile { get; set; }
}

public class UserProfile
{
    public int Id { get; set; }
    public string Bio { get; set; } = "";

    public int UserId { get; set; }
    public User User { get; set; } = null!;
}

modelBuilder.Entity<User>()
    .HasOne(u => u.Profile)
    .WithOne(p => p.User)
    .HasForeignKey<UserProfile>(p => p.UserId);

در رابطه‌ی یک‌به‌یک باید با HasForeignKey<T> به EF Core بگویید کدام طرف وابسته است. EF Core خودکار یک ایندکس یکتا روی UserId می‌سازد.

وقتی داده‌ی مرتبط کوچک است و همیشه با هم بارگذاری می‌شود، Owned Type (OwnsOne) را در نظر بگیرید که ستون‌ها را در همان جدول ذخیره می‌کند:

modelBuilder.Entity<Order>().OwnsOne(o => o.ShippingAddress);

رابطه‌ی چندبه‌چند

هر پست تگ‌های زیادی دارد و هر تگ متعلق به پست‌های زیادی است.

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

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

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

EF Core جدول واسط PostTag را خودکار می‌سازد. اضافه کردن تگ به همین سادگی است:

post.Tags.Add(existingTag);
await db.SaveChangesAsync();

موجودیت واسط صریح (وقتی رابطه داده دارد)

اگر خود رابطه اطلاعاتی دارد — مثلاً زمان ثبت‌نام دانشجو یا نمره‌اش — موجودیت واسط را صریح مدل کنید:

public class Enrollment
{
    public int StudentId { get; set; }
    public Student Student { get; set; } = null!;

    public int CourseId { get; set; }
    public Course Course { get; set; } = null!;

    public DateTime EnrolledAt { get; set; }
    public decimal? Grade { get; set; }
}

modelBuilder.Entity<Enrollment>()
    .HasKey(e => new { e.StudentId, e.CourseId });   // کلید ترکیبی

خیلی از پروژه‌ها با Skip Navigation شروع می‌کنند و بعداً به ستون‌های اضافه نیاز پیدا می‌کنند؛ مدل کردن موجودیت واسط از ابتدا، دردسر یک Migration را کم می‌کند.

رفتار حذف (Delete Behavior)

وقتی والد حذف می‌شود، چه اتفاقی برای وابسته‌ها می‌افتد؟

DeleteBehavior اثر کاربرد رایج
Cascade وابسته‌ها هم حذف می‌شوند پست ← نظرات، سفارش ← آیتم‌های سفارش
Restrict / NoAction اگر وابسته وجود داشته باشد، حذف خطا می‌دهد مشتری ← سفارش‌ها (حفظ سابقه)
SetNull کلید خارجی null می‌شود (رابطه‌ی اختیاری) حذف دسته‌بندی ← محصولات بدون دسته
ClientSetNull پیش‌فرض برای FK اختیاری؛ فقط برای موجودیت‌های ردیابی‌شده null می‌کند —

هشدار: SQL Server ساختارهایی با چند مسیر آبشاری به یک جدول را رد می‌کند (خطای «may cause cycles or multiple cascade paths»). یکی از مسیرها را Restrict یا NoAction کنید.

برای داده‌های بیزینسی به‌جای حذف فیزیکی، حذف نرم (Soft Delete) را در نظر بگیرید:

modelBuilder.Entity<Customer>().HasQueryFilter(c => !c.IsDeleted);

رابطه‌ی خودارجاع

دسته‌بندی‌هایی با زیردسته یا کارمندانی با مدیر:

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = "";

    public int? ParentId { get; set; }
    public Category? Parent { get; set; }
    public List<Category> Children { get; set; } = [];
}

modelBuilder.Entity<Category>()
    .HasOne(c => c.Parent)
    .WithMany(c => c.Children)
    .HasForeignKey(c => c.ParentId)
    .OnDelete(DeleteBehavior.Restrict);

بارگذاری داده‌های مرتبط

// Eager Loading
var blog = await db.Blogs
    .Include(b => b.Posts.Where(p => p.IsPublished))   // Include فیلترشده
        .ThenInclude(p => p.Tags)
    .FirstAsync(b => b.Id == id);

// Projection — معمولاً بهترین گزینه برای API
var dto = await db.Blogs
    .Where(b => b.Id == id)
    .Select(b => new BlogDto(
        b.Title,
        b.Posts.Count,
        b.Posts.OrderByDescending(p => p.Id).Select(p => p.Title).Take(5).ToList()))
    .FirstAsync();

// Explicit Loading
await db.Entry(blog).Collection(b => b.Posts).LoadAsync();

وقتی چند کالکشن را Include می‌کنید، نتیجه‌ی Join ممکن است به‌شدت بزرگ شود («Cartesian Explosion»). با .AsSplitQuery() هر کالکشن را با یک کوئری جداگانه بارگذاری کنید.

جلوگیری از چرخه در JSON

موجودیت‌هایی با Navigation دوطرفه (blog.Posts[0].Blog...) هنگام Serialize شدن حلقه‌ی ارجاع می‌سازند. راه‌حل واقعی این است که Entity را از API برنگردانید؛ DTOهایی متناسب با نیاز کلاینت برگردانید. اگر مجبورید، ReferenceHandler.IgnoreCycles را تنظیم کنید.

چک‌لیست

  • Property کلید خارجی را صریح تعریف و ایندکس کنید (EF Core طبق قرارداد ایندکس می‌سازد).
  • رفتار حذف را برای هر رابطه آگاهانه انتخاب کنید.
  • وقتی رابطه داده دارد، از موجودیت واسط صریح استفاده کنید.
  • در APIها Projection را به زنجیره‌های عمیق Include ترجیح دهید.
  • برای Include چند کالکشن از AsSplitQuery() استفاده کنید.

جمع‌بندی

EF Core روابط یک‌به‌یک، یک‌به‌چند، چندبه‌چند و خودارجاع را از طریق Navigation Property و کلید خارجی پشتیبانی می‌کند. قراردادها حالت‌های ساده را پوشش می‌دهند و Fluent API اجازه می‌دهد روابط الزامی/اختیاری، رفتار حذف و موجودیت‌های واسط را دقیق تعریف کنید. روابط را آگاهانه مدل و بهینه بارگذاری کنید تا لایه‌ی داده هم درست بماند و هم سریع.

پرسش‌های متداول

نه. Navigation در یک طرف کافی است. طرف مقابل را فقط وقتی اضافه کنید که واقعاً از آن استفاده می‌کنید.

چرا EF Core ستونی به اسم BlogId1 ساخت؟

معمولاً چون EF Core دو رابطه پیدا کرده که نتوانسته خودکار جفتشان کند. رابطه را با HasOne/WithMany صریح پیکربندی کنید.

از Lazy Loading استفاده کنم؟

در Web APIها نه؛ کوئری‌ها را پنهان می‌کند و مشکل N+1 می‌سازد. به‌جای آن از Projection یا Include صریح استفاده کنید.