Wszystkie artykuły

KajoData / SQL

Formatowanie SQL: czytelny kod i kontrola wyniku

Porządkuję zapis SQL i oddzielam preferencje stylu od zmian obliczenia. Na małej tabeli sprawdzisz aliasy, złączenia, filtry i refaktoryzację.

Kajo RudzińskiOpublikowano Aktualizacja 11 min czytania

Pixelartowy stół z plątaniną połączeń oraz uporządkowanymi rzędami złotych bloków

Otwierasz zapytanie sprzed miesiąca. Wiesz, że działało, ale nie pamiętasz, dlaczego warunek znajduje się akurat w tym miejscu. Nazwy a, b i temp2 nie pomagają. Zaczynasz od czytania kodu jak zagadki kryminalnej. Lubię zagadki, ale w raporcie wolę szybko ustalić, co zostało policzone.

Dobry styl SQL pomaga odtworzyć intencję autora i zauważyć ryzykowną zmianę. Wcięcia, aliasy i komentarze mają temu służyć. Nie ma jednego obowiązkowego układu przecinków, który zamienia każde zapytanie w dobry kod. Są za to decyzje, które wyglądają jak kosmetyka, a zmieniają wynik. Na nich szczególnie się zatrzymam.

Poniżej używam kompletnego, syntetycznego przykładu w SQLite. Najpierw pokażę zmianę układu bez zmiany znaczenia, potem zmianę złączenia, która daje inną odpowiedź. To pozwala oddzielić preferencję redakcyjną od reguły obliczenia. Wszystkie bloki wykonuj kolejno w jednym pustym połączeniu testowym.

Najpierw umowa zespołu, potem osobiste preferencje

Preferuję czytelne nazwy, konsekwentne wcięcia i wyraźny podział klauzul. Wielkość liter w słowach kluczowych jest wyborem stylu. W tym tekście używam dużych liter, bo łatwo zobaczyć granice zapytania. W istniejącym projekcie dopasowałbym się do uzgodnionej konwencji, zamiast zmieniać setki linii wyłącznie dla własnej wygody.

Konsekwencja pomaga szczególnie podczas przeglądania zmian. Jeżeli jedna poprawka obejmuje zarówno nowy warunek, jak i przeformatowanie całego pliku, trudno zauważyć, co faktycznie zmieniło obliczenie. Zwykle rozdzielam te kroki. Najpierw porządkuję zapis i sprawdzam zgodność wyników. Dopiero później zmieniam regułę raportu i opisuję oczekiwaną różnicę.

Warto ustalić kilka prostych rzeczy: wcięcia, sposób zapisu aliasów, położenie przecinków i nazewnictwo. Taka umowa powinna dać się zastosować do prawdziwego kodu, także z długą funkcją albo kilkoma złączeniami. Gdy reguła utrudnia czytanie konkretnego fragmentu, wolę uzasadniony wyjątek niż konkurs na idealnie równe kolumny.

Dane do porównania dwóch zapisów

Mamy trzech klientów i trzy zamówienia. Ada złożyła dwa zamówienia, Ben jedno, a Celina żadnego. Jeden wiersz customers oznacza klienta; jeden wiersz orders oznacza zamówienie. Ta różnica będzie decydowała o liczbie wierszy po złączeniu. Nie zastąpi jej najładniejsze formatowanie.

-- example: fixture
CREATE TEMP TABLE customers (
    customer_id INTEGER PRIMARY KEY,
    customer_name TEXT
);
CREATE TEMP TABLE orders (
    order_id INTEGER PRIMARY KEY,
    customer_id INTEGER,
    status TEXT,
    amount INTEGER
);
INSERT INTO customers VALUES (1, 'Ada'), (2, 'Ben'), (3, 'Celina');
INSERT INTO orders VALUES
    (101, 1, 'paid', 100),
    (102, 1, 'cancelled', 50),
    (103, 2, 'paid', 80);

Kwoty są umownymi punktami. Nie opisują rzeczywistej sprzedaży. Chcę policzyć opłacone zamówienia dla każdego klienta, również dla osoby bez zakupów. Zapisuję to pytanie przed kodem, bo właśnie obecność Celiny odróżnia dwa pozornie podobne raporty. Bez jawnego oczekiwania łatwo uznać oba za poprawne.

Zwróć też uwagę na anulowane zamówienie Ady. Dzięki niemu możemy sprawdzić, gdzie działa filtr statusu. W zbiorze zawierającym wyłącznie opłacone zakupy błąd położenia tego warunku mógłby zostać niezauważony. Małe dane testowe dobieram tak, żeby pokazywały różnice między rozważanymi interpretacjami.

Zmieniam układ, zachowuję odpowiedź

Pierwszy zapis jest ciasny, ale wykonuje to samo obliczenie co drugi. Białe znaki w tych miejscach nie zmieniają wybranych kolumn, połączeń ani warunków. Porównuję oba wyniki na tym samym zbiorze, zamiast zakładać równoważność tylko dlatego, że narzędzie nazwało operację formatowaniem.

-- example: compact
SELECT c.customer_id,c.customer_name,COUNT(o.order_id) AS paid_orders FROM customers AS c LEFT JOIN orders AS o ON o.customer_id=c.customer_id AND o.status='paid' GROUP BY c.customer_id,c.customer_name ORDER BY c.customer_id;
-- example: formatted
SELECT
    c.customer_id,
    c.customer_name,
    COUNT(o.order_id) AS paid_orders
FROM customers AS c
LEFT JOIN orders AS o
    ON o.customer_id = c.customer_id
    AND o.status = 'paid'
GROUP BY
    c.customer_id,
    c.customer_name
ORDER BY c.customer_id;
customer_id customer_name paid_orders
1 Ada 1
2 Ben 1
3 Celina 0

W drugim zapisie szybciej widzę źródło, rodzaj złączenia i warunek dopasowania. Widzę również, że liczymy identyfikatory zamówień. COUNT(*) policzyłoby także wiersz zachowany dla Celiny, mimo braku dopasowanego zamówienia. To już inny problem niż układ kodu. Dobra forma pomaga go zauważyć, ale sama nie wybierze właściwej funkcji.

Nazwa powinna wyjaśniać znaczenie wyniku

Alias paid_orders mówi więcej niż cnt albo value. Nadal można go doprecyzować, jeżeli raport rozróżnia zamówienia złożone, opłacone i wysłane. W nazwach staram się zawrzeć to, co odróżnia obliczenie od sąsiednich kolumn. Nie muszę streszczać całej specyfikacji, ale nie chcę też ukrywać ważnego warunku.

Stosuję snake_case, bo jest wygodny przy pisaniu i przenoszeniu kodu. Spacje w nazwach mogą być dozwolone po odpowiednim zacytowaniu identyfikatora, więc nie opisuję ich jako błędu języka. Po prostu utrudniają codzienną pracę i zwiększają zależność od zasad konkretnego narzędzia. Etykiety dla użytkownika można ustalić później w warstwie prezentacji.

Unikam też nadawania tej samej nazwy różnym miarom. Jeżeli revenue raz oznacza wartość przed zwrotami, a raz po zwrotach, równy zapis nie naprawi niejasności. Warto nazwać zakres i jednostkę w dokumentacji modelu. Kod jest częścią komunikacji, ale nie powinien być jedynym miejscem przechowywania definicji biznesowej.

Aliasy pomagają wskazać pochodzenie kolumn

W złączeniu używam c.customer_id i o.customer_id, żeby odróżnić dwie kolumny o tej samej nazwie. Alias jest krótki, ale ma czytelny związek z tabelą. Przy większej liczbie podobnych źródeł wybrałbym dłuższe nazwy, na przykład current_orders i archived_orders, zamiast zmuszać czytelnika do zapamiętania alfabetu.

Przy jednej tabeli kwalifikowanie kolumn nie jest obowiązkowe w prostych zapytaniach. Nie jest też błędem. Może ułatwiać późniejsze rozszerzenie albo zachować spójność projektu. W poprzedniej wersji poradnika rozróżnienie było zbyt kategoryczne. Obecnie traktuję je jako decyzję o czytelności, zależną od kontekstu i uzgodnionej konwencji.

Szczególnie uważam na warunki, w których po obu stronach pojawia się ta sama nazwa. Czy customer_id = customer_id porównuje dwa źródła, czy kolumnę z nią samą? Jawne aliasy pomagają uniknąć tej niejasności. Gdy silnik zgłasza niejednoznaczną kolumnę, dopisanie pochodzenia jest częścią rozwiązania, a nie tylko estetycznym dodatkiem.

