شروع به کار با EF Core 1.0 - قسمت 11 - بررسی رابطه‌ی Self Referencing
اندازه‌ی قلم متن
تخمین مدت زمان مطالعه‌ی مطلب: پنج دقیقه

پیشنیازها
- بررسی نحوه تعریف نگاشت جداول خود ارجاع دهنده (Self Referencing Entity)
- مباحث تکمیلی مدل‌های خود ارجاع دهنده در EF Code first
- آشنایی با SQL Server Common Table Expressions - CTE
- بدست آوردن برگهای یک درخت توسط Recursive CTE


در پیشنیازهای بحث، روش تعریف روابط خود ارجاع دهنده و یا Self Referencing را تا EF 6.x می‌توانید مطالعه کنید. در این قسمت قصد داریم معادل این روش‌ها را در EF Core بررسی کنیم.


روش تعریف روابط خود ارجاع دهنده توسط Fluent API در EF Core

اگر همان مدل کامنت‌های چندسطحی یک بلاگ را درنظر بگیریم:
    public class BlogComment
    {
        public int Id { get; set; }

        public string Body { get; set; }

        public DateTime Date1 { get; set; }


        public virtual BlogComment Reply { get; set; }
        public int? ReplyId { get; set; }

        public virtual ICollection<BlogComment> Children { get; set; }
    }
اینبار تنظیمات Fluent API معادل EF Core آن به صورت ذیل خواهد بود:
    public class MyDBDataContext : DbContext
    {
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlServer(@"Data Source=(local);Initial Catalog=testdb2;Integrated Security = true");
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<BlogComment>(entity =>
            {
                entity.HasIndex(e => e.ReplyId);

                entity.HasOne(d => d.Reply)
                    .WithMany(p => p.Children)
                    .HasForeignKey(d => d.ReplyId);
            });
        }

        public virtual DbSet<BlogComment> BlogComments { get; set; }
    }
هدف از مدل‌های خود ارجاع دهنده، مدلسازی اطلاعات چند سطحی است؛ مانند منوهای چندسطحی یک سایت، کامنت‌های چند سطحی یک مطلب، سلسله مراتب کارمندان یک شرکت و امثال آن.
نکته‌ها‌ی مهم مدلسازی این نوع روابط، موارد ذیل هستند:
الف) وجود خاصیتی از جنس کلاس اصلی در همان کلاس
   public virtual BlogComment Reply { get; set; }
ب) که در حقیقت مشخص می‌کند، والد رکورد جاری کدام رکورد قبلی است:
   public int? ReplyId { get; set; }
برای اینکه چنین رابطه‌ای تشکیل شود، باید این فیلد، مقدارش را از کلید اصلی جدول تامین کند (تشکیل یک کلید خارجی که به کلید اصلی جدول اشاره می‌کند).
علت نال پذیر بودن این خاصیت، نیاز به ثبت ریشه‌ای است که خودش والد است و فرزند رکوردی پیشین، نیست.


ج) همچنین برای سهولت دریافت فرزندان یک ریشه، مجموعه‌ای از جنس همان کلاس نیز تشکیل می‌شود.
 public virtual ICollection<BlogComment> Children { get; set; }


روش تعریف روابط خود ارجاع دهنده توسط Data Annotations در EF Core

در ادامه معادل تنظیمات فوق را توسط ویژگی‌ها ملاحظه می‌کنید که در اینجا توسط ویژگی‌های InverseProperty و ForeignKey، کار تشکیل روابط صورت گرفته‌است:
    public class BlogComment
    {
        public int Id { get; set; }
        public string Body { get; set; }
        public DateTime Date1 { get; set; }

        [ForeignKey("ReplyId")]
        [InverseProperty("Children")]
        public virtual BlogComment Reply { get; set; }
        public int? ReplyId { get; set; }

        [InverseProperty("Reply")]
        public virtual ICollection<BlogComment> Children { get; set; }
    }
