Kolejność przetwarzania zapytania w SQL
Spis treści:
- Kolejność przetwarzania klauzul w języku SQL
- Zapytanie nr 1: prosty, podstawowy przykład
- Zapytanie 2: poziom średnio-zaawansowany z grupowaniem
- Zapytanie 3: złożony kod z wyrażeniami CTE, podzapytaniami i innymi obliczeniami
- Wprowadzenie do klauzuli WITH i wyrażeń CTE
- Analiza głównego zapytania SELECT
- Kolejność wykonywania klauzul w głównym zapytaniu
- Podsumowanie
Kolejność przetwarzania poleceń w zapytaniach SQL to jedno z najczęściej niezrozumianych zagadnień w świecie baz danych, które może prowadzić do błędnych wyników i problemów z wydajnością. Jej zrozumienie jest kluczowe dla napisania efektywnych i poprawnych zapytań, szczególnie gdy pracujemy z dużymi i złożonymi zbiorami danych. Wiele osób intuicyjnie zakłada, że SQL przetwarza zapytanie w takiej kolejności, w jakiej je napisali – od SELECT na początku, przez FROM i WHERE, aż po ORDER BY na końcu. To podejście może wydawać się poprawne, ale jest błędne. Rzeczywista kolejność przetwarzania jest bardziej skomplikowana i została zaprojektowana z myślą o logicznej spójności operacji w bazach danych. To wiedza, która przekłada się bezpośrednio na jakość kodu, który piszesz drogi czytelniku.
W niniejszym artykule postaramy się rozwiać wszelkie wątpliwości na przykładzie 3 zapytań do bazy danych Microsoft SQL Server – od stosunkowo prostych po bardziej skomplikowane. Zaczniemy od omówienia kluczowych klauzul i kolejności ich wykonywania a następnie przejdziemy do szczegółowej analizy tych zapytań.
Kolejność przetwarzania klauzul w języku SQL
Rzeczywista kolejność przetwarzania zapytania SQL jest następująca:
FROM/JOIN – określenie źródłowych tabel i widoków oraz łączenie ich zgodnie z określonymi warunkami wskazanymi w klauzuli ON
WHERE – ograniczenie liczby wyświetlanych wierszy na podstawie wskazanych warunków
GROUP BY – grupowanie wierszy według określonych kolumn
HAVING – filtrowanie zgrupowanych wierszy, czyli po agregacji
SELECT – wybór określonych kolumn i obliczanie kolumn wynikowych
DISTINCT – usunięcie duplikatów
ORDER BY – sortowanie wyników według określonych kryteriów
TOP – ograniczenie ilości wyświetlanych danych do wskazanej liczby, np. pierwsze 5 lub 10 wierszy (lub inna, dowolna ilość)
Ta kolejność jest logiczna z punktu widzenia operacji na zbiorach danych – najpierw określamy źródła danych, następnie je łączymy, filtrujemy, grupujemy, a dopiero na końcu wyświetlamy i sortujemy wynik. Warto zauważyć, że mimo to, że SELECT pojawia się jako pierwsze w składni zapytania to jest przetwarzane dopiero po FROM, JOIN, WHERE, GROUP BY i HAVING.
Klauzula FROM jest fundamentem każdego zapytania. To właśnie tutaj są identyfikowane źródła danych. JOIN logicznie rozszerza FROM, łącząc dane z różnych źródeł. WHERE to pierwszy punkt filtrowania danych, operujący na indywidualnych wierszach. W tym miejscu mogą zostać wykorzystane indeksy do przyspieszenia tej operacji. GROUP BY zmienia sposób myślenia o danych – przestajemy operować na pojedynczych wierszach, a zaczynamy pracować z grupami. HAVING jest odpowiednikiem WHERE dla zgrupowanych, zagregowanych danych pozwalając filtrować na podstawie wyników funkcji agregujących. SELECT – tutaj określamy, które kolumny mają zostać zwrócone i wykonujemy dodatkowe obliczenia.
Po krótkim wstępie przejdźmy do omówienia kilku przykładowych zapytań. Przypominamy, że dotyczą one bazy danych Microsoft SQL Server.
Zapytanie nr 1: prosty, podstawowy przykład
SELECT
emp.ID,
emp.FirstName + ' ' + emp.LastName AS [Full Name],
emp.Department
FROM ImperiumSzkolenioweDB.dbo.Employees AS emp
WHERE emp.Department = 'Finance'
ORDER BY emp.ID DESC;
Przeanalizujmy kolejność przetwarzania tego prostego zapytania. Silnik bazy danych rozpoczyna od klauzuli FROM, identyfikując tabelę Employees jako źródło danych. Następnie wykonywana jest klauzula WHERE, gdzie silnik przechodzi sekwencyjnie przez każdy wiersz ze zbioru utworzonego w kroku pierwszym i filtruje dane według warunku emp.Department = 'Finance’. Dla każdego wiersza jest wykonywane porównanie wartości w kolumnie Department z tekstem 'Finance’ poprzez zastosowanie operatora równości, a te rekordy, których wartość różni się, są eliminowane z dalszego przetwarzania. Po zakończeniu tego etapu otrzymujemy zredukowany zbiór danych zawierający wyłącznie pracowników przypisanych do działu finansowego, co może znacząco zmniejszyć ilość danych do dalszego przetworzenia. W ten sposób ograniczamy nasz zbiór tylko do tych wierszy, które aktualnie są nam potrzebne.
Dopiero teraz wykonywana jest klauzula SELECT, gdzie system określa, które kolumny mają zostać zwrócone w wynikowym zestawie danych. Dla każdego wiersza z przefiltrowanego zbioru wyświetlane są kolumny i wykonywane obliczenia, w tym konkatenacja (połączenie) imienia i nazwiska pracownika poprzez operację emp.FirstName + ’ ’ + emp.LastName. W ten sposób uzyskujemy imię i nazwisko pracownika rozdzielone spacją. Wynik całej operacji otrzymuje alias [Full Name], który będzie widoczny w końcowych rezultatach zapytania podczas gdy kolumny emp.ID i emp.Department zachowują swoje oryginalne nazwy.
Jako ostatni krok wykonywana jest klauzula ORDER BY, która sortuje już przygotowany kompletny zestaw wyników według kolumny emp.ID w porządku malejącym, czyli od największej wartości do najmniejszej. Operacja sortowania jest wykonywana w pamięci operacyjnej i może być kosztowna obliczeniowo, szczególnie dla dużych zestawów danych. Klauzula ORDER BY jest często najdroższą operacją w zapytaniu i ze względów optymalizacyjnych zaleca się jej unikanie tam, gdzie jest to możliwe.
Bardzo ważne jest też rozróżnienie między logiczną kolejnością przetwarzania klauzul (FROM, WHERE, SELECT, ORDER BY itd.) a fizyczną implementacją, gdzie optymalizator zapytań może zmieniać kolejność operacji dla poprawy wydajności zachowując logiczną poprawność wyników. Silnik bazy danych może zastosować różne optymalizacje, takie jak np. wykorzystanie indeksów na kolumnie Department dla przyspieszenia filtrowania lub indeksów na kolumnie ID dla efektywniejszego sortowania. Po zakończeniu wszystkich operacji system zwraca posortowany zestaw wyników. Nowoczesne systemy baz danych mogą również cache’ować (czyli przechowywać kopie często używanych zapytań w szybszej pamięci jak np. RAM, żeby przy kolejnych uruchomieniach nie musieć ich pobierać z wolniejszego źródła jak np. dysk twardy – to mniej więcej tak jak trzymanie najczęściej używanych książek na biurku zamiast chodzenia za każdym razem do biblioteki, dzięki czemu mamy szybszy dostęp do tego, czego aktualnie potrzebujemy) plany wykonania dla podobnych zapytań, co przyspiesza przetwarzanie kolejnych o podobnej strukturze lub wykonanie dokładnie tego samego zapytania po raz kolejny.
Zapytanie 2: poziom średnio-zaawansowany z grupowaniem
SELECT
dep.DepartmentName AS [Department Name],
COUNT(*) AS [Employee Count],
AVG(emp.Salary) AS [Average Salary],
MAX(emp.HireDate) AS [Latest Hire],
MIN(emp.HireDate) AS [Oldest Hire]
FROM ImperiumSzkolenioweDB.dbo.Employees AS emp
INNER JOIN ImperiumSzkolenioweDB.dbo.Departments AS dep
ON emp.DepartmentID = dep.DepartmentID
WHERE emp.IsActive = 1
GROUP BY dep.DepartmentName
HAVING COUNT(*) > 3
ORDER BY [Average Salary] DESC;
To zapytanie doskonale ilustruje pełną kolejność przetwarzania SQL i zasługuje na szczegółową analizę każdego kroku. Rozpoczynamy od klauzuli FROM, gdzie silnik bazy danych identyfikuje tabelę Employees jako główne źródło danych pod aliasem emp. W tym momencie mamy dostęp wyłącznie do kolumn z tabeli Employees – silnik jeszcze nie „widzi” danych z tabeli Departments. Kolejnym krokiem jest przetworzenie operacji INNER JOIN, która logicznie rozszerza operację FROM o dane z tabeli Departments. Silnik łączy każdy wiersz z tabeli Employees z odpowiadającym mu wierszem z tabeli Departments na podstawie warunku emp.DepartmentID = dep.DepartmentID, tworząc nowy zbiór roboczy. Kluczowe jest zrozumienie, że INNER JOIN eliminuje wszystkie wiersze, które nie mają dopasowań w obu tabelach – pracownicy bez przypisanego działu oraz działy bez przypisanych pracowników nie pojawią się w wynikach i nie zostaną wyświetlone. Nieco inaczej zadziałałyby klauzule LEFT JOIN, RIGHT JOIN oraz FULL JOIN ale to temat na osobny artykuł.
Następnym krokiem jest wykonanie klauzuli WHERE z warunkiem emp.IsActive = 1, która stanowi pierwszy punkt filtrowania danych na poziomie indywidualnych wierszy. Silnik bazy danych przechodzi przez wszystkie wiersze powstałe po operacji JOIN i systematycznie eliminuje te, gdzie pracownik nie jest oznaczony jako aktywny, znacząco redukując objętość danych do dalszego przetwarzania. Właśnie w tym miejscu w naszym zapytaniu wiele osób rozpoczynających przygodę z bazami danych popełnia fundamentalny błąd – próbują umieścić w WHERE warunki oparte na funkcjach agregujących jak np. SUM, AVG czy COUNT nie rozumiejąc, że agregacja nastąpi dopiero w późniejszych krokach.
Klauzula GROUP BY dep.DepartmentName wprowadza radykalną zmianę w sposobie przetwarzania danych – silnik dzieli wszystkie pozostałe po filtracji wiersze na grupy, gdzie każda z nich zawiera wszystkich aktywnych pracowników z tego samego działu. Od tego momentu przestajemy myśleć kategoriami pojedynczych wierszy i zaczynamy operować na grupach jako jednostkach przetwarzania, co ma fundamentalne konsekwencje dla wszystkich kolejnych operacji.
Po grupowaniu wykonywana jest klauzula HAVING z warunkiem COUNT(*) > 3, która pełni rolę filtra na poziomie grup – to odpowiednik WHERE, ale działający na zagregowanych wierszach. Silnik bazy danych oblicza liczbę pracowników w każdej grupie i eliminuje te działy, które zatrudniają 3 lub mniej osób, pozostawiając tylko większe jednostki organizacyjne spełniające kryteria, które zdefiniowaliśmy.
Dopiero teraz wykonywana jest klauzula SELECT, która definiuje strukturę wynikowego zbioru danych poprzez obliczenie wszystkich określonych wyrażeń dla każdej grupy departamentowej. Silnik bazy danych oblicza COUNT(*) jako liczbę pracowników, AVG(emp.Salary) jako średnią pensję, MAX(emp.HireDate) jako datę zatrudnienia ostatniego (najnowszego) pracownika, MIN(emp.HireDate) jako date zatrudnienia pierwszego (najstarszego) pracownika oraz kopiuje kolumnę dep.DepartmentName jako reprezentatywną wartość identyfikującą każdą grupę.
Na samym końcu ORDER BY [Average Salary] DESC sortuje już w pełni przetworzone, sformatowane wyniki według średniej pensji w porządku malejącym, czyli od największej do najmniejszej, co pozwala na łatwe zidentyfikowanie działów o najwyższych średnich zarobkach. Warto zauważyć, że możemy tutaj użyć aliasu [Average Salary] utworzonego w SELECT, ponieważ sortowanie następuje po obliczeniu wszystkich kolumn wynikowych.
Zapytanie 3: złożony kod z wyrażeniami CTE, podzapytaniami i innymi obliczeniami
WITH _cte_Department_Stats AS
(
SELECT
dep.DepartmentID,
dep.DepartmentName,
COUNT(emp.EmployeeID) AS TotalEmployees,
AVG(emp.Salary) AS AvgSalary
FROM ImperiumSzkolenioweDB.dbo.Departments AS dep
LEFT JOIN ImperiumSzkolenioweDB.dbo.Employees AS emp
ON dep.DepartmentID = emp.DepartmentID
AND emp.IsActive = 1
GROUP BY dep.DepartmentID, dep.DepartmentName
)
, _cte_Manager_Info AS
(
SELECT
emp.EmployeeID,
emp.FirstName + ' ' + emp.LastName AS ManagerName,
emp.DepartmentID
FROM ImperiumSzkolenioweDB.dbo.Employees AS emp
WHERE emp.Position = 'Manager'
)
, _cte_Active_Emps_Avg_Salary AS
(
SELECT
AVG(Salary) AS AvgSalary
FROM ImperiumSzkolenioweDB.dbo.Employees
WHERE IsActive = 1
)
SELECT DISTINCT
ds.DepartmentName,
ds.TotalEmployees,
ds.AvgSalary,
mi.ManagerName,
CASE
WHEN ds.AvgSalary > (SELECT AvgSalary FROM _cte_Active_Emps_Avg_Salary)
THEN 'above average'
ELSE 'below average'
END AS SalaryCategory
FROM _cte_Department_Stats AS ds
INNER JOIN _cte_Manager_Info AS mi
ON ds.DepartmentID = mi.DepartmentID
WHERE ds.TotalEmployees > 0
ORDER BY ds.AvgSalary DESC;
To zapytanie demonstruje pełną złożoność przetwarzania w języku SQL i jest bardzo zbliżone do tych, z jakimi na co dzień spotykają się osoby pracujące w obszarze baz danych. Z uwagi na poziom skomplikowania powyższego kodu podzielmy nasze rozważania na kilka etapów.
Wprowadzenie do klauzuli WITH i wyrażeń CTE
Klauzula WITH wprowadza mechanizm nazywany CTE, czyli Common Table Expressions – wspólne wyrażenia tablicowe. Pozwala na definiowanie tymczasowych zestawów wyników, które mogą być wykorzystane w głównym zapytaniu SELECT. CTE działa jak tymczasowa tabela lub widok, który istnieje tylko w kontekście pojedynczego zapytania. Można myśleć o CTE jak o nazwanych podzapytaniach, które znacznie poprawiają czytelność i przejrzystość kodu. W prezentowanym zapytaniu mamy do czynienia z trzema różnymi wyrażeniami CTE, każde służące innemu celowi analitycznemu.
Pierwsze o nazwie _cte_Department_Stats przygotowuje i agreguje dane dotyczące departamentów łącząc tabele Departments i Employees. Ma ono na celu utworzenie statystyk dla każdego departamentu w organizacji. Rozpoczyna się od klauzuli SELECT, która definiuje cztery kolumny wynikowe: DepartmentID, DepartmentName, TotalEmployees (liczba pracowników), oraz AvgSalary (średnia pensja).
Drugie wyrażenie _cte_Manager_Info ma za zadanie przygotowanie listy menedżerów z ich podstawowymi informacjami. Jest ono względnie proste i składa się z trzech kolumn: EmployeeID, ManagerName (będący połączeniem imienia i nazwiska) oraz DepartmentID. Funkcja konkatenacji używa operatora + do połączenia kolumn FirstName, spacji i LastName w jedną kolumnę tekstową. Podobny przykład omawialiśmy już przy okazji zapytania numer 2. Warto wspomnieć, że istnieją jeszcze co najmniej 2 sposoby na łączenie tekstów w Microsoft SQL Server, a są to wbudowanie funkcje CONCAT i CONCAT_WS. Ta druga jest dostępna w nowszych wersjach. Ponadto CTE filtruje tylko pracowników na stanowisku menedżera, o czym świadczy warunek WHERE emp.Position = 'Manager’.
Trzecie CTE o nazwie _cte_Active_Emps_Avg_Salary jest najprostsze z całej trójki i ma jeden konkretny cel – obliczenie średniej pensji wszystkich aktywnych pracowników w całej organizacji. Wynik będzie później użyty w głównym zapytaniu do kategoryzacji departamentów. Składa się tylko z jednej kolumny, która jest obliczana za pomocą funkcji AVG(). Klauzula WHERE filtruje tylko aktywnych pracowników (IsActive = 1) zapewniając, że ci nieaktywni nie wpłyną na obliczaną średnią organizacyjną.
W nazwie każdego z tych wyrażeń możnaby pominąć fragment _cte_ na początku, ale jego dodanie jest często stosowaną, dobrą praktyką w pracy z zapytaniami SQL.
Analiza głównego zapytania SELECT
Główne zapytanie łączy wszystkie trzy wyrażenia CTE w jeden spójny raport analityczny. Klauzula DISTINCT na początku zapewnia, że w wynikach nie będą się powtarzać identyczne wiersze, co może być szczególnie ważne, gdy w danych źródłowych istnieją duplikaty. Lista kolumn w SELECT obejmuje:
- Podstawowe informacje o departamentach pochodzące z pierwszego CTE – DepartmentName, TotalEmployees i AvgSalary,
- Informacje o menedżerach z drugiego CTE – ManagerName,
- Dodatkową kolumnę SalaryCategory obliczaną za pomocą klauzuli CASE.
Wyrażenie CASE porównuje średnią pensję w danym departamencie (odwołując się w podzapytaniu do trzeciego wyrażenia CTE) ze średnią pensją w całej organizacji i przypisuje odpowiednią etykietę tekstową – 'above average’ lub 'below average’.
Klauzula FROM rozpoczyna się od pierwszego CTE z aliasem 'ds’. Następnie wykonywane jest INNER JOIN z drugim CTE (alias 'mi’) na podstawie kolumny DepartmentID. Użycie INNER JOIN oznacza, że w wynikach zostaną wyświetlone się tylko te departamenty, które mają odpowiadające im rekordy w obu CTE. To może oznaczać, że departamenty bez menedżerów (lub pracowników w ogóle) zostaną wykluczone z wyników, co może być zamierzonym efektem lub potencjalnym problemem w zależności od potrzeb biznesowych.
Klauzula WHERE zawiera jeden prosty, ale ważny warunek: ds.TotalEmployees > 0. Ten filtr eliminuje z wyników departamenty, które nie mają żadnych aktywnych pracowników. Jest to logiczne ograniczenie, ponieważ departamenty bez pracowników prawdopodobnie nie są interesujące z perspektywy analizy wynagrodzeń i zarządzania. Klauzula ORDER BY sortuje wyniki według średniej pensji departamentu w porządku malejącym – podobnie jak w poprzednich omówionych zapytaniach. To oznacza, że departamenty o najwyższych średnich pensjach pojawią się na górze listy wyników, a pozostałe niżej. Takie sortowanie ma sens biznesowy, ponieważ pozwala szybko zidentyfikować najbardziej kosztowne departamenty pod względem wynagrodzeń. Może to być przydatne dla analiz budżetowych, planowania kosztów lub identyfikacji obszarów wymagających szczególnej uwagi zarządczej.
Kolejność wykonywania klauzul w głównym zapytaniu
W przypadku przedstawionego zapytania najpierw wykonywane są klauzule CTE, czyli sekcja WITH. Tworzone są trzy tymczasowe zestawy danych omówione powyżej: _cte_Department_Stats, _cte_Manager_Info oraz _cte_Active_Emps_Avg_Salary. Każdy z nich wykonywany jest niezależnie w takiej kolejności w jakiej zostały zadeklarowane. Następnie po przetworzeniu CTE, silnik bazy danych przechodzi do wykonania klauzuli FROM korzystającej z aliasów 'ds’ i 'mi’, czyli wyników dwóch wcześniej zdefiniowanych CTE. Na tym etapie dochodzi też do połączenia (INNER JOIN) danych z obu zestawów według warunku ON ds.DepartmentID = mi.DepartmentID. Następnie wykonywana jest klauzula WHERE, która filtruje rekordy połączone wcześniej w FROM — w tym przypadku do dalszego przetwarzania przechodzą tylko te, które mają więcej niż 0 pracowników w dziale (ds.TotalEmployees > 0).
Po wykonaniu wszystkich 3 wyrażeń CTE oraz klauzul FROM, JOIN i WHERE realizowana jest klauzula SELECT która wybiera kolumny, wylicza dodatkowe wartości oraz wykonuje podzapytanie. To oznacza, że w trakcie budowy każdej z kolumn możliwe jest odwołanie się do wcześniej wyliczonych wartości, a także kolejnych CTE, jeżeli są potrzebne. Po wybraniu danych w SELECT następuje eliminacja duplikatów przez wykonanie klauzuli DISTINCT.
Następnie wykonywana jest klauzula ORDER BY, która sortuje już przefiltrowany i sformatowany zestaw wyników – w tym przypadku według wynagrodzenia działu w kolejności malejącej. Gdyby w zapytaniu znajdowała się klauzula HAVING, byłaby wykonywana po GROUP BY i WHERE, ale przed SELECT, filtrując w ten sposób zagregowane dane. W tym konkretnym przypadku HAVING występuje tylko wewnątrz jednego z CTE.
Podsumowanie
Zrozumienie rzeczywistej kolejności przetwarzania zapytań SQL jest fundamentem dla każdej osoby pracującej z bazami danych. Ta wiedza nie tylko pomaga unikać błędów składniowych, ale także umożliwia pisanie znacznie bardziej wydajnych zapytań. Kluczowym wnioskiem jest to, że silniki baz danych nie przetwarzają zapytań w kolejności, w jakiej je piszemy, lecz według logicznej sekwencji operacji na zbiorach danych. FROM i JOIN określają źródła danych, WHERE i HAVING filtrują wiersze i grupy, GROUP BY organizuje dane, SELECT formatuje wyniki, a ORDER BY je sortuje. Klauzula TOP ograniczana liczbę wyświetlanych wierszy do wskazanej liczby.
Praktyczne implikacje tej wiedzy są ogromne. Rozumiejąc, że aliasy z SELECT nie są dostępne w WHERE, możemy unikać błędów składniowych. Wiedząc, że podzapytania w SELECT są wykonywane dla każdego wiersza, możemy lepiej planować optymalizację wydajności. Świadomość, że ORDER BY jest wykonywane na końcu, pomaga zrozumieć koszt sortowania dużych zbiorów. Optymalizacja zapytań staje się łatwiejsza, gdy rozumiemy kolejność przetwarzania. Możemy umieszczać najbardziej selektywne warunki w WHERE, używać odpowiednich indeksów i minimalizować liczbę podzapytań w SELECT.
W miarę jak bazy danych stają się większe i bardziej złożone, umiejętność pisania efektywnych zapytań staje się coraz cenniejsza. Zrozumienie kolejności przetwarzania to pierwszy krok do opanowania zaawansowanych technik SQL i stania się ekspertem w dziedzinie baz danych. Zachęcamy do praktycznego eksperymentowania z różnymi zapytaniami i obserwowania planów wykonania. Każde kolejne zapytanie napisane z tą wiedzą będzie lepsze, szybsze i bardziej niezawodne.