SQL Server w praktyce – JOIN, GROUP BY i funkcje okna w analizie danych biznesowych

Jak przełożyć dane o zamówieniach na rzetelne wyniki sprzedaży? Poznaj JOIN, GROUP BY i funkcje okna w SQL Server na przykładach biznesowych. Sprawdź, jak tworzyć rankingi, analizować trendy i unikać pułapek łączenia tabel.
14 października 2026
blog

Wprowadzenie: SQL Server w analizie danych biznesowych na przykładzie sprzedaży

Wzrost wartości zamówień nie musi oznaczać, że więcej klientów kupuje częściej. Może wynikać z kilku dużych transakcji, podwyżek cen albo zmiany udziału poszczególnych produktów w sprzedaży. Aby rozróżnić te sytuacje, nie wystarczy odczytać końcowej kwoty z raportu. Trzeba połączyć dane transakcyjne z informacjami o klientach i produktach, a następnie spojrzeć na nie z odpowiedniej perspektywy.

SQL Server pozwala wykonywać takie analizy bezpośrednio w relacyjnej bazie danych, przy użyciu języka T-SQL. Wyniki zapytań mogą służyć do bieżącej weryfikacji sprzedaży, zasilać raporty lub stanowić źródło danych dla narzędzi Business Intelligence. Istotną zaletą tego podejścia jest możliwość zapisania reguł obliczeń w zapytaniu i ponownego zastosowania ich do aktualnych danych.

W analizie sprzedaży trzy mechanizmy pełnią różne, uzupełniające się role. JOIN łączy powiązane dane, dzięki czemu transakcję można rozpatrywać w kontekście klienta czy produktu. GROUP BY sprowadza dane do poziomu grup, na przykład tworząc zestawienie wartości sprzedaży według klientów. Funkcje okna umożliwiają obliczenia obejmujące powiązane wiersze bez zwijania ich do jednego rekordu na grupę. Przydają się wtedy, gdy obok szczegółów potrzebne są porównania, pozycje w rankingu lub wartości narastające.

Wybór mechanizmu powinien wynikać z pytania biznesowego. Zestawienie sprzedaży według klientów ma inną szczegółowość niż lista zamówień z dodatkową informacją o ich znaczeniu dla wyniku klienta. Przed napisaniem zapytania warto więc ustalić, co ma reprezentować jeden wiersz wyniku oraz jak rozumiemy badaną miarę: czy uwzględnia rabaty, zwroty i wyłącznie zrealizowane zamówienia.

Przykład sprzedaży pozwala prześledzić drogę od pojedynczych zapisów transakcyjnych do informacji wspierających decyzje. Celem nie jest samo uzyskanie poprawnego składniowo zapytania, lecz odpowiedź, której znaczenie jest jasne, a sposób obliczenia można zweryfikować.

Model danych i przygotowanie przykładowych tabel (Klienci, Zamówienia, Pozycje, Produkty)

W modelu sprzedażowym jedno zamówienie nie musi oznaczać jednego sprzedanego produktu. Klient może złożyć wiele zamówień, a każde z nich może obejmować kilka pozycji o różnych ilościach i cenach. Dlatego przed rozpoczęciem analizy trzeba ustalić ziarnistość danych, czyli to, co reprezentuje pojedynczy wiersz w każdej tabeli. Od tej decyzji zależy, czy późniejsze obliczenia będą odpowiadały na właściwe pytania biznesowe.

W Cognity często słyszymy pytania, jak przygotować dane do analizy w SQL Server – odpowiadamy na nie także na blogu. Punktem wyjścia będzie model sprzedażowy, na którym można przećwiczyć łączenie tabel, grupowanie wyników i stosowanie funkcji okna.

Cztery tabele i ich odpowiedzialność

Do ćwiczeń w SQL Server wystarczy prosty model, który oddziela dane klientów i produktów od informacji o transakcjach:

  • Klienci — jeden wiersz opisuje jednego klienta. Oprócz identyfikatora warto uwzględnić segment i miejscowość. Takie atrybuty pozwolą później analizować sprzedaż według grup odbiorców bez wykorzystywania danych osobowych.
  • Zamówienia — jeden wiersz reprezentuje nagłówek zamówienia. Powinien zawierać identyfikator zamówienia, identyfikator klienta, datę złożenia oraz status. To informacje wspólne dla wszystkich pozycji danej transakcji.
  • Pozycje — jeden wiersz opisuje pojedynczą pozycję zamówienia, a niekoniecznie jedną sztukę towaru. Potrzebne są identyfikatory pozycji, zamówienia i produktu, a także ilość oraz jednostkowa cena sprzedaży.
  • Produkty — jeden wiersz opisuje produkt. Podstawowe atrybuty to identyfikator, nazwa i kategoria. Bieżąca cena katalogowa może być dodatkową informacją, ale nie powinna zastępować ceny zapisanej przy sprzedaży.

Cena historyczna należy do pozycji zamówienia. Zmiana cennika produktu nie może zmieniać wartości wcześniejszych transakcji. Na potrzeby przykładu przyjmijmy więc, że pozycja przechowuje jednostkową cenę netto po rabacie, a wszystkie kwoty są wyrażone w PLN. Pomijamy koszty dostawy, podatki i zwroty, aby zakres ćwiczeń był jednoznaczny.

Klucze, relacje i typy danych

Każda tabela powinna mieć klucz główny jednoznacznie identyfikujący wiersz. Klucze obce określają powiązania: zamówienie wskazuje klienta, a pozycja wskazuje zamówienie oraz produkt. Otrzymujemy relacje jeden do wielu: klient może mieć wiele zamówień, zamówienie wiele pozycji, a produkt może występować w wielu pozycjach. Sam identyfikator produktu nie wystarcza zatem do rozpoznania konkretnej pozycji sprzedaży.

