CodeGym /Các khóa học /C# SELF /Kết hợp nhóm với group join<...

Kết hợp nhóm với group join

C# SELF
Mức độ , Bài học
Có sẵn

1. Giới thiệu

Hãy tưởng tượng một bài toán kinh điển: bạn có hai collection liên quan logic với nhau. Ví dụ, danh sách các danh mục sản phẩm và danh sách các sản phẩm. Cần lấy tất cả sản phẩm của từng danh mục. Hoặc, có danh sách các phòng ban và danh sách nhân viên, và bạn muốn hiển thị tất cả nhân viên cho từng phòng ban.

Trong SQL, cái này gọi là "kết hợp nhóm" (GROUP JOIN hoặc chính xác hơn là LEFT OUTER JOIN với group). Trong LINQ có operator riêng – GroupJoin. Nó kiểu như ở giữa giữa join thường (Join), nơi mỗi bản ghi bên trái chỉ có đúng một bản ghi bên phải, và group theo key. GroupJoin sẽ liên kết mỗi phần tử của một collection với tất cả phần tử liên quan từ collection kia dưới dạng collection.

So sánh dễ hiểu

Nếu Join thường giống như ghép cặp “bố và con trai” theo họ, thì GroupJoin là xây cây: mỗi ông bố có danh sách tất cả con của mình.

Minh họa sơ đồ


           Danh mục                   Sản phẩm
        +--------------+         +---------------------+
        | Id | Tên     |         | Tên       | CatId   |
        +----+---------+         +-----------+---------+
        | 1  | Bánh mì |   --->  | Bánh mì ổ | 1       |
        | 2  | Đồ uống |         | Xúc xích  | 3       |
        | 3  | Thịt    |         | Pepsi     | 2       |
        |    |         |         | Trà       | 2       |
        +----+---------+         +-----------+---------+

Sau GroupJoin:

  • Bánh mì — [Bánh mì ổ]
  • Đồ uống — [Pepsi, Trà]
  • Thịt — [Xúc xích]

2. Chữ ký method và khái niệm cơ bản

Extension method


public static IEnumerable<TResult> GroupJoin<TOuter, TInner, TKey, TResult>(
    this IEnumerable<TOuter> outer,                   // Collection "bên ngoài" (ví dụ: danh mục)
    IEnumerable<TInner> inner,                        // Collection "bên trong" (ví dụ: sản phẩm)
    Func<TOuter, TKey> outerKeySelector,              // Lấy key từ phần tử bên ngoài
    Func<TInner, TKey> innerKeySelector,              // Lấy key từ phần tử bên trong
    Func<TOuter, IEnumerable<TInner>, TResult> resultSelector // Factory tạo object/result cuối cùng
)
  • outer: collection sẽ được duyệt và gắn thêm các phần tử (ví dụ: danh mục).
  • inner: collection lấy các phần tử để gắn vào (ví dụ: sản phẩm).
  • outerKeySelector: lambda trả về key cho phần tử "bên trái".
  • innerKeySelector: lambda trả về key cho phần tử "bên phải".
  • resultSelector: function xác định kết quả cho mỗi cặp (bên trái + group bên phải).

3. Ví dụ thực tế: danh mục và sản phẩm

Giả sử có các model như sau:


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; }
}

Collection ví dụ:


var categories = new List<Category>
{
    new Category { Id = 1, Name = "Bánh mì" },
    new Category { Id = 2, Name = "Đồ uống" },
    new Category { Id = 3, Name = "Thịt" }
};

var products = new List<Product>
{
    new Product { Name = "Bánh mì ổ", CategoryId = 1 },
    new Product { Name = "Pepsi", CategoryId = 2 },
    new Product { Name = "Trà", CategoryId = 2 },
    new Product { Name = "Xúc xích", CategoryId = 3 }
};

Dùng GroupJoin (Method Syntax)


var groupJoin = categories.GroupJoin(
    products,
    category => category.Id,                    // key của danh mục
    product => product.CategoryId,              // key của sản phẩm
    (category, prods) => new                   // tạo result ngay tại đây
    {
        CategoryName = category.Name,
        Products = prods.Select(p => p.Name).ToList() // danh sách tên sản phẩm của danh mục này
    }
);

Cách duyệt kết quả:


foreach (var group in groupJoin)
{
    Console.WriteLine($"Danh mục: {group.CategoryName}");
    foreach (var product in group.Products)
    {
        Console.WriteLine($"  - {product}");
    }
}

Kết quả:

Danh mục: Bánh mì
  - Bánh mì ổ
Danh mục: Đồ uống
  - Pepsi
  - Trà
Danh mục: Thịt
  - Xúc xích

4. GroupJoin: Query Syntax (cú pháp truy vấn)

LINQ hỗ trợ cú pháp giống SQL. Để group join dùng từ khóa join ... into ..., và truy vấn này hoạt động gần giống ví dụ trên.


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()
                 };

Cái này rất giống truy vấn SQL với LEFT OUTER JOIN ... GROUP BY.

Sơ đồ trực quan: GroupJoin hoạt động thế nào


[Danh mục]         [Sản phẩm]           Nhóm (GroupJoin)

  Bánh mì   -------->   Bánh mì ổ    =>   Bánh mì:    [Bánh mì ổ]
  Đồ uống  -------->   Pepsi        =>   Đồ uống: [Pepsi, Trà]
  Đồ uống  -------->   Trà
  Thịt     -------->   Xúc xích     =>   Thịt:    [Xúc xích]

Mỗi danh mục sẽ có một “ngăn” (IEnumerable<Product>), trong đó chứa tất cả sản phẩm thuộc danh mục đó.

5. Đặc điểm và những bẫy thường gặp

GroupJoin vs. Join thường

Khác biệt giữa Join thường và GroupJoin là số lượng kết quả. Join trả về một cặp cho mỗi lần khớp, còn GroupJoin trả về một phần tử cho mỗi phần tử của collection bên ngoài, bên trong là collection tất cả phần tử khớp.

Nếu trong sơ đồ có danh mục không có sản phẩm nào, với GroupJoin nó vẫn xuất hiện, chỉ là collection sản phẩm của nó sẽ rỗng. Hành vi này giống LEFT OUTER JOIN trong SQL (kết hợp ngoài bên trái).

Ví dụ với danh mục không có sản phẩm:


categories.Add(new Category { Id = 4, Name = "Phô mai" });

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($"Danh mục: {group.CategoryName}");
    if (group.Products.Count == 0)
        Console.WriteLine("  (Không có sản phẩm)");
    else
        foreach (var product in group.Products)
            Console.WriteLine($"  - {product}");
}
Danh mục: Bánh mì
  - Bánh mì ổ
Danh mục: Đồ uống
  - Pepsi
  - Trà
Danh mục: Thịt
  - Xúc xích
Danh mục: Phô mai
  (Không có sản phẩm)

Tình huống này rất hay gặp trong ứng dụng business: cần hiển thị tất cả danh mục (hoặc nhóm), kể cả khi không có phần tử nào trong đó.

Làm bằng GroupBy? Đừng!

Rất nhiều bạn nhầm lẫn, cố “giả lập” GroupJoin bằng double GroupBy. Đừng làm vậy — GroupJoin sinh ra để làm chuyện này, và nó làm “kết hợp ngoài bên trái” một cách native.

6. Ứng dụng với dữ liệu thực tế

Bổ sung cho các bài trước, hãy thêm vào app học tập của mình chức năng xuất báo cáo: “Mỗi danh mục — danh sách sản phẩm của nó”. Bài toán này rất hay gặp trong web shop, CRM, hệ thống quản lý hay báo cáo.

Thêm code vào app demo:


// Giả sử đã có class Category và Product và các collection đã tạo

Console.WriteLine("BÁO CÁO DANH MỤC VÀ SẢN PHẨM:");
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($"Danh mục: {row.Name}");
    if (row.ProductNames.Count == 0)
        Console.WriteLine("  (Không có hàng hóa)");
    else
        foreach (var prodName in row.ProductNames)
            Console.WriteLine($"  - {prodName}");
}

7. Nhóm lồng nhau và làm việc với aggregate

GroupJoin có thể kết hợp với hàm aggregate để làm báo cáo phức tạp hơn.

Ví dụ: Đếm số sản phẩm trong mỗi danh mục


var reportWithCount = categories.GroupJoin(
    products,
    category => category.Id,
    product => product.CategoryId,
    (category, prods) => new
    {
        Category = category.Name,
        Count = prods.Count()   // Hàm aggregate!
    });

foreach (var rec in reportWithCount)
{
    Console.WriteLine($"{rec.Category}: {rec.Count} sản phẩm");
}

Giả sử collection categoriesproducts có dữ liệu như sau:


var categories = new[]
{
    new { Id = 1, Name = "Trái cây" },
    new { Id = 2, Name = "Rau củ" },
    new { Id = 3, Name = "Sản phẩm sữa" }
};

var products = new[]
{
    new { Id = 1, Name = "Táo", CategoryId = 1 },
    new { Id = 2, Name = "Chuối", CategoryId = 1 },
    new { Id = 3, Name = "Cà rốt", CategoryId = 2 }
};

Kết quả in ra console sẽ là:

Danh mục: Trái cây — 2 sản phẩm
Danh mục: Rau củ — 1 sản phẩm
Danh mục: Sản phẩm sữa — 0 sản phẩm

8. GroupJoin và lỗi phổ biến của newbie

Lỗi hay gặp — nghĩ rằng kết quả GroupJoin sẽ là bảng phẳng các cặp, giống Join thường. "Bảng phẳng" ở đây là mỗi dòng là một cặp: một phần tử ngoài và một phần tử trong tương ứng (kiểu bảng SQL sau INNER JOIN).

Điều này dễ gây nhầm lẫn cho ai từng làm database: họ nghĩ GroupJoin sẽ giống LEFT JOIN, nhưng trả về cặp từng dòng, không phải group. Nhưng GroupJoin trả về phần tử ngoài collection các phần tử trong liên quan — tức là cấu trúc lồng nhau.

Đừng quên “mở” collection lồng nhau khi cần — ví dụ dùng SelectMany nếu muốn lấy sequence cặp bình thường.

Một lỗi nữa — quên rằng với phần tử không khớp, group sẽ chỉ là list rỗng. Đây là mặc định, không phải bug — nhưng nhớ để khỏi thắc mắc sao không thấy gì trong output.

Khi nào dùng GroupJoin, khi nào không?

Dùng GroupJoin khi:

  • Bạn có hai tập dữ liệu (ví dụ: phòng ban và nhân viên, danh mục và sản phẩm) và muốn hiển thị dạng phân cấp: mỗi “cha” có tất cả “con”.
  • Làm báo cáo phức tạp, cần hiển thị tất cả phần tử chính kể cả khi không có “con”.
  • Khi cần giống SQL LEFT OUTER JOIN với group theo key.

Đừng dùng GroupJoin khi chỉ cần giao nhau hai collection hoặc lấy đúng một cặp cho mỗi lần khớp — dùng Join thường cho việc đó.

Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION