CodeGym /Kursy /Spring REST & MVC /Wyszukiwanie q i zakres dueAfter/ dueBefore

Wyszukiwanie q i zakres dueAfter/ dueBefore

Spring REST & MVC
Poziom 23, Lekcja 1
Dostępny

1. Semantyka q i dueAfter/dueBefore

Bardzo łatwo wpaść w pułapkę: „dodamy q – będzie wyszukiwanie, dodamy dueAfter – będzie filtr po datach". Ale takie parametry mają nieprzyjemną właściwość: jeśli nie ustalono reguł, nie projektujesz już API – przeprowadzasz eksperyment na klientach. Jeden człowiek oczekuje wyszukiwania „po wszystkich polach", drugi – tylko po tytule, trzeci myśli, że dueAfter nie obejmuje podanej daty, czwarty jest pewny czegoś odwrotnego. A potem jeszcze próbujesz pisać testy… i nagle odkrywasz, że testy to nie weryfikacja kodu, a weryfikacja domysłów.

Podstawowe equality filters dały nam już najprostszy scenariusz: jedno pole, jedna wartość, jedna reguła porównania. q i zakres dat są trudniejsze właśnie dlatego, że tu już pojawia się podciąg, granice i kombinacje parametrów. Dlatego lepiej omówić je osobno, a nie mieszać z filtrami dokładnego dopasowania.

Celem tego wykładu jest więc zamienienie q, dueAfter, dueBefore w parametry kontraktowe. Kontrakt w naszym kontekście to nie „mniej więcej tak", ale bezpośrednie ustalenia: po których polach szukamy, jak porównujemy ciągi znaków, jaki format daty oczekujemy, czy granice zakresu są inkluzywne czy ekskluzywne i co zrobić z nieprawidłowymi kombinacjami. W takiej formie endpoint staje się przewidywalny: to samo żądanie na tych samych danych daje ten sam wynik – i nikt się nie kłóci (no prawie).

Kontrakt dla q

Parametr q to „query", po prostu: ciąg znaków, według którego chcemy znajdować zadania. W praktyce q to nie equality filter. Nie porównujemy pola z dokładną wartością, jak przy status=IN_PROGRESS. Szukamy, czy podciąg występuje w z góry ustalonych polach tekstowych. I właśnie to „z góry ustalonych" – to kluczowa część. Bez niej q zamienia się w małą czarną dziurę: klienci zaczną oczekiwać, że wyszukiwanie działa po tagach, po wykonawcy, po statusie, po UUID, po wszystkim naraz. A nasz edukacyjny serwis to nie Google ani Bing (i chwała za to dla latency).

Dla Task Tracker API wybieramy deliberately simple semantics: q szuka jako podciąg (substring match), bez rankingu, bez „inteligentnych" form słów, bez fuzzy‑matching. Szukamy po title i description. Dlaczego nie po tags i assigneeName? Bo mamy już oddzielne filtry tag i assigneeName – są precyzyjniejsze, prostsze dla klienta i mniej „magiczne". Wyszukiwanie tekstowe to wygodny „pasek wyszukiwania", gdy człowiek nie chce pamiętać, jak dokładnie nazywa się parametr.

Żeby q nie łamał kontraktu, warto od razu ustalić małe, ale ważne reguły:

Co Reguła w naszym API Dlaczego to ważne
q Szukamy po title i description Klient z góry wie, które pola są przeszukiwane
Wielkość liter case-insensitive Użytkownik nie musi pamiętać „Report" czy „report"
Spacje q.trim() „ report " nie powinno zachowywać się jak oddzielne zapytanie
Pusta wartość null/blank → filtr nieobecny Inaczej q= zaczyna być dziwnym „szukaniem pustki"
„Inteligencja" Brak rankingu i trafności To edukacyjne API i in-memory storage

Osobny szczegół: q to query‑parametr, więc w URL powinien być URL‑encoded. Ciąg annual report powinien przyjść jako q=annual%20report (lub q=annual+report, to też często spotykane). Jeśli tego nie ustalasz, część żądań okaże się „nie wiadomo czemu nie działa".

Kontrakt dla dueAfter i dueBefore

Zakres dat to kolejna kategoria parametrów, których nie można wprowadzać „na wyczucie". Słowa after i before brzmią jak oczywiste, ale w praktyce ludzie od razu zaczynają się sprzeczać: dueBefore=2026-03-01 oznacza „do pierwszego marca włącznie" czy „ściśle przed"? A jeśli zadanie w ogóle nie ma dueDate, czy pasuje czy nie? A co jeśli klient przysłał obie granice, ale je pomieszał? I oto jesteśmy już nie w programowaniu, a w rekonstrukcji archeologicznych znaczeń.

W tym kursie robimy maksymalnie zrozumiałą, „codzienną" semantykę. Oba parametry odnoszą się tylko do jednego pola zadania – dueDate. Używamy LocalDate, czyli pracujemy z datą „dzień‑miesiąc‑rok" bez czasu i stref czasowych. I wybieramy granice inkluzywne: dueAfter oznacza „nie wcześniej niż podana data", a dueBefore oznacza „nie później niż podana data". Czyli faktycznie:

dueAfter → dueDate >= dueAfter
dueBefore → dueDate <= dueBefore

Jest to bardzo wygodne dla człowieka: jeśli pisze „po 1 marca", zwykle oczekuje, że 1 marca też jest wliczony. Można wybrać semantykę ekskluzywną, ale wtedy musisz to jawnie uzgodnić, a potem tłumaczyć ludziom, dlaczego „nie działa na granicy". Wybieramy to, co mniej zaskakuje.

Tabelka, żeby utrwalić:

Parametr Dotyczy Znaczenie Przykład
dueAfter Task.dueDate termin nie wcześniej niż data dueAfter=2026-03-01
dueBefore Task.dueDate termin nie później niż data dueBefore=2026-03-31

Jeszcze jedna ważna decyzja kontraktowa: co zrobić z zadaniami bez dueDate? W rzeczywistości takie zadania pojawiają się ciągle: „kiedyś zrobię". Dla naszego API logicznie jest ustalić tak: jeśli zakres dat nie jest podany, zadania bez dueDate nie przeszkadzają i trafiają do wyników. Ale jeśli klient podaje dueAfter i/lub dueBefore, zadania bez dueDate nie są uznawane za pasujące, bo dosłownie prosisz o „zadania z terminem", a terminu brak. To nie jest jedyny możliwy wybór, ale jest najbardziej przewidywalny.

Żeby wizualnie nie zgubić się, można wyobrazić zakres jako „korytarz":

flowchart LR
    A[dueAfter] -->|włącznie| R[Dopuszczalne dueDate]
    R -->|włącznie| B[dueBefore]

3. Bindowanie query‑parametrów w Spring MVC

Gdy przyjmujemy JSON przez @RequestBody, włącza się Jackson: czyta treść żądania, parsuje JSON, buduje DTO. Ale query‑parametry działają inaczej: wiąże je Spring MVC przez mechanizm konwersji prostych typów (ConversionService). I z tego powodu u początkujących często pojawia się bardzo dziwna myśl: „ale przecież Jackson umie obsługiwać daty, niech on to zrobi też tutaj". Nie zrobi. Tutaj inna droga – i to normalne.

Jeśli chcesz, żeby kontrakt był jasny, możesz zrobić dwie rzeczy. Po pierwsze, wybrać typ parametru jako LocalDate, żeby semantyka była „data bez czasu". Po drugie, jawnie podpowiedzieć Springowi, że oczekujesz formatu ISO daty. W większości projektów z LocalDate i tak stosowany jest ISO (YYYY-MM-DD), ale jawne jest lepsze niż niejawne, szczególnie w projekcie edukacyjnym.

Mini‑przykład sygnatury kontrolera (bez obiektu-kryteriów, żeby zobaczyć mechanikę na żywo):

import java.time.LocalDate;

import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@GetMapping("/api/v1/tasks")
public Object getTasks(
        // Kontrakt: q – opcjonalny parametr, puste/blank wartości traktujemy jako brak filtru
        @RequestParam(required = false) String q,

        // Kontrakt: data ISO-8601 (YYYY-MM-DD), bez czasu i strefy czasowej
        @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dueAfter,

        // Kontrakt: data ISO-8601 (YYYY-MM-DD), bez czasu i strefy czasowej
        @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dueBefore
) {
    return null; // tutaj będzie wywołanie serwisu
}

