Atestia
Înapoi la teorie

Interogări SQL pe două tabele — JOIN, subinterogări şi agregare

INNER JOIN, LEFT JOIN, subinterogări cu MAX/MIN/AVG, GROUP BY şi DISTINCT — tot ce cere Subiectul I când baza de date are două tabele legate. 40 de probleme rezolvate.

Există două feluri de subiecte de baze de date la atestat. În primul, baza de date se construieşte de la zero: creezi tabelul, introduci datele, afişezi ceva, modifici ceva — patru paşi pe un singur tabel. Acela e explicat la Baze de date şi SQL la atestatul de informatică.

În al doilea, baza de date există deja, are două tabele legate între ele, şi tot ce ţi se cere sunt două interogări. Enunţul e scurt — „afişaţi numele angajaţilor din departamentul Comercial" — şi exact de aceea e mai greu: nu-ţi mai spune nimeni ce structură să scrii.

Pagina asta acoperă al doilea fel, pe 40 de probleme rezolvate din sesiunea Galaţi 2026, grupate pe tehnica pe care o cer.

Tiparul care se repetă la toate cele 40

Cerinţa Ce atinge Ce cere de fapt
a) un singur tabel SELECT cu ORDER BY, WHERE sau o funcţie de agregare
b) ambele tabele joncţiune, de obicei plus o filtrare sau o subinterogare

Nu e o coincidenţă: cerinţa a) verifică dacă ştii să citeşti dintr-un tabel, cerinţa b) dacă ai înţeles de ce sunt două. Odată ce vezi tiparul, fiecare problemă se descompune în aceleaşi două întrebări: ce coloane se cer? şi din câte tabele vin?

Cele patru baze de date din sesiune, ca să ai contextul exemplelor de mai jos:

Baza de date Tabelele Legătura
FIRMA Angajati, Departamente idd
FOOD_DELIVERY Restaurante, Comenzi idr
DIVERTISMENT Filme, Cinematografe idc
GAMING Jucatori, Jocuri idj

De ce sunt două tabele şi nu unul

Un angajat lucrează într-un departament. Dacă denumirea departamentului ar fi scrisă în tabelul Angajati, „Comercial" s-ar repeta la fiecare dintre cei 30 de oameni care lucrează acolo. La o redenumire ar trebui modificate 30 de rânduri, iar o singură greşeală de tastare ar crea un departament fantomă.

Soluţia e să scrii denumirea o singură dată, într-un tabel separat, iar în Angajati să păstrezi doar un cod care trimite la ea:

Angajati                          Departamente
nume        idd                   idd   denumire      data_infiintare
Popescu     D01  ───────────────► D01   Comercial     2015-03-01
Ionescu     D01  ───────────────┘
Georgescu   D02  ───────────────► D02   Financiar     2012-09-15

Coloana idd din Angajati se numeşte cheie externă: nu conţine informaţie în sine, doar adresa rândului din celălalt tabel. Asta rezolvă problema repetării, dar creează alta: numele angajatului şi denumirea departamentului lui nu mai sunt în acelaşi loc. De aici încolo, orice întrebare care le amestecă are nevoie de o joncţiune.

Tehnica 1 — joncţiunea (INNER JOIN)

Joncţiunea lipeşte două tabele pe coloana care le e comună:

SELECT a.nume, a.prenume, d.denumire AS departament
FROM Angajati a
INNER JOIN Departamente d ON a.idd = d.idd;

Forma echivalentă, mai veche, acceptată la fel în Access şi Oracle:

SELECT a.nume, d.denumire
FROM Angajati a, Departamente d
WHERE a.idd = d.idd;

Un rând din tabelul „părinte" apare o dată pentru fiecare rând care trimite la el: un restaurant cu trei comenzi apare de trei ori în rezultat, cu altă comandă pe fiecare rând. Corect — sunt comenzi diferite. Devine problemă doar când se cere lista restaurantelor, nu a comenzilor; atunci vezi tehnica 6.

Probleme: angajaţii şi departamentul fiecăruia · filme şi cinematografe · jucători şi jocurile lor de pe PC · restaurante şi valorile comenzilor · angajaţii din primul departament înfiinţat

Tehnica 2 — LEFT JOIN, când nu vrei să pierzi rânduri

INNER JOIN păstrează doar rândurile care au corespondent în ambele tabele. Un departament fără angajaţi, un jucător fără jocuri, un restaurant fără comenzi — toate dispar din rezultat. Nu apare nicio eroare; răspunsul arată corect, doar că îi lipseşte un rând.

