Wszystkie artykuły

KajoData / SQL

Kolejność klauzul SQL: od źródła do wyniku

Pokazuję drogę sześciu zamówień przez filtry, grupowanie i limit. Wyjaśniam aliasy, remisy i różnicę między znaczeniem zapytania a planem bazy.

Kajo RudzińskiOpublikowano Aktualizacja 11 min czytania

Wielostopniowa maszyna sortuje kostki przez sito, pojemniki i bramkę

Piszesz SELECT na początku zapytania, a potem próbujesz użyć jego aliasu w WHERE. Baza odpowiada, że kolumna nie istnieje. Tymczasem ten sam alias działa w ORDER BY. Na pierwszy rzut oka wygląda to jak złośliwość narzędzia. Ja widzę tu dobry moment, żeby uporządkować sposób czytania SQL.

Zapytanie opisuje oczekiwany wynik. Kolejność słów w pliku różni się od logicznej kolejności przetwarzania danych, a ta z kolei nie jest instrukcją fizycznego odczytu dysku. Te trzy porządki warto rozdzielić od początku. Dzięki temu łatwiej wyjaśnisz błąd, poprawisz raport i unikniesz zgadywania, dlaczego liczby przestały się zgadzać.

Przykłady poniżej mają składnię PostgreSQL 18. Wszystkie dane są syntetyczne. Wyniki wyprowadzam z dokumentacji i małych tabel, bez deklarowania uruchomienia zapytań. Jeśli dopiero zaczynasz, przyda się najpierw wprowadzenie do SELECT.

Kolejność zapisu i kolejność logiczna

Typowe zapytanie zapisuję jako SELECT, FROM, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT. Najpierw mówię więc, co chcę otrzymać. Czytając znaczenie tego zapytania, zaczynam od źródła i stopniowo przechodzę do wyniku.

Dla pojedynczego prostego SELECT przyjmuję taki model:

Etap Pytanie, na które odpowiada
FROM i JOIN z warunkiem ON Z jakich wierszy buduję źródło?
WHERE Które wiersze źródła zachowuję?
GROUP BY i agregaty Jakie grupy i podsumowania tworzę?
HAVING Które grupy spełniają warunek?
Obliczenia okienkowe Co obliczam na pozostałych wierszach lub grupach?
Lista SELECT Jakie wartości pokazuję w kolumnach wyniku?
DISTINCT Które powtarzające się wiersze wyniku usuwam?
ORDER BY W jakiej kolejności przedstawiam wynik?
OFFSET i LIMIT Którą część uporządkowanego wyniku oddaję?

To pomoc w rozumowaniu. Lista nie obejmuje wszystkich konstrukcji, takich jak operatory zbiorowe czy blokowanie wierszy. Dokumentacja SELECT w PostgreSQL opisuje pełny zakres. Funkcje okienkowe wydzieliłem w tabeli jako etap myślowy, chociaż ich wyrażenia zapisujemy zwykle w liście SELECT.

W prostym raporcie nie musisz używać każdego etapu. Bez grupowania masz nadal poprawne zapytanie. Bez ORDER BY dostaniesz wynik, ale nie masz obietnicy jego kolejności. Bez WHERE analizujesz całe wskazane źródło. Pominięta klauzula nie oznacza ukrytego domyślnego filtra.

Jedna mała tabela zamiast abstrakcyjnej regułki

Chcę znaleźć kanał z największą sumą opłaconych zamówień, ale uwzględnić tylko kanały z przynajmniej dwoma takimi zamówieniami. Jeden wiersz źródła oznacza jedno zamówienie. Kwoty są umownymi liczbami w tej samej jednostce, bez dodatkowych reguł podatkowych.

order_id channel status amount
1 web paid 40
2 web paid 60
3 web cancelled 900
4 store paid 80
5 store paid 10
6 partner paid 200

Oto kompletne zapytanie, w którym źródłem jest wyrażenie VALUES. Nie musisz szukać bazy z poprzedniej lekcji ani dopasowywać nazw kolumn do zrzutu ekranu.

