Group By Jak więc GroupBy w LINQ grupuje dane w C#. Zacznijmy od podstaw, a potem spójrzmy na zaawansowane przykłady. 

Group By pozwala na szybkie grupowanie kolekcji połączony ze sobą danych poprzez określoną właściwość. 

Pogrupowane dane staje się osobną kolekcją, które nie są zazwyczaj typem anonimowy, a interfejsem generycznym IGrouping<K,V>

Do przykładu potrzebujemy jakiegoś modelu. Oto model wpisu na blogu.

public class BlogPost
{
    public string Title { get; set; }

    public string Author { get; set; }

    public string Subject { get; set; }

    public bool CreatedInCurrentYear { get; set; }
}

Potrzebujemy także kolekcji tego modelu.

public static class BlogPosts
{
    public static string GetJson()
    {
        var list = Get();
        return Newtonsoft.Json.JsonConvert.SerializeObject(list);
    }

    public static IEnumerable<BlogPost> Get()
    {
        List<BlogPost> blogPosts = new List<BlogPost>();

        BlogPost p1 = new BlogPost()
        {
            Title = "Kuberetes i Docker : Wytłumacz mi i pokaż",
            Author = "Jacke",
            Subject = "Kubernetes"
        };

        BlogPost p2 = new BlogPost()
        {
            Title = "Zakup mieszkania w Warszawie : Zarobki programisty",
            Author = "Damian",
            Subject = "Filozfia"
        };

        BlogPost p3 = new BlogPost()
        {
            Title = "Wypoczęty programista. CO ? : Aktywny programista",
            Author = "Damian",
            Subject = "Filozfia"
        };

        BlogPost p4 = new BlogPost()
        {
            Title = "Zakładanie Rodziny czy Kariera czy Marzenia : Serce Programisty",
            Author = "Damian",
            Subject = "Filozfia"
        };

        BlogPost p5 = new BlogPost()
        {
            Title = "Sukces? To gra długodystansowa : Aktywny programista",
            Author = "Damian",
            Subject = "Filozfia"
        };

        BlogPost p6 = new BlogPost()
        {
            Title = "Co nowego w C# 9.0 ? Rekordy i Pattern Matching",
            Author = "Cezary",
            Subject = "c#"
        };

        BlogPost p7 = new BlogPost()
        {
            Title = "Asynchroniczny C# : Awaitables, Maszyna stanów i TaskCompletionSource",
            Author = "Cezary",
            Subject = "C#"
        };

        BlogPost p8 = new BlogPost()
        {
            Title = "TDD z C# : Test jednostkowy z XUnit, NUnit, MSUnit",
            Author = "Cezary",
            Subject = "c#"
        };

        BlogPost p9 = new BlogPost()
        {
            Title = "Higher Order Function : Przykłady Funkcji wyższego rzędu w C#",
            Author = "Cezary",
            Subject = "Csharp"
        };

        BlogPost p10 = new BlogPost()
        {
            Title = "Mailchimp i ASP.NET : Stworzenie newslettera",
            Author = "Kamil",
            Subject = "ASP.NET"
        };

        BlogPost p11 = new BlogPost()
        {
            Title = "ASP.NET i Angular : Caching, konfiguracja, REST API, Components",
            Author = "Adam",
            Subject = "asp net"
        };

        BlogPost p12 = new BlogPost()
        {
            Title = "Kurs ASP.NET i Angular : Zobaczmy domyślny szablon",
            Author = "Adam",
            Subject = "ASP.NET"
        };

        BlogPost p13 = new BlogPost()
        {
            Title = "ASP.NET Core Response Compression na Blog",
            Author = "Kamil",
            Subject = "ASP.NET"
        };

        blogPosts.Add(p1); blogPosts.Add(p2);
        blogPosts.Add(p3); blogPosts.Add(p4);
        blogPosts.Add(p5); blogPosts.Add(p6);
        blogPosts.Add(p7); blogPosts.Add(p8);
        blogPosts.Add(p9); blogPosts.Add(p10);
        blogPosts.Add(p11); blogPosts.Add(p12);
        blogPosts.Add(p13);

        return blogPosts;
    }
}