البته قسمت تشکیل ایندکس بر روی ReplyId را فقط توسط Fluent API می‌توان انجام داد:
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<BlogComment>(entity =>
            {
                entity.HasIndex(e => e.ReplyId);
            });
        }


ثبت اطلاعات و کوئری گرفتن از روابط خود ارجاع دهنده در EF Core

در اینجا نحوه‌ی ثبت دو سری نظر و زیر نظر را مشاهده می‌کنید:
var comment1 = new BlogComment { Body = "نظر من این است که" };
var comment12 = new BlogComment { Body = "پاسخی به نظر اول", Reply = comment1 };
var comment121 = new BlogComment { Body = "پاسخی به پاسخ به نظر اول", Reply = comment12 };

context.BlogComments.Add(comment121);

var comment2 = new BlogComment { Body = "نظر من این بود که" };
var comment22 = new BlogComment { Body = "پاسخی به نظر قبلی", Reply = comment2 };
var comment221 = new BlogComment { Body = "پاسخی به پاسخ به نظر من اول", Reply = comment22 };
context.BlogComments.Add(comment221);

context.SaveChanges();


نظرات اصلی، با ReplyId مساوی نال قابل تشخیص هستند. سایر نظرات، توسط همین ReplyId به یکی از Idهای موجود، متصل شده‌اند.

در تصویر فوق و با توجه به اطلاعات ثبت شده، فرض کنید می‌خواهیم ریشه‌ی با id مساوی 1 و تمام زیر ریشه‌های آن‌را بیابیم. انجام یک چنین کاری نه در EF Core و نه در EF 6.x، پشتیبانی نمی‌شود. بدیهی است در اینجا هنوز روش‌های Include و ThenInclue هم جواب می‌دهند؛ اما چون Lazy loading فعال نیست، عملا نمی‌توان تمام زیر ریشه‌ها را یافت و همچنین به چندین و چند رفت و برگشت به ازای هر زیر ریشه خواهیم رسید که اصلا بهینه نیست.
برای اینکار نیاز است مستقیما کوئری نویسی کرد که در مطلب «شروع به کار با EF Core 1.0 - قسمت 10 - استفاده از امکانات بومی بانک‌های اطلاعاتی» زیر ساخت آن‌را بررسی کردیم:
var id = 1;
var childIds = new List<int>();
 
var connection = context.Database.GetDbConnection();
connection.Open();
var command = connection.CreateCommand();
command.CommandText =
    @"WITH Hierachy(ChildId, ParentId) AS (
                SELECT Id, ReplyId
                    FROM BlogComments
                UNION ALL
                SELECT h.ChildId, bc.ReplyId
                    FROM BlogComments bc
                    INNER JOIN Hierachy h ON bc.Id = h.ParentId
            )
 
            SELECT h.ChildId
                FROM Hierachy h
                WHERE h.ParentId = @Id";
command.Parameters.Add(new SqlParameter("id", id));
 
var reader = command.ExecuteReader();
while (reader.Read())
{
    var childId = (int)reader[0];
    childIds.Add(childId);
}
reader.Dispose();

 
var list = context.BlogComments
                .Where(blogComment => blogComment.Id == id ||
                                      childIds.Contains(blogComment.Id))
                .ToList();
زمانیکه کدهای فوق اجرا می‌شوند، تنها دو کوئری ذیل به بانک اطلاعاتی ارسال خواهند شد:
exec sp_executesql N'WITH Hierachy(ChildId, ParentId) AS (
                            SELECT Id, ReplyId
                                FROM BlogComments
                            UNION ALL
                            SELECT h.ChildId, bc.ReplyId
                                FROM BlogComments bc
                                INNER JOIN Hierachy h ON bc.Id = h.ParentId
                        )

                        SELECT h.ChildId
                            FROM Hierachy h
                            WHERE h.ParentId = @Id',N'@id int',@id=1
که لیست Idهای تمام زیر ریشه‌های مربوط به id مساوی یک را بر می‌گرداند:


و پس از آن:
 exec sp_executesql N'SELECT [blogComment].[Id], [blogComment].[Body], [blogComment].[Date1], [blogComment].[ReplyId]