W SQL Server identyfikatory można przechowywać jako int, tekst z polskimi znakami jako nvarchar, a datę i czas złożenia zamówienia jako datetime2. Dla cen odpowiedni jest typ decimal o świadomie dobranej precyzji i skali, zamiast przybliżonego typu float. Ilość może być liczbą całkowitą, jeśli sprzedajemy wyłącznie towary na sztuki. Wymagane pola warto zabezpieczyć przed brakiem wartości, a ilości i ceny — przed wartościami sprzecznymi z przyjętymi zasadami, na przykład niedodatnią ilością lub ujemną ceną.

Jak przygotować użyteczny zestaw przykładowy

Dane należy wprowadzać zgodnie z zależnościami: najpierw klientów i produkty, następnie zamówienia, a na końcu ich pozycje. Zestaw powinien obejmować kilka kolejnych miesięcy, klientów z wieloma zamówieniami oraz zamówienia zawierające różne produkty i ilości. Warto dodać również klienta bez zamówień i produkt, który nie został sprzedany — takie przypadki są istotne w raportowaniu.

Status zamówienia powinien mieć jasno określone znaczenie. Jeżeli zestaw obejmuje zamówienia anulowane, trzeba ustalić, że nie zaliczamy ich do sprzedaży. Przed rozpoczęciem ćwiczeń warto też ręcznie sprawdzić kilka transakcji: ich pozycje, ilości, ceny i oczekiwane wartości. Taki niewielki zestaw kontrolny ułatwi ocenę poprawności wyników zapytań.

JOIN w praktyce: INNER, LEFT, RIGHT i FULL na realnych pytaniach biznesowych

Raport sprzedaży zwykle potrzebuje informacji z kilku tabel: klienta z tabeli Klienci, daty zakupu z Zamówienia, liczby kupionych sztuk z Pozycje oraz nazwy towaru z Produkty. JOIN pozwala połączyć te informacje w jednym wyniku. Wybór rodzaju złączenia odpowiada przy tym na ważne pytanie: czy raport ma pokazywać wyłącznie dopasowane rekordy, czy również te, dla których brakuje powiązania?

Rodzaj złączeniaCo zachowuje w wyniku?Przykładowe zastosowanie
INNER JOINTylko rekordy spełniające warunek połączenia.Lista zamówień z danymi przypisanych do nich klientów.
LEFT JOINWszystkie rekordy z lewej tabeli oraz dopasowania z prawej.Katalog produktów uwzględniający towary bez sprzedaży.
RIGHT JOINWszystkie rekordy z prawej tabeli oraz dopasowania z lewej.Ten sam katalog, gdy tabela Produkty stoi po prawej stronie złączenia.
FULL OUTER JOINWszystkie rekordy z obu stron, z połączeniem tych, które do siebie pasują.Uzgadnianie dwóch źródeł i wykrywanie braków po każdej stronie.

INNER JOIN: które zamówienia mają przypisanego klienta?

INNER JOIN sprawdza się wtedy, gdy potrzebujesz danych występujących w obu łączonych tabelach. W zestawieniu zamówień z klientami każdy wiersz wyniku musi spełniać warunek powiązania, na przykład zgodność identyfikatora klienta. Klient bez zamówień nie pojawi się w takim raporcie.

Przyjmując przykładowe nazwy kolumn KlientID, ZamowienieID, NazwaKlienta i DataZamowienia, zapytanie może wyglądać tak:

SELECT
    z.ZamowienieID,
    z.DataZamowienia,
    k.NazwaKlienta
FROM [Zamówienia] AS z
INNER JOIN Klienci AS k
    ON k.KlientID = z.KlientID;

To zestawienie zamówień, a nie unikalna lista klientów: klient z kilkoma zamówieniami może wystąpić w kilku wierszach. Rozbudowanie połączenia o tabele Pozycje i Produkty pozwoli zejść do szczegółów zakupionych towarów. Warto więc najpierw ustalić, co ma reprezentować pojedynczy wiersz raportu.

LEFT JOIN: które produkty nie zostały jeszcze sprzedane?

LEFT JOIN zachowuje wszystkie rekordy tabeli zapisanej po lewej stronie. Jeśli po prawej nie znajdzie dopasowania, jej kolumny otrzymują w wyniku wartość NULL. Dzięki temu brak transakcji nie powoduje zniknięcia produktu z zestawienia.

Aby wskazać produkty bez żadnej pozycji zamówienia, można połączyć katalog z tabelą Pozycje, a następnie pozostawić wyłącznie rekordy bez dopasowania. Poniższy przykład zakłada, że PozycjaID jest niepustym identyfikatorem pozycji:

SELECT
    p.ProduktID,
    p.NazwaProduktu
FROM Produkty AS p
LEFT JOIN Pozycje AS poz
    ON poz.ProduktID = p.ProduktID
WHERE poz.PozycjaID IS NULL;

Wynik oznacza brak pozycji zamówienia w całym analizowanym zbiorze, a nie automatycznie brak sprzedaży w bieżącym miesiącu. Trzeba też ustalić, czy wszystkie zamówienia są traktowane jako sprzedaż — przykładowo zamówienie anulowane może nadal mieć zapisane pozycje. Analogiczne złączenie klientów z zamówieniami pomaga znaleźć klientów, którzy nie dokonali jeszcze zakupu.

RIGHT JOIN: ta sama potrzeba, odwrócona kolejność tabel

