فصل ۹: اپراتورهای LINQ؛ Filtering، Projecting و SelectMany

فصل ۹: اپراتورهای LINQ؛ Filtering، Projecting و SelectMany

فصل ۹: اپراتورهای LINQ؛ Filtering، Projecting و SelectMany

فصل ۹ — اپراتورهای LINQ

تصویر آغاز فصل ۹ — LINQ Operatorsتصویر تزئینی آغاز فصل ۹ در نسخهٔ اصلی.
تصویر آغاز فصل ۹ — LINQ Operators

این فصل هر یک از اپراتورهای پرس‌وجوی LINQ را شرح می‌دهد. افزون بر اینکه به‌عنوان یک مرجع عمل می‌کند، دو بخش «Projecting» در صفحهٔ ۴۷۳ و «Joining» در صفحهٔ ۴۹۲ چند حوزهٔ مفهومی مهم را پوشش می‌دهند:

  • پروجکت‌کردن سلسله‌مراتب اشیا؛
  • اتصال (Join) با Select، SelectMany، Join و GroupJoin؛
  • عبارت‌های پرس‌وجو با چند متغیر بازه (range variable).

تمام مثال‌های این فصل فرض می‌کنند آرایهٔ names به شکل زیر تعریف شده است:

string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };

مثال‌هایی که از پایگاه داده پرس‌وجو می‌کنند فرض می‌کنند متغیری با نام dbContext به شکل زیر نمونه‌سازی شده است:

var dbContext = new NutshellContext();

که در آن NutshellContext چنین تعریف می‌شود:

public class NutshellContext : DbContext
{
  public DbSet<Customer> Customers { get; set; }
  public DbSet<Purchase> Purchases { get; set; }
  protected override void OnModelCreating(ModelBuilder modelBuilder)
  {
    modelBuilder.Entity<Customer>(entity =>
    {
      entity.ToTable("Customer");
      entity.Property(e => e.Name).IsRequired();  // ستون nullable نیست
    });
    modelBuilder.Entity<Purchase>(entity =>
    {
      entity.ToTable("Purchase");
      entity.Property(e => e.Date).IsRequired();
      entity.Property(e => e.Description).IsRequired();
    });
  }
}
public class Customer
{
  public int ID { get; set; }
  public string Name { get; set; }
  public virtual List<Purchase> Purchases { get; set; }
    = new List<Purchase>();
}
public class Purchase
{
  public int ID { get; set; }
  public int? CustomerID { get; set; }
  public DateTime Date { get; set; }
  public string Description { get; set; }
  public decimal Price { get; set; }
  public virtual Customer Customer { get; set; }
}

تعریف جدول‌های متناظر در SQL Server چنین است:

CREATE TABLE Customer (
  ID int NOT NULL IDENTITY PRIMARY KEY,
  Name nvarchar(30) NOT NULL
)
CREATE TABLE Purchase (
  ID int NOT NULL IDENTITY PRIMARY KEY,
  CustomerID int NOT NULL REFERENCES Customer(ID),
  Date datetime NOT NULL,
  Description nvarchar(30) NOT NULL,
  Price decimal NOT NULL
)

مرور کلی