JOIN i LEFT JOIN odpowiadają na różne pytania

Zapis JOIN w zwykłym złączeniu oznacza złączenie wewnętrzne. Dopisanie INNER może poprawić czytelność intencji. Zamiana na LEFT JOIN zmienia natomiast zachowanie wobec wierszy bez dopasowania. Nie wolno przedstawiać jej jako uniwersalnej poprawki stylu. W naszym raporcie Celina ma pozostać, więc świadomie wybieram złączenie lewe.

-- example: inner
SELECT c.customer_id, COUNT(o.order_id) AS paid_orders
FROM customers AS c
INNER JOIN orders AS o
    ON o.customer_id = c.customer_id
    AND o.status = 'paid'
GROUP BY c.customer_id
ORDER BY c.customer_id;

Ten wariant zwraca tylko (1, 1) i (2, 1). To poprawna odpowiedź na pytanie o klientów posiadających dopasowane opłacone zamówienia. Jeżeli odbiorca oczekiwał wszystkich klientów, odpowiedź będzie niepełna. Baza nie zna intencji odbiorcy i nie zgłosi z tego powodu błędu.

Podobnie kolejność źródeł przy złączeniu lewym jest istotna. Zachowujemy niedopasowane wiersze z lewej strony, więc wybór tabeli po FROM musi odpowiadać populacji raportu. Czy punktem wyjścia są wszyscy klienci, wszystkie zamówienia czy tylko zwroty? To pytanie zadaję przed zastanawianiem się, gdzie ustawić przecinek.

Dwie pixelartowe ścieżki z dopasowanymi parami i dodatkowym zachowanym elementem

Przeniesienie warunku może zmienić złączenie

W poprawnym raporcie filtr opłacenia należy do warunku dopasowania. Dzięki temu klient bez pasującego zakupu pozostaje w wyniku z pustymi kolumnami zamówienia. Jeśli przeniosę warunek do WHERE, brakujący status nie spełni porównania i taki klient zostanie odrzucony. Ładniejsze wcięcie nie cofnie tej różnicy.

-- example: where-filter
SELECT c.customer_id, COUNT(o.order_id) AS paid_orders
FROM customers AS c
LEFT JOIN orders AS o
    ON o.customer_id = c.customer_id
WHERE o.status = 'paid'
GROUP BY c.customer_id
ORDER BY c.customer_id;

Ponownie otrzymamy tylko klientów 1 i 2. Właśnie dlatego przegląd kodu powinien obejmować wiersz bez dopasowania oraz klienta posiadającego wyłącznie anulowane zamówienia. Ten drugi przypadek nie występuje jeszcze w podstawowej próbce, ale warto go dopisać podczas testowania. Oba przypadki mają zostać w raporcie wszystkich klientów.

Reguły złączeń i filtrowania możesz sprawdzić w dokumentacji SQLite. Kiedy porządkuję istniejący kod, nie przesuwam warunków między ON a WHERE bez osobnej oceny. Taką zmianę opisuję jak zmianę obliczenia, wraz z oczekiwaną różnicą wyniku i przykładem, który ją pokazuje.

Cudzysłowy, nierówność i inne drobne reguły

Literały tekstowe zapisuję w pojedynczych cudzysłowach, tak jak 'paid'. Cytowanie identyfikatorów zależy od dialektu i ustawień. Nie opieram przenośnego przykładu na tym, że konkretna baza toleruje podwójne cudzysłowy jako tekst. Tolerowane rozszerzenie potrafi przestać działać po zmianie ustawień albo silnika.

Operator <> oznaczający nierówność jest poprawnym zapisem SQL. SQLite obsługuje także !=. Nie ma podstaw, żeby nazywać pierwszy zapis błędem albo obiecywać przewagę wydajności drugiego. Wybieram konwencję projektu i pamiętam, że żaden z nich nie zastępuje IS NULL przy wyszukiwaniu braków. Dokumentacja wyrażeń pozwala sprawdzić konkretne reguły silnika.

Przecinki mogą stać na końcu linii albo przed kolejną pozycją. Ważne, by lista była czytelna i łatwa do zmiany. W tym poradniku używam końcowych. Nie zmieniam jednak układu całego repozytorium przy okazji poprawki jednego filtra. Koszt przeglądu takiej zmiany może być większy niż zysk z osobistej preferencji.

