Czysty kod SQL

Dzisiaj o tym, jak powinien wyglądać prawidłowo napisany kod SQL. Mogłoby się wydawać, że zwracanie uwagi na czysty kod to fanaberia czy luksus dostępny tylko dla tych co mają za dużo czasu, ale absolutnie tak nie jest – pisanie czystego kodu jest tak samo ważne jak pisanie działającego kodu. Czysty kod to nie luksus, to konieczność

Dlaczego?

Większość czasu praca z danymi to czytanie i przerabianie kodu, który już został napisany, a nie pisanie kodu czy tworzenie analizy od zera. W artykule „Dlaczego Excel i VBA są słabe?” pisałam dlaczego powinniśmy opierać analizę na kodzie a nie na programie z graficznym interfejsem – kod gwarantuje, że jesteśmy w stanie odtworzyć analizę krok po kroku. Ale przy źle napisanym kodzie wracamy do punktu wyjścia – taki kod nie pozwala na łatwe odtworzenie, zrozumienie lub zmianę analizy. Same napisanie analizy w kodzie nie wystarczy, aby stworzyć dobrą analizę – kluczowe jest, aby napisać ten kod dobrze

Kod powinien być tak napisany, aby jak najbardziej ułatwić przyszłemu czytelnikowi jego zrozumienie. Musimy go napisać w sposób, aby każda kolejna osoba bez konsultacji z nami była w stanie na nim pracować wkładając w to minimalny wysiłek. Jeżeli użyjemy pokrętnej logiki, zapomnimy o dobrych praktykach lub beznadziejnie go sformatujemy, to ciężko będzie zrozumieć lub zmienić kod przyszłemu odbiorcy.

SQL

SQL jest językiem w którym stosunkowo łatwo jest “coś” napisać, ale równie łatwo jest zrobić błąd logiczny i wyciągnąć złe dane. Dlatego pisanie czystego kodu w przypadku SQL jest ultraważne, ponieważ minimalizuje możliwość popełnienia błędów oraz usprawnia ich lokalizowanie i naprawę.

W przypadku SQL nie ma oficjalnego zestawu zasad jak na przykład ma to miejsce w przypadku zasad PEP8 dla pythona. Dlatego poniższe punkty są zlepkiem zasad stosowanych w branży (np. stosowanie wielkich liter dla słów kluczowych), dobrych praktyk (np. stosowanie CTE) lub moich własnych doświadczeń (pisanie komentarzy). Przy każdej zasadzie próbuje wytłumaczyć czemu ona służy i dlaczego warto ją stosować. Nie są to sztywne zasady, które nie podlegają dyskusji, a wręcz część z nich może być trochę kontrowersyjna – w takich przypadkach zapraszam do merytorycznej dyskusji (merytoryczny przykład: uważam, że ta zasada jest zła, ponieważ napisanie kodu w ten sposób umożliwia …. ; niemerytoryczny przykład: jestem już 15 lat w branży i tak to się pisze). Nie mam zasad co do każdego słowa i każdej linijki kodu – niektóre z kwestii zostawiam otwarte, m.in. czy stosować przecinek na końcu czy początku linijki, czy używać AS przy aliasach, itd. ponieważ te aspekty są (według mnie) mniej istotne.

W jakim stopniu artykuł wyczerpuje temat? Artykuł całkowicie wyczerpuje temat o formatowaniu i poprawnych zasadach pisania SQLa. Artykuł dotyczy tylko języka SQL.

Czego artykuł nie zawiera? Chociaż jeden z punktów mówi o logice, to artykuł nie tłumaczy w pełni jak napisać kod prawidłowy logicznie. Jeżeli wasz kod ma złą logikę wyciągania danych lub tworzenia obliczeń, zastosowanie wszystkich poniższych zasad samo w sobie nie pomoże w jej naprawieniu, aczkolwiek może pomóc w identyfikacji logicznych błędów. 

Zasady

1. Formatowanie

