• C# i .NET
  • Sortowanie w LINQ - OrderBy, ThenBy i EF bez pułapek

Sortowanie w LINQ - OrderBy, ThenBy i EF bez pułapek

Dłonie przeglądają grzbiety książek na półce, jakby sortowały je za pomocą LINQ OrderBy.

Spis treści

Masz listę produktów, użytkowników albo zamówień i chcesz pokazać ją w konkretnej kolejności? Właśnie temu służy sortowanie w LINQ, często opisywane hasłem linq order by. Pokażę, jak działają OrderBy, OrderByDescending i ThenBy, czym różni się sortowanie kolekcji w pamięci od zapytań do bazy oraz jakie błędy najczęściej prowadzą do nieoczekiwanych wyników.

Najważniejsze zasady sortowania danych w LINQ

  • OrderBy sortuje rosnąco według wskazanego klucza.
  • OrderByDescending odwraca kierunek sortowania.
  • ThenBy i ThenByDescending służą do sortowania po kolejnych właściwościach.
  • Kolejne użycie OrderBy tworzy nowe sortowanie główne i może anulować wcześniejsze kryterium.
  • W przypadku IQueryable sortowanie może zostać wykonane w bazie danych, ale zależy to od możliwości dostawcy.

OrderBy zaczyna od jednego klucza sortowania

Najprostszy przypadek wygląda tak, że wybieram właściwość, według której chcę ustawić elementy. Wyrażenie OrderBy przyjmuje funkcję zwracającą tak zwany klucz sortowania, czyli wartość używaną do porównania elementów.

var products = new List
{
    new("Monitor", 1299.00m),
    new("Klawiatura", 299.00m),
    new("Mysz", 149.00m)
};

var orderedProducts = products
    .OrderBy(product => product.Price);

foreach (var product in orderedProducts)
{
    Console.WriteLine($"{product.Name}: {product.Price} zł");
}

Wynik będzie uporządkowany od najtańszego produktu do najdroższego. Sortowanie rosnące jest domyślne, dlatego nie trzeba dopisywać żadnego dodatkowego parametru.

Istotne jest też to, że OrderBy nie zmienia oryginalnej listy. Zwraca nową sekwencję, a zapytanie jest wykonywane dopiero wtedy, gdy wynik zostanie wyliczony, na przykład przez foreach, ToList() albo First(). Taki mechanizm nazywa się odroczonym wykonaniem.

Przeczytaj również: Metoda statyczna w C# - kiedy warto jej używać?

Kluczem nie musi być bezpośrednia właściwość

Lambda może obliczać wartość pomocniczą. Dzięki temu można sortować tekst według długości, datę według roku albo obiekt według wyniku wyrażenia.

var words = new[] { "kot", "samochód", "dom", "programowanie" };

var byLength = words
    .OrderBy(word => word.Length);

var byLastCharacter = words
    .OrderBy(word => word[^1]);

W praktyce najczęściej sortuję po właściwości obiektu, ale możliwość użycia wyrażenia jest bardzo wygodna przy prostych regułach biznesowych. Trzeba tylko uważać, aby nie umieszczać w kluczu kosztownych operacji wykonywanych tysiące razy.

Sortowanie malejące i po wielu właściwościach

Gdy potrzebuję kolejności od największej wartości do najmniejszej, używam OrderByDescending. Nazwa jest długa, ale zasada pozostaje prosta.

var newestOrders = orders
    .OrderByDescending(order => order.CreatedAt);

Znacznie ciekawszy przypadek pojawia się wtedy, gdy jedna właściwość nie wystarcza. Załóżmy, że chcę pogrupować produkty według kategorii, a w każdej kategorii wyświetlić najpierw najtańsze produkty. Do tego służy ThenBy.

var sortedProducts = products
    .OrderBy(product => product.Category)
    .ThenBy(product => product.Price)
    .ThenBy(product => product.Name);

Kolejność ma tutaj znaczenie. Najpierw działa kategoria, potem cena, a na końcu nazwa jako dodatkowe rozstrzygnięcie remisu. ThenBy nie rozpoczyna nowego sortowania, tylko doprecyzowuje poprzednie.

Każde kryterium może mieć inny kierunek. Przykładowo kategorie mogą być alfabetyczne, ale produkty w każdej z nich mogą być ułożone od najdroższego:

var sortedProducts = products
    .OrderBy(product => product.Category)
    .ThenByDescending(product => product.Price);

W składni zapytań ten sam zapis wygląda tak:

var sortedProducts =
    from product in products
    orderby product.Category, product.Price descending
    select product;

Składnia zapytań jest tłumaczona na wywołania metod. Wielokrotne pola po orderby odpowiadają użyciu OrderBy oraz kolejnych metod ThenBy. Ja zwykle wybieram składnię metod, gdy zapytanie jest krótkie i zawiera kilka operacji łańcuchowych, ale składnia zapytań może być czytelniejsza przy rozbudowanych połączeniach i grupowaniu.