Komentarz wyjaśnia decyzję i wyjątek

Komentarz typu „wybierz identyfikator” nad SELECT customer_id niewiele wnosi. Bardziej pomaga informacja, dlaczego anulowane zamówienia nie należą do wskaźnika albo dlaczego raport zachowuje klientów bez zakupów. To intencja, której nie zawsze da się odczytać z samej składni. Dobry komentarz odpowiada na pytanie przyszłego czytelnika.

Komentarz liniowy -- kończy się wraz z końcem linii, a blokowy /* ... */ ma jawne zamknięcie. Oba są prawidłowe w SQLite. Jeśli narzędzie skleja linie bez rozumienia komentarzy, może uszkodzić SQL. To problem przetwarzania tekstu, który trzeba sprawdzić w tym narzędziu, a nie dowód, że komentarze liniowe są z zasady złe.

Komentarze również się starzeją. Gdy zmieniam warunek, sprawdzam sąsiedni opis, żeby nie zostawić dwóch sprzecznych wersji reguły. Nie przechowuję wielkich bloków starego kodu jako komentarza „na wszelki wypadek”, jeśli historia zmian jest już w repozytorium. Aktualny plik powinien jasno pokazywać aktualne obliczenie i jego uzasadnienie.

CTE porządkuje etapy, nie gwarantuje szybkości

Nazwany etap bywa łatwiejszy do przeczytania niż kilka zagnieżdżonych podzapytań. CTE pozwala oddzielić przygotowanie danych od końcowego raportu. Wybieram jednak granice etapów według ich znaczenia, nie według liczby linii. Trzy nazwy opisujące realne kroki pomagają bardziej niż dziesięć nazw typu step1, step2 i final_final.

Nie zakładam, że CTE będzie zawsze szybsze od podzapytania. Sposób realizacji zależy od silnika, wersji, planu i konkretnej kwerendy. SQLite opisuje materializację CTE jako element zachowania planera, a nie obietnicę wynikającą z samego słowa WITH. Wydajność oceniam na podstawie pomiaru odpowiedniego zapytania.

Przy refaktoryzacji sprawdzam też szczegółowość danych między etapami. Jeżeli etap zwraca jeden wiersz na klienta, jego złączenie z tabelą zamówień może znów powielić klienta. Dobra nazwa i komentarz o tym, co oznacza wiersz, ułatwiają zauważenie takiej zmiany. Pełny przykład znajdziesz w poradniku CTE.

Jawne kolumny ułatwiają późniejszą zmianę

W przykładzie grupuję po nazwanych kolumnach zamiast pisać GROUP BY 1, 2. Zapis pozycyjny jest dostępny w niektórych dialektach, ale wiąże znaczenie grupowania z kolejnością listy SELECT. Jeżeli ktoś przestawi kolumny, może przypadkowo zmienić obliczenie albo otrzymać błąd. Jawna nazwa ułatwia ocenę lokalnej zmiany bez śledzenia numerów pozycji.

Podobnie traktuję ORDER BY. Odbiorca widzi wynik w określonej kolejności, więc warto wiedzieć, czy jest ona częścią wymagań. Sortowanie tylko po liczbie zamówień pozostawia remis między Adą a Benem. Jeżeli raport ma dawać powtarzalną kolejność przy tych samych danych, dodaję identyfikator jako rozstrzygnięcie. Formatowanie powinno pokazać oba kryteria, żeby drugie nie zniknęło w długiej linii.

Przy eksporcie zwracam uwagę również na aliasy kolumn. Zmiana paid_orders na orders_count może być neutralna dla człowieka, ale program odczytujący wynik po nazwie przestanie znajdować pole. Podobnie przestawienie kolumn może zaszkodzić odbiorcy czytającemu je pozycyjnie. Dlatego zakres refaktoryzacji obejmuje także umowę z konsumentem danych, nie tylko równość widocznych liczb.

Nie każda zmiana nazwy wymaga wielkiej procedury. W samodzielnym ćwiczeniu można swobodnie eksperymentować. W istniejącym raporcie najpierw sprawdzam, kto używa wyniku i jak go odczytuje. Ta krótka kontrola często jest ważniejsza niż wybór wielkich lub małych liter w słowach kluczowych. Czytelny kod powinien ułatwiać współpracę z otoczeniem, w którym będzie działał.