Wszystkie zasady związane z formatowaniem poprawiają czytelność kodu.

  • Każda “sekcja” kodu (SELECT, FROM, WHERE) powinna zacząć się od nowej linijki, podobnie jak każda kolumna w SELECT/WHERE. Umożliwia to szybką możliwość dodania, usunięcia lub zakomentowania danej kolumny.
źle:
SELECT id, first_name, last_name, age, birthdate FROM users WHERE registration_date > '2022-01-01'

SELECT id, first_name, last_name, age, birthdate 
FROM users 
WHERE registration_date > '2022-01-01' AND country = 'PL'

dobrze:
SELECT id
   , first_name
   , last_name
   , age
   , birthdate 
FROM users 
WHERE registration_date > '2022-01-01' 
AND country = 'PL'
  • Indentacja (wyrównanie kodu) powinna być spójna w całym kodzie oraz podążać za logiką obliczeń.
źle:
SELECT id
, first_name
     , last_name
 FROM users 
       WHERE registration_date > '2022-01-01' 
      AND country = 'PL'


dobrze:
SELECT id
     , first_name
     , last_name
FROM users 
WHERE registration_date > '2022-01-01'
  AND country = 'PL'
  • Słowa kluczowe (SELECT, FROM, JOIN, WHERE, itd.) oraz funkcje (FIRST_VALUE, MAX, itd.) SQL powinny być pisane wielkimi literami, a nazwy kolumn i tabel małymi.
źle:
select first_name
     , last_name
from USERS
where registration_date > '2022-01-01'

dobrze:
SELECT first_name
     , last_name
FROM users 
WHERE registration_date > '2022-01-01' 

2. Logika

  • Obliczenia powinny być logiczne, podążać zasadą od ogółu do szczegółu, nie powinno być zbędnych obliczeń lub skakania między nimi.
  • Funkcje powinny być użyte zgodnie z przeznaczeniem, np. zamiast wielu OR powinno się pisać IN.
źle:
SELECT first_name
     , last_name
FROM users 
WHERE country = 'PL'
   OR country = 'NL'

dobrze:
SELECT first_name
     , last_name
FROM users 
WHERE country IN ('PL', 'NL')
  • Kod nie powinien zawierać nieużywanych kolumn (SELECT *), tabel, JOINów, obliczeń lub logiki, która nic nie wnosi, np. ORDER BY w CTE lub subquery jest zbędny, ponieważ sortowanie powinno być tylko na końcu.

3. Nazwy zmiennych

  • Każda nazwa musi mieć znaczenie! Nie nazywamy kolumn czy zmiennych a, a1, a2! Każda nazwa powinna tłumaczyć co dana tabela/kolumna reprezentuje.
źle:
WITH a AS (
  SELECT first_name
       , last_name 
  FROM users 
  WHERE country = 'PL'
) ...

dobrze:
WITH pl_users AS (
  SELECT first_name
       , last_name
  FROM users 
  WHERE country = 'PL'
) ...
  • Nazwy zmiennych są pisane z małych liter i oddzielone _
źle:
SELECT id AS UserId

dobrze:
SELECT id AS user_id
  • Nazwy powinny być jak najmniej dwuznaczne, np. często w każdej tabeli mamy kolumnę ID, która odnosi się do różnych IDs, np. user ID w tabeli users, transaction ID w tabeli transactions. Jeżeli potrzebujemy jedną z ID powinniśmy nadać jej nową nazwę wskazującą do jakiego ID się odnosi.
źle:
SELECT u.id
     , t.category
FROM users u
JOIN transactions t ON u.id = t.user_id

dobrze:
SELECT u.id AS user_id
     , t.category
FROM users u 
JOIN transactions t ON u.id = t.user_id
  • Jeżeli agregujemy dane używając funkcji SUM, COUNT to dobrze jest nazwać nowo powstałą kolumnę używając nazwy agregacji, np. sum_transactions, cnt_transactions, a nie używać słów typu total lub number, które potrafią być mylące.