Składnia metod czy składnia zapytań

Oba warianty wykonują tę samą operację, ale różnią się stylem zapisu. Najważniejsze, aby nie mieszać ich bez powodu i zachować kolejność operacji, która odpowiada temu, co rzeczywiście ma zostać posortowane.

Cel Składnia metod Składnia zapytań
Sortowanie rosnące OrderBy(x => x.Name) orderby x.Name
Sortowanie malejące OrderByDescending(x => x.Price) orderby x.Price descending
Drugie kryterium ThenBy(x => x.Id) orderby x.Name, x.Id

Przykładowo filtr i sortowanie można połączyć w taki sposób:

var activeUsers = users
    .Where(user => user.IsActive)
    .OrderBy(user => user.LastName)
    .ThenBy(user => user.FirstName)
    .Select(user => new
    {
        user.FirstName,
        user.LastName
    });

Najpierw wybieram aktywnych użytkowników, później sortuję ich po nazwisku i imieniu, a dopiero na końcu tworzę uproszczony wynik. Taka kolejność jest czytelna i pozwala uniknąć sortowania danych, które i tak zostaną odrzucone przez Where.

Trzeba uważać na projekcję wykonaną zbyt wcześnie. Jeżeli po Select usunę właściwość potrzebną do sortowania, nie będę mógł się do niej odwołać w dalszej części zapytania. Z tego powodu najczęściej filtruję i sortuję przed projekcją do obiektu anonimowego lub DTO.

IEnumerable i IQueryable wymagają innego myślenia

Dla kolekcji typu IEnumerable, na przykład listy lub tablicy, sortowanie odbywa się w pamięci aplikacji. OrderBy musi przeanalizować wszystkie elementy, dlatego przy dużych kolekcjach operacja ma realny koszt obliczeniowy i pamięciowy.

Jeżeli pracuję z IQueryable, jak w przypadku Entity Framework Core, zapytanie może zostać przetłumaczone na SQL. Wtedy sortowanie wykona baza danych, a do aplikacji trafi już uporządkowany fragment danych.

var page = await dbContext.Products
    .Where(product => product.IsAvailable)
    .OrderBy(product => product.Name)
    .Skip(pageNumber * pageSize)
    .Take(pageSize)
    .ToListAsync();

Przy paginacji dobrze dodaję jeszcze unikalne kryterium końcowe, na przykład identyfikator. Sama nazwa produktu może się powtarzać, a baza danych nie musi zachować stałej kolejności rekordów o identycznej wartości sortowania.

Duże znaczenie ma również moment wywołania ToList(). Zbyt wczesna materializacja przenosi dane do pamięci i sprawia, że kolejne operacje wykonują się lokalnie. Przy pracy z listami przydaje się też rozróżnienie, czym jest długość listy w C#, a czym jej pojemność, bo Count opisuje liczbę elementów, natomiast Capacity zarezerwowane miejsce.

Nie każda lambda da się przetłumaczyć na zapytanie bazy danych. Prosty dostęp do właściwości zwykle nie sprawia problemu, ale własne metody, niestandardowe komparatory czy skomplikowane operacje na tekście mogą wymagać wcześniejszego pobrania danych i sortowania w pamięci.

Teksty, wartości null i własny comparer

Sortowanie liczb i dat jest zazwyczaj przewidywalne. Przy tekstach dochodzą reguły porównywania wielkości liter, kultury i znaków diakrytycznych. Dla technicznych identyfikatorów, kodów i kluczy często wybieram porównywanie niezależne od kultury.

var sortedCodes = codes
    .OrderBy(code => code, StringComparer.OrdinalIgnoreCase);

StringComparer.OrdinalIgnoreCase porównuje tekst bez uwzględniania wielkości liter, ale nie próbuje dopasować reguł języka polskiego czy innej kultury. To dobry wybór dla kodów, nazw kluczy i wartości przeznaczonych do porównań technicznych. Dla nazw przeznaczonych do prezentacji użytkownikowi decyzję trzeba dopasować do reguł konkretnego języka.

Przy wartościach opcjonalnych trzeba świadomie zdecydować, gdzie mają trafić elementy z null. Prostym rozwiązaniem jest zastąpienie braku pustym tekstem:

var sortedCustomers = customers
    .OrderBy(customer => customer.CompanyName ?? string.Empty);

To nie jest jednak uniwersalna reguła. Pusta wartość może znaleźć się na początku, podczas gdy w interfejsie użytkownika bardziej sensowne będzie pokazanie nieuzupełnionych rekordów na końcu. Wtedy tworzę dodatkowy klucz logiczny:

var sortedCustomers = customers
    .OrderBy(customer => customer.CompanyName is null)
    .ThenBy(customer => customer.CompanyName);