W formacie JSON wygląda to tak :

[
   {
      "Title":"Kuberetes i Docker : Wytłumacz mi i pokaż",
      "Author":"Jacke",
      "Subject":"Kubernetes"
   },
   {
      "Title":"Zakup mieszkania w Warszawie : Zarobki programisty",
      "Author":"Damian",
      "Subject":"Filozfia"
   },
   {
      "Title":"Wypoczęty programista. CO ? : Aktywny programista",
      "Author":"Damian",
      "Subject":"Filozfia"
   },
   {
      "Title":"Zakładanie Rodziny czy Kariera czy Marzenia : Serce Programisty",
      "Author":"Damian",
      "Subject":"Filozfia"
   },
   {
      "Title":"Sukces? To gra długodystansowa : Aktywny programista",
      "Author":"Damian",
      "Subject":"Filozfia"
   },
   {
      "Title":"Co nowego w C# 9.0 ? Rekordy i Pattern Matching",
      "Author":"Cezary",
      "Subject":"C#"
   },
   {
      "Title":"Asynchroniczny C# : Awaitables, Maszyna stanów i TaskCompletionSource",
      "Author":"Cezary",
      "Subject":"c#"
   },
   {
      "Title":"TDD z C# : Test jednostkowy z XUnit, NUnit, MSUnit",
      "Author":"Cezary",
      "Subject":"c"
   },
   {
      "Title":"Higher Order Function : Przykłady Funkcji wyższego rzędu w C#",
      "Author":"Cezary",
      "Subject":"csharp"
   },
   {
      "Title":"Mailchimp i ASP.NET : Stworzenie newslettera",
      "Author":"Kamil",
      "Subject":"ASP.NET"
   },
   {
      "Title":"ASP.NET i Angular : Caching, konfiguracja, REST API, Components",
      "Author":"Adam",
      "Subject":"asp net"
   },
   {
      "Title":"Kurs ASP.NET i Angular : Zobaczmy domyślny szablon",
      "Author":"Adam",
      "Subject":"ASP.NET"
   },
   {
      "Title":"ASP.NET Core Response Compression na Blog",
      "Author":"Kamil",
      "Subject":"ASP.NET"
   }
]

Mając taką kolekcję mogą ją po grupować np. po autorze. 

var blogPosts = BlogPosts.Get();

IEnumerable<IGrouping<string, BlogPost>>  g =
blogPosts.GroupBy(b => b.Author);

Nie typ zwracany Ciebie nie wystraszy. Zazwyczaj zapewne tego nie widzisz, ale warto na to spojrzeć. Jak widzisz nadal mamy do dyspozycji cały obiekt BlogPost tylko został on jeszcze podzielony względem danego klucza.

Oto jak nasza kolekcja pogrupowana prezentuje się w JSON. Co ciekawe właściwość Key nie jest serializowane.

var json = Newtonsoft.Json.JsonConvert.SerializeObject(g);

...