źle:
SELECT COUNT(id)   AS transactions_number
     , SUM(amount) AS transactions_total
FROM transactions

dobrze:
SELECT COUNT(id)   AS cnt_transactions
     , SUM(amount) AS sum_amount
FROM transactions
  • Nazwy kolumn, tabel i CTE powinny być inne.
źle:
WITH users AS (
  SELECT first_name AS user 
  FROM users
  WHERE country = 'PL'
) ...

dobrze:
WITH pl_users AS (
  SELECT first_name
  FROM users
  WHERE country = 'PL'
) ...
  • Warto przyjąć konwencję nazw w zespole/firmie i się jej trzymać, czyli nie używać naprzemiennie podobnych słów typu user/client, mail/email, employee/agent, ticket/issue, tylko zawsze używać jednego z nich.

4. Aliasy

Aliasy służą 2 celom: 1) aby odróżnić, która z kolumn pochodzi z której tabeli; 2) aby nie wypisywać za każdym razem nazwy całej tabeli.

  • Aliasy powinny być krótkie, konwencją jest używanie pierwszych liter słów tabeli.
źle:
SELECT us.first_name
FROM users us
JOIN transactions trans ON us.id = trans.user_id 

dobrze:
SELECT u.first_name
FROM users u
JOIN transations t ON u.id = t.user_id
  • Jeżeli joinujemy ze sobą przynajmniej dwie tabele to aliasy są obowiązkowe. Jasno to pokazuje z której tabeli jest jaka kolumna, więc ułatwia nam szybką modyfikację kodu. W przypadku użycia tylko 1 tabeli w FROM alias nie jest obowiązkowy, ponieważ nie wnosi on nic do kodu, nie spełnia żadnej funkcji – ja w tym przypadku go nie używam. Aliasy stosujemy w każdej sekcji (SELECT, JOIN, WHERE, GROUP BY, itd.).
źle:
SELECT first_name
FROM users u
JOIN transactions t ON us.id = t.user_id
WHERE country = 'PL'

dobrze:
SELECT u.first_name
FROM users u
JOIN transations t ON u.id = t.user_id
WHERE u.country = 'PL'

jedna tabela -> brak aliasów:
SELECT first_name
FROM users
WHERE country = 'PL'
  • W przypadku SELF JOIN (łączenia ze sobą dwóch tych samych tabel) odchodzimy od zasady krótkich aliasów z pierwszych liter tabel – w takim przypadku dużo bardziej sprawdzają się aliasy „logiczne”, które mówią o intencji łączonych tabel.
źle:
SELECT u1.first_name AS buyer_first_name
     , u2.first_name AS seller_first_name 
FROM transactions t 
JOIN users u1 ON t.buyer_id = u1.id 
JOIN users u2 ON t.seller_id = u2.id

dobrze:
SELECT buyer.first_name  AS buyer_first_name
     , seller.first_name AS seller_first_name
FROM transations t 
JOIN users buyer  ON t.buyer_id = buyer.id 
JOIN users seller ON t.seller_id = seller.id

skrócone aliasy pochodzące od słów "buyer" / "seller"
SELECT b.first_name  AS buyer_first_name
     , s.first_name  AS seller_first_name
FROM transations t 
JOIN users b  ON t.buyer_id = b.id 
JOIN users s ON t.seller_id = s.id

5. LEFT JOIN

LEFT JOIN oraz RIGHT JOIN działają tak samo, różnią się tylko tym, którą tabelę używać w jakiej kolejności. Ogólnie przyjęło się (przynajmniej w moim środowisku), aby za każdym razem używać LEFT JOINa. Jeżeli wszyscy go używamy w każdej query, to przyzwyczajamy się do pewnej konwencji dzięki której szybciej jesteśmy w stanie przejrzeć kod. Zresztą wydaje mi się, że nie wzięło się to znikąd, ponieważ prościej zrozumieć analizę w której najpierw bierzemy więcej danych (wszystkie rekordy z jednej tabeli), a później dokładamy do niej dodatkowe informacje (rekordy które się joinują z drugiej tabeli), a nie odwrotnie.