RIGHT JOIN działa jak lustrzane odbicie LEFT JOIN: zachowuje tabelę po prawej stronie. Zapis Pozycje AS poz RIGHT JOIN Produkty AS p ON poz.ProduktID = p.ProduktID zachowuje więc cały katalog produktów, tak jak wcześniejsze złączenie lewostronne.

Nie jest to osobny sposób analizy biznesowej. Ten sam zakres danych można uzyskać przez zamianę kolejności tabel i użycie LEFT JOIN. W praktyce warto wybierać zapis, który ułatwia czytelnikowi rozpoznanie, jaki zbiór ma pozostać kompletny.

FULL OUTER JOIN: czego brakuje po obu stronach?

FULL OUTER JOIN przydaje się przy uzgadnianiu danych, gdy żadna ze stron nie może zostać pominięta. Przykładem jest porównanie katalogu produktów z listą kodów produktów pochodzącą z importu sprzedaży. Złączenie po kodzie pokaże zarówno dopasowania, jak i produkty katalogowe nieobecne w imporcie oraz kody z importu, których nie ma w katalogu.

To zastosowanie różni się od zwykłego łączenia poprawnie powiązanych tabel transakcyjnych. Jeśli aktywny klucz obcy wymusza przypisanie pozycji zamówienia do istniejącego produktu, pozycje wskazujące nieistniejący produkt nie powinny występować. FULL OUTER JOIN jest szczególnie użyteczny przy porównywaniu niezależnych źródeł, a nie jako domyślny wybór do raportu sprzedaży.

Typowe pułapki JOIN: duplikacje przez zły klucz oraz filtry w ON vs WHERE

Zapytanie może wykonać się bez błędu, a mimo to zawyżyć sprzedaż lub pominąć klientów, którzy powinni znaleźć się w raporcie. Przy łączeniu tabel trzeba kontrolować dwie rzeczy: ile dopasowań przypada na każdy rekord oraz czy filtr ogranicza dopasowanie, czy usuwa wiersze z wyniku. W Cognity omawiamy te zagadnienia zarówno od strony technicznej, jak i praktycznej – zgodnie z realiami pracy uczestników.

Zwielokrotnienie wierszy: błąd klucza czy prawidłowa relacja?

JOIN zwraca wszystkie pary rekordów spełniające warunek połączenia. Jeśli jedno zamówienie ma trzy pozycje, po połączeniu tych tabel pojawi się w trzech wierszach. To prawidłowy rezultat relacji jeden do wielu, nie błąd SQL Servera. Problem zaczyna się wtedy, gdy raport traktuje każdy taki wiersz jak osobne zamówienie.

Przykładowo: zamówienie o wartości 300 zł zawiera dwie pozycje. Po połączeniu nagłówka z pozycjami wartość całego zamówienia zostanie pokazana dwukrotnie. Jej późniejsze zsumowanie da 600 zł. Poprawny JOIN nie gwarantuje więc poprawnej miary biznesowej — trzeba jeszcze ustalić, czy pojedynczy wiersz wyniku reprezentuje zamówienie, pozycję, czy klienta.

Innym źródłem zwielokrotnienia jest niewłaściwy klucz. Łączenie po nazwie produktu zamiast jego identyfikatorze może dopasować kilka różnych produktów o tej samej nazwie. Podobnie pominięcie części klucza złożonego, na przykład identyfikatora oddziału przy numerze dokumentu unikalnym tylko w obrębie oddziału, tworzy dodatkowe, nieprawidłowe dopasowania.

W przykładach poniżej przyjmujemy kolumny ZamowienieID, KlientID i DataZamowienia. Pozycje należy przypisać do konkretnego zamówienia przez jego identyfikator:

SELECT z.ZamowienieID, p.*
FROM [Zamówienia] AS z
INNER JOIN [Pozycje] AS p
    ON p.ZamowienieID = z.ZamowienieID;

Przed wykorzystaniem wyniku w raporcie sprawdź:

  • czy warunek połączenia zawiera pełny klucz relacji;
  • czy po stronie oczekiwanego pojedynczego dopasowania klucz rzeczywiście jest unikalny;
  • czy liczba wierszy dla wybranego zamówienia odpowiada liczbie jego pozycji.

Nie dodawaj DISTINCT tylko po to, by ukryć powtórzenia. Usuwa on identyczne wiersze w zakresie wybranych kolumn, ale nie naprawia błędnego powiązania. Może też scalić odrębne pozycje, jeśli zapytanie nie pokazuje ich identyfikatorów.

ON a WHERE: filtr, który zmienia sens LEFT JOIN

Przy LEFT JOIN miejsce warunku ma znaczenie biznesowe. Załóżmy, że raport ma pokazać wszystkich klientów i ich zamówienia złożone od 1 stycznia 2025 roku — również klientów bez zamówień z tego okresu. Warunek daty powinien ograniczać dopasowania w ON:

SELECT k.KlientID, z.ZamowienieID
FROM [Klienci] AS k
LEFT JOIN [Zamówienia] AS z
    ON z.KlientID = k.KlientID
    AND z.DataZamowienia >= '20250101';

Każdy klient pozostanie w wyniku. Jeśli nie ma pasującego zamówienia, kolumny pochodzące z tabeli zamówień otrzymają NULL. Przeniesienie warunku do WHERE odpowiada już na inne pytanie:

SELECT k.KlientID, z.ZamowienieID
FROM [Klienci] AS k
LEFT JOIN [Zamówienia] AS z
    ON z.KlientID = k.KlientID
WHERE z.DataZamowienia >= '20250101';