در این بخش، مروری بر اپراتورهای استاندارد پرس‌وجو ارائه می‌کنیم. آن‌ها در سه دسته قرار می‌گیرند:

  • ورودی دنباله، خروجی دنباله (sequence→sequence
  • ورودی دنباله، خروجی یک عنصر یا مقدار اسکالر؛
  • بدون ورودی، خروجی دنباله (روش‌های تولید یا generation).

ابتدا هر سه دسته و اپراتورهای موجود در آن‌ها را معرفی می‌کنیم و سپس هر اپراتور را جداگانه با جزئیات بررسی می‌کنیم.

دنباله → دنباله

بیشتر اپراتورهای پرس‌وجو در این دسته قرار می‌گیرند: یک یا چند دنباله را به‌عنوان ورودی می‌پذیرند و یک دنبالهٔ خروجی تولید می‌کنند. شکل ۹-۱ اپراتورهایی را نشان می‌دهد که شکل دنباله‌ها را بازساخت می‌کنند.

شکل 9-1 — دسته‌بندی Standard Query Operatorهانقشهٔ دسته‌بندی اپراتورهای استاندارد LINQ بر اساس Filtering، Projection، Joining، Ordering و سایر گروه‌ها.
شکل 9-1 — دسته‌بندی Standard Query Operatorها

شکل جایگاه گروه‌های اصلی Query Operatorها را در LINQ نشان می‌دهد.

Filtering

IEnumerable<TSource> → IEnumerable<TSource>. زیرمجموعه‌ای از عناصر اصلی را برمی‌گرداند.

Where, Take, TakeLast, TakeWhile, Skip, SkipLast, SkipWhile, Distinct, DistinctBy

Projecting

IEnumerable<TSource> → IEnumerable<TResult>. هر عنصر را با یک تابع لامبدا تبدیل می‌کند. SelectMany دنباله‌های تو‌در‌تو را تخت می‌کند؛ Select و SelectMany در EF Core می‌توانند inner join، left outer join، cross join و non-equi join را انجام دهند.

Select, SelectMany

Joining

IEnumerable<TOuter>, IEnumerable<TInner> → IEnumerable<TResult>. عناصر یک دنباله را با دنبالهٔ دیگر درهم می‌آمیزد. Join و GroupJoin برای کارآمدی در پرس‌وجوهای محلی طراحی شده‌اند و inner join و left outer join را پشتیبانی می‌کنند. اپراتور Zip دو دنباله را هم‌قدم پیمایش می‌کند و روی هر جفت عنصر تابعی اعمال می‌کند.

در Zip به‌جای نام‌های TOuter و TInner از TFirst و TSecond استفاده می‌شود:

IEnumerable<TFirst>, IEnumerable<TSecond>→IEnumerable<TResult>

Join, GroupJoin, Zip

Ordering

IEnumerable<TSource>→IOrderedEnumerable<TSource>. ترتیب عناصر یک دنباله را تغییر می‌دهد.

OrderBy, OrderByDescending, ThenBy, ThenByDescending, Reverse

Grouping

IEnumerable<TSource>→IEnumerable<IGrouping<TKey,TElement>> یا IEnumerable<TSource>→IEnumerable<TElement[]>. دنباله را به زیردنباله‌ها گروه‌بندی می‌کند.

GroupBy, Chunk

Set operators

دو دنبالهٔ هم‌نوع را می‌گیرد و اشتراک، اجتماع/ترکیب یا تفاوت آن‌ها را برمی‌گرداند.

Concat, Union, UnionBy, Intersect, IntersectBy, Except, ExceptBy

روش‌های تبدیل: Import

IEnumerable→IEnumerable<TResult>: OfType, Cast

روش‌های تبدیل: Export

IEnumerable<TSource>→ آرایه، لیست، دیکشنری، lookup یا دنباله: ToArray, ToList, ToDictionary, ToLookup, AsEnumerable, AsQueryable

دنباله → عنصر یا مقدار

اپراتورهای زیر یک دنبالهٔ ورودی می‌پذیرند و یک عنصر یا مقدار واحد تولید می‌کنند.

Element operators

IEnumerable<TSource>→TSource. یک عنصر منفرد از دنباله انتخاب می‌کند.

First, FirstOrDefault, Last, LastOrDefault, Single, SingleOrDefault, ElementAt, ElementAtOrDefault, MinBy, MaxBy, DefaultIfEmpty

Aggregation methods

IEnumerable<TSource>→scalar. محاسبه‌ای روی کل دنباله انجام می‌دهد و یک مقدار اسکالر، معمولاً عدد، برمی‌گرداند.

Aggregate, Average, Count, LongCount, Sum, Max, Min

Quantifiers

IEnumerable<TSource>→bool. نوعی تجمیع که true یا false برمی‌گرداند.

All, Any, Contains, SequenceEqual

void → دنباله

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

Generation methods

void→IEnumerable<TResult>. یک دنبالهٔ ساده تولید می‌کند: Empty, Range, Repeat.

Filtering

اپراتورهای Filtering
روششرحمعادل SQL
Whereزیرمجموعه‌ای از عناصر را که شرط داده‌شده را برآورده می‌کنند برمی‌گرداند.WHERE
Takeاولین count عنصر را برمی‌گرداند و بقیه را کنار می‌گذارد.WHERE ROW_NUMBER()... یا TOP n subquery
Skipاولین count عنصر را نادیده می‌گیرد و بقیه را برمی‌گرداند.WHERE ROW_NUMBER()... یا NOT IN (SELECT TOP n...)
TakeLastفقط آخرین count عنصر را می‌گیرد.Exception thrown
SkipLastآخرین count عنصر را نادیده می‌گیرد.Exception thrown
TakeWhileتا وقتی predicate درست است از ورودی عنصر منتشر می‌کند؛ با اولین false متوقف می‌شود.Exception thrown
روششرحمعادل SQL
SkipWhileتا وقتی predicate درست است عناصر ورودی را نادیده می‌گیرد و پس از false شدن، باقی عناصر را منتشر می‌کند.Exception thrown
Distinct, DistinctByدنباله‌ای بدون موارد تکراری برمی‌گرداند.SELECT DISTINCT...

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

Where

آرگوماننوع
Source sequenceIEnumerable<TSource>
PredicateTSource => bool یا (TSource,int) => bool؛ حالت دوم در LINQ to SQL و Entity Framework ممنوع است.
Query syntaxwhere bool-expression

پیاده‌سازی داخلی Enumerable.Where، با صرف‌نظر از کنترل null، از نظر عملکرد معادل زیر است:

public static IEnumerable<TSource> Where<TSource>
  (this IEnumerable<TSource> source, Func <TSource, bool> predicate)
{
  foreach (TSource element in source)
    if (predicate (element))
      yield return element;
}

مرور Where

Where عناصری از دنبالهٔ ورودی را بازمی‌گرداند که predicate داده‌شده را ارضا می‌کنند:

string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<string> query = names.Where (name => name.EndsWith ("y"));
// Harry
// Mary
// Jay

در query syntax:

IEnumerable<string> query = from n in names
                            where n.EndsWith ("y")
                            select n;

یک بند where می‌تواند بیش از یک‌بار در پرس‌وجو ظاهر شود و با بندهای let، orderby و join درهم آمیخته شود:

from n in names
where n.Length > 3
let u = n.ToUpper()
where u.EndsWith ("Y")
select u;
// HARRY
// MARY

قواعد معمول scope در C# برقرار است؛ یعنی پیش از اعلام یک متغیر با range variable یا بند let نمی‌توانید به آن ارجاع دهید.

فیلتر ایندکس‌دار

predicate در Where می‌تواند آرگومان دومی از نوع int بپذیرد. موقعیت هر عنصر در دنبالهٔ ورودی به این آرگومان داده می‌شود تا تصمیم فیلتر از آن استفاده کند. مثال زیر هر عنصر دوم را رد می‌کند:

IEnumerable<string> query = names.Where ((n, i) => i % 2 == 0);
// Tom
// Harry
// Jay

استفاده از فیلتر ایندکس‌دار در EF Core باعث exception می‌شود.

مقایسه‌های SQL LIKE در EF Core

متدهای Contains، StartsWith و EndsWith روی string به اپراتور LIKE در SQL ترجمه می‌شوند.

برای نمونه، c.Name.Contains("abc") به چیزی شبیه customer.Name LIKE '%abc%' (دقیق‌تر: نسخهٔ پارامتری‌شده) ترجمه می‌شود. Contains فقط امکان مقایسه با عبارتی را می‌دهد که محلی ارزیابی شده است؛ برای مقایسه با ستونی دیگر باید از EF.Functions.Like استفاده کنید:

... where EF.Functions.Like (c.Description, "%" + c.Name + "%")

EF.Functions.Like مقایسه‌های پیچیده‌تر مانند LIKE 'abc%def%' را نیز ممکن می‌کند.

مقایسهٔ رشته با < و > در EF Core

برای مقایسهٔ ترتیبی رشته‌ها می‌توانید از CompareTo استفاده کنید؛ این فراخوانی به اپراتورهای < و > در SQL نگاشت می‌شود:

dbContext.Purchases.Where (p => p.Description.CompareTo ("C") < 0)

WHERE x IN (…) در EF Core

در EF Core می‌توانید اپراتور Contains را روی یک collection محلی داخل predicate فیلتر اعمال کنید:

string[] chosenOnes = { "Tom", "Jay" };
from c in dbContext.Customers
where chosenOnes.Contains (c.Name)
...

این به اپراتور IN در SQL نگاشت می‌شود؛ یعنی:

WHERE customer.Name IN ("Tom", "Jay")

اگر collection محلی آرایه‌ای از entityها یا نوع‌های non-scalar باشد، EF Core ممکن است به‌جای آن بند EXISTS تولید کند.

Take، TakeLast، Skip و SkipLast

Take اولین n عنصر را منتشر و بقیه را دور می‌ریزد؛ Skip اولین n عنصر را دور می‌ریزد و بقیه را منتشر می‌کند. این دو برای صفحه‌بندی مجموعه‌های بزرگ نتایج مفیدند. مثال: از ۱۰۰ کتابی که با «mercury» تطابق دارند، ۲۰ نتیجهٔ نخست:

IQueryable<Book> query = dbContext.Books
  .Where   (b => b.Title.Contains ("mercury"))
  .OrderBy (b => b.Title)
  .Take (20);

برای کتاب‌های ۲۱ تا ۴۰:

IQueryable<Book> query = dbContext.Books
  .Where   (b => b.Title.Contains ("mercury"))
  .OrderBy (b => b.Title)
  .Skip (20).Take (20);

EF Core، Take و Skip را در SQL Server 2005 به تابع ROW_NUMBER و در نسخه‌های قدیمی‌تر SQL Server به یک زیرپرس‌وجوی TOP n ترجمه می‌کند. TakeLast و SkipLast آخرین n عنصر را می‌گیرند یا رد می‌کنند.

از .NET 6، متد Take overloadی دارد که یک Range می‌پذیرد. این overload می‌تواند کار هر چهار متد را پوشش دهد؛ مثلاً Take(5..) معادل Skip(5) و Take(..^5) معادل SkipLast(5) است.

TakeWhile و SkipWhile

TakeWhile ورودی را پیمایش می‌کند و تا وقتی predicate درست است هر مورد را خروجی می‌دهد؛ سپس باقی را نادیده می‌گیرد:

int[] numbers      = { 3, 5, 2, 234, 4, 1 };
var takeWhileSmall = numbers.TakeWhile (n => n < 100);   // { 3, 5, 2 }

SkipWhile تا وقتی predicate درست است موارد را رد می‌کند و سپس باقی را خروجی می‌دهد:

int[] numbers      = { 3, 5, 2, 234, 4, 1 };
var skipWhileSmall = numbers.SkipWhile (n => n < 100);   // { 234, 4, 1 }

TakeWhile و SkipWhile ترجمهٔ SQL ندارند و در پرس‌وجوی EF Core exception می‌دهند.

Distinct و DistinctBy

Distinct دنبالهٔ ورودی را بدون تکراری‌ها برمی‌گرداند و می‌توانید equality comparer سفارشی بدهید. مثال:

char[] distinctLetters = "HelloWorld".Distinct().ToArray();
string s = new string (distinctLetters);                     // HeloWrd

چون string، IEnumerable<char> را پیاده‌سازی می‌کند می‌توان متدهای LINQ را مستقیماً روی رشته صدا زد. DistinctBy در .NET 6 معرفی شد و اجازه می‌دهد پیش از مقایسهٔ برابری، key selector اعمال کنید. نتیجهٔ عبارت زیر {1,2,3} است:

new[] { 1.0, 1.1, 2.0, 2.1, 3.0, 3.1 }.DistinctBy (n => Math.Round (n, 0))

Projecting

IEnumerable<TSource> → IEnumerable<TResult>

روششرحمعادل SQL
Selectهر عنصر ورودی را با عبارت لامبدای داده‌شده تبدیل می‌کند.SELECT
SelectManyهر عنصر ورودی را تبدیل می‌کند و سپس زیردنباله‌های حاصل را تخت و به هم متصل می‌کند.INNER JOIN، LEFT OUTER JOIN، CROSS JOIN

Select

آرگوماننوع
Source sequenceIEnumerable<TSource>
Result selectorTSource => TResult یا (TSource,int) => TResult؛ حالت ایندکس‌دار در EF Core ممنوع است.
Query syntaxselect projection-expression

پیاده‌سازی Enumerable.Select:

public static IEnumerable<TResult> Select<TSource,TResult>
  (this IEnumerable<TSource> source, Func<TSource,TResult> selector)
{
  foreach (TSource element in source)
    yield return selector (element);
}

با Select همیشه همان تعداد عنصری را که شروع کرده‌اید دریافت می‌کنید، اما هر عنصر می‌تواند توسط لامبدا به هر شکل دلخواه تبدیل شود. مثال زیر نام تمام فونت‌های نصب‌شده را از System.Drawing انتخاب می‌کند:

IEnumerable<string> query = from f in FontFamily.Families
                            select f.Name;
foreach (string name in query) Console.WriteLine (name);

در این مثال، بند select یک شیء FontFamily را به نام آن تبدیل می‌کند. معادل لامبدا:

IEnumerable<string> query = FontFamily.Families.Select (f => f.Name);

Select اغلب برای پروجکت‌کردن به anonymous type به‌کار می‌رود:

var query =
  from f in FontFamily.Families
  select new { f.Name, LineSpacing = f.GetLineSpacing (FontStyle.Bold) };

در query syntax گاهی یک projection بدون تبدیل فقط برای برآورده‌کردن الزام پایان‌یافتن پرس‌وجو به select یا group نوشته می‌شود. مثال زیر فونت‌هایی را انتخاب می‌کند که strikeout را پشتیبانی می‌کنند:

IEnumerable<FontFamily> query =
  from f in FontFamily.Families
  where f.IsStyleAvailable (FontStyle.Strikeout)
  select f;
foreach (FontFamily ff in query) Console.WriteLine (ff.Name);

در چنین حالتی کامپایلر هنگام ترجمه به fluent syntax، projection زائد را حذف می‌کند.

Indexed projection

selector می‌تواند آرگومان عدد صحیح اختیاری به‌عنوان indexer بگیرد و موقعیت هر ورودی در دنباله را در اختیار عبارت قرار دهد. این فقط در پرس‌وجوهای محلی کار می‌کند:

string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<string> query = names
  .Select ((s,i) => i + "=" + s);     //  { "0=Tom", "1=Dick", ... }

Select subqueries و سلسله‌مراتب اشیا

می‌توانید یک subquery را در بند select تو‌در‌تو کنید تا یک سلسله‌مراتب شیء بسازید. مثال زیر مجموعه‌ای توصیف‌کنندهٔ هر دایرکتوری زیر Path.GetTempPath() همراه با زیرمجموعهٔ فایل‌های آن را برمی‌گرداند:

string tempPath = Path.GetTempPath();
DirectoryInfo[] dirs = new DirectoryInfo (tempPath).GetDirectories();
var query =
  from d in dirs
  where (d.Attributes & FileAttributes.System) == 0
  select new
  {
    DirectoryName = d.FullName,
    Created = d.CreationTime,
    Files = from f in d.GetFiles()
            where (f.Attributes & FileAttributes.Hidden) == 0
            select new { FileName = f.Name, f.Length, }
  };
foreach (var dirFiles in query)
{
  Console.WriteLine ("Directory: " + dirFiles.DirectoryName);
  foreach (var file in dirFiles.Files)
    Console.WriteLine ("  " + file.FileName + " Len: " + file.Length);
}

بخش داخلی این پرس‌وجو را می‌توان correlated subquery نامید. یک subquery زمانی correlated است که به شیئی از پرس‌وجوی بیرونی ارجاع دهد؛ در اینجا به d، یعنی دایرکتوری در حال پیمایش، ارجاع می‌دهد.

در پرس‌وجوهای محلی، subquery داخل Select باعث deferred execution دوگانه می‌شود. در مثال ما، فایل‌ها تا زمانی که foreach داخلی پیمایش نشود فیلتر یا project نمی‌شوند.

Subqueryها و Joinها در EF Core

پروجکشن‌های subquery در EF Core خوب کار می‌کنند و می‌توانید از آن‌ها برای انجام کاری شبیه joinهای SQL استفاده کنید. برای بازیابی نام هر مشتری همراه با خریدهای باارزش او:

var query =
  from c in dbContext.Customers
  select new {
               c.Name,
               Purchases = (from p in dbContext.Purchases
                           where p.CustomerID == c.ID && p.Price > 1000
                           select new { p.Description, p.Price })
                           .ToList()
             };
foreach (var namePurchases in query)
{
  Console.WriteLine ("Customer: " + namePurchases.Name);
  foreach (var purchaseDetail in namePurchases.Purchases)
    Console.WriteLine ("  - $$$: " + purchaseDetail.Price);
}

این پرس‌وجو اشیایی از دو مجموعهٔ جدا را با هم تطبیق می‌دهد و می‌توان آن را یک «join» دانست. تفاوت آن با join سنتی پایگاه داده یا subquery این است که خروجی را به یک result set دوبعدی و تخت تبدیل نمی‌کنیم؛ دادهٔ رابطه‌ای را به دادهٔ سلسله‌مراتبی نگاشت می‌کنیم.

همان پرس‌وجو با استفاده از navigation property مجموعهٔ Purchases روی entity Customer ساده‌تر می‌شود:

from c in dbContext.Customers
select new
{
  c.Name,
  Purchases = from p in c.Purchases
              where p.Price > 1000
              select new { p.Description, p.Price }
};

کتاب یادآور می‌شود EF Core 3 هنگام اجرای subquery روی navigation property به ToList نیاز ندارد.

هر دو پرس‌وجو از این جهت شبیه left outer join در SQL هستند که تمام مشتریان در پیمایش بیرونی می‌آیند، حتی اگر خریدی نداشته باشند. برای شبیه‌سازی inner join و حذف مشتریان بدون خرید باارزش، باید شرط فیلتر روی مجموعهٔ خریدها اضافه شود:

from c in dbContext.Customers
where c.Purchases.Any (p => p.Price > 1000)
select new {
             c.Name,
             Purchases = from p in c.Purchases
                         where p.Price > 1000
                         select new { p.Description, p.Price }
           };

این کمی نامرتب است چون predicate Price > 1000 دو بار نوشته شده. با let تکرار را حذف می‌کنیم:

from c in dbContext.Customers
let highValueP = from p in c.Purchases
                 where p.Price > 1000
                 select new { p.Description, p.Price }
where highValueP.Any()
select new { c.Name, Purchases = highValueP };

این سبک انعطاف‌پذیر است. با تغییر Any به Count می‌توان فقط مشتریانی را گرفت که حداقل دو خرید باارزش دارند:

...
where highValueP.Count() >= 2
select new { c.Name, Purchases = highValueP };

Projecting به نوع‌های concrete

در مثال‌های قبلی anonymous type ساخته شد. می‌توان کلاس‌های نام‌دار معمولی را هم با object initializer مقداردهی کرد. چنین کلاس‌هایی می‌توانند منطق سفارشی داشته باشند و بدون تکیه بر اطلاعات نوع بین متدها و assemblyها منتقل شوند.

یک نمونهٔ معمول، business entity سفارشی است: کلاسی که خودتان با چند property می‌نویسید تا جزئیات سطح پایین مربوط به پایگاه داده را پنهان کند؛ مثلاً می‌توانید فیلدهای foreign key را از کلاس‌های business entity حذف کنید. اگر کلاس‌هایی با نام CustomerEntity و PurchaseEntity داشته باشیم:

IQueryable<CustomerEntity> query =
  from c in dbContext.Customers
  select new CustomerEntity
  {
    Name = c.Name,
    Purchases =
      (from p in c.Purchases
       where p.Price > 1000
       select new PurchaseEntity {
                                   Description = p.Description,
                                   Value = p.Price
                                 }
      ).ToList()
  };
List<CustomerEntity> result = query.ToList();

تا اینجا نیازی به Join یا SelectMany نداشتیم، چون شکل سلسله‌مراتبی داده را حفظ کرده‌ایم، همان‌طور که شکل ۹-۲ نشان می‌دهد. در LINQ اغلب می‌توانید از رویکرد سنتی SQL برای تخت‌کردن جدول‌ها در یک result set دوبعدی اجتناب کنید.

شکل 9-2 — SelectMany و Sequenceهای تو‌در‌تونحوهٔ Flatten شدن Sequenceهای داخلی توسط SelectMany و تولید یک Sequence خروجی واحد.
شکل 9-2 — SelectMany و Sequenceهای تو‌در‌تو

هر Element ورودی به یک Sequence داخلی نگاشت می‌شود و خروجی‌ها در یک Sequence ترکیب می‌شوند.

SelectMany

آرگوماننوع
Source sequenceIEnumerable<TSource>
Result selectorTSource => IEnumerable<TResult> یا (TSource,int) => IEnumerable<TResult>؛ حالت دوم در EF Core ممنوع است.
Query syntaxfrom identifier1 in enumerable-expression1
from identifier2 in enumerable-expression2
...

پیاده‌سازی:

public static IEnumerable<TResult> SelectMany<TSource,TResult>
  (IEnumerable<TSource> source,
   Func <TSource,IEnumerable<TResult>> selector)
{
  foreach (TSource element in source)
    foreach (TResult subElement in selector (element))
      yield return subElement;
}

SelectMany زیردنباله‌ها را به یک دنبالهٔ تخت خروجی متصل می‌کند. برای هر عنصر ورودی، Select دقیقاً یک عنصر خروجی می‌دهد؛ اما SelectMany صفر تا n عنصر خروجی می‌دهد. این عناصر از child sequenceای می‌آیند که عبارت لامبدا باید تولید کند.

می‌توانید از SelectMany برای گسترش child sequenceها، تخت‌کردن collectionهای تو‌در‌تو و join دو collection به یک دنبالهٔ تخت استفاده کنید. در تشبیه تسمه‌نقاله، SelectMany مواد تازه را روی تسمه می‌ریزد؛ هر عنصر ورودی ماشه‌ای است برای افزودن مواد جدید. لامبدای selector باید برای هر عنصر ورودی یک child sequence تولید کند و نتیجهٔ نهایی اتصال همهٔ آن‌هاست.

مثال ساده:

string[] fullNames = { "Anne Williams", "John Fred Smith", "Sue Green" };

هدف، تبدیل این آرایه به یک collection تخت از واژه‌هاست: "Anne", "Williams", "John", "Fred", "Smith", "Sue", "Green". چون هر ورودی به تعداد متغیری خروجی نگاشت می‌شود، SelectMany مناسب است. string.Split هر رشته را به آرایه‌ای از واژه‌ها تبدیل می‌کند:

string testInputElement = "Anne Williams";
string[] childSequence  = testInputElement.Split();
// childSequence is { "Anne", "Williams" };

پرس‌وجو:

IEnumerable<string> query = fullNames.SelectMany (name => name.Split());
foreach (string name in query)
  Console.Write (name + "|");
// Anne|Williams|John|Fred|Smith|Sue|Green|

SelectMany در query syntax با یک generator اضافی، یعنی یک بند from دیگر، فراخوانی می‌شود. واژهٔ from دو معنا دارد: در ابتدای پرس‌وجو range variable اصلی و دنبالهٔ ورودی را معرفی می‌کند؛ هر جای دیگر به SelectMany ترجمه می‌شود.

IEnumerable<string> query =
  from fullName in fullNames
  from name in fullName.Split()     // Translates to SelectMany
  select name;

generator اضافی range variable جدیدی به نام name معرفی می‌کند. range variable قدیمی یعنی fullName همچنان در scope باقی می‌ماند و بعداً به هر دو دسترسی داریم.

چند range variable

در مثال قبل، name و fullName تا پایان query یا رسیدن به into در scope می‌مانند. گستردگی scope این متغیرها یکی از مهم‌ترین مزیت‌های query syntax نسبت به fluent syntax است.

IEnumerable<string> query =
  from fullName in fullNames
  from name in fullName.Split()
  select name + " came from " + fullName;
// Anne came from Anne Williams
// Williams came from Anne Williams
// John came from John Fred Smith
// ...

نوشتن معادل همین پرس‌وجو در fluent syntax دشوار است، و با افزودن where یا orderby پیش از projection دشوارتر می‌شود:

from fullName in fullNames
from name in fullName.Split()
orderby fullName, name
select name + " came from " + fullName;

مشکل این است که SelectMany یک دنبالهٔ تخت از child elementها تولید می‌کند و outer element اصلی یعنی fullName از دست می‌رود. راه‌حل این است که outer element را همراه هر child در یک anonymous type موقت حمل کنیم:

from fullName in fullNames
from x in fullName.Split().Select (name => new { name, fullName } )
orderby x.fullName, x.name
select x.name + " came from " + x.fullName;

تبدیل نهایی به fluent syntax:

IEnumerable<string> query = fullNames
  .SelectMany (fName => fName.Split()
                             .Select (name => new { name, fName } ))
  .OrderBy (x => x.fName)
  .ThenBy  (x => x.name)
  .Select  (x => x.name + " came from " + x.fName);

فکرکردن با query syntax

وقتی به چند range variable نیاز دارید، دلایل خوبی برای استفاده از query syntax وجود دارد؛ در این موارد بهتر است مستقیماً با واژگان query syntax فکر کنید.

دو الگوی پایه برای generatorهای اضافی وجود دارد. اول، گسترش و تخت‌کردن زیردنباله‌ها: روی range variable موجود property یا method صدا می‌زنید:

from fullName in fullNames
from name in fullName.Split()

در اینجا از پیمایش full nameها به پیمایش واژه‌ها گسترش داده‌ایم. نمونهٔ مشابه در EF Core، گسترش collection navigation propertyهاست:

IEnumerable<string> query = from c in dbContext.Customers
                            from p in c.Purchases
                            select c.Name + " bought a " + p.Description;
// Tom bought a Bike
// Tom bought a Holiday
// Dick bought a Phone
// Harry bought a Car
// ...

الگوی دوم، cartesian product یا cross join است که هر عنصر یک دنباله را با هر عنصر دنبالهٔ دیگر تطبیق می‌دهد. برای آن generatorی معرفی می‌کنید که selector آن دنباله‌ای مستقل از range variable برمی‌گرداند:

int[] numbers = { 1, 2, 3 };  string[] letters = { "a", "b" };
IEnumerable<string> query = from n in numbers
                            from l in letters
                            select n.ToString() + l;
// RESULT: { "1a", "1b", "2a", "2b", "3a", "3b" }

این سبک پرس‌وجو اساس joinهای مبتنی بر SelectMany است.

Joining با SelectMany

می‌توانید دو دنباله را با فیلترکردن نتایج cross product به‌سادگی join کنید. فرض کنید برای یک بازی بازیکنان را جفت می‌کنیم:

string[] players = { "Tom", "Jay", "Mary" };
IEnumerable<string> query = from name1 in players
                            from name2 in players
                            select name1 + " vs " + name2;
// RESULT: { "Tom vs Tom", "Tom vs Jay", "Tom vs Mary",
//           "Jay vs Tom", "Jay vs Jay", "Jay vs Mary",
//           "Mary vs Tom", "Mary vs Jay", "Mary vs Mary" }

این cross join دقیقاً همان چیزی است که خواسته بودیم، اما تا زمانی که فیلتر اضافه نکنیم مفید نیست:

IEnumerable<string> query = from name1 in players
                            from name2 in players
                            where name1.CompareTo (name2) < 0
                            orderby name1, name2
                            select name1 + " vs " + name2;
// RESULT: { "Jay vs Mary", "Jay vs Tom", "Mary vs Tom" }

predicate فیلتر همان join condition است. چون شرط از equality استفاده نمی‌کند، این پرس‌وجو یک non-equi join است.

SelectMany در EF Core

SelectMany در EF Core می‌تواند cross join، non-equi join، inner join و left outer join انجام دهد؛ هم با associationهای ازپیش‌تعریف‌شده و هم رابطه‌های ad hoc. تفاوت با Select این است که SelectMany result set تخت می‌دهد.

cross join:

var query = from c in dbContext.Customers
            from p in dbContext.Purchases
            select c.Name + " might have bought a " + p.Description;

برای تطبیق هر مشتری فقط با خریدهای خودش، شرط join اضافه می‌کنیم؛ نتیجه یک equi-join استاندارد SQL است:

var query = from c in dbContext.Customers
            from p in dbContext.Purchases
            where c.ID == p.CustomerID
            select c.Name + " bought a " + p.Description;

اگر entityها collection navigation property داشته باشند، همان پرس‌وجو را می‌توانید با گسترش زیرمجموعه به‌جای فیلترکردن cross product بنویسید:

from c in dbContext.Customers
from p in c.Purchases
select new { c.Name, p.Description };

مزیت این است که join predicate حذف شده و به‌جای فیلتر cross product، زیرمجموعه را گسترش و تخت کرده‌ایم.

برای فیلتر اضافی می‌توانید where اضافه کنید. مثلاً فقط مشتریانی که نامشان با T شروع می‌شود:

from c in dbContext.Customers
where c.Name.StartsWith ("T")
from p in c.Purchases
select new { c.Name, p.Description };

در EF Core جابه‌جایی where یک خط پایین‌تر همان SQL را تولید می‌کند، اما در پرس‌وجوی محلی کم‌کارآمدتر می‌شود؛ در queryهای محلی پیش از join فیلتر کنید.

با fromهای اضافی می‌توان جدول‌های بیشتری وارد کرد. اگر هر purchase دارای child rowهای purchase item باشد:

from c in dbContext.Customers
from p in c.Purchases
from pi in p.PurchaseItems
select new { c.Name, p.Description, pi.Detail };

هر from یک child table جدید معرفی می‌کند. برای دادهٔ parent table از طریق navigation property، from اضافه نمی‌کنید و مستقیم property را پیمایش می‌کنید:

from c in dbContext.Customers
select new { Name = c.Name, SalesPerson = c.SalesPerson.Name };

اینجا SelectMany لازم نیست چون زیرمجموعه‌ای برای تخت‌کردن وجود ندارد؛ parent navigation property فقط یک item برمی‌گرداند.

Outer join با SelectMany

پیش‌تر دیدیم که یک Select subquery نتیجه‌ای شبیه left outer join می‌دهد:

from c in dbContext.Customers
select new {
             c.Name,
             Purchases = from p in c.Purchases
                         where p.Price > 1000
                         select new { p.Description, p.Price }
           };

هر customer بیرونی می‌آید، چه خرید داشته باشد چه نه. اما اگر پرس‌وجو را با SelectMany تخت کنیم:

from c in dbContext.Customers
from p in c.Purchases
where p.Price > 1000
select new { c.Name, p.Description, p.Price };

در فرایند تخت‌کردن به inner join تبدیل شده‌ایم: فقط مشتریانی می‌آیند که دست‌کم یک خرید باارزش دارند. برای left outer join تخت باید DefaultIfEmpty را روی inner sequence اعمال کنیم. اگر ورودی خالی باشد، این متد دنباله‌ای با یک عنصر null برمی‌گرداند:

from c in dbContext.Customers
from p in c.Purchases.DefaultIfEmpty()
select new { c.Name, p.Description, Price = (decimal?) p.Price };

در EF Core درست کار می‌کند و همهٔ مشتریان را برمی‌گرداند؛ اما به‌عنوان query محلی وقتی p برابر null باشد دسترسی به p.Description و p.Price، NullReferenceException می‌دهد. نسخهٔ مقاوم:

from c in dbContext.Customers
from p in c.Purchases.DefaultIfEmpty()
select new {
             c.Name,
             Descript = p == null ? null : p.Description,
             Price = p == null ? (decimal?) null : p.Price
           };

اگر فیلتر قیمت را دوباره اضافه کنیم، نمی‌توانیم مانند قبل where را بعد از DefaultIfEmpty بگذاریم، زیرا آن‌گاه بعد از این اپراتور اجرا می‌شود:

from c in dbContext.Customers
from p in c.Purchases.DefaultIfEmpty()
where p.Price > 1000...

پایان محتوای تخصیص‌یافته از فایل PDF برای این مقاله.

امتیاز کاربران به این مقاله

☆☆☆☆☆

0 نفر امتیاز داده اند. میانگین: 0.0 از 5

 

0 نظر

نظر محترم شما در مورد مقاله های وب سایت برنامه نویسی و پایگاه داده

نظرات محترم شما در خدمات رسانی بهتر ما را یاری می نمایند. لطفا اگر مایل بودید یک نظر ما را مهمان فرمائید. آدرس ایمیل و وب سایت شما نمایش داده نخواهد شد.

0 / 500

اطلاعات تماس

  • آدرس:اصفهان-خیابان ام کلثوم غربی - بعد خیابان تخم چی - بیست متر بعد از پیتزا ننه شب - کوچه تعمیر گاه سمار زغالی - پلاک 354 - درب مشکی - طبقه هفتم
  • آدرس ایمیل:najafzade@gmail.com
  • وب سایت:http://www.a00b.com/
  • تلفن ثابت:(+98)9131253620
  • تلفن همراه:09131253620