SELECT
    channel,
    COUNT(*) AS paid_orders,
    SUM(amount) AS total_amount
FROM (VALUES
    (1, 'web', 'paid', 40),
    (2, 'web', 'paid', 60),
    (3, 'web', 'cancelled', 900),
    (4, 'store', 'paid', 80),
    (5, 'store', 'paid', 10),
    (6, 'partner', 'paid', 200)
) AS orders(order_id, channel, status, amount)
WHERE status = 'paid'
GROUP BY channel
HAVING COUNT(*) >= 2
ORDER BY total_amount DESC, channel ASC
LIMIT 1;

Oczekiwany wynik to web, licznik 2 i suma 100. Prześledźmy drogę do tej jednej pozycji. Właśnie na takiej drodze widać sens kolejności klauzul. Samo zapamiętanie skrótu nie pokaże, dlaczego kanał z największą pojedynczą kwotą nie wygrał.

FROM i WHERE ustalają materiał do raportu

FROM udostępnia sześć wierszy i cztery nazwane kolumny. Tutaj źródło jest dosłownie zapisane w zapytaniu. W normalnym raporcie byłaby to tabela, widok, podzapytanie albo wynik połączenia. Na tym etapie muszę wiedzieć, co oznacza jeden rekord.

WHERE status = 'paid' odrzuca zamówienie numer 3. Pozostaje pięć zamówień. Kwota 900 nie ma już wpływu na dalsze sumy. Nie odejmuję jej po obliczeniu raportu i nie filtruję gotowej sumy według statusu. Wybieram odpowiednie wiersze, zanim zbuduję podsumowania.

Zwróć uwagę na pytanie biznesowe. Gdyby celem była łączna wartość wszystkich zamówień, ten filtr byłby błędem mimo poprawnej składni. Dlatego ja zapisuję zakres analizy zwykłym zdaniem, zanim zacznę zastanawiać się nad wydajnością. SQL bardzo sprawnie policzy również źle postawione pytanie.

Osobna kwestia to NULL. Porównanie status = 'paid' nie zachowa brakującego statusu. Warunek musi dać prawdę, a porównanie z brakiem daje wartość nieznaną. Braki wyszukuję przez IS NULL, zgodnie z regułami porównań PostgreSQL. Trzeba zdecydować, czy taki rekord jest poza zakresem, czy wymaga osobnego wyjaśnienia.

GROUP BY i HAVING zmieniają poziom szczegółowości

Po filtrze tworzę trzy grupy według kanału. Web ma dwa zamówienia i sumę 100, store dwa i sumę 90, partner jedno i sumę 200. Jeden wiersz raportu opisuje teraz kanał. Numer pojedynczego zamówienia przestał być naturalną kolumną takiego wyniku.

HAVING COUNT(*) >= 2 zostawia web i store. Partner znika, chociaż jego suma jest najwyższa. Powód jest prosty: nie spełnia warunku liczby zamówień. Dopiero wśród grup spełniających ten warunek wybiorę zwycięzcę. Gdy zmienisz kolejność tych decyzji, zaczniesz odpowiadać na inne pytanie.

W tym miejscu warto odróżnić dwie podobne prośby. „Pokaż kanały z sumą powyżej 90” prowadzi do HAVING SUM(amount) > 90. „Zsumuj wyłącznie zamówienia z kwotą powyżej 90” prowadzi do filtra w WHERE. W naszym zbiorze drugi wariant zostawi tylko zamówienie partnera. Różnica ma znaczenie dla wyniku, a nie tylko dla stylu zapisu.

Dokumentacja wyrażeń tabelowych wyjaśnia rozdzielenie filtrowania wierszy i grup. Więcej przykładów znajdziesz w moim tekście o WHERE, GROUP BY i HAVING. Nie przenoszę warunku między tymi miejscami bez sprawdzenia jego znaczenia.

SELECT nadaje wynikowi kształt i nazwy

Lista SELECT określa trzy kolumny końcowej tabeli. Alias total_amount opisuje wynik SUM(amount). Nie zmienia danych źródłowych i nie tworzy trwałej kolumny w tabeli. Jest nazwą wartości wyjściowej w tym zapytaniu.

