پیادهسازی رابطههای یکبهیک، یکبهچند و چندبهچند در 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 استفاده کنید.