Jeśli klucz sortowania powstaje po manipulacji tekstem, trzeba poprawnie obsłużyć Unicode. Dotyczy to także nietypowego przypadku sortowania słów według znaków od końca. Przy takich operacjach pomocne jest wyjaśnienie, jak działa odwracanie znaków Unicode w C#, ponieważ zwykłe odwracanie elementów typu char może rozbić niektóre znaki zapisane za pomocą par zastępczych.

Błędy, które psują wynik sortowania

Najczęstszy błąd wygląda niewinnie i polega na użyciu dwóch metod OrderBy jedna po drugiej:

var result = products
    .OrderBy(product => product.Category)
    .OrderBy(product => product.Price);

Drugie wywołanie nie sortuje ceny wewnątrz kategorii. Tworzy nowe sortowanie główne według ceny i ignoruje wcześniejszą kolejność kategorii. Poprawny zapis wykorzystuje ThenBy:

var result = products
    .OrderBy(product => product.Category)
    .ThenBy(product => product.Price);
  • Oczekiwanie modyfikacji listy - OrderBy zwraca wynik, więc trzeba go przypisać lub wyliczyć.
  • Brak materializacji - przy odroczonym wykonaniu zmiana danych źródłowych może wpłynąć na późniejszy wynik.
  • Sortowanie po ToString() - często zależy od kultury i bywa kosztowne.
  • Brak końcowego tie-breakera - przy paginacji powtarzające się wartości mogą powodować zmianę kolejności rekordów.
  • Własny comparer w zapytaniu do bazy - dostawca może nie umieć przetłumaczyć go na SQL.

W praktyce szczególnie pilnuję ostatniego kryterium sortowania. Dla danych biznesowych często dodaję ThenBy(x => x.Id), nawet jeśli użytkownik tego nie widzi. Dzięki temu wynik jest deterministyczny, a kolejne strony tabeli nie zmieniają zawartości tylko dlatego, że kilka rekordów ma tę samą nazwę lub datę.

Dobry klucz sortowania rozwiązuje większość problemów

Najprostsza reguła brzmi tak: najpierw wybieram główne kryterium, potem dopisuję ThenBy dla remisów, a na końcu sprawdzam, gdzie faktycznie wykona się zapytanie. Dla małych kolekcji w pamięci wystarczy czytelny łańcuch metod, natomiast przy bazie danych trzeba kontrolować moment materializacji i możliwości translacji.

Samą metodę OrderBy trudno uznać za skomplikowaną. Problemy zaczynają się zwykle wtedy, gdy sortowanie łączymy z wartościami null, tekstem zależnym od kultury, paginacją albo nieświadomym użyciem drugiego OrderBy. Jasno określony klucz i jednoznaczne kryterium końcowe sprawiają, że wynik pozostaje przewidywalny także wtedy, gdy dane rosną i trafiają do produkcyjnej bazy.

FAQ - Najczęstsze pytania

ThenBy dodaje kolejne kryterium i rozstrzyga remisy w ramach wcześniejszego sortowania. Następne OrderBy tworzy nowe sortowanie główne, przez co może anulować znaczenie poprzedniego kryterium.

Dla IEnumerable, na przykład listy lub tablicy, sortowanie odbywa się w pamięci aplikacji. Dla IQueryable, jak w Entity Framework Core, zapytanie może zostać przetłumaczone na SQL i wykonane w bazie, o ile dostawca obsługuje daną operację.

Po głównym kryterium sortowania warto dodać unikalny końcowy klucz, na przykład ThenBy(product => product.Id). Sama nazwa lub data może się powtarzać, a bez dodatkowego kryterium kolejność rekordów na kolejnych stronach nie musi być stabilna.

Można najpierw sortować po wyrażeniu customer.CompanyName is null, a następnie po nazwie. Wtedy rekordy z uzupełnioną nazwą otrzymują wartość false i pojawiają się przed rekordami z null.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi

linq
entity framework
paginacja
unicode
orderby
Autor Przemysław Kwiatkowski
Przemysław Kwiatkowski
Jestem Przemysław i od 15 lat zajmuję się programowaniem .NET, chmurą Azure oraz sztuczną inteligencją. Moja przygoda z tymi technologiami zaczęła się od fascynacji możliwościami, jakie dają, a z czasem przerodziła się w pasję do tworzenia rozwiązań, które realnie wpływają na pracę i życie ludzi. Na kursdotnet.pl staram się dzielić się swoją wiedzą w sposób przystępny, tłumacząc złożone zagadnienia i pomagając zrozumieć, jak te dynamicznie rozwijające się obszary IT mogą być wykorzystane w praktyce. Dokładam wszelkich starań, aby prezentowane przeze mnie materiały były rzetelne, aktualne i oparte na sprawdzonych źródłach, a także aby uporządkować wiedzę w sposób ułatwiający jej przyswojenie.

Udostępnij artykuł

Napisz komentarz