Dlatego w PostgreSQL nie użyję tego aliasu bezpośrednio w WHERE ani w HAVING tego samego poziomu. W HAVING zapisuję SUM(amount), a w ORDER BY mogę napisać total_amount. PostgreSQL dopuszcza nazwy wyjściowe także w określonych zastosowaniach GROUP BY; model etapów nie znosi szczegółowych reguł dialektu.

Gdy chcę filtrować gotowy wynik po aliasie, tworzę kolejny poziom zapytania. Podzapytanie lub CTE daje zewnętrznemu WHERE normalną kolumnę wejściową. To pomaga również wtedy, gdy wyrażenie jest długie i jego kilkukrotne powtarzanie utrudnia czytanie.

Nie traktuję jednak każdego dodatkowego CTE jako gwarancji przyspieszenia. Podział na etapy ułatwia opisanie odpowiedzialności fragmentów kodu. O faktycznej realizacji decyduje plan. Czytelna struktura ma wartość także wtedy, gdy plan pozostaje taki sam jak przed zmianą.

ORDER BY i LIMIT wybierają określoną odpowiedź

Po HAVING zostały dwie grupy. Sortowanie po sumie malejąco ustawia web przed store. LIMIT 1 oddaje pierwszy wiersz tej kolejności. Wynik 100 dotyczy już całej grupy web, a nie największej kwoty pojedynczego zamówienia.

Dodałem drugi klucz sortowania, channel ASC. W naszym przykładzie sumy są różne, więc ten klucz nie zmienia wyniku. Gdyby jednak web i store miały po 100, zwyciężyłby store według porządku tych nazw. Dzięki temu remis ma określoną regułę zamiast zależeć od przypadku.

Bez ORDER BY słowo „pierwszy” jest niepełne. Ani kolejność wstawienia rekordów, ani obecność indeksu, ani poprzedni wynik na ekranie nie stanowią umowy dla następnego odczytu. Oficjalny opis sortowania i LIMIT podkreśla potrzebę jednoznacznego porządku.

Jeżeli dodasz OFFSET 1 LIMIT 1, pominiesz pierwszą grupę i dostaniesz drugą, czyli store. Nadal operujesz na gotowych grupach. Nie pomijasz pierwszego zamówienia wejściowego. Taka pomyłka łatwo przechodzi niezauważona, jeśli testowy raport pokazuje tylko jeden rekord.

Dwa ciągi produkcyjne pokazują filtrowanie pojedynczych elementów i całych stosów

JOIN trzeba zrozumieć przed sumowaniem

Główne ćwiczenie celowo ma jedno źródło, żeby nie mieszać dwóch problemów naraz. W rzeczywistym raporcie FROM często łączy kilka tabel. Wtedy pytam, czy połączenie zachowuje poziom jednego zamówienia, czy tworzy wiersz na jego pozycję albo na zdarzenie.

Jeśli do zamówienia o kwocie 40 dołączysz trzy pozycje, otrzymasz trzy kopie tej kwoty w wyniku połączenia. Późniejsze SUM może dać 120. Znajomość kolejności klauzul nie wystarczy, dopóki nie zrozumiesz liczności relacji. DISTINCT na końcu nie jest uniwersalną naprawą takiego mnożenia.

Przy LEFT JOIN znaczenie ma także umiejscowienie filtra. Warunek na prawej tabeli w ON ogranicza dopasowania, zachowując lewe wiersze bez dopasowania. Ten sam warunek w WHERE może je odrzucić, ponieważ prawa kolumna ma wtedy NULL. Zanim przeniosę filtr, porównuję właśnie te niedopasowane rekordy.

Ja zaczynam kontrolę połączenia od liczby wierszy i kilku identyfikatorów, które znam. Osobno oglądam zamówienie bez pozycji oraz zamówienie z kilkoma pozycjami. Dopiero potem dodaję agregację. Inaczej duża suma wygląda jak sukces biznesowy, choć jest sukcesem mnożenia rekordów.