[
   [
      {
         "Title":"Kuberetes i Docker : Wytłumacz mi i pokaż",
         "Author":"Jacke",
         "Subject":"Kubernetes"
      }
   ],
   [
      {
         "Title":"Zakup mieszkania w Warszawie : Zarobki programisty",
         "Author":"Damian",
         "Subject":"Filozfia"
      },
      {
         "Title":"Wypoczęty programista. CO ? : Aktywny programista",
         "Author":"Damian",
         "Subject":"Filozfia"
      },
      {
         "Title":"Zakładanie Rodziny czy Kariera czy Marzenia : Serce Programisty",
         "Author":"Damian",
         "Subject":"Filozfia"
      },
      {
         "Title":"Sukces? To gra długodystansowa : Aktywny programista",
         "Author":"Damian",
         "Subject":"Filozfia"
      }
   ],
   [
      {
         "Title":"Co nowego w C# 9.0 ? Rekordy i Pattern Matching",
         "Author":"Cezary",
         "Subject":"C#"
      },
      {
         "Title":"Asynchroniczny C# : Awaitables, Maszyna stanów i TaskCompletionSource",
         "Author":"Cezary",
         "Subject":"c#"
      },
      {
         "Title":"TDD z C# : Test jednostkowy z XUnit, NUnit, MSUnit",
         "Author":"Cezary",
         "Subject":"c#"
      },
      {
         "Title":"Higher Order Function : Przykłady Funkcji wyższego rzędu w C#",
         "Author":"Cezary",
         "Subject":"csharp"
      }
   ],
   [
      {
         "Title":"Mailchimp i ASP.NET : Stworzenie newslettera",
         "Author":"Kamil",
         "Subject":"ASP.NET"
      },
      {
         "Title":"ASP.NET Core Response Compression na Blog",
         "Author":"Kamil",
         "Subject":"asp net"
      }
   ],
   [
      {
         "Title":"ASP.NET i Angular : Caching, konfiguracja, REST API, Components",
         "Author":"Adam",
         "Subject":"ASP.NET"
      },
      {
         "Title":"Kurs ASP.NET i Angular : Zobaczmy domyślny szablon",
         "Author":"Adam",
         "Subject":"ASP.NET"
      }
   ]
]

Jak widzisz nasza kolekcja została rozdzielona na sub kolekcję i każda z nich ma właściwość Key.

Group By ma Key

Mogę napisać taki kod.

foreach (var item in g)
{
    Console.WriteLine(item.Key);
    foreach (var blogpost in item)
    {
        Console.WriteLine(blogpost.Title);
    }
}

Mogę w taki sposób po iterować po kolekcji, która wiem, że będzie zawierać tego samego autora.

Jeżeli nie chcesz całego obiektu grupować do pod kolekcji to nie ma problemu, ponieważ metoda LINQ Group By ma także swoje przeciążenie, która przyjmuje drugi parametr.

W tym drugim parametrze mówisz co ma być zawarte do pod kolekcji. Oto przykład z tytułem wpisu.

IEnumerable<IGrouping<string, string>> g =
    blogPosts.GroupBy(b => b.Author,b => b.Title);

var json = Newtonsoft.Json.JsonConvert.SerializeObject(g);

Oto JSON tej kolekcji w kolekcji. Pamiętaj, że właściwość "Key" nie jest serializować.

[
   [
      "Kuberetes i Docker : Wytłumacz mi i pokaż"
   ],
   [
      "Zakup mieszkania w Warszawie : Zarobki programisty",
      "Wypoczęty programista. CO ? : Aktywny programista",
      "Zakładanie Rodziny czy Kariera czy Marzenia : Serce Programisty",
      "Sukces? To gra długodystansowa : Aktywny programista"
   ],
   [
      "Co nowego w C# 9.0 ? Rekordy i Pattern Matching",
      "Asynchroniczny C# : Awaitables, Maszyna stanów i TaskCompletionSource",
      "TDD z C# : Test jednostkowy z XUnit, NUnit, MSUnit",
      "Higher Order Function : Przykłady Funkcji wyższego rzędu w C#"
   ],
   [
      "Mailchimp i ASP.NET : Stworzenie newslettera",
      "ASP.NET Core Response Compression na Blog"
   ],
   [
      "ASP.NET i Angular : Caching, konfiguracja, REST API, Components",
      "Kurs ASP.NET i Angular : Zobaczmy domyślny szablon"
   ]
]

Oczywiście analogicznie do poprzedniego przykładu mogę skorzystać z pętli foreach, aby tą kolekcję w kolekcji wyświetlić.

foreach (var item in g)
{
    Console.WriteLine(item.Key);
    foreach (var title in item)
    {
        Console.WriteLine(title);
    }
}

Teraz gdy wiesz jak określać klucz i wartość grupowaną pomówmy o trzecim typie selekcji danych, które nazywają się : result selectors.

Zamiast pracować na IGrouping<K,V> dlaczego nie transformować danych do innej kolekcji. 

Widziałeś dwa razy, że klucz nie był serializowany więc naprawmy to:

