عنوان:

‫EF Core 10: جوین‌ها را دوباره زیبا می‌کند؛ خداحافظ DefaultIfEmpty!


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۹/۲۰ ۱۰:۰۷
آدرس: www.dntips.ir
اگر با Entity Framework Core کار کرده باشید، حتماً با چالش بیان اتصالات خارجی مانند LEFT JOIN یا RIGHT JOIN در LINQ مواجه شده‌اید. برای سال‌ها، متد DefaultIfEmpty() راه‌حل اصلی بوده، اما اغلب احساس می‌شد که این روش، پیچیده، غیرشهودی و خوانایی کمی دارد. خوشبختانه، با انتشار EF Core 10 در کنار .NET 10، تیم توسعه‌دهندگان مایکروسافت سینتکس طبیعی‌تر و بیان‌گرتری برای این اتصالات معرفی کرده‌اند. این بهبود، کوئری‌های LINQ را به ساختار SQL نزدیک‌تر می‌کند و نوشتن کد را لذت‌بخش‌تر می‌سازد.

توضیح مسئله
اتصالات خارجی (Outer Joins) در بانک‌های اطلاعاتی، مانند LEFT JOIN، اجازه می‌دهند رکوردهایی را که در یک سمت جدول وجود دارند اما با سمت دیگر تطبیقی ندارند، حفظ کنیم. مثلاً، می‌خواهیم همه نویسندگان را لیست کنیم، حتی اگر کتابی ننوشته باشند. در EF Core نسخه‌های قبل از 10، بیان این عملیات در LINQ نیاز به ترکیبی پیچیده داشت که خوانایی کد را کاهش می‌داد. مشکلات اصلی عبارت بودند از:
  • خوانایی پایین: کوئری به راحتی مقصود یک LEFT JOIN را نشان نمی‌داد و برای توسعه‌دهندگان آشنا با SQL، گیج‌کننده بود.
  • پیچیدگی: استفاده از GroupJoin و SelectMany برای شبیه‌سازی اتصال خارجی، برای تازه‌کاران ترسناک بود.
  • عدم شهود: این روش بیشتر یک "ترفند" بود تا بیان مستقیم هدف. علاوه بر این، در سناریوهای پیچیده، ممکن بود به تولید SQL غیربهینه منجر شود یا حتی باگ‌هایی مانند از دست رفتن اطلاعات والد در کوئری‌های تو در تو ایجاد کند (که در EF Core 10 برطرف شده).
این مسائل نه تنها زمان توسعه را افزایش می‌داد، بلکه نگهداری کد را هم سخت‌تر می‌کرد. حالا بیایید ببینیم چطور قبلاً این کار را انجام می‌دادیم.

راه‌حل قدیمی: استفاده از DefaultIfEmpty!
قبل از EF Core 10، برای ایجاد یک LEFT JOIN، باید از GroupJoin برای گروه‌بندی و سپس SelectMany با DefaultIfEmpty برای صاف کردن نتایج و ایجاد ورودی‌های null استفاده می‌کردیم. فرض کنید مدل‌های ساده زیر را داریم:
public class Author
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ICollection<Book> Books { get; set; } = new List<Book>();
}

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; }
    public int AuthorId { get; set; }
    public Author Author { get; set; }
}
کوئری LINQ قدیمی برای لیست نویسندگان و کتاب‌هایشان (شامل نویسندگان بدون کتاب) به این شکل بود:
var authorsAndBooksOld = await context.Authors
    .GroupJoin(
        context.Books,
        author => author.Id,
        book => book.AuthorId,
        (author, books) => new { Author = author, Books = books }
    )
    .SelectMany(
        x => x.Books.DefaultIfEmpty(),
        (x, book) => new
        {
            AuthorName = x.Author.Name,
            BookTitle = book != null ? book.Title : "No Books"
        }
    )
    .ToListAsync();
این کد کار می‌کند، اما نیاز به درک عمیق از GroupJoin (که گروهی از رکوردها ایجاد می‌کند) و SelectMany (که آن را صاف می‌کند) دارد. DefaultIfEmpty هم برای ایجاد null در موارد عدم تطابق استفاده می‌شود. این رویکرد، اگرچه کاربردی، اغلب به عنوان یک "ترکیب پیچیده" توصیف می‌شود و می‌تواند در کوئری‌های بزرگ‌تر به مشکلات عملکردی منجر شود، مانند تولید SQL غیربهینه یا حتی خطاهای ترجمه در نسخه‌های قدیمی‌تر EF Core.

راه‌حل جدید: اتصالات خارجی مستقیم و شهودی
EF Core 10 با بهره‌گیری از ویژگی‌های جدید .NET 10، متدهای LeftJoin و RightJoin را معرفی کرده که اتصالات خارجی را مستقیم و خوانا می‌سازد. این متدها جایگزین ترکیبی پیچیده قدیمی می‌شوند و مستقیماً به SQL LEFT JOIN یا RIGHT JOIN ترجمه می‌گردند. علاوه بر این، ترجمه DefaultIfEmpty بهبود یافته و باگ‌های قبلی مانند از دست رفتن اطلاعات والد یا مشکلات در COALESCE برطرف شده‌اند.
بیایید همان مثال را با سینتکس جدید ببینیم. اول، با متد LeftJoin (روش پیشنهادی برای سادگی):
var authorsAndBooksNew = await context.Authors
    .LeftJoin(
        context.Books,
        author => author.Id,
        book => book.AuthorId,
        (author, book) => new
        {
            AuthorName = author.Name,
            BookTitle = book != null ? book.Title : "No Books",
            BookId = book != null ? book.Id : (int?)null
        }
    )
    .ToListAsync();
این کد مستقیما هدف را نشان می‌دهد: همه‌ی نویسندگان را نگه دار و کتاب‌های matching را اضافه کن؛ اگر کتابی نبود، null بگذار. EF Core این را به SQL زیر ترجمه می‌کند (تقریبی):
SELECT
    a.Name AS AuthorName,
    b.Title AS BookTitle,
    b.Id AS BookId
FROM Authors AS a
LEFT JOIN Books AS b ON a.Id = b.AuthorId;
اگر ترجیح می‌دهید از سینتکس query LINQ استفاده کنید (که هنوز از DefaultIfEmpty بهره می‌برد اما بهبود یافته)، به این شکل است:
var authorsAndBooksBeautiful = await (
    from author in context.Authors
    join book in context.Books on author.Id equals book.AuthorId into authorBooks
    from book in authorBooks.DefaultIfEmpty()
    select new
    {
        AuthorName = author.Name,
        BookTitle = book != null ? book.Title : "No Books",
        BookId = book != null ? book.Id : (int?)null
    }
).ToListAsync();
این سینتکس هم حالا به طور کارآمد به LEFT JOIN ترجمه می‌شود. چرا بهتر است؟
  • خوانایی: مستقیماً شبیه SQL است و هدف را واضح بیان می‌کند.
  • سادگی: بدون نیاز به GroupJoin پیچیده؛ تازه‌کاران سریع‌تر یاد می‌گیرند.
  • عملکرد: ترجمه بهینه‌تر SQL در سناریوهای پیچیده، و رفع باگ‌های قدیمی DefaultIfEmpty (مثل حفظ اطلاعات والد در کوئری‌های تو در تو).
  • نکته اضافی برای غنی‌سازی: اگر با RIGHT JOIN کار می‌کنید (که همه رکوردهای سمت راست را حفظ می‌کند)، از RightJoin استفاده کنید. همچنین، این ویژگی‌ها با Azure SQL و SQL Server کاملاً یکپارچه هستند و می‌توانند در کوئری‌های پیچیده‌تر مانند joins چندگانه مفید باشند. اگر کوئری شما شامل nullable properties است، همیشه چک کنید که null-handling درست باشد تا از NullReferenceException جلوگیری شود.


مشاهده مطلب اصلی