DISTINCT i okna wymagają dodatkowego kroku myślowego

DISTINCT porównuje całe wybrane wiersze. SELECT DISTINCT channel da listę kanałów. Po dołożeniu numeru zamówienia porównywane pary kanał i numer mogą być już wszystkie różne. Nie prosisz wtedy o jedną pozycję na kanał, choć nazwa kanału nadal jest widoczna.

Przy funkcjach okienkowych pytanie brzmi inaczej: jakie wiersze widzi okno? Według dokumentacji okien są to wiersze pozostałe po filtrach, grupowaniu i HAVING danego poziomu. Ranking obliczony po odrzuceniu części danych opisuje zatem ten ograniczony zbiór.

Jeśli nadaję grupom numer przez ROW_NUMBER(), nie filtruję jego aliasu w WHERE tego samego poziomu PostgreSQL. Najpierw obliczam numer w podzapytaniu, potem wybieram go na zewnątrz. W innych produktach możesz spotkać QUALIFY; tutaj trzymam się składni PostgreSQL 18.

Nie wrzucam wszystkich tych mechanizmów do pierwszego zapytania ucznia. Wolę najpierw uzyskać pewny wynik dla sześciu zamówień. Później dokładam jeden element i pytam, co zmienił w liczbie wierszy, zakresie danych i interpretacji wyniku. Taki sposób nauki pozwala znaleźć przyczynę pomyłki.

Fizyczny plan wykonania jest osobnym tematem

Baza może użyć indeksu, zmienić dopuszczalną kolejność połączeń, wybrać agregację haszującą lub sortowanie. Nie musi najpierw materializować wszystkich danych z FROM i dopiero potem zacząć sprawdzać WHERE. Logiczny model opisuje znaczenie wyniku, a plan realizuje je dostępnymi metodami.

Do poznania planu służy EXPLAIN. Z samego położenia klauzuli w tekście nie wyciągam wniosków o ilości odczytanych stron ani czasie pracy. Nie obiecuję też, że przepisanie zapytania zgodnie z pewną estetyką automatycznie przyspieszy raport.

Podobnie nie zakładam, że warunki połączone przez AND wykonają się od lewej do prawej. Unikanie błędu obliczenia wymaga właściwej konstrukcji wyrażenia, a nie wiary w kolejność zapisu filtrów. To oddzielne zagadnienie od kolejności klauzul, ale wyrasta z tego samego odruchu czytania SQL jak skryptu.

Kolejność operatorów wewnątrz warunku

Osobno sprawdzam nawiasy, gdy w jednym filtrze pojawia się AND i OR. To inny rodzaj kolejności: sposób wiązania wyrażeń. W PostgreSQL AND ma wyższy priorytet niż OR. Warunek bez nawiasów może więc obejmować inną grupę rekordów, niż sugeruje szybkie czytanie zdania po polsku.

Załóżmy, że chcę opłacone zamówienia z kanału web lub store. Zapisuję to tak:

WHERE status = 'paid'
  AND (channel = 'web' OR channel = 'store')

Gdy usunę nawiasy, warunek będzie rozumiany jak połączenie „opłacone i web” lub „store”. Wtedy każdy rekord store spełnia drugą część, również anulowany. W głównym zbiorze nie ma anulowanego zamówienia store, więc oba zapisy dadzą ten sam zestaw. To pokazuje, dlaczego przykład bez właściwego przypadku brzegowego potrafi uśpić czujność.

Nie wystarczy zatem przepisać kolejności klauzul z kartki. Muszę jeszcze rozumieć warunek wewnątrz każdej z nich. Przy bardziej rozbudowanym filtrze używam nawiasów nawet wtedy, gdy znam domyślny priorytet. Odbiorca kodu szybciej zobaczy zamierzoną regułę i łatwiej porówna ją z wymaganiem biznesowym.

Reguły wiązania opisuje dokumentacja operatorów PostgreSQL. Nie przenoszę z nich wniosku, że komputer musi fizycznie oceniać wszystkie części w takiej kolejności. Priorytet określa budowę wyrażenia; optymalizator nadal ma swobodę realizacji dozwolonych przekształceń.