FROM [BlogComments] AS [blogComment]
WHERE [blogComment].[Id] IN (@__id_0, 3, 5)',N'@__id_0 int',@__id_0=1
اکنون که Idهای مساوی 3 و 5 را یافتیم، با استفاده از متد Contains آن‌ها را تبدیل به where in کرده و لیست نهایی گزارش را تهیه می‌کنیم:


یافتن Idهای زیر ریشه‌ها توسط روش CTE (پیشنیازهای ابتدای بحث) و سپس کوئری گرفتن بر روی این Idها، بهینه‌ترین روشی‌است که در EF می‌توان جهت کار با مدل‌های خود ارجاع دهنده بکار گرفت.
  • #
    ‫۷ سال و ۱۲ ماه قبل، سه‌شنبه ۶ مهر ۱۳۹۵، ساعت ۰۰:۱۲
    استفاده از second level caching in EF Core برای واکشی Self Referencing در زمان واکشی ToList دوم به درستی عمل نمیکند و Null برگشت میدهد

    var menuesFirst = await _publicMenus.Where(p => p.Language == _caltureName).Cacheable().ToListAsync();
    var menues = menuesFirst.Where(x => x.MenuId == null).ToList();

    • #
      ‫۷ سال و ۱۲ ماه قبل، سه‌شنبه ۶ مهر ۱۳۹۵، ساعت ۰۰:۵۴
      مشکلی مشاهده نشد:

      • #
        ‫۷ سال و ۱۲ ماه قبل، سه‌شنبه ۶ مهر ۱۳۹۵، ساعت ۰۸:۰۰
        without Cacheable:

        with Cacheable:

        ساختار موجودیت در DomainLayer :

        public class PublicMenu : BaseEntity
            {
                public PublicMenu()
                {
                    SubMenus = new List<PublicMenu>();
                }
        
                public string Title { get; set; }
                public string Url { get; set; }
                public string Icon { get; set; }
                public bool IsShow { get; set; }
        
                public virtual PublicMenu Menu { set; get; }
                public int? MenuId { get; set; }
                public ICollection<PublicMenu> SubMenus { get; set; }
            }

        • #
          ‫۷ سال و ۱۲ ماه قبل، سه‌شنبه ۶ مهر ۱۳۹۵، ساعت ۱۳:۴۰
          - شما در هر دو حالت، دارای لیست منوهایی با 5 آیتم هستید. یعنی خروجی نهایی هر دو کوئری یکی هست و متد Cacheable درست عمل کرده‌است.
          - اینکه در کوئری بعدی LINQ to Objects شما، در حالت بدون Cacheable، «امکانات debug visualizer ویژوال استودیو» موفق به تشکیل روابط شده‌است، صرفا به عدم فراخوانی متد AsNoTracking در کوئری غیر کش شده‌ی شما بر می‌گردد. متد AsNoTracking جهت کاهش سربار کوئری‌های EF و حذف پروکسی‌های آن به صورت خودکار توسط متد  Cacheable اعمال می‌شود؛ چون این نوع کوئری‌های کش شده صرفا مختص به گزارشگیری هستند.
          - اگر AsNoTracking فراخوانی شود، برای تشکیل روابط صرفا باید از متدهای Include و ThenInclude برای واکشی سطوح دیگر به هم مرتبط استفاده کنید. همچنین هر Include یا ThenInclude فقط یک سطح را واکشی می‌کند.
          «...
          بدیهی است در اینجا هنوز روش‌های Include و ThenInclue هم جواب می‌دهند؛ اما چون Lazy loading فعال نیست، عملا نمی‌توان تمام زیر ریشه‌ها را یافت ...»

          در کل، کوئری دوم شما (حاصل نهایی واکشی تمام عناصر یک جدول) متصل به Context نیست و LINQ to Objects است. بنابراین زمانیکه لیست کامل عناصر را در حافظه‌ی سمت کلاینت دارید (و در اینجا هر عملی بر روی این لیست دوم، نیازی به رفت و برگشت به بانک اطلاعاتی را ندارد)، تشکیل درخت آن‌ها کار مشکلی نیست؛ چون هر آیتم، توسط خاصیت MenuId، به قبلی متصل است و یا خیر. در مثال زیر، لیست نهایی، بر اساس ReplyIdها (دقیقا همان اطلاعات اصلی که در بانک اطلاعاتی ذخیره می‌شود)، حاوی لیست Children چند سطحی خواهد شد. زمانی هم که این لیست را داشتید، از کلاس TreeViewHelper برای نمایش آن استفاده کنید.
                  void buildTreeLinqToObjects()
                  {
                      var comment1 = new BlogComment { Id = 1, Body = "نظر من این است که" };
                      var comment12 = new BlogComment { Id = 2, Body = "پاسخی به نظر اول", ReplyId = 1 };
                      var comment121 = new BlogComment { Id = 3, Body = "پاسخی به پاسخ به نظر اول", ReplyId = 2 };
          
                      var comment2 = new BlogComment { Id = 4, Body = "نظر من این بود که" };
                      var comment22 = new BlogComment { Id = 5, Body = "پاسخی به نظر قبلی", ReplyId = 4 };
                      var comment221 = new BlogComment { Id = 6, Body = "پاسخی به پاسخ به نظر من اول", ReplyId = 5 };
          
                      var list = new List<BlogComment>
                      {
                          comment1, comment12, comment121,
                          comment2, comment22, comment221
                      };
          
                      foreach (var item in list)
                      {
                          if (item.ReplyId == null)
                          {
                              continue;
                          }
          
                          var parent = list.First(x => x.Id == item.ReplyId.Value);
                          parent.Children.Add(item);
                      }
          
                  }
  • #
    ‫۶ سال و ۲ ماه قبل، سه‌شنبه ۲۶ تیر ۱۳۹۷، ساعت ۲۳:۱۰
    آیا روش زیر برای پیاده‌سازی Self Referencing در حالی که هر عضو فقط صفر یا یک عضو فرزند می‌تواند داشته باشد، صحیح است؟
    به نوعی پیاده‌سازی Self Referencing و  One-to-Zero-or-One  با هم:
    public class EventItem
    {
        public int Id { get; set; }
    
        public int? ParentId { get; set; }
        public virtual EventItem Parent { get; set; }
    
        public virtual EventItem Alternative { get; set; }
    }
    public class EventItemConfiguration : IEntityTypeConfiguration<EventItem>
    {
        public void Configure(EntityTypeBuilder<EventItem> builder)
        {
            builder.HasIndex(e => e.ParentId);
            builder.HasOne(e => e.Parent)
                .WithOne(e => e.Alternative)
                .HasForeignKey<EventItem>(e => e.ParentId);
        }
    }

  • #
    ‫۴ سال و ۱۰ ماه قبل، شنبه ۲۷ مهر ۱۳۹۸، ساعت ۱۹:۱۵
    کلاس مدل
        public class Category
        {
            public int Id { get; set; }
    
            [StringLength(450)]
            public string Name { get; set; }
    
            public int? ParentId { get; set; }
    
            public virtual Category Parent { get; set; }
    
            public virtual ICollection<Category> Children { get; set; }
        }
    
        public class CategoryConfiguration : IEntityTypeConfiguration<Category>
        {
            public void Configure(EntityTypeBuilder<Category> builder)
            {
                builder.HasIndex(c => c.ParentId);
    
                builder.HasOne(c => c.Parent)
                       .WithMany(c => c.Children)
                       .HasForeignKey(c => c.ParentId);
            }
        }
    ودر نهایت برای افزودن مایگریشن جدید خطای ذیل
    Introducing FOREIGN KEY constraint 'FK_Categories_Categories_ParentId' on table 'Categories' may cause cycles or multiple cascade paths. Specify ON DELETE NO ACTION or ON UPDATE NO ACTION, or modify other FOREIGN KEY constraints.
    Could not create constraint or index. See previous errors.
    • #
      ‫۴ سال و ۱۰ ماه قبل، شنبه ۲۷ مهر ۱۳۹۸، ساعت ۲۲:۵۹
      خطایی که عنوان کردید فقط در صورتی رخ می‌دهد که ParentId ذکر شده، Nullable نباشد که البته در کدهایی که عنوان کردید، چنین چیزی نیست. به همین جهت بر اساس این تنظیمات، نمونه مثال EFCoreSelfReferencing.zip ایجاد شد و بدون مشکل کار می‌کند.
  • #
    ‫۳ سال و ۶ ماه قبل، جمعه ۱۵ اسفند ۱۳۹۹، ساعت ۲۲:۴۸
    این روش تمامی زیر فرزندان نظرات رو برمیگیردونه؛ با یک بار رفت و برگشت به بانک اطلاعاتی؛ آیا این روش نیز بهینه است ؟ ( + )
    var comments = _dbContext.Comments.Where(x => x.ReplyId == null).Include(x => x.Children).ToList();

  • #
    ‫۱ ماه قبل، یکشنبه ۲۱ مرداد ۱۴۰۳، ساعت ۱۹:۲۸

    یک نکته‌ی تکمیلی: تاثیر فراخوانی متد AsNoTracking بر روی کوئری‌های خود ارجاعی

    همانطور که در مطلب «مباحث تکمیلی مدل‌های خود ارجاع دهنده در EF Code first» هم مشاهده کردید، خود EF، قابلیت تشکیل درخت نهایی خود ارجاع دهنده را دارد و به این ترتیب کوئری گرفتن از نتیجه‌ی آن، بسیار ساده می‌شود. اما ... اگر در این بین، از متد AsNoTracking برای بهینه سازی، کاهش میزان حافظه و حذف پروکسی‌های ردیابی تغییرات EF استفاده شود، دیگر این درخت خودکار، تشکیل نخواهد شد. برای پوشش این حالت می‌توان به صورت زیر عمل کرد:

    الف) تشکیل یک کلاس پایه برای تعریف ساده‌تر و مشخص رابطه‌های خود ارجاعی

    public abstract class BaseEntity
    {
        public int Id { get; set; }
    }
    
    public abstract class BaseSelfReferencingEntity<TSelfEntity> : BaseEntity
        where TSelfEntity : BaseEntity
    {
        public virtual TSelfEntity? Reply { set; get; }
    
        public int? ReplyId { get; set; }
    
        public virtual ICollection<TSelfEntity>? Children { get; set; }
    }

    که ساختار معرفی شده‌ی در اینجا، با توضیحات موجود در متن، انطباق دارد.

    ب) پر کردن درخت نهایی حاصل به صورت دستی:

    چون دیگر EF این درخت را برای ما تشکیل نمی‌دهد، اکنون باید خودمان کار تشکیل آن‌را به صورت زیر انجام دهیم:

    public static class SelfReferencingExtensions
    { 
        public static List<TEntity> ToSelfReferencingTree<TEntity>(this ICollection<TEntity>? originalList)
            where TEntity : BaseSelfReferencingEntity<TEntity>
        {
            var results = new List<TEntity>();
    
            if (originalList is null || originalList.Count == 0)
            {
                return results;
            }
    
            foreach (var rootItem in originalList.Where(x => !x.ReplyId.HasValue))
            {
                results.Add(rootItem);
                AppendChildren(originalList, rootItem);
            }
    
            return results;
        }
    
        private static void AppendChildren<TEntity>(ICollection<TEntity> originalList, TEntity parentItem)
            where TEntity : BaseSelfReferencingEntity<TEntity>
        {
            foreach (var kid in originalList.Where(x => x.ReplyId.HasValue && x.ReplyId.Value == parentItem.Id))
            {
                parentItem.Children ??= new List<TEntity>();
                parentItem.Children.Add(kid);
                AppendChildren(originalList, kid);
            }
        }
    }

    در اینجا کار تشکیل درخت نهایی، با استفاده از یک متد بازگشتی، انجام می‌شود.

    پس از این مقدمات، نحوه‌ی استفاده از آن به صورت زیر است:

    var comments = await _comments.AsNoTracking()
                .Where(x => x.ParentId == postId)
                .OrderBy(x => x.Id)
                .Take(count)
                .ToListAsync();
    
    var commentsTree = comments.ToSelfReferencingTree();

    کوئری نویسی ابتدایی آن، کاملا استاندارد و بدون هیچگونه نکته‌ی خاصی است. ابتدا تمام نظرات یک مطلب (به صورت AsNoTracking) بازگشت داده می‌شوند و سپس متد ToSelfReferencingTree کار اتصالات نهایی درخت پاسخ‌ها را به صورت خودکار انجام می‌دهد.

      • #
        ‫۱ ماه قبل، دوشنبه ۲۲ مرداد ۱۴۰۳، ساعت ۲۱:۳۶

        با اجرای این کوئری خاص به همراه AsNoTrackingWithIdentityResolution خطای زیر مشاهده می‌شود و اصلا کار نمی‌کند (چون فیلد JSON هم دارد):

        System.InvalidOperationException: Invalid token type: 'StartObject'.

        در EF-Core و در طی طول عمر «یک» Context:

        • اگر یک کوئری معمولی، به همراه Change Tracker گرفته شود، اطلاعات اشیاء بازگشتی از آن، بر اساس ID آن‌ها «کش موقت می‌شوند». اگر در ادامه‌ی همین Context، مجددا این کوئری تکرار شود، این اشیاء مشابه با ID یکسان، نمونه سازی مجدد «نمی‌شوند» (هرچند یکبار دیگر از بانک اطلاعاتی دریافت شده‌اند و این کش موقت، به معنای عدم واکشی اطلاعات از بانک اطلاعاتی نیست) و از همان کش محلی Change Tracker مجددا خوانده می‌شوند.
        • اگر یک کوئری از نوع AsNoTracking گرفته شود، اشیاء بازگشتی از آن، بر اساس ID آن‌ها «کش نمی‌شوند». اگر همین کوئری مجددا در طول عمر Context جاری تکرار شود، نمونه سازی «مجددی» از اشیاء مشابه با ID یکسان را شاهد «خواهیم بود» و EF آن‌ها را از کش موقت Change Tracker جاری، بازیابی مجدد نمی‌کند. بنابراین تکرار این کوئری، مصرف حافظه‌ی بیشتری را به همراه دارد؛ هرچند اگر فقط قرار است یکبار انجام شود (برای انجام یک عملیات گزارشگیری فقط خواندنی)، به دلیل نداشتن سیستم ردیابی، سربار کمتری را از حالت اول دارد.
        • اگر یک کوئری از نوع AsNoTrackingWithIdentityResolution گرفته شود، اشیاء بازگشتی از آن بر اساس ID آن‌ها «کش موقت می‌شوند». اگر همین کوئری مجددا در طول عمر Context جاری تکرار شود، نمونه سازی مجددی از اشیاء مشابه با ID یکسان را شاهد «نخواهیم بود» (هر چند کوئری از بانک اطلاعاتی، داده‌هایی را واکشی کرده‌است)؛ اما وارد سیستم Tracking هم نمی‌شوند و تغییرات بر روی آن‌ها ردیابی نمی‌شوند. به همین جهت این روش در حین تکرار کوئری‌های مشابه در طول عمر یک Context نسبت به AsNoTracking، مصرف حافظه‌ی کمتری دارد.

        بنابراین وجود AsNoTrackingWithIdentityResolution فقط در طی طول عمر یک Conetxt و برای کاهش سربار نمونه سازی اشیاء مشابه با IDهای یکسان کوئری‌های آن Context ارزشمند است و اگر Context شما از چندین کوئری مشابه تشکیل نمی‌شود، عملا کار بیشتری را انجام نمی‌دهد و تفاوتی با AsNoTracking ندارد.