فصل ۹: اپراتورهای LINQ؛ Filtering، Projecting و SelectMany
فروش یا انتشار این ترجمه منوط به داشتن مجوز لازم از صاحب حقوق اثر است.
فصل ۹ — اپراتورهای LINQ
تصویر آغاز فصل ۹ — 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هاشکل جایگاه گروههای اصلی 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 sequence | IEnumerable<TSource> |
| Predicate | TSource => bool یا (TSource,int) => bool؛ حالت دوم در LINQ to SQL و Entity Framework ممنوع است. |
| Query syntax | where 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 sequence | IEnumerable<TSource> |
| Result selector | TSource => TResult یا (TSource,int) => TResult؛ حالت ایندکسدار در EF Core ممنوع است. |
| Query syntax | select 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های تودرتوهر Element ورودی به یک Sequence داخلی نگاشت میشود و خروجیها در یک Sequence ترکیب میشوند.
SelectMany
| آرگومان | نوع |
|---|
| Source sequence | IEnumerable<TSource> |
| Result selector | TSource => IEnumerable<TResult> یا (TSource,int) => IEnumerable<TResult>؛ حالت دوم در EF Core ممنوع است. |
| Query syntax | from 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...