Jak czytam cudzy raport podczas przeglądu

Na początku zasłaniam listę kolumn wyjściowych i czytam samo źródło. Sprawdzam klucze połączeń, liczbę dopasowań i zakres czasu. Potem pytam, co znika w filtrach. Dopiero z tak opisanych rekordów próbuję odtworzyć agregaty. Ten porządek daje mi konkretny punkt zaczepienia, gdy końcowa suma budzi wątpliwość.

Przy grupowaniu dopisuję na marginesie znaczenie jednego wiersza wyniku. Jeśli widzę miasto oraz miesiąc, oba wymiary muszą wejść do interpretacji. Łatwo powiedzieć „suma dla miasta”, chociaż raport pokazuje sumę dla miasta w konkretnym miesiącu. Taki skrót później psuje porównania i łączenie raportów.

Na końcu oglądam nazwy kolumn, sortowanie i limit. Pytam też, czy użytkownik ma zobaczyć wszystkie remisy, czy jednego zwycięzcę według ustalonej reguły. Dopiero po tym przeglądzie przechodzę do planu wykonania. Najpierw chcę mieć pewność, że przyspieszam raport odpowiadający na właściwe pytanie.

Ćwiczenie: zmień jeden warunek i opowiedz wynik

Weź główne zapytanie i podnieś próg HAVING do trzech zamówień. Wynik powinien być pusty, bo żaden kanał nie ma trzech opłaconych zamówień. Web ma trzy rekordy w źródle, ale jeden z nich został wcześniej odrzucony. To najważniejszy szczegół tego wariantu.

Następnie wróć do progu dwa i usuń filtr statusu. Web będzie miał trzy zamówienia i sumę 1000. Wynik wygląda imponująco, ale obejmuje anulowane zamówienie. Odpowiedź może być poprawna technicznie i nie odpowiadać na pierwotne pytanie o sprzedaż opłaconą.

Na końcu przywróć filtr i usuń LIMIT. Otrzymasz web z sumą 100 oraz store z sumą 90, w tej kolejności. Zapisz obok każdego etapu liczbę wierszy: sześć, pięć, trzy grupy, dwie grupy, jeden końcowy wiersz po limicie. Ten mały rachunek jest dla mnie dużo bardziej przydatny niż recytacja siedmiu słów bez związku z danymi.

Film do pierwotnego materiału

Poniżej zostawiam polski film o kolejności klauzul. Rozwinięcie powyżej doprecyzowuje różnicę między znaczeniem zapytania a jego planem oraz dodaje samodzielny przykład zamiast opierać wyjaśnienie na zrzutach ekranu.

Najważniejsze w pigułce

Czy SQL wykonuje SELECT jako pierwszy?

SELECT jest zapisany na początku, ale w logicznym modelu wybiera kolumny z przygotowanego źródła po filtrowaniu i grupowaniu. Fizyczny plan jest osobną sprawą.

Dlaczego alias działa w ORDER BY, a nie w WHERE?

W PostgreSQL alias wyjściowy jest dostępny w ORDER BY, ale nie w WHERE i HAVING tego samego poziomu. Podzapytanie udostępnia go jako kolumnę zewnętrznemu zapytaniu.

Czy LIMIT 1 zwraca największą wartość?

Tylko gdy odpowiednie ORDER BY ustawia ją na początku. Dla jednoznacznego wyboru przy remisie potrzebny jest dodatkowy klucz sortowania.

Przy pracy nad artykułem użyto w zautomatyzowany sposób sztucznej inteligencji. Jeśli też chcesz tak umieć, sprawdź ten projekt.

Kajo Rudziński

Newsletter KajoData

Zostańmy w kontakcie.

Piszę o analizie danych, uczeniu się i zmianie pracy. Dzielę się tym, co pomaga mi ogarniać technologię i zawodowe decyzje. Jeśli lubisz ten sposób myślenia, zapraszam do mojego newslettera.