مديريت روابط بين جداول در EF Code first به كمك Fluent API
EF Code first بجاي اتلاف وقت شما با نوشتن فايلهاي XML تهيه نگاشتها يا تنظيم آنها با كد، رويه Convention over configuration را پيشنهاد ميدهد. همين رويه، جهت مديريت روابط بين جداول نيز برقرار است. روابط one-to-one، one-to-many، many-to-many و موارد ديگر را بدون يك سطر تنظيم اضافي، صرفا بر اساس يك سري قراردادهاي توكار ميتواند تشخيص داده و اعمال كند. عموما زماني نياز به تنظيمات دستي وجود خواهد داشت كه قراردادهاي توكار رعايت نشوند و يا براي مثال قرار است با يك بانك اطلاعاتي قديمي از پيش موجود كار كنيم.
مفاهيمي به نامهاي Principal و Dependent
در EF Code first از يك سري واژههاي خاص جهت بيان ابتدا و انتهاي روابط استفاده شده است كه عدم آشنايي با آنها درك خطاهاي حاصل را مشكل ميكند:
الف) Principal : طرفي از رابطه است كه ابتدا در بانك اطلاعاتي ذخيره خواهد شد.
ب) Dependent : طرفي از رابطه است كه پس از ثبت Principal در بانك اطلاعاتي ذخيره ميشود.
Principal ميتواند بدون نياز به Dependent وجود داشته باشد. وجود Dependent بدون Principal ممكن نيست زيرا ارتباط بين اين دو توسط يك كليد خارجي تعريف ميشود.
كدهاي مثال مديريت روابط بين جداول
در دنياي واقعي، همهي مثالها به مدل بلاگ و مطالب آن ختم نميشوند. به همين جهت نياز است يك مدل نسبتا پيچيدهتر را در اينجا بررسي كنيم. در ادامه كدهاي كامل مثال جاري را مشاهده خواهيد كرد:
using System.Collections.Generic; namespace EF_Sample35.Models { public class Customer { public int Id { set; get; } public string FirstName { set; get; } public string LastName { set; get; } public virtual AlimentaryHabits AlimentaryHabits { set; get; } public virtual ICollection<CustomerAlias> Aliases { get; set; } public virtual ICollection<Role> Roles { get; set; } public virtual Address Address { get; set; } } }
namespace EF_Sample35.Models { public class CustomerAlias { public int Id { get; set; } public string Aka { get; set; } public virtual Customer Customer { get; set; } } }
using System.Collections.Generic; namespace EF_Sample35.Models { public class Role { public int Id { set; get; } public string Name { set; get; } public virtual ICollection<Customer> Customers { set; get; } } }
namespace EF_Sample35.Models { public class AlimentaryHabits { public int Id { get; set; } public bool LikesPasta { get; set; } public bool LikesPizza { get; set; } public int AverageDailyCalories { get; set; } public virtual Customer Customer { get; set; } } }
using System.Collections.Generic; namespace EF_Sample35.Models { public class Address { public int Id { set; get; } public string City { set; get; } public string StreetAddress { set; get; } public string PostalCode { set; get; } public virtual ICollection<Customer> Customers { set; get; } } }
همچنين تعاريف نگاشتهاي برنامه نيز مطابق كدهاي زير است:
using System.Data.Entity.ModelConfiguration; using EF_Sample35.Models; namespace EF_Sample35.Mappings { public class CustomerAliasConfig : EntityTypeConfiguration<CustomerAlias> { public CustomerAliasConfig() { // one-to-many this.HasRequired(x => x.Customer) .WithMany(x => x.Aliases) .WillCascadeOnDelete(); } } }
using System.Data.Entity.ModelConfiguration; using EF_Sample35.Models; namespace EF_Sample35.Mappings { public class CustomerConfig : EntityTypeConfiguration<Customer> { public CustomerConfig() { // one-to-one this.HasOptional(x => x.AlimentaryHabits) .WithRequired(x => x.Customer) .WillCascadeOnDelete(); // many-to-many this.HasMany(p => p.Roles) .WithMany(t => t.Customers) .Map(mc => { mc.ToTable("RolesJoinCustomers"); mc.MapLeftKey("RoleId"); mc.MapRightKey("CustomerId"); }); // many-to-one this.HasOptional(x => x.Address) .WithMany(x => x.Customers) .WillCascadeOnDelete(); } } }
به همراه Context زير:
using System.Data.Entity; using System.Data.Entity.Migrations; using EF_Sample35.Mappings; using EF_Sample35.Models; namespace EF_Sample35.DataLayer { public class Sample35Context : DbContext { public DbSet<AlimentaryHabits> AlimentaryHabits { set; get; } public DbSet<Customer> Customers { set; get; } protected override void OnModelCreating(DbModelBuilder modelBuilder) { modelBuilder.Configurations.Add(new CustomerConfig()); modelBuilder.Configurations.Add(new CustomerAliasConfig()); base.OnModelCreating(modelBuilder); } } public class Configuration : DbMigrationsConfiguration<Sample35Context> { public Configuration() { AutomaticMigrationsEnabled = true; AutomaticMigrationDataLossAllowed = true; } protected override void Seed(Sample35Context context) { base.Seed(context); } } }
كه نهايتا منجر به توليد چنين ساختاري در بانك اطلاعاتي ميگردد:
توضيحات كامل كدهاي فوق:
تنظيمات روابط one-to-one و يا one-to-zero
زمانيكه رابطهاي 0..1 و يا 1..1 است، مطابق قراردادهاي توكار EF Code first تنها كافي است يك navigation property را كه بيانگر ارجاعي است به شيء ديگر، تعريف كنيم (در هر دو طرف رابطه).
براي مثال در مدلهاي فوق يك مشتري كه در حين ثبت اطلاعات اصلي او، «ممكن است» اطلاعات جانبي ديگري (AlimentaryHabits) نيز از او تنها در طي يك ركورد، دريافت شود. قصد هم نداريم يك ComplexType را تعريف كنيم. نياز است جدول AlimentaryHabits جداگانه وجود داشته باشد.
namespace EF_Sample35.Models { public class Customer { // ... public virtual AlimentaryHabits AlimentaryHabits { set; get; } } }
namespace EF_Sample35.Models { public class AlimentaryHabits { // ... public virtual Customer Customer { get; set; } } }
در اينجا خواص virtual تعريف شده در دو طرف رابطه، به EF خواهد گفت كه رابطهاي، 1:1 برقرار است. در اين حالت اگر برنامه را اجرا كنيم، به خطاي زير برخواهيم خورد:
Unable to determine the principal end of an association between the types 'EF_Sample35.Models.Customer' and 'EF_Sample35.Models.AlimentaryHabits'. The principal end of this association must be explicitly configured using either the relationship fluent API or data annotations.
EF تشخيص داده است كه رابطه 1:1 برقرار است؛ اما با قاطعيت نميتواند طرف Principal را تعيين كند. بنابراين بايد اندكي به او كمك كرد:
using System.Data.Entity.ModelConfiguration; using EF_Sample35.Models; namespace EF_Sample35.Mappings { public class CustomerConfig : EntityTypeConfiguration<Customer> { public CustomerConfig() { // one-to-one this.HasOptional(x => x.AlimentaryHabits) .WithRequired(x => x.Customer) .WillCascadeOnDelete(); } } }
همانطور كه ملاحظه ميكنيد در اينجا توسط متد WithRequired طرف Principal و توسط متد HasOptional، طرف Dependent تعيين شده است. به اين ترتيب EF ميتوان يك رابطه 1:1 را تشكيل دهيد.
توسط متد WillCascadeOnDelete هم مشخص ميكنيم كه اگر Principal حذف شد، لطفا Dependent را به صورت خودكار حذف كن.
توضيحات ساختار جداول تشكيل شده:
هر دو جدول با همان خواص اصلي كه در دو كلاس وجود دارند، تشكيل شدهاند.
فيلد Id جدول AlimentaryHabits اينبار ديگر Identity نيست. اگر به تعريف قيد FK_AlimentaryHabits_Customers_Id دقت كنيم، در اينجا مشخص است كه فيلد Id جدول AlimentaryHabits، به فيلد Id جدول مشتريها متصل شده است (يعني در آن واحد هم primary key است و هم foreign key). به همين جهت به اين روش one-to-one association with shared primary key هم گفته ميشود (كليد اصلي جدول مشتري با جدول AlimentaryHabits به اشتراك گذاشته شده است).
تنظيمات روابط one-to-many
براي مثال همان مشتري فوق را درنظر بگيريد كه داراي تعدادي نام مستعار است:
using System.Collections.Generic; namespace EF_Sample35.Models { public class Customer { // ... public virtual ICollection<CustomerAlias> Aliases { get; set; } } }
namespace EF_Sample35.Models { public class CustomerAlias { // ... public virtual Customer Customer { get; set; } } }
همين ميزان تنظيم كفايت ميكند و نيازي به استفاده از Fluent API براي معرفي روابط نيست.
در طرف Principal، يك مجموعه يا ليستي از Dependent وجود دارد. در Dependent هم يك navigation property معرف طرف Principal اضافه شده است.
جدول CustomerAlias اضافه شده، توسط يك كليد خارجي به جدول مشتري مرتبط ميشود.
سؤال: اگر در اينجا نيز بخواهيم CascadeOnDelete را اعمال كنيم، چه بايد كرد؟
پاسخ: جهت سفارشي سازي نحوه تعاريف روابط حتما نياز به استفاده از Fluent API به نحو زير ميباشد:
using System.Data.Entity.ModelConfiguration; using EF_Sample35.Models; namespace EF_Sample35.Mappings { public class CustomerAliasConfig : EntityTypeConfiguration<CustomerAlias> { public CustomerAliasConfig() { // one-to-many this.HasRequired(x => x.Customer) .WithMany(x => x.Aliases) .WillCascadeOnDelete(); } } }
اينكار را بايد در كلاس تنظيمات CustomerAlias انجام داد تا بتوان Principal را توسط متد HasRequired به Customer و سپس dependent را به كمك متد WithMany مشخص كرد. در ادامه ميتوان متد WillCascadeOnDelete يا هر تنظيم سفارشي ديگري را نيز اعمال نمود.
متد HasRequired سبب خواهد شد فيلد Customer_Id، به صورت not null در سمت بانك اطلاعاتي تعريف شود؛ متد HasOptional عكس آن است.
تنظيمات روابط many-to-many
براي تنظيم روابط many-to-many تنها كافي است دو سر رابطه ارجاعاتي را به يكديگر توسط يك ليست يا مجموعه داشته باشند:
using System.Collections.Generic; namespace EF_Sample35.Models { public class Role { // ... public virtual ICollection<Customer> Customers { set; get; } } }
using System.Collections.Generic; namespace EF_Sample35.Models { public class Customer { // ... public virtual ICollection<Role> Roles { get; set; } } }
همانطور كه مشاهده ميكنيد، يك مشتري ميتواند چندين نقش داشته باشد و هر نقش ميتواند به چندين مشتري منتسب شود.
اگر برنامه را به اين ترتيب اجرا كنيم، به صورت خودكار يك رابطه many-to-many تشكيل خواهد شد (بدون نياز به تنظيمات نگاشتهاي آن). نكته جالب آن تشكيل خودكار جدول ارتباط دهنده واسط يا اصطلاحا join-table ميباشد:
CREATE TABLE [dbo].[RolesJoinCustomers]( [RoleId] [int] NOT NULL, [CustomerId] [int] NOT NULL, )
سؤال: نامهاي خودكار استفاده شده را ميخواهيم تغيير دهيم. چكار بايد كرد؟
پاسخ: اگر بانك اطلاعاتي براي بار اول است كه توسط اين روش توليد ميشود شايد اين پيش فرضها اهميتي نداشته باشد و نسبتا هم مناسب هستند. اما اگر قرار باشد از يك بانك اطلاعاتي موجود كه امكان تغيير نام فيلدها و جداول آن وجود ندارد استفاده كنيم، نياز به سفارشي سازي تعاريف نگاشتها به كمك Fluent API خواهيم داشت:
using System.Data.Entity.ModelConfiguration; using EF_Sample35.Models; namespace EF_Sample35.Mappings { public class CustomerConfig : EntityTypeConfiguration<Customer> { public CustomerConfig() { // many-to-many this.HasMany(p => p.Roles) .WithMany(t => t.Customers) .Map(mc => { mc.ToTable("RolesJoinCustomers"); mc.MapLeftKey("RoleId"); mc.MapRightKey("CustomerId"); }); } } }
تنظيمات روابط many-to-one
در تكميل مدلهاي مثال جاري، به دو كلاس زير خواهيم رسيد. در اينجا تنها در كلاس مشتري است كه ارجاعي به كلاس آدرس او وجود دارد. در كلاس آدرس، يك navigation property همانند حالت 1:1 تعريف نشده است:
namespace EF_Sample35.Models { public class Address { public int Id { set; get; } public string City { set; get; } public string StreetAddress { set; get; } public string PostalCode { set; get; } } }
using System.Collections.Generic; namespace EF_Sample35.Models { public class Customer { // … public virtual Address Address { get; set; } } }
اين رابطه توسط EF Code first به صورت خودكار به يك رابطه many-to-one تفسير خواهد شد و نيازي به تنظيمات خاصي ندارد.
زمانيكه جداول برنامه تشكيل شوند، جدول Addresses موجوديتي مستقل خواهد داشت و جدول مشتري با يك فيلد به نام Address_Id به جدول آدرسها متصل ميگردد. اين فيلد نال پذير است؛ به عبارتي ذكر آدرس مشتري الزامي نيست.
اگر نياز بود اين تعاريف نيز توسط Fluent API سفارشي شوند، بايد خاصيت public virtual ICollection<Customer> Customers به كلاس Address نيز اضافه شود تا بتوان رابطه زير را توسط كدهاي برنامه تعريف كرد:
using System.Data.Entity.ModelConfiguration; using EF_Sample35.Models; namespace EF_Sample35.Mappings { public class CustomerConfig : EntityTypeConfiguration<Customer> { public CustomerConfig() { // many-to-one this.HasOptional(x => x.Address) .WithMany(x => x.Customers) .WillCascadeOnDelete(); } } }
متد HasOptional سبب ميشود تا فيلد Address_Id اضافه شده به جدول مشتريها، null پذير شود.