LEFT JOIN păstrează tot din tabelul scris primul şi completează cu NULL unde nu găseşte potrivire:

SELECT d.denumire, COUNT(a.ida) AS nr_angajati
FROM Departamente d
LEFT JOIN Angajati a ON d.idd = a.idd
GROUP BY d.denumire;

Şi aici vine detaliul care decide dacă răspunsul e corect — COUNT(a.ida), nu COUNT(*):

Scris Pentru un departament fără angajaţi
COUNT(*) 1 — numără rândul plin de NULL produs de joncţiune
COUNT(a.ida) 0COUNT pe o coloană ignoră valorile NULL

Un „1" în loc de „0". Trei variante de scriere (INNER JOIN, LEFT JOIN + COUNT(*), LEFT JOIN + COUNT(coloana)), trei rezultate diferite, niciuna nu dă eroare.

O a doua capcană a lui LEFT JOIN: o condiţie pusă în WHERE pe tabelul din dreapta anulează tot efectul, pentru că elimină rândurile cu NULL. Condiţiile pe tabelul din dreapta se mută în ON; în WHERE rămân doar cele pe tabelul păstrat.

Probleme: număr de angajaţi pe fiecare departament · număr de jocuri pe fiecare jucător · filme pe fiecare cinematograf · comenzi pe fiecare tip de bucătărie · jucătorii din România şi jocurile lor

Tehnica 3 — funcţiile de agregare

Cinci funcţii, toate cu acelaşi comportament: strâng mai multe rânduri într-unul singur.

Funcţie Ce dă Cuvântul din enunţ
COUNT(*) câte rânduri „numărul", „câţi/câte"
SUM(x) suma valorilor „suma totală", „fondul de salarii", „încasările"
AVG(x) media „media", „nivelul mediu", „preţul mediu"
MIN(x) cea mai mică valoare „minim", „cel mai ieftin", „primul" (la date)
MAX(x) cea mai mare valoare „maxim", „cel mai lung", „cel mai recent" (la date)

Trei lucruri de ştiut despre toate cinci:

  1. Ignoră valorile NULL. AVG împarte la numărul de rânduri care au valoare, deci nu e SUM(x) / COUNT(*). SUM nu tratează NULL ca 0 — pur şi simplu nu-l adună.
  2. COUNT(*) şi COUNT(coloana) sunt diferite. Al doilea nu numără rândurile în care coloana e necompletată. Pe un tabel plin arată identic; subiectele conţin aproape întotdeauna un câmp gol.
  3. Rezultatul e un singur rând. Nu poţi pune o coloană obişnuită lângă o agregare fără GROUP BY — vezi tehnica 4.

La AVG şi la orice împărţire, ROUND(..., 2) e reflexul corect: media a trei salarii dă 3866.6666..., iar o coloană de bani cu 13 zecimale arată ca o greşeală.

Probleme: numărul de angajaţi şi cei din Comercial · fondul de salarii · salariul mediu · suma comenzilor livrate · preţul minim al biletului · preţul mediu la Dramă · numărul de filme SF · numărul total de filme

Tehnica 4 — GROUP BY, când enunţul spune fiecare

Un singur cuvânt schimbă interogarea: „fiecare".

"numarul de angajati din firma"              ->  COUNT(*)                un rand
"numarul de angajati din fiecare departament"->  COUNT(*) + GROUP BY     un rand per departament

GROUP BY aşază rândurile în grămăjoare după valoarea unei coloane, iar funcţia de agregare se calculează separat în fiecare grămăjoară.

Regula care nu se încalcă niciodată: tot ce apare în SELECT şi nu e într-o funcţie de agregare trebuie să apară în GROUP BY. Altfel, în Oracle şi SQL Server primeşti eroare (not a single-group group function), iar în MySQL şi SQLite — mai rău — interogarea merge şi afişează o valoare aleasă arbitrar. Rezultat credibil, greşit.

Şi diferenţa dintre WHERE şi HAVING, care vine din ordinea de execuţie:

FROM + JOIN   ->  lipeste tabelele
WHERE         ->  arunca RANDURI care nu trec conditia
GROUP BY      ->  aseaza ce a ramas in grupuri
COUNT/SUM/... ->  calculeaza in fiecare grup
HAVING        ->  arunca GRUPURI care nu trec conditia
ORDER BY      ->  sorteaza rezultatul final