var g3 = blogPosts.GroupBy
    (b => b.Author,
    b => b.Title, (key,title) =>
    new { Author = key, Title = title });

Teraz masz większą kontrolę nad tym, jak twoje dane się układają. Jak widzisz te grupowanie też są czytelniejsze w formacie JSON niż wcześniej.

[
   {
      "Author":"Jacke",
      "Title":[
         "Kuberetes i Docker : Wytłumacz mi i pokaż"
      ]
   },
   {
      "Author":"Damian",
      "Title":[
         "Zakup mieszkania w Warszawie : Zarobki programisty",
         "Wypoczęty programista. CO ? : Aktywny programista",
         "Zakładanie Rodziny czy Kariera czy Marzenia : Serce Programisty",
         "Sukces? To gra długodystansowa : Aktywny programista"
      ]
   },
   {
      "Author":"Cezary",
      "Title":[
         "Co nowego w C# 9.0 ? Rekordy i Pattern Matching",
         "Asynchroniczny C# : Awaitables, Maszyna stanów i TaskCompletionSource",
         "TDD z C# : Test jednostkowy z XUnit, NUnit, MSUnit",
         "Higher Order Function : Przykłady Funkcji wyższego rzędu w C#"
      ]
   },
   {
      "Author":"Kamil",
      "Title":[
         "Mailchimp i ASP.NET : Stworzenie newslettera",
         "ASP.NET Core Response Compression na Blog"
      ]
   },
   {
      "Author":"Adam",
      "Title":[
         "ASP.NET i Angular : Caching, konfiguracja, REST API, Components",
         "Kurs ASP.NET i Angular : Zobaczmy domyślny szablon"
      ]
   }
]

A co tak naprawdę ta kolekcja zwróciła?

Anonimowy typ

LINQ pozwala nam korzystać z typów anonimowych. Jak widzisz nie mamy klasy, która by miała właściwości Author oraz kolejną kolekcje Title.

Kompilator oczywiście taką klasę utworzy, ale ty tego nie musisz robić jawnie w kodzie.

To jeszcze nie koniec sekretów w metodzie GroupBy. Istnieje jeszcze jeden parametr do tej metody LINQ i jest to "equality comparer".

Czyli do metody możesz dodać logikę, która określi czy dany element należy do danej grupy. Przydaje się, gdy stwierdzasz, że twoje grupowanie powinno być bardziej złożone.

Załóżmy, że nasi autorzy są pisani raz z małej, a raz dużej litery. Wiemy, że bez względu na wielkość liter Ci autorzy powinni być grupowani tak samo.

Załóżmy także, że tematy naszych wpisów są pisane różnie, ale my wiemy, że pewne nazwy reprezentują taką samą grupę.

Oto na ile sposób temat języka programowania C# został napisany :

  • C#
  • c#
  • csharp
  • c-sharp
  • Csharp
  • C-Sharp

Oto na ile sposób temat ASP.NET został napisany :

  • ASP.NET
  • asp net

Wysyłamy więc do metody GroupBy implementacje interfejsu "IEqualityComparer<Key>", a on będzie porównywywał różne klucze.

Dla różnych wielkości liter dla autorów na pomoc przychodzi gotowa klasa "StringComparer". Do rozwiązania takiego problemu skorzystalibyśmy z StringComparer.CurrentCultureIgnoreCase

var g4 = blogPosts.GroupBy
(b => b.Author,
b => b.Title, (key, title) =>
new { Author = key, Title = title },
StringComparer.CurrentCultureIgnoreCase);

Dla grupowania tematów musimy napisać swój kod. Oto moja klasa i tak trzeba w niej napisać implementacje Equals(), GetHashCode() i Compare().

Metoda Compare() uruchomi się, dopiero gdy metoda GetHashCode() zwróci hash dla jednego obiektu i Equals() stwierdziły, że obie wartości są sobie równe.