źle:
SELECT u.first_name
FROM transactions t
RIGHT JOIN users u ON t.user_id = u.id

dobrze:
SELECT u.first_name
FROM users u
LEFT JOIN transactions t ON u.id = t.user_id 

6. CTE zamiast subquery

CTE poświęcony jest cały artykuł. CTE powoduje, że kod jest napisany w kolejności w jakiej są wykonywane obliczenia, zatem prościej go zrozumieć niż przy zastosowaniu subquery. Zagnieżdzone subqueries komplikują czytelność, ponieważ nagle w trakcie czytania kodu musimy oderwać się do całkowicie nowych obliczeń. Więcej info i przykłady znajdziecie w artykule o CTE.

7. Nazwy kolumn w GROUP BY i ORDER BY

Używanie pełnych nazw kolumn zamiast cyfr w GROUP BY i ORDER BY powoduje, że jedno spojrzenie na te linijki umożliwia nam zrozumienie jak są pogrupowane lub posortowane dane. Przykład poniżej jest dość prosty, więc być może nie jest to zbyt widoczne, ale im dłuższa i bardziej skomplikowana query tym więcej benefitów płynie z tej reguły.

źle:
SELECT t.user_id
     , u.first_name
     , COUNT(t.id) AS cnt_transactions
FROM users u 
JOIN transactions t ON u.id = t.user_id
GROUP BY 1,2 
ORDER BY 2

dobrze:
SELECT t.user_id
     , u.first_name 
     , COUNT(t.id) AS cnt_transactions
FROM users u
JOIN transactions t ON u.id = t.user_id
GROUP BY t.user_id, u.first_name 
ORDER BY u.first_name

8. Komentarze

Ogólnie są dwie szkoły pisania komentarzy. Jedna mówi, że kod sam w sobie powinien być samwytłumaczalny, więc komentarze powinny tylko wskazywać dlaczego coś zostało zrobione, a nie jak. I rzeczywiście, pisanie komentarzy, które duplikują to co jest w kodzie może wydawać się zbędne, ale mimo wszystko ja osobiście jestem zwolenniczką większej ilości komentarzy, przede wszystkim takich, które umożliwiają szybką nawigację w długim kodzie. Dlatego komentarze które ja stosuję to:

  • Ogólny komentarz na początku: krótkie wytłumaczenie co robi kod i jakie przyjmuje definicje. Dzięki temu nie muszę w ogóle patrzeć na sam SQL aby wiedzieć, czy robi on to czego szukam, jakie dane wyciąga z bazy i jakie obliczenia wykonuje. Dodatkowo, taki komentarz jest bardzo pożyteczny dla ludzi biznesu, którzy nawet nie znając SQL mogą wziąć sobie tę query i już użyć (bez zawracania dupy analitykom).
/* Code calculates number of transactions per polish user which happened since 1st March 2022 */
 
SELECT t.user_id
     , u.first_name 
     , COUNT(t.id) AS cnt_transactions
FROM users u
JOIN transactions t ON u.id = t.user_id
WHERE u.country = 'PL' 
  AND t.date > '2022-03-01'
GROUP BY t.user_id, u.first_name 
ORDER BY u.first_name
  • Metadane: do ogólnego komentarza na początku lubię dodać dodatkowe informacje, typu: autor kodu, numer jiry, link do wizualizacji / dashboardu. Te informacje mogą być przydatne dla kolejnych osób pracujących nad kodem oraz posłużyć do szybkiego wyszukania kodu w lupce githuba (szczególnie w sytuacji gdy repozytorium na githubie nie jest połączone np. z programem do wizualizacji).
/* Code calculates number of transactions per polish user which happened since 1st March 2022.

author: kasiadyl@crappydata.pl
jira: www.jira.com/CD-1
dashboard: www.metabase.com/dashboard/1234
*/
 
SELECT t.user_id
     , u.first_name 
     , COUNT(t.id) AS cnt_transactions