Teraz pozostaną tylko klienci z zamówieniami spełniającymi warunek daty. Porównanie NULL z datą nie daje wartości prawdziwej, więc wiersze bez dopasowania zostaną odrzucone. Dla tego warunku wynik będzie odpowiadał INNER JOIN.

Dopisanie w WHERE alternatywy OR z.ZamowienieID IS NULL nie jest równoważną naprawą: nadal znikną klienci mający wyłącznie starsze zamówienia. Ich rekordy zostały dopasowane, a następnie odfiltrowane — SQL Server nie zastąpi ich nowym wierszem z wartościami NULL.

Praktyczna zasada: warunek w ON określa, które rekordy mogą się połączyć, natomiast WHERE określa, które wiersze pozostaną w wyniku. W przypadku INNER JOIN przeniesienie takiego zwykłego warunku daty między tymi miejscami nie zmieni wyniku; przy złączeniu zewnętrznym może usunąć właśnie te braki, które raport miał ujawnić.

💡 Pro tip: Przy LEFT JOIN przygotuj test z trzema klientami: bez zamówień, wyłącznie ze starszymi zamówieniami i z zamówieniami z badanego okresu — szybko sprawdzisz, czy filtr nie usuwa klientów, których raport ma zachować. Jednemu zamówieniu dodaj dwie pozycje i zweryfikuj sumę sprzedaży, aby wychwycić podwójne naliczanie wartości nagłówka.

Agregacje i GROUP BY: miary sprzedaży, top produkty i klienci, udział w sprzedaży

Lista pozycji zamówień pokazuje, co kupiono. Raport sprzedażowy powinien jednak odpowiadać na inne pytania: które produkty generują największą wartość sprzedaży, ilu klientów złożyło zamówienia i jaka część wyniku przypada na najważniejszych odbiorców. Do takich obliczeń służą funkcje agregujące oraz GROUP BY, które sprowadzają wiele rekordów do wyniku na wybranym poziomie szczegółowości.

Najpierw ustal, co oznacza jeden wiersz raportu

Poziom agregacji powinien wynikać z pytania biznesowego. Grupowanie po identyfikatorze produktu daje jeden wiersz na produkt, a po identyfikatorze klienta — jeden wiersz na klienta. Dodanie kolejnego wymiaru, na przykład miesiąca, zmienia wynik: zamiast całkowitej sprzedaży klienta otrzymujesz jego sprzedaż w poszczególnych miesiącach.

W SQL Server każda zwykła kolumna wskazana w SELECT, która nie jest objęta funkcją agregującą, musi znaleźć się również w GROUP BY. Nie warto jednak dopisywać kolumn tylko po to, aby zapytanie przeszło walidację. Dodanie numeru zamówienia do zestawienia klientów rozbije ich wyniki na pojedyncze zamówienia i zmieni znaczenie raportu.

Podstawowe miary sprzedaży i ich interpretacja

Funkcje agregujące są proste składniowo, ale wymagają precyzyjnej definicji mierzonej wartości:

  • Wartość sprzedaży: SUM(Ilosc * CenaJednostkowa). Cena powinna pochodzić z pozycji transakcji, nie z aktualnego cennika produktu. Trzeba też ustalić, czy uwzględnia rabaty i czy jest ceną netto, czy brutto.
  • Liczba sprzedanych sztuk: SUM(Ilosc). To inna miara niż liczba pozycji lub zamówień.
  • Liczba zamówień: na poziomie pozycji można użyć COUNT(DISTINCT ZamowienieID). Samo COUNT(*) policzy wówczas pozycje, a nie transakcje.
  • Średnia wartość zamówienia: wartość sprzedaży podzielona przez liczbę zamówień. AVG(Ilosc * CenaJednostkowa) oblicza średnią wartość pozycji, więc nie jest zamiennikiem tej miary.

Równie istotne są granice zbioru: okres raportowania, statusy zamówień, sposób rozliczania zwrotów i waluta. Sumowanie kwot w różnych walutach bez wcześniejszego przeliczenia nie daje miarodajnego wyniku. Większość funkcji agregujących pomija wartości NULL; brak ceny może więc zaniżyć sumę, zamiast ujawnić błąd danych.

Top produkty oraz udział w całkowitej sprzedaży

Poniższy przykład zakłada tabelę Pozycje z kolumnami ProduktID, Ilosc i CenaJednostkowa. Pokazuje pięć produktów o największej wartości sprzedaży oraz ich udział w sumie dla wszystkich produktów. Dla uproszczenia obejmuje wszystkie pozycje i zakłada jedną walutę oraz cenę transakcyjną uwzględniającą przyjęte zasady naliczania rabatów.

WITH SprzedazProduktow AS (
    SELECT
        ProduktID,
        SUM(Ilosc) AS LiczbaSztuk,
        SUM(
            CAST(Ilosc AS decimal(19, 4)) * CenaJednostkowa
        ) AS WartoscSprzedazy
    FROM Pozycje
    GROUP BY ProduktID
)
SELECT TOP (5)
    ProduktID,
    LiczbaSztuk,
    WartoscSprzedazy,
    CAST(
        100.0 * WartoscSprzedazy
        / NULLIF(
            (SELECT SUM(WartoscSprzedazy)
             FROM SprzedazProduktow),
            0
        )
        AS decimal(6, 2)
    ) AS UdzialProcentowy
FROM SprzedazProduktow
ORDER BY WartoscSprzedazy DESC, ProduktID;

Wyrażenie CTE porządkuje obliczenia: najpierw powstają sumy dla produktów, a następnie wybierana jest pierwsza piątka. Mianownik udziału obejmuje wszystkie produkty, nie tylko pięć wyświetlonych. Dlatego udziały widoczne w raporcie nie muszą sumować się do 100%. NULLIF zabezpiecza dzielenie przez zero — przy zerowej sumie udział będzie miał wartość NULL.