public class SubjectComparer : IComparer<string?>, IEqualityComparer<string?>, IComparer, IEqualityComparer
{
    public int Compare([AllowNull] string x, [AllowNull] string y)
    {
        if (x == null || y == null)
            return -1;

        var x1 = x.ToLowerInvariant();
        var y1 = y.ToLowerInvariant();

        if ((x1 == "csharp" || x1 == "c#" || x1 == "c-sharp")
           &&
           (y1 == "csharp" || y1 == "c#" || y1 == "c-sharp"))
        {
            return 0;
        }

        if ((x1 == "asp.net" || x1 == "asp net")
            &&
            (y1 == "asp.net" || y1 == "asp net"))
        {
            return 0;
        }

        return StringComparer.CurrentCultureIgnoreCase.Compare(x, y);
    }

    public int Compare(object x, object y)
    {
        return Compare(x as string, y as string);
    }

    public bool Equals([AllowNull] string x, [AllowNull] string y)
    {
        if (x == null || y == null)
            return false;

        var x1 = x.ToLowerInvariant();
        var y1 = y.ToLowerInvariant();

        if ((x1 == "csharp" || x1 == "c#" || x1 == "c-sharp")
           &&
           (y1 == "csharp" || y1 == "c#" || y1 == "c-sharp"))
        {
            return true;
        }

        if ((x1 == "asp.net" || x1 == "asp net")
            &&
            (y1 == "asp.net" || y1 == "asp net"))
        {
            return true;
        }

        return StringComparer.CurrentCultureIgnoreCase.Equals(x, y);
    }

    public new bool Equals(object x, object y)
    {
        return Equals(x as string, y as string);
    }

    public int GetHashCode([DisallowNull] string obj)
    {
        var objx = obj.ToLowerInvariant();

        if (objx == "csharp" || objx == "c#" || objx == "c-sharp")
        {
            return "csharp".GetHashCode();
        }

        if (objx == "asp.net" || objx == "asp net")
        {
            return "asp.net".GetHashCode();
        }

        return StringComparer.CurrentCultureIgnoreCase.GetHashCode(obj);
    }

    public int GetHashCode(object obj)
    {
        return GetHashCode(obj as string);
    }
}

Sprawdźmy czy wszystko działa.

var g5 = blogPosts.GroupBy
(b => b.Subject,
b => b.Title, (key, title) =>
new { Subject = key, Title = title },
new SubjectComparer()); ;

var json = Newtonsoft.Json.JsonConvert.SerializeObject(g5);

JSON pokazuje, że dane zostały odpowiednio pogrupowane po temacie wpisów.

[
   {
      "Subject":"Kubernetes",
      "Title":[
         "Kuberetes i Docker : Wytłumacz mi i pokaż"
      ]
   },
   {
      "Subject":"Filozfia",
      "Title":[
         "Zakup mieszkania w Warszawie : Zarobki programisty",
         "Wypoczęty programista. CO ? : Aktywny programista",
         "Zakładanie Rodziny czy Kariera czy Marzenia : Serce Programisty",
         "Sukces? To gra długodystansowa : Aktywny programista"
      ]
   },
   {
      "Subject":"c#",
      "Title":[
         "Co nowego w C# 9.0 ? Rekordy i Pattern Matching",
         "Asynchroniczny C# : Awaitables, Maszyna stanów i TaskCompletionSource",
         "TDD z C# : Test jednostkowy z XUnit, NUnit, MSUnit",
         "Higher Order Function : Przykłady Funkcji wyższego rzędu w C#"
      ]
   },
   {
      "Subject":"ASP.NET",
      "Title":[
         "Mailchimp i ASP.NET : Stworzenie newslettera",
         "ASP.NET i Angular : Caching, konfiguracja, REST API, Components",
         "Kurs ASP.NET i Angular : Zobaczmy domyślny szablon",
         "ASP.NET Core Response Compression na Blog"
      ]
   }
]

Będę szczery, że nie korzystałem tak często z metody Group By w swojej pracy. Wynika to z tego, że nie wiele osób wie, że ona potrafi wypluwać coś innego niż interfejs "IGrouping".

Jak poznasz więcej jednak jego metod to potrafisz się w nim odnaleźć.