Co warto zauważyć: jeśli klient prześle dueAfter=2026-03-01, Spring starannie zamieni to w LocalDate.of(2026, 3, 1). A jeśli prześle dueAfter=hello, błąd wystąpi przed twoim serwisem, bo konwersja typu nie powiodła się. I to dobrze: takie wejście nie powinno trafiać do logiki aplikacji.

W naszym projekcie jest już global error handling i ProblemDetail, więc takie błędy nie powinny zamieniać się w „stronę HTML z błędem serwera", a powinny stać się schludnym 400 Bad Request z czytelnym code. Nie piszemy tego tu od nowa (zrobiliśmy to już w module o błędach), ale ważne, żeby rozumieć przyczynę: data „zepsuła się" jeszcze na etapie bindowania query‑parametrów.

4. Nieprawidłowy zakres dat: 400 Bad Request

Jest subtelna granica między „nic nie znaleziono" a „wysłałeś bezsensowne żądanie". Jeśli klient prosi o dueAfter=2026-04-01&dueBefore=2026-03-01, nie zrobił „złego wyszukiwania". Pomylił granice zakresu i stworzył logicznie niemożliwy warunek. Z punktu widzenia kontraktu API to structural invalid input, a więc jest to 400 Bad Request. Jeśli zwrócisz pustą listę, ukryjesz problem: klient może przez tygodnie myśleć, że „po prostu nie ma zadań", choć w rzeczywistości robi żądanie, które nigdy nie może dać wyniku.

Na poziomie kodu może to być bardzo mała weryfikacja. Nawet jeśli parametry na razie przychodzą oddzielnie, zakres lepiej walidować przed samym filtrowaniem, a nie czekać, aż „przypadkowo" da pusty wynik. Na przykład:

import java.time.LocalDate;

private void validateDueRange(LocalDate dueAfter, LocalDate dueBefore) {
    // Kontrakt: jeśli obie granice są podane, muszą tworzyć poprawny zakres (dueAfter <= dueBefore)
    if (dueAfter != null && dueBefore != null && dueAfter.isAfter(dueBefore)) {
        // Kontrakt: to nie jest „nic nie znaleziono", a nieprawidłowe żądanie → 400 Bad Request
        throw new InvalidInputException("dueAfter must be before or equal to dueBefore");
    }
}

Tutaj InvalidInputException to twój wyjątek aplikacyjny, który już umie zamieniać się w ProblemDetail z kodem 400. Ważna nie jest konkretna nazwa klasy, a sens: sygnalizujemy klientowi „wejście jest nieprawidłowe", a nie „nic nie znaleziono".

Jeśli przyłapujesz się na myśli „no daj spokój, to tylko filtr, po co 400", przypomnij sobie, że kontrakt HTTP to też część UX. 400 pozwala klientowi szybko zrozumieć: żądanie trzeba naprawić, a nie „czekać, aż pojawią się dane".

5. Predykaty matchesText i matchesDueDate

Żeby kod filtrowania nie zamieniał się w jeden gigantyczny spaghetti, wygodnie trzymać każdy filtr jako małą regułę: „czy zadanie pasuje do tego warunku". Robiliśmy tak już dla podstawowych filtrów (status, priority, archived, assigneeName, tag). Teraz dodamy dwa predykaty: matchesText i matchesDueDate. Muszą być maksymalnie proste, czytelne i – co najważniejsze – zgodne z kontraktem, który właśnie ustaliliśmy słowami.