Condiţie pe statusul unei comenzi (o proprietate a rândului) → WHERE. Condiţie pe suma încasată de un restaurant (o proprietate a grupului) → HAVING SUM(c.valoare) > 200.

Probleme: angajaţi pe fiecare departament · suma comenzilor livrate pe restaurant · comenzi anulate pe restaurant · jocuri pe fiecare platformă · filme pe fiecare cinematograf

Tehnica 5 — subinterogarea: cine are maximul

Cea mai cerută construcţie din tot Subiectul I, şi cea care merită învăţată prima. Apare de fiecare dată când enunţul spune „cel cu valoarea maximă / minimă" sau „peste medie".

Problema: MAX(salariul) îţi spune cât e salariul maxim, dar strânge toate rândurile într-unul — deci pierde numele. Ca să afişezi rândul, maximul trebuie calculat separat, într-o interogare interioară:

SELECT nume, prenume, salariul
FROM Angajati
WHERE salariul = (SELECT MAX(salariul) FROM Angajati);

Se execută în doi paşi: subinterogarea dă 5200, apoi WHERE compară fiecare rând cu valoarea aceea.

Ce nu funcţionează, şi de ce:

Scris Ce se întâmplă
SELECT nume, MAX(salariul) FROM Angajati eroare în Oracle; în MySQL/SQLite, maximul lângă un nume arbitrar
WHERE salariul = MAX(salariul) eroare: agregările nu se pot folosi direct în WHERE
ORDER BY salariul DESC + primul rând nu e o interogare, e o citire cu ochii; la egalitate pe maxim pierzi jumătate din răspuns
WHERE salariul = 5200 ai citit răspunsul din date; se strică la primul angajat nou

Egalitatea pe maxim nu e o ipoteză teoretică: la ratinguri (4.8), la durate în minute sau la date calendaristice, două rânduri egale sunt regula. De altfel, enunţurile o recunosc explicit — „comanda/comenzile", „jucătorului (sau jucătorilor)".

Aceeaşi construcţie acoperă, cu mici variaţii:

Probleme: angajaţii cu salariul maxim · comanda cu valoarea maximă · restaurantele cu rating maxim · filmele cu durata maximă · scorul maxim la jocuri · jucătorii peste nivelul mediu · jocul cu denumirea cea mai lungă · primul jucător înscris · departamentul înfiinţat cel mai recent

Tehnica 6 — DISTINCT, când lista are dubluri

După o joncţiune, numele restaurantului apare o dată pentru fiecare comandă a lui. Dacă se cere lista restaurantelor, dublurile trebuie eliminate:

SELECT DISTINCT r.nume
FROM Restaurante r
INNER JOIN Comenzi c ON r.idr = c.idr
WHERE c.metoda_plata = 'card';

Detaliul care încurcă pe toată lumea: DISTINCT se aplică pe tot rândul, nu pe o coloană. Deci

SELECT DISTINCT r.nume, c.idc FROM ...      -- DUBLURILE RAMAN

pentru că perechea (nume, idc) e unică la fiecare comandă — nu există două rânduri identice de eliminat. DISTINCT nu e o funcţie care se aplică pe r.nume; e o instrucţiune care spune „aruncă rândurile repetate". Ca să funcţioneze, în SELECT trebuie să rămână doar coloanele după care vrei unicitate.

Variantă care evită dublurile prin construcţie, fără joncţiune:

SELECT nume FROM Restaurante
WHERE idr IN (SELECT idr FROM Comenzi WHERE metoda_plata = 'card');

Iar când se cere câte valori distincte există, nu care sunt: COUNT(DISTINCT tip_bucatarie). Diferenţa dintre COUNT(*) (6 restaurante) şi COUNT(DISTINCT tip_bucatarie) (5 tipuri) e un singur rând dublat — şi toată diferenţa dintre corect şi greşit.

Probleme: restaurantele cu plăţi prin card, fără dubluri · restaurantele cu comenzi în luna curentă · tipuri de bucătărie distincte · genurile distincte de jocuri

Tehnica 7 — ORDER BY

ORDER BY salariul DESC, nume ASC

Al doilea criteriu nu e decorativ: la salarii egale, fără el ordinea rămâne la voia serverului şi poate diferi de la o rulare la alta. „Ordine alfabetică" la o listă de persoane înseamnă ORDER BY nume, prenume, nu doar nume.

