Agregacje i grupowanie w SOQL
Licz, sumuj i grupuj dane bezpośrednio w zapytaniu: COUNT, SUM, AVG, MIN, MAX, GROUP BY i HAVING, także do szukania duplikatów.
Zamiast eksportować tysiące rekordów i liczyć je w Excelu, możesz policzyć wynik od razu w SOQL. Zapytania agregujące zwracają nie pojedyncze rekordy, ale podsumowania: liczby, sumy, średnie.
Przykładowe dane
Wszystkie przykłady w tej lekcji działają na tych samych, małych danych z obiektów zbudowanych w module 2 kursu Podstawy Salesforce: 5 kontaktów, 6 nieruchomości i 8 ofert. Tych samych danych używamy w lekcjach o filtrowaniu i relacjach, więc wystarczy przygotować je raz.
Obiekt Contact (kontakty):
| FirstName | LastName | |
|---|---|---|
| Anna | Nowak | anna.nowak@example.com |
| Piotr | Kowalski | piotr.kowalski@example.com |
| Ewa | Wiśniewska | ewa.wisniewska@example.com |
| Ania | Nowak | anna.nowak@example.com |
| Jan | Zieliński | (puste) |
Ania Nowak to ta sama osoba co Anna Nowak, wprowadzona drugi raz przy imporcie. W tej lekcji nauczysz się wyszukiwać takie duplikaty.
Obiekt Nieruchomość (Property__c):
| Name | Price__c | Area_m2__c | Status__c | Sprzedający (Seller__c) |
|---|---|---|---|---|
| Lipowa 5/12, Gdańsk | 450 000 | 50 | Dostępna | Anna Nowak |
| Kwiatowa 3, Sopot | 1 250 000 | 140 | Zarezerwowana | Anna Nowak |
| Słoneczna 8/4, Gdańsk | 380 000 | 36 | Sprzedana | Piotr Kowalski |
| Ogrodowa 15, Gdynia | 890 000 | 120 | Dostępna | (puste) |
| Morska 21/3, Gdynia | 640 000 | 62 | Nowa | Piotr Kowalski |
| Polna 2/7, Gdańsk | 520 000 | 55 | Dostępna | Ewa Wiśniewska |
Pozostałe pola tych samych nieruchomości:
| Name | Amenities__c | Is_Furnished__c | Available_From__c |
|---|---|---|---|
| Lipowa 5/12, Gdańsk | Balkon;Winda | true | 2026-11-01 |
| Kwiatowa 3, Sopot | Miejsce parkingowe;Ogród;Piwnica | false | (puste) |
| Słoneczna 8/4, Gdańsk | Balkon | true | 2026-03-01 |
| Ogrodowa 15, Gdynia | Miejsce parkingowe;Ogród | false | 2026-10-01 |
| Morska 21/3, Gdynia | Winda;Miejsce parkingowe | true | 2026-12-15 |
| Polna 2/7, Gdańsk | Piwnica | false | 2026-10-20 |
Obiekt Oferta (Offer__c), powiązany z nieruchomością relacją Master-Detail:
| Name | Amount__c | Nieruchomość (Property__c) |
|---|---|---|
| OF-00001 | 395 000 | Lipowa 5/12, Gdańsk |
| OF-00002 | 430 000 | Lipowa 5/12, Gdańsk |
| OF-00003 | 1 200 000 | Kwiatowa 3, Sopot |
| OF-00004 | 360 000 | Słoneczna 8/4, Gdańsk |
| OF-00005 | 375 000 | Słoneczna 8/4, Gdańsk |
| OF-00006 | 380 000 | Słoneczna 8/4, Gdańsk |
| OF-00007 | 850 000 | Ogrodowa 15, Gdynia |
| OF-00008 | 505 000 | Polna 2/7, Gdańsk |
Uwagi do tabel z wynikami. Kwoty są sformatowane dla czytelności, a Salesforce zwraca je jako zwykłe liczby, np.
450000. BezORDER BYSalesforce nie gwarantuje kolejności wierszy, więc w takich wynikach pokazujemy grupy w kolejności, w jakiej pierwszy raz pojawiają się w tabelach powyżej. Przykład z datami zakłada, że dziś jest czwartek, 15 października 2026.
Chcesz sprawdzić przykłady u siebie? Dodaj te rekordy w swoim orgu do nauki: ręcznie albo importem z pliku CSV, jak w lekcji Praca z rekordami i widokami list. Numery ofert nadaje Salesforce, więc u Ciebie mogą być inne. Pole Dostępna od ma wartość domyślną TODAY(), więc wpisz daty z tabeli ręcznie, a w Kwiatowej 3 wyczyść to pole.
Funkcje agregujące
| Funkcja | Co zwraca | Przykład |
|---|---|---|
COUNT() | liczbę rekordów (sam wynik, bez wierszy) | SELECT COUNT() FROM Contact |
COUNT(pole) | liczbę rekordów z wypełnionym polem | COUNT(Email) |
COUNT_DISTINCT(pole) | liczbę różnych wartości | COUNT_DISTINCT(Industry) |
SUM(pole) | sumę | SUM(Amount) |
AVG(pole) | średnią | AVG(Price__c) |
MIN(pole) / MAX(pole) | najmniejszą / największą wartość | MAX(CloseDate) |
COUNT() z pustymi nawiasami zwraca samą liczbę i nie można go łączyć z innymi polami. Do raportów z grupowaniem używaj COUNT(Id).
Przykład 1. Sama liczba: COUNT()
SELECT COUNT()
FROM Property__c
WHERE Status__c = 'Dostępna'
Co zwróci: liczbę dostępnych nieruchomości i nic poza nią. W Apex taki wynik przypiszesz od razu do zmiennej typu Integer.
Wynik: liczba 3, bez żadnych wierszy z danymi.
Przykład 2. COUNT(Id), COUNT(pole) i COUNT_DISTINCT
SELECT COUNT(Id) wszystkie,
COUNT(Email) z_emailem,
COUNT_DISTINCT(Email) rozne_emaile
FROM Contact
Co zwróci: jeden wiersz z trzema liczbami. Kontaktów jest pięć, e-mail ma cztery, bo Jan Zieliński go nie ma, a różnych adresów są trzy, bo Anna i Ania Nowak mają ten sam.
Wynik (1 wiersz):
| wszystkie | z_emailem | rozne_emaile |
|---|---|---|
| 5 | 4 | 3 |
Przykład 3. Suma, średnia, minimum i maksimum
SELECT SUM(Amount__c) suma, AVG(Amount__c) srednia,
MIN(Amount__c) najnizsza, MAX(Amount__c) najwyzsza
FROM Offer__c
Co zwróci: podsumowanie wszystkich ośmiu ofert w jednym wierszu. Bez aliasów kolumny wyniku nazywałyby się expr0, expr1 i tak dalej, więc aliasy warto nadawać zawsze.
Wynik (1 wiersz):
| suma | srednia | najnizsza | najwyzsza |
|---|---|---|---|
| 4 495 000 | 561 875 | 360 000 | 1 200 000 |
Grupowanie: GROUP BY
GROUP BY dzieli rekordy na grupy i liczy agregaty dla każdej z nich:
SELECT StageName, COUNT(Id) liczba, SUM(Amount) suma
FROM Opportunity
WHERE IsClosed = false
GROUP BY StageName
ORDER BY SUM(Amount) DESC
Zasady:
- Każde pole w
SELECT, które nie jest agregatem, musi być wGROUP BY. Inaczej dostaniesz błąd Field must be grouped or aggregated. liczbaisumato aliasy, czyli własne nazwy kolumn wyniku. W SOQL piszesz je bez słowaAS.- Sortując po agregacie, powtórz funkcję (
ORDER BY SUM(Amount) DESC) zamiast aliasu.
Przykład na obiektach z modułu 2 kursu podstaw:
SELECT Status__c, COUNT(Id) liczba, AVG(Price__c) srednia_cena
FROM Property__c
GROUP BY Status__c
Przykład 4. Grupy według statusu, od najwyższej średniej
SELECT Status__c, COUNT(Id) liczba, AVG(Price__c) srednia_cena
FROM Property__c
GROUP BY Status__c
ORDER BY AVG(Price__c) DESC
Co zwróci: po jednym wierszu na każdy status, z liczbą nieruchomości i średnią ceną, od najwyższej średniej. Dla statusu Dostępna średnia to (450 000 + 890 000 + 520 000) / 3 = 620 000.
Wynik (4 wiersze):
| Status__c | liczba | srednia_cena |
|---|---|---|
| Zarezerwowana | 1 | 1 250 000 |
| Nowa | 1 | 640 000 |
| Dostępna | 3 | 620 000 |
| Sprzedana | 1 | 380 000 |
Przykład 5. Grupowanie po polu rodzica i grupa pustych
SELECT Seller__r.LastName nazwisko, COUNT(Id) liczba
FROM Property__c
GROUP BY Seller__r.LastName
Co zwróci: liczbę nieruchomości według nazwiska sprzedającego. Ogrodowa 15 nie ma sprzedającego, więc tworzy osobną grupę z pustym nazwiskiem. Rekordy z pustym polem nie znikają z grupowania, tylko trafiają do własnej grupy. W prawdziwym orgu grupowanie po nazwisku połączyłoby różne osoby o tym samym nazwisku, więc w raportach bezpieczniej grupować po Seller__c, czyli po Id kontaktu.
Wynik (4 wiersze):
| nazwisko | liczba |
|---|---|
| Nowak | 2 |
| Kowalski | 2 |
| (puste) | 1 |
| Wiśniewska | 1 |
Grupowanie po dacie
Funkcje dat pozwalają zbudować zestawienie miesięczne:
SELECT CALENDAR_MONTH(CloseDate) miesiac, COUNT(Id) liczba, SUM(Amount) suma
FROM Opportunity
WHERE StageName = 'Closed Won' AND CloseDate = THIS_YEAR
GROUP BY CALENDAR_MONTH(CloseDate)
ORDER BY CALENDAR_MONTH(CloseDate)
Inne przydatne funkcje to CALENDAR_YEAR(), CALENDAR_QUARTER() i DAY_ONLY(). Ostatnia wycina datę z pola Date/Time, np. DAY_ONLY(CreatedDate).
Przykład 6. Zestawienie miesięczne
SELECT CALENDAR_MONTH(Available_From__c) miesiac,
COUNT(Id) liczba, SUM(Price__c) suma
FROM Property__c
WHERE Available_From__c = THIS_YEAR
GROUP BY CALENDAR_MONTH(Available_From__c)
ORDER BY CALENDAR_MONTH(Available_From__c)
Co zwróci: liczbę i łączną cenę nieruchomości według miesiąca, od którego są dostępne. Przyjmujemy, że dziś jest 15 października 2026, więc THIS_YEAR oznacza rok 2026. Kwiatowa 3 ma pustą datę i odpada już w WHERE. Miesiące bez żadnej nieruchomości nie pojawiają się w wyniku, nawet jako zera.
Wynik (4 wiersze):
| miesiac | liczba | suma |
|---|---|---|
| 3 | 1 | 380 000 |
| 10 | 2 | 1 410 000 |
| 11 | 1 | 450 000 |
| 12 | 1 | 640 000 |
Filtrowanie grup: HAVING
WHERE filtruje rekordy przed grupowaniem, a HAVING filtruje gotowe grupy. Klasyczne zastosowanie to szukanie duplikatów:
SELECT Email, COUNT(Id) liczba
FROM Contact
WHERE Email != null
GROUP BY Email
HAVING COUNT(Id) > 1
Wynik to lista adresów e-mail, które występują w więcej niż jednym kontakcie. To dobry punkt startowy do porządkowania danych przed migracją albo kampanią.
Podobnie znajdziesz nieruchomości z wieloma ofertami:
SELECT Property__c, COUNT(Id) liczba_ofert, MAX(Amount__c) najwyzsza
FROM Offer__c
GROUP BY Property__c
HAVING COUNT(Id) >= 2
Przykład 7. Nieruchomości z co najmniej dwiema ofertami
SELECT Property__r.Name nieruchomosc,
COUNT(Id) liczba_ofert, MAX(Amount__c) najwyzsza
FROM Offer__c
GROUP BY Property__r.Name
HAVING COUNT(Id) >= 2
Co zwróci: to samo co zapytanie powyżej, ale z nazwą nieruchomości zamiast jej Id. Z pięciu grup zostają dwie. Morskiej 21/3 nie ma nawet wśród grup, bo zapytanie liczy oferty, a do niej nikt nie złożył oferty.
Wynik (2 wiersze):
| nieruchomosc | liczba_ofert | najwyzsza |
|---|---|---|
| Lipowa 5/12, Gdańsk | 2 | 430 000 |
| Słoneczna 8/4, Gdańsk | 3 | 380 000 |
Przykład 8. WHERE i HAVING w jednym zapytaniu
SELECT Property__r.Name nieruchomosc, COUNT(Id) liczba_ofert
FROM Offer__c
WHERE Amount__c >= 380000
GROUP BY Property__r.Name
HAVING COUNT(Id) >= 2
Co zwróci: nieruchomości z co najmniej dwiema ofertami od 380 000 wzwyż. WHERE najpierw odrzuca oferty OF-00004 i OF-00005, więc Słonecznej 8/4 zostaje jedna oferta i jej grupa odpada w HAVING. Lipowa 5/12 ma obie oferty powyżej progu.
Wynik (1 wiersz):
| nieruchomosc | liczba_ofert |
|---|---|
| Lipowa 5/12, Gdańsk | 2 |
Przykład 9. Duplikaty po dwóch polach
SELECT LastName, Email, COUNT(Id) liczba
FROM Contact
WHERE Email != null
GROUP BY LastName, Email
HAVING COUNT(Id) > 1
Co zwróci: pary nazwisko i e-mail, które powtarzają się w więcej niż jednym kontakcie. Anna i Ania Nowak różnią się imieniem, więc grupowanie po imieniu i nazwisku by ich nie wyłapało. Duplikat zdradza wspólny e-mail i nazwisko.
Wynik (1 wiersz):
| LastName | liczba | |
|---|---|---|
| Nowak | anna.nowak@example.com | 2 |
Agregaty czy Roll-Up Summary? Pole Roll-Up Summary z kursu podstaw liczy wynik na stałe i zapisuje go na rekordzie. Zapytanie agregujące liczy go na żądanie, z dowolnym filtrem i grupowaniem. Do jednorazowych analiz wystarczy zapytanie. Jeśli wynik ma być widoczny na rekordzie lub w dokumencie, lepsze jest pole.
Ćwiczenie
- Policz kontakty w każdej branży (
Account.Industry) ich firm. Podpowiedź: grupuj poAccount.Industry. - Dla każdej nieruchomości policz oferty i podaj najniższą oraz najwyższą kwotę.
- Znajdź firmy (
Account) o tej samej nazwie, czyli potencjalne duplikaty. - Zestaw liczbę utworzonych zgłoszeń (
Case) w podziale na miesiące bieżącego roku.
Sprawdź się
1. Zapytanie SELECT Name, COUNT(Id) FROM Account GROUP BY Industry zwraca błąd. Dlaczego?
Pokaż odpowiedź
Pole Name nie jest agregatem i nie ma go w GROUP BY. Trzeba je usunąć z SELECT albo dodać do grupowania.
2. Czym różni się WHERE od HAVING?
Pokaż odpowiedź
WHERE filtruje pojedyncze rekordy przed grupowaniem. HAVING filtruje gotowe grupy, np. tylko te, w których liczba rekordów jest większa niż 1.
3. Kiedy użyjesz COUNT(), a kiedy COUNT(Id)?
Pokaż odpowiedź
COUNT() zwraca samą liczbę rekordów i nie łączy się z innymi polami ani grupowaniem. COUNT(Id) działa razem z GROUP BY i innymi kolumnami, więc nadaje się do zestawień.
Następny krok: wykorzystasz SOQL w praktyce, przy budowaniu danych do dokumentów w PDF Butlerze.