Zacznijmy od wyszukiwania tekstowego. Ważny szczegół: jeśli robisz porównanie case‑insensitive przez toLowerCase, lepiej użyć Locale.ROOT, żeby nie zależeć od lokalizacji systemu (klasyczne „tureckie i" to nie żart, to realne bolączki). I jeszcze: description może być null, więc lepiej nie urządzać sobie NPE.

import java.util.Locale;

private boolean matchesText(Task task, String q) {
    // Kontrakt: jeśli q nie jest podane lub blank, filtr tekstowy nie jest stosowany
    if (q == null || q.isBlank()) return true;

    // Kontrakt: normalizujemy zapytanie (trim + case-insensitive)
    String needle = q.trim().toLowerCase(Locale.ROOT);

    // Szukamy tylko po z góry ustalonych polach: title i description
    String title = task.getTitle().toLowerCase(Locale.ROOT);

    // description może być null – zgodnie z kontraktem to po prostu „pusty tekst", a nie powód do rzucania wyjątku
    String description = nullToEmpty(task.getDescription()).toLowerCase(Locale.ROOT);

    return title.contains(needle) || description.contains(needle);
}

nullToEmpty – mały helper (tak, banalny; za to nie łapiesz wyjątków „na prostej drodze"):

private String nullToEmpty(String value) {
    // Narzędzie do null-safe pracy z ciągami znaków w filtrach
    return value == null ? "" : value;
}

Teraz zakres dat. Uzgodniliśmy, że filtr jest stosowany do dueDate, granice są inkluzywne, a zadania bez dueDate nie pasują, jeśli podano co najmniej jedną granicę. To zamienia się w zwartą logikę:

import java.time.LocalDate;

private boolean matchesDueDate(Task task, LocalDate dueAfter, LocalDate dueBefore) {
    // Kontrakt: jeśli zakres nie jest podany, filtr po dueDate nie jest stosowany
    if (dueAfter == null && dueBefore == null) return true;

    LocalDate dueDate = task.getDueDate();

    // Kontrakt: jeśli klient prosi o filtrowanie po terminie, zadania bez dueDate nie pasują
    if (dueDate == null) return false;

    // Kontrakt: dueAfter włącznie → dueDate >= dueAfter
    if (dueAfter != null && dueDate.isBefore(dueAfter)) return false;

    // Kontrakt: dueBefore włącznie → dueDate <= dueBefore
    return dueBefore == null || !dueDate.isAfter(dueBefore);
}

I teraz najfajniejsza część: kompozycja czyta się prawie jak ludzkie zdanie. Wewnątrz twojego ogólnego sprawdzenia po prostu dodajesz dwa nowe warunki „i tekst pasuje, i data pasuje". Na przykład w stylu podobnym do tego, co mieliśmy już w pierwszym wykładzie:

private boolean matchesAllFilters(Task task, BasicFilters basic, String q,
                                 LocalDate dueAfter, LocalDate dueBefore) {
    // Zbieramy predykaty „AND" – każdy kawałek odpowiada za swój parametr i swój kontrakt
    return matchesBasicFilters(task, basic)
            && matchesText(task, q)
            && matchesDueDate(task, dueAfter, dueBefore);
}

Tutaj BasicFilters to warunkowy obiekt/zestaw parametrów z poprzedniego wykładu (lub po prostu zestaw argumentów). Ważne jest właśnie to, że q i zakres dat stają się kolejnymi dwoma filtrami, a nie oddzielnym „podsystemem wyszukiwania".

6. Przykłady żądań i odpowiedzi

Gdy projektujesz API, bardzo przydatne jest regularne przełączanie się w tryb klienta. Nie w filozoficznym sensie „czuję ból klienta", ale w bardzo praktycznym: „widzę URL i rozumiem, co on robi". Jeśli żądanie wygląda jak zaklęcie z książki o czarnej magii – znaczy, że gdzieś nie uzgodniliśmy semantyki.

Przykład prostego wyszukiwania po tekście (zwróć uwagę na spacje – trzeba je kodować):

GET http://localhost:8080/api/v1/tasks?q=annual%20report
Accept: application/json

Przykład wyszukiwania po zakresie dat:

GET http://localhost:8080/api/v1/tasks?dueAfter=2026-03-01&dueBefore=2026-03-31
Accept: application/json

Przykład kombinowanego scenariusza (jeszcze szczegółowo nie omawiamy kolejności „filtering + sorting + pagination" – to następny wykład, ale klient może już tak robić):

GET http://localhost:8080/api/v1/tasks?q=report&dueAfter=2026-03-01&page=0&size=20&sort=updatedAt,desc
Accept: application/json

Jeśli wszystko jest poprawne, odpowiedź pozostaje w tej samej formie PagedResponse<T>. Przykład (skrócony, żeby widzieć shape, a nie utonąć w JSON):

{
  "items": [
    { "id": "c2f0...", "title": "Annual report draft", "status": "IN_PROGRESS" }
  ],
  "page": 0,
  "size": 20,
  "totalElements": 1,
  "totalPages": 1,
  "sort": "updatedAt,desc"
}

A teraz ważne scenariusze negatywne.

Jeśli klient przysłał nieprawidłowy format daty, np. dueAfter=2026-99-99, Spring nie będzie mógł skonwertować tego do LocalDate i twój handler błędów powinien zwrócić 400. Przykład problem details (pola mogą się różnić w szczegółach, ale sens jest jeden):

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "Failed to convert value of 'dueAfter' to LocalDate",
  "instance": "/api/v1/tasks",
  "code": "INVALID_INPUT"
}

Jeśli klient pomylił granice zakresu i wybrałeś strategię „to jest 400", odpowiedź będzie podobna, tylko detail będzie o twoim kontrakcie, a nie o konwersji:

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "dueAfter must be before or equal to dueBefore",
  "instance": "/api/v1/tasks",
  "code": "INVALID_INPUT"
}

Zwróć uwagę, o ile to jest bardziej przydatne niż cicha pusta lista. Pusta lista mówi „danych nie ma". Taki ProblemDetail mówi „popraw żądanie".

7. Typowe błędy przy q i zakresie dat

W tym temacie najczęściej się nie psuje w kodzie, a w ustaleniach. Czyli aplikacja „jakoś działa", ale potem nagle okazuje się, że różni ludzie mają różne rozumienie tego, co oznacza q i co oznacza „after/before". Dobra wiadomość: prawie wszystkie typowe błędy leczy jedno lekarstwo – jawna semantyka i małe, czytelne sprawdzenia w kodzie.

Błąd nr 1: uważać, że q to „inteligentne wyszukiwanie po wszystkim, co się porusza".
Jeśli nie podałeś pól wyszukiwania, klient zacznie oczekiwać, że q szuka i po tagach, i po wykonawcy, i po statusie, i po czymkolwiek jeszcze. W efekcie API wydaje się „niestabilne": raz znajduje, raz nie. Lekarstwo jest proste: ustal, że q szuka po title i description (i trzymaj się tego), a do pozostałych pól używaj oddzielnych filtrów.

Błąd nr 2: nie normalizować q i dostawać „magiczne" różnice między q=report a q= report .
Bez trim() i obsługi isBlank() łatwo dostać sytuację, gdy ten sam tekst wpisany ze spacjami nie daje wyników. Użytkownik czuje się, jakby kłócił się z komputerem z lat 90. Normalizacja q to nie „zbędne przetwarzanie", a część kontraktu: pusty ciąg traktujemy jako brak filtru.

Błąd nr 3: robić case‑insensitive porównanie przez toLowerCase() bez Locale.ROOT.
Na twojej maszynie może być jedna lokalizacja, na serwerze – inna, i nagle porównanie ciągów zaczyna się dziwnie zachowywać. To rzadki, ale bardzo nieprzyjemny bug: żyje cicho, a potem wypływa w najgorszym możliwym momencie. Używaj toLowerCase(Locale.ROOT) – i śpij spokojniej.

Błąd nr 4: nie ustalić, co zrobić z zadaniami bez dueDate.
Jeśli klient podaje dueAfter lub dueBefore, a ty włączasz do wyników zadania z dueDate = null, część ludzi będzie zaskoczona: „prosiłem po terminie". Jeśli je wykluczasz – inna część powie: „czemu zniknęły moje bezterminowe zadania?". Ważne nie jest zgadnięcie „jedynej właściwej" odpowiedzi, ale wybranie reguły i utrwalenie jej. W naszym kontrakcie: jeśli zakres jest podany, zadania bez dueDate nie pasują.

Błąd nr 5: traktować dueAfter > dueBefore jako „no po prostu pusty wynik".
W ten sposób ukrywasz błąd klienta i może on bardzo długo nie rozumieć, dlaczego jego żądanie niczego nie znajduje. Dla parametrów zakresu logiczniej jest uznać to za nieprawidłowe wejście i zwrócić 400 Bad Request z czytelną przyczyną.

1
Zadanie
Spring REST & MVC, poziom 23, lekcja 1
Niedostępne
Wyszukiwanie po `q` w polach `title` i `description`
Wyszukiwanie po `q` w polach `title` i `description`
1
Zadanie
Spring REST & MVC, poziom 23, lekcja 1
Niedostępne
Zakres `dueAfter` i `dueBefore` dla pola `dueDate`
Zakres `dueAfter` i `dueBefore` dla pola `dueDate`
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION