CodeGym /課程 /C# SELF /group join 做群組合併

group join 做群組合併

C# SELF
等級 33 , 課堂 1
開放

1. 前言

想像一下經典的情境:你有兩個集合,它們邏輯上有關聯。比如說,商品分類清單跟商品清單。你要對每個分類拿到這個分類下的所有商品。或者有公司部門清單和員工清單,要列出每個部門的所有員工。

在 SQL 裡這叫「群組連接」(GROUP JOIN,更精確一點是 LEFT OUTER JOIN 加上分組)。LINQ 有個專門的 operator —— GroupJoin。它有點像一般的 Join(每個左邊的記錄對應一個右邊的),又有點像根據 key 分組。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,              // 怎麼從外部元素拿 key
    Func<TInner, TKey> innerKeySelector,              // 怎麼從內部元素拿 key
    Func<TOuter, IEnumerable<TInner>, TResult> resultSelector // 組合結果物件/記錄的工廠
)
  • outer:要遍歷的集合,會把元素 join 進來(例如分類)。
  • inner:要被 join 進來的集合(例如商品)。
  • outerKeySelector:lambda,回傳「左邊」元素的 key。
  • innerKeySelector:lambda,回傳「右邊」元素的 key。
  • resultSelector:function,決定每組(左+右群組)結果長什麼樣。

3. 實作範例:分類和商品

假設我們有這樣的 model:


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(方法語法)


var groupJoin = categories.GroupJoin(
    products,
    category => category.Id,                    // 分類的 key
    product => product.CategoryId,              // 商品的 key
    (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 怎麼運作


[分類]         [商品]           分組(GroupJoin)

  麵包   -------->   法國麵包      =>   麵包:    [法國麵包]
  飲料   -------->   百事可樂      =>   飲料:    [百事可樂, 茶]
  飲料   -------->   茶
  肉類   -------->   香腸          =>   肉類:    [香腸]

每個分類都會有自己的「口袋」(IEnumerable<Product>),裡面放這個分類下的所有商品。

5. 特點與陷阱

GroupJoin vs. 一般 Join

一般 JoinGroupJoin 差別在於結果數量。Join 每個 match 回傳一組 pair,GroupJoin 則是每個外部元素回傳一個,裡面包一個所有 match 的集合。

如果有分類沒有商品,用 GroupJoin 它還是會出現,只是商品集合是空的。這就跟 SQL 的 LEFT OUTER JOIN(左外連接)一樣。

來個沒有商品的分類範例:


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 來實作?千萬別!

很多人會搞混,想用雙重 GroupBy 來「模擬」 GroupJoin。別這樣 —— GroupJoin 就是為這種需求設計的,直接做「左連接」最原生。

6. 實戰應用

延續前面的課程,來給我們的學習 app 加個報表功能:「每個分類下有哪些商品」。這在電商、CRM、會計或報表系統都很常見。

加到 demo app 的程式碼:


// 假設已經有 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} 個商品");
}

假設 categoriesproducts 集合內容如下:


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

Console 輸出會是:

分類: 水果 — 2 個商品
分類: 蔬菜 — 1 個商品
分類: 乳製品 — 0 個商品

8. GroupJoin 與新手常見錯誤

常見錯誤 —— 以為 GroupJoin 會回傳扁平表格的 pair,就像一般 Join。這裡的「扁平」意思是每一行就是一個 pair:一個外部元素配一個對應的內部元素(像 SQL INNER JOIN 之後的表格)。

這對有用過資料庫的人特別容易搞混:他們會以為 GroupJoinLEFT JOIN 一樣,但回傳的是一行一 pair,不是群組。其實 GroupJoin 回傳的是外部集合的元素加上一個相關內部元素的集合 —— 本質上是巢狀結構。

記得要「展開」巢狀集合(比如用 SelectMany),如果你想要一般的 pair 序列。

另一個常見錯誤 —— 忘了沒 match 的元素會拿到空集合。這是預設行為,不是 bug —— 但要記得,不然會納悶為什麼輸出「沒東西」。

什麼時候該用 GroupJoin?什麼時候不要?

GroupJoin 的時機:

  • 你有兩組資料(像部門和員工、分類和商品),要階層式顯示:每個「父」底下所有「子」。
  • 要做複雜報表,需要顯示所有主體元素,即使「子」是空的。
  • 要做 SQL LEFT OUTER JOIN 加 key 分組的效果。

不要用 GroupJoin 在只要交集或一對一 pair 的情境 —— 這種用一般 Join 就好。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION