JavaRush /Курси /C# SELF /Групове об’єднання з group j...

Групове об’єднання з group join

C# SELF
Рівень 33 , Лекція 1
Відкрита

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.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