در معماری داده، تعریف درست رابطهها میان موجودیتها (Entities) برای یکپارچگی، کارایی و سادگی نگهداری ضروری است. در EF Core سه الگوی رایج روابط عبارتاند از: یکبهیک (1–1)، یکبهچند (1–N) و چندبهچند (N–N). در این مقاله، هر سه رابطه را با دو رویکرد Data Annotations و Fluent API پیادهسازی میکنیم و نکات مهمی مثل Required/Optional، رفتار حذف (DeleteBehavior)، کلیدها و ایندکسها، و مدلسازی جدول واسط با یا بدون Payload را مرور میکنیم.
۱) رابطه یکبهیک (One-to-One)
سناریوی مرسوم: هر User
دقیقاً یک UserProfile
دارد.
کلید خارجی معمولاً در جدول وابسته (UserProfile
) نگهداری میشود.
دقت کنید که برای 1–1 باید یکی از طرفین مالک کلید خارجی باشد.
Data Annotations
public class User
{
public int Id { get; set; }
[Required, MaxLength(100)]
public string Name { get; set; } = string.Empty;
public UserProfile? Profile { get; set; } // Optional navigation from principal
}
public class UserProfile
{
public int Id { get; set; } // PK == FK (الگوی متداول برای 1-1)
[Required, MaxLength(200)]
public string Address { get; set; } = string.Empty;
[Required]
public User User { get; set; } = default!;
}
Fluent API (کلید/کلیدخارجی، Required، DeleteBehavior)
// در OnModelCreating:
modelBuilder.Entity<User>(e =>
{
e.ToTable("Users");
e.HasKey(u => u.Id);
e.Property(u => u.Name).IsRequired().HasMaxLength(100);
});
modelBuilder.Entity<UserProfile>(e =>
{
e.ToTable("UserProfiles");
e.HasKey(p => p.Id);
// الگوی 1-1 با PK=FK در جدول وابسته
e.HasOne(p => p.User)
.WithOne(u => u.Profile)
.HasForeignKey<UserProfile>(p => p.Id) // FK = Id
.IsRequired()
.OnDelete(DeleteBehavior.Cascade); // حذف کاربر، پروفایل را هم حذف کند
});
نکته: اگر نخواهید PK=FK باشد، میتوانید در UserProfile
فیلد UserId
داشته باشید
و با HasForeignKey<UserProfile>(x => x.UserId)
نگاشت کنید؛ اما برای 1–1 باید
Unique Index روی UserId
بگذارید تا یکبهیک باقی بماند:
modelBuilder.Entity<UserProfile>()
.HasIndex(p => p.UserId)
.IsUnique();
۲) رابطه یکبهچند (One-to-Many)
سناریوی مرسوم: یک Category
چندین Product
دارد.
کلید خارجی در سمت «چند» (Product
) قرار میگیرد.
Data Annotations
public class Category
{
public int Id { get; set; }
[Required, MaxLength(120)]
public string Title { get; set; } = string.Empty;
public List<Product> Products { get; set; } = new();
}
public class Product
{
public int Id { get; set; }
[Required, MaxLength(150)]
public string Name { get; set; } = string.Empty;
[Precision(18,2)]
public decimal Price { get; set; }
public int CategoryId { get; set; } // FK
public Category Category { get; set; } = default!;
}
Fluent API (ایندکسها، رفتار حذف، Navigation)
// OnModelCreating:
modelBuilder.Entity<Category>(e =>
{
e.ToTable("Categories");
e.HasKey(c => c.Id);
e.Property(c => c.Title).IsRequired().HasMaxLength(120);
// ایندکس برای جستجو
e.HasIndex(c => c.Title);
});
modelBuilder.Entity<Product>(e =>
{
e.ToTable("Products");
e.HasKey(p => p.Id);
e.Property(p => p.Name).IsRequired().HasMaxLength(150);
e.Property(p => p.Price).HasPrecision(18,2);
e.HasOne(p => p.Category)
.WithMany(c => c.Products)
.HasForeignKey(p => p.CategoryId)
.IsRequired()
.OnDelete(DeleteBehavior.Restrict); // جلوگیری از حذف دستهای که محصول دارد
});
اگر بخواهید رابطه اختیاری باشد، CategoryId
را int?
تعریف و
.IsRequired(false)
تنظیم کنید. همچنین برای سناریوهای گزارشگیری سنگین،
استفاده از AsNoTracking()
و SplitQuery()
را در نظر بگیرید.
۳) رابطه چندبهچند (Many-to-Many) – بدون جدول واسط سفارشی
از EF Core 5 به بعد، میتوان N–N را بدون تعریف کلاس واسط صریح مدل کرد. مثال: دانشجوهای متعدد در دورههای متعدد شرکت میکنند.
مدل ساده (Implicit Join)
public class Student
{
public int Id { get; set; }
[Required, MaxLength(120)]
public string Name { get; set; } = string.Empty;
public List<Course> Courses { get; set; } = new();
}
public class Course
{
public int Id { get; set; }
[Required, MaxLength(160)]
public string Title { get; set; } = string.Empty;
public List<Student> Students { get; set; } = new();
}
Fluent API (نامگذاری جدول واسط و ایندکسها)
// OnModelCreating:
modelBuilder.Entity<Student>(e =>
{
e.ToTable("Students");
e.HasKey(s => s.Id);
e.Property(s => s.Name).IsRequired().HasMaxLength(120);
e.HasMany(s => s.Courses)
.WithMany(c => c.Students)
.UsingEntity<Dictionary<string, object>>(
"StudentCourse", // نام جدول واسط
r => r.HasOne<Course>().WithMany().HasForeignKey("CourseId")
.OnDelete(DeleteBehavior.Cascade),
l => l.HasOne<Student>().WithMany().HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Cascade),
j => {
j.ToTable("StudentCourses");
j.HasKey("StudentId", "CourseId");
j.HasIndex("CourseId");
j.HasIndex("StudentId");
}
);
});
۴) رابطه چندبهچند با جدول واسط سفارشی (Payload)
وقتی جدول واسط نیاز به ستونهای اضافی (مثل تاریخ ثبتنام، نمره، وضعیت) دارد، باید کلاس واسط مجزا بسازید و رابطه را به دو رابطه 1–N بشکنید.
مدل با Payload
public class Enrollment // جدول واسط با Payload
{
public int StudentId { get; set; }
public int CourseId { get; set; }
public DateTime EnrolledAt { get; set; } = DateTime.UtcNow;
[Precision(5,2)]
public decimal? Grade { get; set; }
public Student Student { get; set; } = default!;
public Course Course { get; set; } = default!;
}
public class Student
{
public int Id { get; set; }
[Required, MaxLength(120)]
public string Name { get; set; } = string.Empty;
public List<Enrollment> Enrollments { get; set; } = new();
}
public class Course
{
public int Id { get; set; }
[Required, MaxLength(160)]
public string Title { get; set; } = string.Empty;
public List<Enrollment> Enrollments { get; set; } = new();
}
Fluent API (کلید مرکب، روابط، DeleteBehavior)
// OnModelCreating:
modelBuilder.Entity<Enrollment>(e =>
{
e.ToTable("Enrollments");
e.HasKey(x => new { x.StudentId, x.CourseId }); // PK مرکب
e.Property(x => x.EnrolledAt)
.HasDefaultValueSql("GETUTCDATE()");
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);
});
۵) Required/Optional، رفتار حذف، و نکات تکمیلی
- رابطه Required: نوع FK غیر Nullable (مثل
int
) و.IsRequired()
در Fluent. - رابطه Optional: نوع FK Nullable (مثل
int?
) و.IsRequired(false)
. - DeleteBehavior:
از
Restrict
برای جلوگیری از حذف والد دارای فرزند، ازCascade
برای حذف خودکار فرزندان، و در صورت نیاز ازSetNull
استفاده کنید (FK باید Nullable باشد). - ایندکسها: بر روی کلیدهای خارجی و ستونهای پرجستوجو ایندکس بگذارید.
- نامگذاری: با
ToTable
وHasColumnName
استاندارد خود را اعمال کنید.
نمونهی کامل DbContext (تجمیع پیکربندیها)
public class AppDbContext : DbContext
{
public DbSet<User> Users => Set<User>();
public DbSet<UserProfile> UserProfiles => Set<UserProfile>();
public DbSet<Category> Categories => Set<Category>();
public DbSet<Product> Products => Set<Product>();
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: User <-> UserProfile (PK=FK)
modelBuilder.Entity<User>(e =>
{
e.ToTable("Users");
e.HasKey(u => u.Id);
e.Property(u => u.Name).IsRequired().HasMaxLength(100);
});
modelBuilder.Entity<UserProfile>(e =>
{
e.ToTable("UserProfiles");
e.HasKey(p => p.Id);
e.Property(p => p.Address).IsRequired().HasMaxLength(200);
e.HasOne(p => p.User)
.WithOne(u => u.Profile)
.HasForeignKey<UserProfile>(p => p.Id)
.IsRequired()
.OnDelete(DeleteBehavior.Cascade);
});
// 1-N: Category -> Products
modelBuilder.Entity<Category>(e =>
{
e.ToTable("Categories");
e.HasKey(c => c.Id);
e.Property(c => c.Title).IsRequired().HasMaxLength(120);
e.HasIndex(c => c.Title);
});
modelBuilder.Entity<Product>(e =>
{
e.ToTable("Products");
e.HasKey(p => p.Id);
e.Property(p => p.Name).IsRequired().HasMaxLength(150);
e.Property(p => p.Price).HasPrecision(18,2);
e.HasOne(p => p.Category)
.WithMany(c => c.Products)
.HasForeignKey(p => p.CategoryId)
.IsRequired()
.OnDelete(DeleteBehavior.Restrict);
});
// N-N (Implicit): Student <-> Course با جدول واسط خودکار
modelBuilder.Entity<Student>(e =>
{
e.ToTable("Students");
e.HasKey(s => s.Id);
e.Property(s => s.Name).IsRequired().HasMaxLength(120);
e.HasMany(s => s.Courses)
.WithMany(c => c.Students)
.UsingEntity<Dictionary<string, object>>(
"StudentCourses",
r => r.HasOne<Course>().WithMany().HasForeignKey("CourseId").OnDelete(DeleteBehavior.Cascade),
l => l.HasOne<Student>().WithMany().HasForeignKey("StudentId").OnDelete(DeleteBehavior.Cascade),
j => {
j.ToTable("StudentCourses");
j.HasKey("StudentId", "CourseId");
j.HasIndex("CourseId");
j.HasIndex("StudentId");
}
);
});
// N-N با Payload: Enrollment بهصورت 1-N + 1-N
modelBuilder.Entity<Enrollment>(e =>
{
e.ToTable("Enrollments");
e.HasKey(x => new { x.StudentId, x.CourseId });
e.Property(x => x.EnrolledAt).HasDefaultValueSql("GETUTCDATE()");
e.Property(x => x.Grade).HasPrecision(5,2);
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);
});
}
}
جمعبندی: برای 1–1 یکی از طرفین باید مالک FK باشد و معمولاً از PK=FK استفاده میشود؛ برای 1–N، FK در سمت «چند» قرار میگیرد و رفتار حذف را آگاهانه انتخاب کنید؛ برای N–N اگر Payload ندارید از نگاشت ضمنی استفاده کنید و در صورت نیاز به ستونهای اضافی، جدول واسط سفارشی (Payload) بسازید.