Jeżeli tworzysz standard dla zespołu, dopisz przykład takiej zmiany kontraktu. Abstrakcyjna zasada „stosuj dobre nazwy” pozostawia zbyt wiele interpretacji. Konkretne wyjaśnienie, że nagłówek eksportu jest używany przez kolejny proces, pokazuje powód ostrożności. Dzięki temu reguła ma praktyczny sens i łatwiej ją stosować podczas codziennego przeglądu kodu.

Formatter pomaga, ale wymaga kontroli

Automatyczny formatter oszczędza ręczne ustawianie wcięć. Przed użyciem wybieram właściwy dialekt i sprawdzam, czy narzędzie rozumie konstrukcje projektu. Kod SQL osadzony w szablonie może zawierać dodatkową składnię, której zwykły formatter nie zna. Najpierw próbuję na małym fragmencie i oglądam różnicę.

Po formatowaniu czytam diff. Zmiana odstępów jest czymś innym niż usunięcie nawiasu, komentarza albo cudzysłowu. Jeżeli narzędzie zmieniło coś więcej niż oczekiwany układ, zatrzymuję się i sprawdzam przyczynę. Nie akceptuję automatycznej poprawki tylko dlatego, że wynik wygląda profesjonalniej w edytorze.

Wspólna konfiguracja powinna być zapisana w projekcie, żeby każdy otrzymywał podobny rezultat. Nie trzeba jednak wdrażać rozbudowanego systemu do jednego ćwiczenia. Na początku wystarczy stały układ, kilka jasnych nazw i umiejętność porównania wyników. Narzędzie ma wspierać tę pracę, a nie tworzyć dodatkowy rytuał bez widocznej korzyści.

Jak sprawdzam zmianę przed oddaniem kodu

Najpierw porównuję nazwy i kolejność kolumn, potem liczbę wierszy oraz wartości. Przy ważnej kolejności dodaję jawne sortowanie. Jeśli kolejność nie należy do kontraktu raportu, porównuję zawartość z uwzględnieniem liczby powtórzeń. Zwykłe porównanie zbiorów może ukryć przypadkowe pomnożenie identycznych wierszy.

Sprawdzam również sytuacje graniczne: brak zamówień, klient bez dopasowania, kilka zamówień jednego klienta i wartości brakujące. Pomyślny test na próbce nie dowodzi równoważności dla wszystkich możliwych danych, ale celowo dobrane przypadki znacząco ułatwiają wykrycie typowych błędów. Wtedy review ma konkretny materiał, zamiast samego zapewnienia, że „to tylko formatowanie”.

Samodzielne ćwiczenie jest proste: dopisz czwartego klienta z jednym anulowanym zamówieniem. Porównaj trzy pokazane zapytania. Raport formatted powinien zachować nowego klienta z zerem; inner oraz where-filter powinny go pominąć. Następnie przeformatuj poprawny raport według własnej konwencji i sprawdź, czy odpowiedź pozostała taka sama.

Historyczny pakiet Format SQL - przykłady zachowuję jako materiał z wcześniejszej wersji artykułu. Używa źródłowej bazy i dawnych konwencji; nie jest zestawem wykonywanym w tym ćwiczeniu. Do nauki i porównania wyników użyj kompletnego kodu powyżej. Jeśli dopiero zaczynasz, wróć najpierw do SELECT i filtrowania.

Najważniejsze w pigułce

Czy formatowanie SQL zmienia wynik?

Sama zmiana odstępów w odpowiednich miejscach nie zmienia obliczenia. Zmiana złączenia, położenia warunku albo wyrażenia może zmienić wynik i wymaga osobnej weryfikacji.

Czy LEFT JOIN jest lepszym stylem niż INNER JOIN?

Rodzaj złączenia wynika z pytania. LEFT JOIN zachowuje niedopasowane wiersze lewej strony, a INNER JOIN wymaga dopasowania. Zamiana jednego na drugie nie jest kosmetyczna.

Czy CTE zawsze przyspiesza zapytanie?

Nie ma takiej gwarancji. CTE pomaga nazwać etapy, natomiast wydajność zależy od silnika, wersji, danych i planu wykonania.

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.