Un detaliu care surprinde: ORDER BY pe un câmp text care conţine numere sortează alfabetic — '90' iese înaintea lui '100', pentru că se compară caracter cu caracter. Acelaşi lucru la date stocate ca text: '09.12.2020' pare mai mare decât '11.01.2012'. E motivul pentru care tipul coloanei (number, date) chiar contează.

Probleme: angajaţi în ordine alfabetică · salarii descrescător · jocurile după scor · filme după preţul biletului · restaurante alfabetic · jucătorii după username

Tehnica 8 — filtrarea: praguri, şabloane, text şi date

Pragurile se citesc din enunţ

Formularea din enunţ Operator
„mai mare de 4000" > 4000 (strict)
„de cel puţin 4000", „minim 4000" >= 4000
„între 80 şi 100" BETWEEN 80 AND 100 (inclusiv ambele capete)
„strict între 80 şi 100" > 80 AND < 100
„diferit de" <> (sau !=)

Subiectele conţin aproape întotdeauna o valoare aflată exact pe prag — un salariu de exact 4000, un film de exact 150 de minute. Acolo se decide dacă foloseşti > sau >=, iar un semn greşit schimbă rezultatul fără să dea nicio eroare.

BETWEEN are o capcană proprie: ordinea argumentelor. BETWEEN 100 AND 80 întoarce zero rânduri, fără eroare — serverul verifică „≥ 100 şi ≤ 80", condiţie imposibilă.

LIKE — potrivire după şablon

Şablon Găseşte
'A%' începe cu A
'%A' se termină cu A
'%A%' conţine A oriunde
'A_ex%' A, exact un caracter oarecare, apoi „ex"

În Access, caracterul pentru „orice şir" e *, nu %. Iar cu = în loc de LIKE, % e un caracter obişnuit: WHERE username = 'A%' caută textul literal „A%".

Text: literă mare/mică şi diacritice

Două surse de „zero rânduri" fără niciun mesaj de eroare:

Funcţii pe text: SUBSTR şi LENGTH

Apar la cerinţele care cer informaţie ascunsă într-un şir — tipic, CNP-ul:

SUBSTR(cnp, 1, 1)      -- prima cifra: gen + secol
SUBSTR(cnp, 2, 2)      -- anul nasterii, ultimele doua cifre

Al doilea argument e poziţia de start, al treilea câte caractere (nu poziţia de sfârşit). Caracterele se numără de la 1, nu de la 0 ca la vectori.

Prima cifră a CNP-ului: 1/2 = născut 1900–1999, 5/6 = după 2000, impar = masculin, par = feminin. Deci anul nașterii nu se poate presupune ca '19' || SUBSTR(cnp, 2, 2) — pentru cineva născut în 2001 ar da 1901. Un secol greşit, perfect plauzibil, invizibil.

Funcţie Oracle / SQLite / MySQL Access / SQL Server
subşir SUBSTR(s, 1, 1) Mid(s, 1, 1) / SUBSTRING(s, 1, 1)
lungime LENGTH(s) LEN(s)
concatenare s1 || s2 s1 & s2 / s1 + s2

Date: luna curentă nu e o lună anume

Nu se scrie WHERE data >= '2026-09-01' — ar fi corect o lună şi greşit tot restul anului. Data de azi se citeşte de la sistem:

WHERE EXTRACT(MONTH FROM data_comanda) = EXTRACT(MONTH FROM CURRENT_DATE)
  AND EXTRACT(YEAR  FROM data_comanda) = EXTRACT(YEAR  FROM CURRENT_DATE);
Sistem Data de azi Luna dintr-o dată
SQL standard CURRENT_DATE EXTRACT(MONTH FROM d)
Oracle SYSDATE EXTRACT(MONTH FROM d), TO_CHAR(d, 'MM')
Access Date() Month(d)
MySQL CURDATE() MONTH(d)

Condiţia pe an nu e opţională. Doar cu luna, o comandă din septembrie 2024 trece la fel de bine ca una din septembrie 2026 — iar rezultatul arată plauzibil, pentru că toate datele afişate sunt într-adevăr din septembrie.

Probleme: salarii peste 4000 · scoruri între 80 şi 100 · username care începe cu A · angajaţii de gen masculin, din CNP · anul naşterii din CNP · comenzile din luna curentă · restaurantele din Galaţi · filmele din Bucureşti

Tehnica 9 — UPDATE şi DELETE (rar, dar decisiv)

Doar patru din cele 40 de probleme cer modificarea datelor, şi toate patru se joacă pe aceeaşi distincţie:

UPDATE Angajati SET salariul = salariul + 100;                        -- fara WHERE: CERUT de enunt
UPDATE Comenzi  SET valoare = valoare * 1.1 WHERE metoda_plata = 'cash';  -- cu WHERE: obligatoriu

Când enunţul spune „fiecărui angajat" sau „pentru toate filmele", lipsa lui WHERE e răspunsul corect — merită un comentariu, ca să se vadă că nu ai uitat-o. Când spune „comenzile plătite cash", WHERE e singurul lucru care stă între tine şi distrugerea întregului tabel.

SET salariul = salariul + 100 nu e o ecuaţie, e o atribuire: citeşte valoarea actuală a rândului, adună, scrie rezultatul la loc. Iar SET salariul = 100 înlocuieşte — cea mai scumpă greşeală posibilă.

La procente, singura confuzie care contează:

Cerinţa Scris corect Greşeala tipică
„majoraţi cu 10%" valoare * 1.1 valoare * 10 / 100 — dă 10% din valoare, adică o scade cu 90%
„măriţi cu 100" salariul + 100 salariul * 100

Iar la DELETE, capcana e de altă natură: o cerinţă care şterge rânduri şi o alta care le numără se contrazic. Dacă ştergi întâi, nu mai ai ce număra. Se execută numărarea prima, cu o explicaţie scrisă — subiectul oficial e cel inconsecvent, nu tu.

Probleme: mărirea salariilor cu 100 · majorarea cu 10% a comenzilor cash · mărirea preţului biletelor · ştergerea comenzilor anulate

Greşelile care se repetă la toate cele 40

Cele adunate din toate rezolvările, în ordinea gravităţii. Toate au aceeaşi trăsătură: nu dau eroare.

  1. Joncţiune fără condiţia de legătură — produs cartezian. De cinci-zece ori mai multe rânduri, toate false.
  2. SELECT coloana, MAX(x) fără GROUP BY — eroare în Oracle, dar în MySQL/SQLite afişează maximul lângă o valoare arbitrară. Cea mai periculoasă, pentru că rezultatul arată perfect.
  3. UPDATE / DELETE fără WHERE, când enunţul cerea o condiţie — datele sunt pierdute definitiv.
  4. COUNT(*) în loc de COUNT(coloana) după LEFT JOIN1 în loc de 0 pentru grupurile goale.
  5. INNER JOIN unde trebuia LEFT JOIN — răspunsul e corect, doar incomplet. Se vede doar dacă numeri rândurile.
  6. Condiţia pe an uitată la „luna curentă" — intră aceeaşi lună din toţi anii.
  7. Diacritice sau literă mică în şirul comparat — zero rânduri, fără mesaj.
  8. >= în loc de > la un prag — exact valoarea de pe limită intră sau nu, iar subiectul o conţine dinadins.
  9. ORDER BY + „iau primul rând" în loc de subinterogare cu MAX/MIN — pierzi rândurile egale.
  10. DISTINCT cu mai multe coloane în SELECT — nu elimină nimic, pentru că lucrează pe rândul întreg.
  11. WHERE în loc de HAVING pentru o condiţie pe o agregare — eroare.
  12. Valoarea maximului sau media scrise de mână, citite din rezultatul cerinţei precedente — corect azi, greşit mâine.

Cum se exersează

Toate cele 40 de interogări de mai jos au fost rulate pe date de probă, în sqlite3, nu doar scrise pe hârtie. Poţi face exact acelaşi lucru gratuit: instalează sqlite3 (sau foloseşte un executor SQL în browser), creează cele două tabele cu CREATE TABLE, introdu cinci-zece rânduri inventate şi rulează interogarea.

Verificarea care prinde cele mai multe greşeli e numărarea rândurilor: dacă ai 10 angajaţi şi interogarea întoarce 50 de rânduri, ai uitat condiţia de joncţiune. Dacă întoarce 0, e aproape sigur un şir scris altfel decât în tabel.

Pentru subiectele care construiesc baza de date de la zero — CREATE TABLE, INSERT, tipurile de date din notaţia (N,5) / (T,20) — vezi Baze de date şi SQL la atestatul de informatică.

Toate problemele din această categorie