Dodatkowe sortowanie po ProduktID rozstrzyga remisy w sposób powtarzalny. Taki wariant zwraca najwyżej pięć produktów; jeżeli raport ma uwzględniać wszystkie produkty remisujące na granicy zestawienia, trzeba przyjąć inną regułę wyboru.

Top klienci i filtrowanie wyników agregacji

Zestawienie najlepszych klientów opiera się na tej samej zasadzie, lecz jednostką grupowania jest KlientID. Warto pokazać obok siebie wartość sprzedaży, liczbę zamówień i średnią wartość zamówienia. Dzięki temu można odróżnić klientów kupujących często od tych, którzy składają nieliczne, ale duże zamówienia. Samo określenie „najlepszy klient” jest nieprecyzyjne — największa sprzedaż nie musi oznaczać najwyższej marży.

Do ograniczania danych przed agregacją służy WHERE, na przykład przy wyborze okresu raportowego. Warunki dotyczące gotowych grup zapisuje się w HAVING: HAVING SUM(Ilosc * CenaJednostkowa) > 10000 pozostawi tylko grupy przekraczające wskazany próg. Przy obliczaniu udziałów trzeba dodatkowo zdecydować, czy podstawą procentu jest cały wybrany okres, czy wyłącznie grupy spełniające próg — to dwa różne wskaźniki biznesowe.

6. Funkcje okna: OVER/PARTITION BY, ROW_NUMBER/RANK oraz SUM/AVG po oknie

Raport sprzedaży często wymaga pokazania jednocześnie wartości szczegółowej i jej kontekstu: miejsca klienta w rankingu, udziału w miesięcznym przychodzie czy sprzedaży narastającej. Funkcje okna pozwalają wykonać takie obliczenia bez redukowania liczby wierszy wyniku. Przy każdym kliencie można więc zachować jego sprzedaż za dany miesiąc, a obok wyświetlić miary obliczone na podstawie innych wierszy.

To podstawowa różnica względem GROUP BY, które sprowadza dane do jednego wiersza na grupę. Oba mechanizmy dobrze się uzupełniają: najpierw można przygotować miesięczne wartości sprzedaży klientów, a następnie na tym poziomie szczegółowości obliczyć rankingi i udziały.

OVER i PARTITION BY — jakie dane obejmuje obliczenie?

Klauzula OVER określa zbiór wierszy dostępnych dla funkcji. Jej elementy odpowiadają za trzy różne aspekty obliczenia:

  • PARTITION BY dzieli dane na niezależne grupy obliczeniowe. Podział według miesiąca pozwala tworzyć osobne rankingi miesięczne, a podział według klienta — liczyć jego sprzedaż narastająco.
  • ORDER BY wewnątrz OVER ustala kolejność na potrzeby funkcji, np. od największej sprzedaży albo chronologicznie. Nie gwarantuje kolejności wyświetlania całego wyniku — do tego służy końcowe ORDER BY zapytania.
  • Rama okna, np. ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, określa zakres wierszy uwzględnianych w obliczeniu względem bieżącego wiersza. Ten zapis oznacza zakres od początku partycji do aktualnej pozycji.

Zapis SUM(WartoscSprzedazy) OVER () zwraca przy każdym wierszu sumę dla całego zbioru dostępnego funkcji. Dodanie PARTITION BY Miesiac zawęża tę sumę do danego miesiąca. W obu przypadkach wiersze szczegółowe pozostają w wyniku.

ROW_NUMBER i RANK — różne zasady rozstrzygania remisów

ROW_NUMBER() nadaje kolejne, niepowtarzalne numery w obrębie partycji. Sprawdza się, gdy potrzebna jest określona liczba pozycji, np. dokładnie dziesięciu najlepszych klientów, o ile tylu występuje w danych. Przy jednakowej sprzedaży warto dodać jednoznaczne kryterium pomocnicze, np. identyfikator klienta. Dzięki temu numeracja remisujących pozycji będzie powtarzalna.

RANK() przyznaje tę samą pozycję wierszom o jednakowych wartościach sortowania, a po remisie pozostawia lukę: 1, 1, 3. To właściwy wybór, gdy ranking ma odzwierciedlać równorzędne wyniki biznesowe. Warunek „ranga nie większa niż 10” może wówczas zwrócić więcej niż dziesięć osób lub podmiotów. Jeżeli o remisie ma decydować wyłącznie sprzedaż, nie należy dodawać identyfikatora klienta do sortowania funkcji RANK().

Przykład: ranking, udział, średnia i sprzedaż narastająca

Przyjmijmy, że przygotowany wcześniej zbiór SprzedazMiesieczna zawiera dokładnie jeden wiersz dla pary klient–miesiąc. Kolumna Miesiac przechowuje datę pierwszego dnia miesiąca, a WartoscSprzedazy — liczbową wartość sprzedaży klienta w tym okresie. Na takim zbiorze można zestawić kilka perspektyw analizy:

