1. Вступ
Уявіть собі типове завдання: у вас є дві колекції, логічно пов’язані між собою. Наприклад, список категорій товарів і список самих товарів. Потрібно для кожної категорії отримати усі товари цієї категорії. Або, скажімо, є список відділів компанії та список співробітників, і слід вивести всіх співробітників для кожного відділу.
У SQL це називається «групове об’єднання» (GROUP JOIN або, точніше, LEFT OUTER JOIN із групуванням). У LINQ для цього є спеціальний оператор — GroupJoin. Це щось між звичайним об’єднанням (Join), яке повертає пари для кожного збігу, і групуванням за ключем. GroupJoin зв’язує кожен елемент однієї колекції з усіма пов’язаними елементами з другої колекції у вигляді колекції.
Аналогія
Якщо звичайний Join — це ніби поєднати пари «тато й син» за прізвищем, то GroupJoin — це побудувати дерево: до кожного тата приєднати список усіх його дітей.
Схематично
Категорії Товари
+--------------+ +---------------------+
| Id | Назва | | Назва | CatId |
+----+---------+ +-----------+---------+
| 1 | Хліб | ---> | Батон | 1 |
| 2 | Напої | | Ковбаса | 3 |
| 3 | М'ясо | | Пепсі | 2 |
| | | | Чай | 2 |
+----+---------+ +-----------+---------+
Після GroupJoin:
- Хліб — [Батон]
- Напої — [Пепсі, Чай]
- М’ясо — [Ковбаса]
2. Сигнатура методу та базові поняття
Метод-розширення
public static IEnumerable<TResult> GroupJoin<TOuter, TInner, TKey, TResult>(
this IEnumerable<TOuter> outer, // "Зовнішня" колекція (наприклад, категорії)
IEnumerable<TInner> inner, // "Внутрішня" колекція (наприклад, товари)
Func<TOuter, TKey> outerKeySelector, // Як із зовнішнього елемента отримати ключ
Func<TInner, TKey> innerKeySelector, // Як із внутрішнього елемента отримати ключ
Func<TOuter, IEnumerable<TInner>, TResult> resultSelector // Фабрика для створення результату/запису
)
- outer: колекція, якою відбувається обхід, до якої приєднуються елементи (наприклад, категорії).
- inner: колекція, з якої вибираються приєднувані елементи (наприклад, товари).
- outerKeySelector: лямбда-вираз, який повертає ключ для елемента «зліва».
- innerKeySelector: лямбда-вираз, який повертає ключ для елемента «справа».
- resultSelector: функція, що визначає, як виглядатиме результат для кожної пари (лівий + група правих).
3. Практичний приклад: категорії та товари
Припустимо, у нас є такі моделі:
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
}
public class Product
{
public string Name { get; set; }
public int CategoryId { get; set; }
}
Колекції для прикладу:
var categories = new List<Category>
{
new Category { Id = 1, Name = "Хліб" },
new Category { Id = 2, Name = "Напої" },
new Category { Id = 3, Name = "М'ясо" }
};
var products = new List<Product>
{
new Product { Name = "Батон", CategoryId = 1 },
new Product { Name = "Пепсі", CategoryId = 2 },
new Product { Name = "Чай", CategoryId = 2 },
new Product { Name = "Ковбаса", CategoryId = 3 }
};
Використання GroupJoin (Method Syntax)
var groupJoin = categories.GroupJoin(
products,
category => category.Id, // ключ категорії
product => product.CategoryId, // ключ товару
(category, prods) => new // будуємо результат на льоту
{
CategoryName = category.Name,
Products = prods.Select(p => p.Name).ToList() // список імен товарів цієї категорії
}
);
Як обходити результат:
foreach (var group in groupJoin)
{
Console.WriteLine($"Категорія: {group.CategoryName}");
foreach (var product in group.Products)
{
Console.WriteLine($" - {product}");
}
}
Виведення:
Категорія: Хліб
- Батон
Категорія: Напої
- Пепсі
- Чай
Категорія: М'ясо
- Ковбаса
4. GroupJoin: Query Syntax (синтаксис запитів)
LINQ підтримує синтаксис, подібний до SQL. Для group join використовується конструкція join ... into ..., і такий запит працює майже так само, як приклад вище.
var groupJoin2 = from c in categories
join p in products on c.Id equals p.CategoryId into prodGroup
select new
{
CategoryName = c.Name,
Products = prodGroup.Select(p => p.Name).ToList()
};
Це дуже схоже на SQL-запит із LEFT OUTER JOIN ... GROUP BY.
Візуальна схема: як працює GroupJoin
[Категорія] [Product] Групування (GroupJoin)
Хліб --------> Батон => Хліб: [Батон]
Напої --------> Пепсі => Напої: [Пепсі, Чай]
Напої --------> Чай
М'ясо --------> Ковбаса => М'ясо: [Ковбаса]
Кожна категорія отримує свою «кишеню» (IEnumerable<Product>), у яку потрапляють усі товари цієї категорії.
5. Особливості та підводні камені
GroupJoin vs. звичайний Join
Різниця між звичайним Join і GroupJoin — у кількості результатів. Join повертає одну пару на кожний збіг, а GroupJoin — по одному елементу на кожний елемент зовнішньої колекції; усередині нього — колекція всіх відповідних елементів.
Якщо в нашій схемі з’явиться категорія без товарів, із GroupJoin вона все одно буде в результаті: її колекція товарів просто порожня. Це поведінка, аналогічна LEFT OUTER JOIN у SQL (ліве зовнішнє об’єднання).
Ось приклад із категорією без товарів:
categories.Add(new Category { Id = 4, Name = "Сири" });
var groupJoin3 = categories.GroupJoin(
products,
c => c.Id,
p => p.CategoryId,
(c, prods) => new
{
CategoryName = c.Name,
Products = prods.Select(p => p.Name).ToList()
});
foreach (var group in groupJoin3)
{
Console.WriteLine($"Категорія: {group.CategoryName}");
if (group.Products.Count == 0)
Console.WriteLine(" (Немає товарів)");
else
foreach (var product in group.Products)
Console.WriteLine($" - {product}");
}
Категорія: Хліб
- Батон
Категорія: Напої
- Пепсі
- Чай
Категорія: М'ясо
- Ковбаса
Категорія: Сири
(Немає товарів)
Такий сценарій часто потрібен у бізнес-застосунках: слід показати всі категорії (або групи), навіть якщо в якійсь із них немає елементів.
Реалізація через GroupBy? Ні!
Багато хто плутається, намагаючись «відтворити» GroupJoin за допомогою подвійного GroupBy. Не варто — GroupJoin створено саме для цього й виконує «ліве об’єднання» нативно.
6. Застосування з реальними даними
На додачу до попередніх лекцій додаймо в наше навчальне застосування можливість вивести звіт: «Для кожної категорії — список її товарів». Це завдання часто потрібне в інтернет-магазинах, CRM, облікових та звітних системах.
Додаймо код до нашого демо-застосунку:
// Припустімо, що в нас є класи Category і Product, а колекції вже створені
Console.WriteLine("ЗВІТ ЗА КАТЕГОРІЯМИ ТА ТОВАРАМИ:");
var categoryReport = categories.GroupJoin(
products,
cat => cat.Id,
prod => prod.CategoryId,
(cat, prods) => new
{
cat.Name,
ProductNames = prods.Select(p => p.Name).ToList()
});
foreach (var row in categoryReport)
{
Console.WriteLine($"Категорія: {row.Name}");
if (row.ProductNames.Count == 0)
Console.WriteLine(" (Немає товарів)");
else
foreach (var prodName in row.ProductNames)
Console.WriteLine($" - {prodName}");
}
7. Вкладені групування та робота з агрегатами
GroupJoin можна поєднувати з агрегатними функціями, щоб будувати складніші звіти.
Приклад: підрахувати кількість товарів у кожній категорії
var reportWithCount = categories.GroupJoin(
products,
category => category.Id,
product => product.CategoryId,
(category, prods) => new
{
Category = category.Name,
Count = prods.Count() // Агрегатна функція!
});
foreach (var rec in reportWithCount)
{
Console.WriteLine($"{rec.Category}: {rec.Count} товар(ів)");
}
Припустімо, колекції categories і products містять такі дані:
var categories = new[]
{
new { Id = 1, Name = "Фрукти" },
new { Id = 2, Name = "Овочі" },
new { Id = 3, Name = "Молочні продукти" }
};
var products = new[]
{
new { Id = 1, Name = "Яблуко", CategoryId = 1 },
new { Id = 2, Name = "Банан", CategoryId = 1 },
new { Id = 3, Name = "Морква", CategoryId = 2 }
};
Результат у консолі буде таким:
Категорія: Фрукти — 2 товар(ів)
Категорія: Овочі — 1 товар(ів)
Категорія: Молочні продукти — 0 товар(ів)
8. GroupJoin та типові помилки початківців
Поширена помилка — очікувати, що результатом GroupJoin буде плоска таблиця пар, як у звичайного Join. Під «плоскою» тут мається на увазі структура, де кожен рядок — це одна пара: один зовнішній елемент і один відповідний внутрішній (ніби SQL-таблиця після INNER JOIN).
Це особливо збиває з пантелику тих, хто вже трохи працював із базами даних: вони очікують, що GroupJoin поводитиметься як LEFT JOIN, але повертатиме пари в рядках, а не групи. Насправді GroupJoin повертає елемент із зовнішньої колекції і колекцію пов’язаних внутрішніх елементів — по суті, вкладену структуру.
Не забувайте «розкривати» вкладені колекції за потреби — наприклад, за допомогою SelectMany, якщо хочете отримати звичайну послідовність пар.
Ще одна поширена помилка — забувати, що для елементів без збігу підгрупа буде просто порожнім списком. Це поведінка за замовчуванням, а не помилка, але про це важливо пам’ятати, щоб не дивуватися, чому у виведенні «нічого не відбувається».
Коли використовувати GroupJoin, а коли ні?
Використовуйте GroupJoin:
- Коли у вас є два набори даних (наприклад, відділи та співробітники, категорії та товари) і треба відобразити їх ієрархічно: для кожного «батька» — усі його «діти».
- Для формування складних звітів, де важливо показати всі основні елементи, навіть якщо «нащадків» немає.
- Коли потрібен аналог SQL LEFT OUTER JOIN із групуванням за ключем.
Не використовуйте GroupJoin там, де треба просто перетнути колекції або отримати рівно одну пару на збіг — для цього є звичайний Join.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