SQL Server, de la zero Lecția 8 / 40

ORDER BY, TOP și OFFSET/FETCH

Sortare, paginare, TIES, sortări stabile și singurul lucru pe care toată lumea îl greșește când încearcă prima dată să obțină primele N rânduri pe grup.

ORDER BY pare nevinovat. Alegi o coloană, alegi o direcție, gata. Apoi scrii un endpoint de paginare, îl pui în producție și o săptămână mai târziu echipa de customer success te întreabă de ce aceeași comandă continuă să apară pe pagina 1 și pe pagina 2 a dashboard-ului de admin. Nimic nu e stricat. Doar că ordinea de sortare nu face ce crezi când există egalități.

Această lecție este despre ORDER BY, TOP și OFFSET ... FETCH — cum interacționează, când mușcă și singurul tipar pe care fiecare dezvoltator SQL îl greșește prima dată când are nevoie de „primele N rânduri pe grup”.

ORDER BY: bazele

ORDER BY sortează setul de rezultate. Ascendent implicit, descendent cu DESC:

SELECT OrderId, Total, OrderDate
FROM Sales.Orders
ORDER BY OrderDate DESC;     -- newest first

Mai multe coloane, aplicate de la stânga la dreapta:

SELECT OrderId, CountryCode, Total, OrderDate
FROM Sales.Orders
ORDER BY CountryCode ASC,    -- group by country
         Total      DESC;    -- inside each country, biggest first

Poți sorta după orice coloană din tabelele sursă — chiar dacă nu e în lista SELECT — pentru că ORDER BY rulează după SELECT în ordinea logică (lecția 6) și are acces la tot ce a produs FROM.

ORDER BY în subinterogări: în mare parte inutil

O greșeală foarte comună în SQL Server: scrierea unui ORDER BY într-o subinterogare, vizualizare sau CTE și așteptarea ca interogarea exterioară să păstreze ordinea.

-- This does NOT guarantee that the outer SELECT returns rows in date order.
SELECT *
FROM (
    SELECT OrderId, OrderDate
    FROM Sales.Orders
    ORDER BY OrderDate DESC     -- ← useless here
) AS o;

Standardul SQL spune că o expresie de tabel nu are o ordine inerentă. Sortarea din interiorul subinterogării are voie să fie ignorată. SQL Server îți va permite în general să faci asta doar combinat cu TOP, iar ordinea interogării exterioare rămâne nedefinită. Pune ORDER BY doar pe interogarea cea mai exterioară pe care o vei consuma.

Dacă scrii o vizualizare sau un CTE, nu te chinui să sortezi înăuntru. Sortează la consumatorul final.

Problema „egalităților” despre care nimeni nu te avertizează

Iată o interogare care pare evident corectă:

-- 10 most recent orders
SELECT TOP (10) OrderId, OrderDate
FROM Sales.Orders
ORDER BY OrderDate DESC;

Să zicem că datele tale au 20 de comenzi, dintre care cinci au fost plasate exact în aceeași secundă (import CSV la miezul nopții, de exemplu). Acele cinci rânduri au OrderDate identic. Sunt la egalitate.

Când există o egalitate, SQL Server alege o ordine nespecificată — în esență ordinea în care se întâmplă să le producă. Nimic nu garantează că rerularea interogării îți dă aceeași ordine. Nimic nu garantează că se potrivește cu ordinea pe care colegul tău o vede pe mașina lui. Nimic nu garantează că se potrivește cu ce a ieșit din job-ul nocturn de ieri.

Pentru paginare, asta e catastrofal. „Pagina 1 arată comenzile A B C D E. Click next. Pagina 2 arată comenzile D E F G H.” D și E au apărut de două ori pentru că sortarea nu era deterministă între apeluri.

Soluția: adaugă întotdeauna un departajator — ideal cheia primară.

SELECT TOP (10) OrderId, OrderDate
FROM Sales.Orders
ORDER BY OrderDate DESC,
         OrderId   DESC;    -- ← ties broken deterministically

OrderId este cheia primară clustered, deci fiecare rând are o valoare unică. Sortarea e acum total ordonată: nu există două rânduri care se compară egal, deci rezultatul e reproductibil între rulări.

Fă din „include cheia primară în ORDER BY” un reflex. Ziua în care livrezi un endpoint de paginare fără asta e ziua în care suportul primește un raport de bug despre rânduri duplicate.

TOP cu o valoare sau o variabilă

TOP limitează rândurile returnate:

SELECT TOP (10) ...          -- always wrap in parens for clarity
SELECT TOP 10 ...            -- old syntax, also works
SELECT TOP (@n) ...           -- parameterized; requires parens
SELECT TOP (10) WITH TIES ...  -- include ties on the last row

WITH TIES e o bijuterie ascunsă. Gândește-te la asta: „arată-mi primele 3 cele mai mari comenzi din Italia”, dar dacă a 3-a și a 4-a comandă au același Total, include-le pe ambele.

SELECT TOP (3) WITH TIES
       OrderId, Total, CountryCode
FROM Sales.Orders
WHERE CountryCode = 'IT'
ORDER BY Total DESC;

Dacă trei rânduri au cele mai mari 3 totaluri și un al patrulea rând se egalează cu al 3-lea, primești înapoi 4 rânduri. Foarte util pentru interogări de tip leaderboard unde nu vrei să tai arbitrar elementele cu rang egal.

TOP fără ORDER BY e o aruncare de monedă

SELECT TOP (10) OrderId FROM Sales.Orders;

Returnează niște 10 rânduri. Ar putea fi primele 10 după ordinea de inserare. Ar putea fi zece aleatoare din pagini diferite. Negarantat. Nu face asta decât dacă chiar faci eșantionare pentru explorare.

OFFSET ... FETCH: paginarea modernă

Sintaxa standard SQL pentru paginare, în SQL Server din 2012:

-- Page 3, 10 rows per page
SELECT OrderId, OrderDate
FROM Sales.Orders
ORDER BY OrderDate DESC, OrderId DESC
OFFSET 20 ROWS
FETCH NEXT 10 ROWS ONLY;

OFFSET N ROWS — sare peste primele N rânduri. FETCH NEXT M ROWS ONLY — returnează următoarele M. Necesită ORDER BY. Direct, și e alegerea corectă pentru UI-uri de admin, vizualizări de tip listă și orice dashboard paginat.

Tipar de apel dintr-o aplicație:

-- Parameterized, page-size is fixed at 25
DECLARE @page INT = 3;
DECLARE @pageSize INT = 25;

SELECT OrderId, OrderDate
FROM Sales.Orders
ORDER BY OrderDate DESC, OrderId DESC
OFFSET (@page - 1) * @pageSize ROWS
FETCH NEXT @pageSize ROWS ONLY;

Notă de performanță: OFFSET nu e gratis. Motorul tot trebuie să parcurgă primele N rânduri pentru a le arunca, apoi să returneze următoarele M. Pentru pagina 1000 dintr-un tabel de un milion de rânduri, motorul citește pagini de index de 10.000 de rânduri, ceea ce e lent.

Pentru paginare foarte profundă pe tabele intens accesate, paginarea pe bază de cheie (keyset) („arată-mi următoarele 25 de comenzi după cea cu OrderId 12345”) e mai rapidă decât OFFSET:

-- Keyset pagination: remember the last row from the previous page
DECLARE @lastOrderDate DATETIME2(0) = '2026-03-15 10:22:00';
DECLARE @lastOrderId  BIGINT = 4821;

SELECT TOP (25) OrderId, OrderDate
FROM Sales.Orders
WHERE (OrderDate < @lastOrderDate)
   OR (OrderDate = @lastOrderDate AND OrderId < @lastOrderId)
ORDER BY OrderDate DESC, OrderId DESC;

Asta folosește indexul direct — fără supraîncărcarea „sari peste N și începe să numeri”. Pasează OrderDate și OrderId ale ultimului rând la apelul următor. Funcționează minunat pentru UI-uri cu scroll infinit și API-uri bine paginate. Pentru dashboard-ul de admin unde cineva apasă „pagina 7”, OFFSET e încă în regulă.

Greșeala „primele N pe grup”

Cerere reală foarte comună la Runehold: „dă-mi cele 3 cele mai mari comenzi pe țară.”

Interogarea intuitivă, dar greșită:

-- THIS DOES NOT WORK LIKE YOU THINK
SELECT TOP (3) OrderId, CountryCode, Total
FROM Sales.Orders
ORDER BY CountryCode, Total DESC;

Asta returnează 3 rânduri în total, nu 3 pe țară. TOP limitează întregul set de rezultate, nu grupurile.

Răspunsul corect folosește o funcție de fereastrăROW_NUMBER() partiționată după grup. Detalii complete în lecția 12, dar iată tiparul:

SELECT OrderId, CountryCode, Total
FROM (
    SELECT OrderId,
           CountryCode,
           Total,
           ROW_NUMBER() OVER (
               PARTITION BY CountryCode
               ORDER BY Total DESC, OrderId DESC
           ) AS rn
    FROM Sales.Orders
) AS ranked
WHERE rn <= 3
ORDER BY CountryCode, Total DESC;

ROW_NUMBER() OVER (PARTITION BY CountryCode ORDER BY Total DESC) atribuie fiecărui rând un rang în cadrul țării sale. Interogarea exterioară păstrează rangurile 1, 2, 3. Exact 3 rânduri pe țară.

E unul dintre cele mai utile tipare în SQL practic. Memorează forma. Vom despacheta ferestrele cum trebuie în lecția 12.

Colație și ordinea de sortare pentru text

Ordinea de sortare a textului depinde de colația coloanei sau a bazei de date (lecția 4). O colație case-insensitive accent-sensitive (implicită în SQL Server în Europa de Vest) tratează 'anne' și 'ANNE' ca egale pentru sortare, dar 'café' și 'cafe' ca diferite.

Suportul clienți Runehold rulează rapoarte ca „top clienți sortați alfabetic după nume”. Într-o bază de date multi-locale, această ordine de sortare e o decizie de business:

  • Case-insensitive: 'Anne' și 'anne' se sortează împreună.
  • Accent-sensitive: 'Bérénice' se sortează după 'Bernice'.
  • Specific localei: 'ß' german se sortează cu 'ss' într-o colație, după 's' în alta.

Poți forța o colație specifică pe o singură interogare:

SELECT Name
FROM Sales.Customer
ORDER BY Name COLLATE Latin1_General_100_CI_AI;  -- accent-insensitive too

Dacă ordinea de sortare internațională contează pentru echipa ta (de obicei contează într-o companie din UE ca Runehold), conveniți asupra unei colații, documentați-o, rămâneți la ea.

Rulează asta pe propria mașină

USE Runehold;
GO

-- Add a few more orders so sorting has interesting ties
INSERT INTO Sales.Orders (CustomerId, OrderDate, Total, CountryCode, VatRate)
VALUES (1, '2026-04-01 10:00:00',  19.00, 'NL', 0.2100),
       (2, '2026-04-01 10:00:00',  19.00, 'IT', 0.2200),   -- same date, same total
       (3, '2026-04-01 10:00:00', 129.00, 'DE', 0.1900),
       (4, '2026-04-05 14:30:00',  75.00, 'RO', 0.1900);

-- Query 1: non-deterministic TOP
-- Run it 5 times; the 10 rows may come back in different orders.
SELECT TOP (5) OrderId, OrderDate, Total
FROM Sales.Orders
ORDER BY OrderDate DESC;

-- Query 2: same, with a tiebreaker — always reproducible
SELECT TOP (5) OrderId, OrderDate, Total
FROM Sales.Orders
ORDER BY OrderDate DESC, OrderId DESC;

-- Query 3: OFFSET-based paging
DECLARE @page INT = 1;
DECLARE @pageSize INT = 3;

SELECT OrderId, OrderDate, Total
FROM Sales.Orders
ORDER BY OrderDate DESC, OrderId DESC
OFFSET (@page - 1) * @pageSize ROWS
FETCH NEXT @pageSize ROWS ONLY;

-- Query 4: TOP WITH TIES
SELECT TOP (2) WITH TIES
       OrderId, CountryCode, Total
FROM Sales.Orders
ORDER BY Total DESC;
-- If 2nd and 3rd both have the same Total, you get 3 rows.

-- Query 5: top-2 orders per country (preview of lesson 12)
SELECT OrderId, CountryCode, Total
FROM (
    SELECT OrderId, CountryCode, Total,
           ROW_NUMBER() OVER (
               PARTITION BY CountryCode
               ORDER BY Total DESC, OrderId DESC
           ) AS rn
    FROM Sales.Orders
) AS ranked
WHERE rn <= 2
ORDER BY CountryCode, Total DESC;

Rulează fiecare interogare. Anticipează numărul de rânduri înainte să te uiți. Observă cum versiunea cu departajator din interogarea 2 e reproductibilă, în timp ce interogarea 1 ar putea să nu fie.

Modulul 1 al cursului e gata. Acum poți crea tabele, alege tipuri de date, scrie SELECT-uri, filtra cu WHERE, gestiona NULL-uri și sorta/pagina corect. E mai mult decât suficient ca să fii productiv în majoritatea joburilor de data engineering.

Modulul 2 începe la lecția următoare: JOIN-uri. Probabil cel mai important subiect SQL. Vom acoperi INNER, LEFT, RIGHT, FULL, CROSS și explicația cu diagrame Venn care e tehnic greșită, dar oricum utilă.

Caută