FROM users u
JOIN transactions t ON u.id = t.user_id
WHERE u.country = 'PL' 
  AND t.date > '2022-03-01'
GROUP BY t.user_id, u.first_name 
ORDER BY u.first_name
  • Komentarze przy każdym CTE – chyba najbardziej kontrowersyjna część, bo, jak już wspominałam, po co pisać co robi jakie CTE skoro możemy to przeczytać w kodzie. Jednak z doświadczenia wiem, że czasami kod jest długi i robi się dość skomplikowany, dlatego takie komentarze łatwo umożliwiają nawigowanie w całej querce. Przydaje się to przede wszystkim przy niestandardowych rozwiązaniach, np. jak przy szybkim spojrzeniu wydaje się, że jedno z CTE jest zbędne, a wcale nie jest.
WITH pl_users AS (     -- select users before counting as there are duplicates in users table
   SELECT DISTINCT id 
   FROM users 
   WHERE country = 'PL'
), 
  pl_users_count AS (  -- count not duplicated users
   SELECT COUNT(id) 
   FROM pl_users
), 
  pl_transactions AS (  -- select transactions before counting as duplicates in the table
   SELECT DISTINCT id 
   FROM transactions t 
   JOIN ...
  • Komentarze w kodzie przy sytuacjach dziwnych, nieoczywistych lub zwróceniu uwagi na ważne aspekty, np. jeżeli JOIN jest niestandardowy (zazwyczaj JOINujemy po user_id a w tej sytuacji po innej kolumnie), albo jeden z warunków w WHERE jest niezwykle istotny i nie należy go przez przypadek usunąć
SELECT COUNT(t.id) AS cnt_transactions
FROM transactions t 
JOIN category c ON t.category_name = c.name   /* JOIN on name not ID is intentional 
                                                 as otherwise some records are lost */
WHERE u.country = 'PL' 
  AND t.date > '2022-03-01' 
  AND t.status = 'completed'  -- important condition! 

9. Testowanie

Pracując z danymi jesteśmy odpowiedzialni za jakość analizy, którą dostarczamy, dlatego aby mieć pewność, że wypuszczamy w świat dobre wyniki powinniśmy testować nasz kod. Temat na tyle rozbudowany, że będzie o nim w odrębnym poście 🙂 

10. Git

Finalnie kod powinien być umieszczony na platformie do zarządzania kontrolą wersji (np. githubie) oraz przejść przez proces peer review, czyli powinien zostać sprawdzony przez inne osoby. Składowanie kodu w każdej innej wersji jest niepoprawne.

Podsumowanie

Trochę się tego nazbierało, więc w ramach powtórki o czym wspominałam:

  1. Formatowanie
  2. Logika
  3. Nazwy zmiennych
  4. Aliasy
  5. LEFT JOIN
  6. CTE zamiast subquery
  7. Nawy kolumn w GROUP BY/ORDER BY
  8. Komentarze
  9. Testowanie
  10. Git

A mój kod SQL wygląda zazwyczaj jak coś w tym stylu:

/* 
This code calculates average (electronic) product cnt with ratio to all products 
from the last 30 days.

author: kasiadyl
link: https://github.com/kdyl/crappydata/blob/main/cte.sql 
*/

WITH all_products AS ( -- count all products per transaction
    SELECT transaction_id
          , month
          , COUNT(product_id) AS count_all_products
    FROM transactions
    WHERE date(created) > date(NOW()) - INTERVAL '30' DAY
    GROUP BY transaction_id, month
)
, electronic_products AS ( -- count electronic products per transaction
    SELECT t.transaction_id
          , COUNT(t.product_id) AS count_electronic_products
    FROM transactions t 
    JOIN category c ON t.category_id = c.category_id
    WHERE c.category_name = 'ELECTRONICS'
      AND date(t.created) > date(NOW()) - INTERVAL '30' DAY
    GROUP BY t.transaction_id
)
, ratio_electronic_to_all AS ( -- calculate ratio of electronic to all products per transaction
    SELECT ap.transaction_id
          , NVL(ep.count_electronic_products, 0) / ap.count_all_products AS ratio
          /* NVL used as if there was no electronic product.
             If ep.count_electronic_products is NULL, it will be replaced with 0 */
    FROM all_products ap
    LEFT JOIN electronic_products ep ON ap.transaction_id = ep.transaction_id
)
SELECT -- at the end calculate averages per month, add number of days
       ap.month
      , AVG(ap.count_all_products)        AS average_product_count
      , AVG(ep.count_electronic_products) AS average_eletronic_product_count
      , AVG(reta.ratio)                   AS average_ratio_electronic_to_al
      , COUNT(c.date)                     AS count_days
FROM all_products ap
LEFT JOIN electronic_products ep       ON ap.transaction_id = ep.transaction_id
LEFT JOIN ratio_electronic_to_all reta ON ap.transaction_id = reta.transaction_id
JOIN calendar c                        ON ap.month = c.month
GROUP BY ap.month

Zapraszam do dyskusji, z jakimi punktami się zgadzacie a z jakimi nie, co byście dodali?

Jeżeli podobają Ci się moje darmowe treści, to będzie mi bardzo miło jak postawisz mi kawę: Postaw mi kawę na buycoffee.to

8 thoughts on “Czysty kod SQL”

  1. „SELECT t.user_id
    , u.first_name
    , COUNT(t.id) AS cnt_transactions
    FROM users u
    JOIN transactions t ON u.id = t.user_id
    WHERE u.country = 'PL’
    AND t.date > '2022-03-01′
    GROUP BY t.user_id, u.first_name
    ORDER BY u.first_name”

    Tutaj chyba masz błąd w kodzie. Moim zdaniem powinno być albo COUNT(u.id) albo COUNT(t.user_id).

    1. Nie, jest ok tak jak jest.

      t.id odnosi się do ID transakcji, u.id (lub t.user_id) odnosi się do ID usera.
      Query liczy liczbę transakcji na usera, zatem w COUNT powinnam liczyć transakcje, czyli t.id (czyli tak jak jest).
      COUNT(u.id) albo COUNT(t.user_id) powodowałoby, że liczyłabym ile userów jest na usera, czyli zawsze dostawałabym 1.

      Aczkolwiek ten przykład dobrze pokazuje jak używanie zwykłych id i nie zmienianie ich na transaction_id / user_id jest mylące – wspominam nawet o przykładzie z ID w punkcie 3 o nazwach zmiennych.

      1. Ok. Teraz rozumiem. Dziękuję za wyjaśnienie 🙂

        Jest możliwość, abyś wrzuciła kod do tych tabel, z któych korzystasz? Chodzi mi o bazę danych z kilkoma rekordami żeby każdy mógł sobie skopiować Twoje zapytania i zobaczyć co wypluwuje.

        1. Niestety, póki co tworzę querki na sucho bez żadnej bazy (dlatego też mogą być błędy, bo nigdzie ich nie testuje), ale zastanawiam się właśnie nad jakimś technicznym rozwiązaniem, aby odbiorcy sami mogli sobie testować kod. Chciałabym, żeby to było bezpośrednio na stronie.

          Jeżeli ktoś ma jakiś pomysł jak to zrobić to proszę o maila na kontakt@crappydata.pl

          1. Nie wiem czy znasz tę stornę: http://sqlfiddle.com/

            Może coś w tym stylu zrobić u Ciebie na blogu w formie zakładki? Z lewej strony wrzucasz bazę danych, z prawej zapytania, a na dole jest rezultat. Tylko nie wiem jak to technicznie zrobić.

  2. Wiola Pawłowska

    To byłoby super takie testowanie kodu!
    Artykuł świetny, dla kogoś kto zaczyna przygodę wiele ciekawych wskazówek, które na pewno wdrożę w swoje nawyki 🙂

  3. Pingback: Jak się nauczyć SQL? – Crappy Data

Comments are closed.