SELECT
    Miesiac,
    KlientID,
    WartoscSprzedazy,
    ROW_NUMBER() OVER (
        PARTITION BY Miesiac
        ORDER BY WartoscSprzedazy DESC, KlientID
    ) AS NumerPozycji,
    RANK() OVER (
        PARTITION BY Miesiac
        ORDER BY WartoscSprzedazy DESC
    ) AS RangaSprzedazy,
    100.0 * WartoscSprzedazy / NULLIF(
        SUM(WartoscSprzedazy) OVER (PARTITION BY Miesiac), 0
    ) AS UdzialProcentowy,
    AVG(WartoscSprzedazy) OVER (
        PARTITION BY Miesiac
    ) AS SredniaSprzedazKlienta,
    SUM(WartoscSprzedazy) OVER (
        PARTITION BY KlientID
        ORDER BY Miesiac
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS SprzedazNarastajaco
FROM SprzedazMiesieczna
ORDER BY Miesiac, NumerPozycji;

Udział procentowy odnosi sprzedaż klienta do sumy za ten sam miesiąc. NULLIF zabezpiecza przed dzieleniem przez zero — przy zerowej sumie udział będzie miał wartość NULL. Średnia pokazuje natomiast przeciętną sprzedaż na klienta reprezentowanego w danym miesiącu. Klienci bez wiersza w tym zbiorze nie są automatycznie uwzględniani jako klienci ze sprzedażą równą zero. Funkcja AVG pomija również wartości NULL; dla miar pieniężnych warto zadbać o odpowiedni typ decimal.

Sprzedaż narastająca jest liczona osobno dla każdego klienta, od najwcześniejszego dostępnego miesiąca. Jawne wskazanie ramy ROWS pozwala uniknąć polegania na domyślnej ramie RANGE, która przy jednakowych wartościach sortowania obejmuje także pozostałe wiersze z tą samą wartością. W tym przykładzie jednoznaczną kolejność zapewnia założenie jednego wiersza na klienta i miesiąc.

Zakres danych decyduje o znaczeniu wyniku

Funkcje okna działają na danych dostępnych po filtrowaniu przez WHERE oraz ewentualnym grupowaniu i filtrowaniu przez HAVING. Ograniczenie danych do wybranego roku sprawi więc, że suma narastająca zacznie się od pierwszego dostępnego miesiąca tego roku, a nie od początku historii klienta. Podobnie wcześniejsze wybranie tylko kilku klientów zmieni mianownik udziału procentowego.

Jeśli celem jest pokazanie najlepszych klientów wraz z ich udziałem w sprzedaży wszystkich klientów, najpierw należy obliczyć ranking i udziały, a dopiero potem odfiltrować pozycje. W SQL Server robi się to zwykle za pomocą CTE lub podzapytania i zewnętrznego WHERE. Nie można filtrować wyniku funkcji okna bezpośrednio w WHERE tego samego poziomu zapytania.

Funkcje LAG/LEAD i analiza trendu: zmiana m/m, porównania okresów

Miesięczna wartość sprzedaży nabiera znaczenia dopiero na tle innych okresów. Czy wynik poprawił się względem poprzedniego miesiąca? Czy spadek powtarza się co roku, czy jest nietypowy? Funkcje LAG i LEAD w SQL Server pozwalają zestawić wynik bieżącego okresu z wartością z innego wiersza, bez konieczności ponownego łączenia tego samego zbioru danych.

LAG pobiera wartość z wcześniejszego wiersza, a LEAD — z późniejszego, zgodnie z porządkiem zdefiniowanym w oknie. Domyślne przesunięcie wynosi jeden wiersz, ale można wskazać większe. W analizie miesięcznej LAG przydaje się przede wszystkim do porównania z poprzednim miesiącem. LEAD pozwala natomiast zestawić dany miesiąc z kolejnym w analizie historycznej. Nie prognozuje przyszłości: odczytuje wyłącznie dane, które już znajdują się w analizowanym zbiorze.

Zmiana miesiąc do miesiąca: kwota i procent

Punktem wyjścia powinien być zbiór zawierający jeden wynik sprzedaży na miesiąc dla każdej analizowanej jednostki, na przykład produktu lub klienta. Dopiero na takim poziomie szczegółowości porównanie kolejnych wierszy odpowiada porównaniu okresów. Obliczenia należy prowadzić oddzielnie dla poszczególnych jednostek, z chronologicznym uporządkowaniem uwzględniającym zarówno rok, jak i miesiąc.

Zmiana kwotowa to sprzedaż bieżąca pomniejszona o sprzedaż poprzednią. Zmiana procentowa m/m to ta różnica podzielona przez sprzedaż poprzedniego miesiąca i pomnożona przez 100%. Jeśli sprzedaż wzrosła z 80 000 zł do 92 000 zł, przyrost wynosi 12 000 zł, czyli 15%. W raporcie warto pokazać obie miary: duży wzrost procentowy przy niewielkiej bazie nie musi oznaczać istotnego wpływu na całkowity wynik.

Gdy poprzednia wartość wynosi zero, standardowej zmiany procentowej nie da się obliczyć. Taki przypadek wymaga osobnego oznaczenia, a nie przypisania wzrostu o 100%. Również brak wcześniejszego okresu nie oznacza zerowej sprzedaży — bez wskazania wartości domyślnej funkcja zwróci wtedy NULL.

Poprzedni wiersz nie zawsze oznacza poprzedni miesiąc

Najważniejsza pułapka dotyczy luk w danych. Jeżeli zestawienie zawiera styczeń i marzec, ale brakuje w nim lutego, LAG dla marca odczyta wynik stycznia. Funkcja przesuwa się po wierszach, nie po kalendarzu. Aby poprawnie liczyć zmianę m/m, trzeba zapewnić ciągłość miesięcy, na przykład z wykorzystaniem kalendarza raportowego. Zerem można uzupełnić potwierdzony brak sprzedaży, ale nie brak danych wynikający z niepełnego zasilenia raportu.

Znaczenie ma także zakres danych dostępnych podczas obliczeń. Jeżeli raport ma pokazywać zmianę za styczeń, obliczenia muszą obejmować również grudzień poprzedniego roku. Ograniczenie zbioru zbyt wcześnie pozbawi pierwszy prezentowany miesiąc podstawy porównania.

Porównania okresów a interpretacja trendu

Porównanie m/m pokazuje krótkoterminowy ruch sprzedaży, ale w działalności sezonowej warto uzupełnić je o zmianę rok do roku. Przesunięcie LAG o 12 wierszy odpowiada temu samemu miesiącowi poprzedniego roku tylko wtedy, gdy miesięczny szereg jest kompletny i zawiera dokładnie jeden wiersz na miesiąc w danej grupie.

Pojedynczy wzrost lub spadek nie przesądza jeszcze o trendzie. Porównuj kilka kolejnych okresów i sprawdzaj, czy mają zbliżone warunki: liczbę dni sprzedażowych, zakres uwzględnionych transakcji oraz stopień kompletności danych. Szczególnie ostrożnie oceniaj miesiąc trwający — zestawienie jego częściowego wyniku z pełnym poprzednim miesiącem może sugerować spadek, którego w rzeczywistości nie ma.

Dobre praktyki wydajności: indeksy, selektywność, ograniczanie kolumn i stabilność zapytań

Raport sprzedaży, który działa płynnie na kilkunastu tysiącach zamówień, po wzroście bazy może wymagać znacznie więcej czasu i zasobów. Nie zawsze oznacza to konieczność rozbudowy serwera. Często największą poprawę daje ograniczenie odczytywanych danych, dopasowanie indeksów do sposobu filtrowania oraz sprawdzenie, czy SQL Server trafnie szacuje liczbę przetwarzanych wierszy. Optymalizację warto zaczynać od pomiaru, a nie od dodawania indeksów na próbę.

Indeksy dopasowane do zapytań raportowych

Indeks pomaga szybciej dotrzeć do potrzebnych rekordów, ale jego przydatność zależy od konstrukcji zapytania. Indeks klastrowany wyznacza sposób organizacji danych tabeli, natomiast indeks nieklastrowany tworzy dodatkową strukturę dostępu. W analizie sprzedaży warto przyjrzeć się przede wszystkim kolumnom używanym do filtrowania i łączenia danych, takim jak data zamówienia czy identyfikator klienta. Samo istnienie klucza obcego nie oznacza, że SQL Server automatycznie utworzył dla niego indeks.

W indeksie wielokolumnowym istotna jest kolejność kolumn. Raport analizujący sprzedaż jednego klienta w określonym przedziale dat może potrzebować innego układu niż zestawienie wszystkich zamówień z danego miesiąca. Kolumny dołączone do indeksu mogą ograniczyć dodatkowe odczyty tabeli, jeśli zawierają informacje potrzebne w wyniku. Nie należy jednak budować indeksów obejmujących wszystko: zwiększają zajętość dysku i koszt zapisu, aktualizacji oraz usuwania danych.

Selektywność i warunki wyszukiwania

Selektywność opisuje, jak mocno warunek zawęża zbiór danych. Filtr wskazujący pojedynczego klienta spośród wielu tysięcy zwykle ogranicza wynik bardziej niż filtr statusu występującego w większości zamówień. Gdy raport obejmuje dużą część tabeli, jej skanowanie może być tańsze niż korzystanie z indeksu nieklastrowanego. Skan w planie wykonania nie jest więc automatycznie oznaką problemu.

Warunki należy formułować tak, aby umożliwiały wykorzystanie indeksów. Przy filtrowaniu dat zazwyczaj lepiej określić granice okresu niż wyliczać miesiąc lub rok dla każdego rekordu. W przypadku danych zawierających godzinę praktyczny jest przedział od początku wybranego okresu włącznie do początku następnego okresu wyłącznie. Warto też dopasowywać typy parametrów do typów kolumn, ponieważ niejawne konwersje mogą utrudniać efektywny dostęp do danych.

Tylko potrzebne kolumny i wiersze

Raport powinien pobierać wyłącznie dane niezbędne do odpowiedzi na pytanie biznesowe. Zwracanie wszystkich kolumn zwiększa ilość przesyłanych informacji i może podnosić zapotrzebowanie na pamięć podczas sortowania lub agregacji. Szczególnie kosztowne bywają rozbudowane opisy i inne szerokie pola, które nie uczestniczą w analizie. Podobnie warto ograniczać zakres dat do rzeczywistych potrzeb odbiorcy, zamiast za każdym razem przetwarzać pełną historię sprzedaży.

Stabilność potwierdzona pomiarami

Podstawą oceny jest rzeczywisty plan wykonania, liczba odczytów logicznych oraz czas i zużycie procesora. Duże rozbieżności między szacowaną a rzeczywistą liczbą wierszy są sygnałem, że warto sprawdzić statystyki i rozkład danych. Statystyki pomagają optymalizatorowi wybierać plan, dlatego ich aktualność ma znaczenie zwłaszcza po dużych zmianach w bazie. W Cognity łączymy teorię z praktyką – dlatego analizę planów wykonania i dobór indeksów rozwijamy także w formie ćwiczeń na szkoleniach.

Zapytania parametryzowane ułatwiają ponowne wykorzystanie planów, lecz plan dobry dla małego klienta nie musi sprawdzać się przy kliencie generującym znaczną część sprzedaży. Dlatego testy powinny obejmować różne wartości parametrów i reprezentatywne zakresy dat. Query Store, jeśli jest dostępny i włączony, pozwala porównywać historię planów oraz wykrywać pogorszenie wydajności. Wymuszanie planu czy rekompilację należy traktować jako świadome rozwiązania konkretnego problemu, nie jako domyślne ustawienie każdego raportu.

💡 Pro tip: Przed zmianą indeksu lub zapytania włącz SET STATISTICS IO, TIME ON i zapisz odczyty logiczne oraz czas CPU, a po zmianie powtórz pomiar dla tych samych parametrów. Porównaj też rzeczywiste plany wykonania dla małego i dużego klienta — poprawa w jednym przypadku nie gwarantuje stabilnej wydajności całego raportu.

Najczęściej zadawane pytania i odpowiedzi odnośnie SQL Server w praktyce – JOIN, GROUP BY i funkcje okna w analizie danych biznesowych

Kiedy używać GROUP BY, a kiedy funkcji okna w SQL Server?

GROUP BY stosuj do podsumowań na poziomie grup, a funkcje okna wtedy, gdy chcesz zachować wiersze i dodać do nich obliczenia porównawcze. Grupowanie pozwala uzyskać jeden wynik sprzedaży na klienta. Funkcja okna może natomiast pokazać każde zamówienie wraz z sumą zakupów jego klienta. Oba mechanizmy można łączyć: najpierw obliczyć sprzedaż miesięczną, a następnie przygotować ranking na podstawie tych podsumowań.

Dlaczego JOIN zawyża sumę sprzedaży i jak temu zapobiec?

Suma sprzedaży może zostać zawyżona, gdy JOIN zwielokrotnia wiersze zawierające tę samą wartość zamówienia. Po połączeniu nagłówka z pozycjami kwota całej transakcji powtarza się przy każdej pozycji. Aby uniknąć błędnego wyniku:

  • sprawdź kompletność klucza połączenia i liczbę dopasowań;
  • sumuj wartości pozycji albo przygotuj agregację na poziomie zamówienia przed dalszym łączeniem;
  • nie używaj DISTINCT do maskowania nieprawidłowych powiązań.
Jak zachować klientów bez zamówień przy filtrowaniu dat w LEFT JOIN?

Warunek daty zamówienia umieść w ON, aby LEFT JOIN zachował także klientów bez zakupów w analizowanym okresie. Filtr ograniczy wtedy dopasowania z tabeli zamówień, zamiast usuwać klientów z wyniku. Umieszczenie porównania daty w WHERE odrzuci wiersze bez dopasowania. Dopisanie alternatywy sprawdzającej NULL nie rozwiązuje problemu klientów, którzy mają wyłącznie zamówienia spoza wybranego okresu.

Jak poprawnie obliczyć średnią wartość zamówienia w SQL Server?

Średnią wartość zamówienia obliczysz, dzieląc łączną wartość sprzedaży przez liczbę unikalnych zamówień. Jeśli analizujesz pozycje transakcji, do liczenia zamówień użyj COUNT(DISTINCT ZamowienieID), a nie COUNT(*). AVG zastosowane do wartości pozycji zwróci inną miarę: średnią wartość pozycji, nie zamówienia. Licznik i mianownik muszą obejmować ten sam okres oraz statusy transakcji. Dzielenie przez zero można zabezpieczyć funkcją NULLIF.

Czym różnią się ROW_NUMBER i RANK przy tworzeniu rankingu klientów?

ROW_NUMBER nadaje każdemu wierszowi osobny numer, natomiast RANK przyznaje wspólne miejsce klientom z jednakowym wynikiem. Po remisie RANK pozostawia lukę w numeracji, dlatego ograniczenie do określonej rangi może zwrócić więcej klientów niż oczekiwana liczba pozycji. Przy ROW_NUMBER dodaj identyfikator klienta jako pomocnicze kryterium sortowania. Nie dodawaj go do RANK, jeżeli o remisie ma decydować wyłącznie sprzedaż.

Jak pokazać najlepszych klientów z udziałem w sprzedaży wszystkich klientów?

Najpierw oblicz ranking i udziały dla wszystkich klientów objętych analizą, a dopiero potem odfiltruj najlepsze pozycje. W SQL Server możesz umieścić obliczenia funkcji okna w CTE lub podzapytaniu, a ograniczenie rankingu zastosować w zewnętrznym WHERE. Wcześniejsze zawężenie zbioru do wybranych klientów zmieni podstawę procentu. Udziały będą wtedy odnosiły się do sprzedaży tej grupy, zamiast do całego analizowanego zbioru.

Dlaczego LAG nie zawsze zwraca sprzedaż z poprzedniego miesiąca?

LAG odczytuje wcześniejszy wiersz według kolejności określonej w oknie, a nie automatycznie poprzedni miesiąc kalendarzowy. Jeśli w zestawieniu brakuje miesiąca, porównanie obejmie wcześniejszy dostępny okres. Do analizy m/m przygotuj ciągły szereg z jednym wierszem na miesiąc dla każdej analizowanej jednostki. Potwierdzony brak sprzedaży można uzupełnić zerem, ale brakujących danych nie należy traktować tak samo. Zachowaj też okres poprzedzający początek raportu.

Od czego zacząć optymalizację zapytań raportowych w SQL Server?

Optymalizację zapytań raportowych zacznij od pomiaru odczytów logicznych, czasu CPU i analizy rzeczywistego planu wykonania. Dopiero na tej podstawie oceniaj zmiany w zapytaniu lub indeksach. Sprawdź przede wszystkim:

  • czy pobierasz tylko potrzebne kolumny i okresy;
  • czy filtry dat oraz typy parametrów umożliwiają wykorzystanie indeksów;
  • czy szacowana liczba wierszy odpowiada rzeczywistej;
  • czy poprawa utrzymuje się dla różnych klientów i zakresów dat.
icon

Formularz kontaktowyContact form

Imię *Name
NazwiskoSurname
Adres e-mail *E-mail address
Telefon *Phone number
UwagiComments