JavaRush /Курси /Spring REST & MVC /Пошук q і діапазон...

Пошук q і діапазон dueAfter/ dueBefore

Spring REST & MVC
Рівень 23, Лекція 1
Відкрита

1. Семантика q і dueAfter/dueBefore

Дуже легко потрапити в пастку: «додамо q — буде пошук, додамо dueAfter — буде фільтр за датами». Але в таких параметрів є неприємна властивість: якщо ви не закріпили правила, то ви вже не проєктуєте API — ви експериментуєте на клієнтах. Одна людина чекає пошук «за всіма полями», інша — лише за заголовком, третя думає, що dueAfter не включає вказану дату, четверта впевнена в протилежному. А потім ви ще й тести намагаєтеся писати… і раптом виявляєте, що тести — це не перевірка коду, а перевірка здогадок.

Базові фільтри точного збігу вже дали нам найпростіший сценарій: одне поле, одне значення, одне правило порівняння. q і діапазон дат складніші саме тому, що тут уже зʼявляються підрядки, межі та комбінації параметрів. Тому їх краще проговорити окремо, а не змішувати з фільтрами точного збігу.

Тому мета цієї лекції — перетворити q, dueAfter, dueBefore на контрактні параметри. Контракт у нашому контексті — це не «приблизно так», а чіткі домовленості: за якими полями шукаємо, як порівнюємо рядки, який формат дати очікуємо, чи межі діапазону включні, і що робити з некоректними комбінаціями. У такому вигляді endpoint стає передбачуваним: однаковий запит на однакових даних дає однаковий результат — і ніхто не свариться (ну, майже).

Контракт для q

Параметр q — це «query», простіше кажучи, рядок, за яким ми хочемо знайти задачі. На практиці q — не фільтр на точний збіг. Ми не порівнюємо поле з точним значенням, як у status=IN_PROGRESS. Ми шукаємо, чи трапляється підрядок у заздалегідь визначених текстових полях. І ось це «заздалегідь визначених» — ключова частина. Без неї q перетворюється на маленьку чорну діру: клієнти почнуть очікувати, що пошук працює за тегами, виконавцем, статусом, UUID і всім одразу. А наш навчальний сервіс — не Google і точно не універсальний пошук.

Для Task Tracker API ми обираємо свідомо просту семантику: q шукає як підрядок (substring match), без ранжування, без «розумних» словоформ, без нечіткого пошуку. Шукаємо за title і description. Чому не за tags і assigneeName? Тому що в нас уже є окремі фільтри tag і assigneeName — вони точніші, простіші для клієнта й менше «магії». Текстовий пошук — це зручний «рядок пошуку», коли людина не хоче пригадувати, як саме називається параметр.

Щоб q не ламав контракт, корисно одразу зафіксувати маленькі, але важливі правила:

Що Правило в нашому API Чому це важливо
q Шукаємо за title і description Клієнт заздалегідь знає межі пошуку
Регістр без урахування регістру Користувач не зобовʼязаний памʼятати «Report» чи «report»
Пробіли q.trim() «   report   » не має поводитися як окремий запит
Порожнє значення null/blank → фільтр відсутній Інакше q= починає бути дивним «пошуком по порожнечі»
«Розумність» Жодного ранжування й релевантності Це навчальне API та сховище в памʼяті

Окрема деталь: q — це query‑параметр, отже в URL він має бути URL‑encoded. Рядок annual report має прийти як q=annual%20report (або q=annual+report, це теж часто трапляється). Якщо ви цього не проговорите, частина запитів виявиться «незрозуміло чому не працює».

Контракт для dueAfter і dueBefore

Діапазон дат — це наступна категорія параметрів, які не можна вводити абияк. Слова after і before звучать начебто очевидно, але на практиці люди миттєво починають сперечатися: dueBefore=2026-03-01 означає «до першого березня включно» чи «строго раніше»? А якщо задача взагалі без dueDate, вона підходить чи ні? А що, якщо клієнт прислав обидві межі, але переплутав їх місцями? І ось ми вже перебуваємо не в програмуванні, а в реконструкції археології смислів.

У цьому курсі ми обираємо максимально зрозумілу, побутову семантику. Обидва параметри стосуються лише одного поля задачі — dueDate. Ми використовуємо LocalDate, тобто працюємо з датою «день‑місяць‑рік» без часу доби та часових зон. І обираємо включні межі: dueAfter означає «не раніше вказаної дати», а dueBefore означає «не пізніше вказаної дати». Тобто фактично:

dueAfterdueDate >= dueAfter
dueBeforedueDate <= dueBefore

Це дуже зручно для людини: якщо вона пише «після 1 березня», вона зазвичай очікує, що 1 березня теж входить. Можна вибрати й невключну семантику, але тоді ви зобовʼязані явно це проговорити і потім пояснювати людям, чому «не працює на межі». Ми оберемо те, що менше дивує.

Табличка, щоб закріпити:

Параметр Застосовується до Сенс Приклад
dueAfter Task.dueDate дата не раніше вказаної dueAfter=2026-03-01
dueBefore Task.dueDate дата не пізніше вказаної dueBefore=2026-03-31

Ще одне важливе рішення контракту: що робити із задачами без dueDate? У реальності такі задачі трапляються постійно: «Колись зроблю». Для нашого API логічно домовитися так: якщо діапазон дат не задано, задачі без dueDate не відсіюються і потрапляють у результат. Але якщо клієнт задає dueAfter і/або dueBefore, то задачі без dueDate не вважаються придатними, тому що ви буквально просите «задачі зі строком», а строк відсутній. Це не єдиний можливий вибір, але він найпередбачуваніший.

Щоб візуально не загубитися, можна уявити діапазон як «коридор»:

flowchart LR
    A[dueAfter] -->|включно| R[Допустимі dueDate]
    R -->|включно| B[dueBefore]

3. Привʼязування query‑параметрів у Spring MVC

Коли ми приймаємо JSON через @RequestBody, за роботу береться Jackson: він читає тіло запиту, парсить JSON, будує DTO. Але query‑параметри працюють інакше: їх звʼязує Spring MVC через механізм конверсії простих типів (ConversionService). І через це в початківців часто зʼявляється дуже дивна думка: «Jackson і тут усе перетворить, нехай допоможе». Не зробить. Тут інший шлях — і це нормально.

Якщо ви хочете, щоб контракт був зрозумілим, ви можете зробити дві речі. По‑перше, обрати тип параметра як LocalDate, щоб семантика була «дата без часу». По‑друге, явно підказати Spring, що очікуєте ISO‑формат дати. У більшості проєктів із LocalDate і так застосовується ISO (YYYY-MM-DD), але явне краще за неявне, особливо в навчальному проєкті.

Мініприклад сигнатури контролера (поки без критерію-обʼєкта, щоб побачити механізм у дії):

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(
        // Контракт: q — опціональний параметр, порожнє значення або лише пробіли означають відсутність фільтра
        @RequestParam(required = false) String q,

        // Контракт: ISO-8601 дата (YYYY-MM-DD), без часу і часової зони
        @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dueAfter,

        // Контракт: ISO-8601 дата (YYYY-MM-DD), без часу і часової зони
        @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dueBefore
) {
    return null; // тут буде виклик сервісу
}

Що важливо помітити: якщо клієнт пришле dueAfter=2026-03-01, Spring акуратно перетворить це на LocalDate.of(2026, 3, 1). А якщо він пришле dueAfter=hello, помилка станеться до вашого сервісу, тому що перетворення типу не вдалося. І це добре: такий ввід не має потрапляти до прикладної логіки.

У нашому проєкті вже є глобальний обробник помилок і ProblemDetail, тому такі помилки не мають перетворюватися на «HTML-сторінку з помилкою сервера», а повинні ставати акуратним 400 Bad Request із зрозумілим code. Ми не переписуємо це тут (ми вже зробили це в модулі про помилки), але важливо розуміти причину: дата «зламалася» ще на етапі звʼязування query‑параметрів.

4. Некоректний діапазон дат: 400 Bad Request

Є тонка межа між «нічого не знайдено» і «ви надіслали беззмістовний запит». Якщо клієнт просить dueAfter=2026-04-01&dueBefore=2026-03-01, він не зробив «поганий пошук». Він переплутав межі діапазону й створив логічно неможливу умову. З погляду API‑контракту це структурно некоректний ввід, а отже це 400 Bad Request. Якщо ви повернете порожній список, ви сховаєте проблему: клієнт може тижнями думати, що «просто немає задач», хоча насправді він робить запит, який ніколи не може дати результат.

На рівні коду це може бути дуже маленька перевірка. Навіть якщо параметри поки надходять окремо, діапазон краще валідовувати до самої фільтрації, а не чекати, поки він «випадково» дасть порожній результат. Наприклад:

import java.time.LocalDate;

private void validateDueRange(LocalDate dueAfter, LocalDate dueBefore) {
    // Контракт: якщо задано обидві межі, вони мають утворювати коректний діапазон (dueAfter <= dueBefore)
    if (dueAfter != null && dueBefore != null && dueAfter.isAfter(dueBefore)) {
        // Контракт: це не "нічого не знайдено", а некоректний запит → 400 Bad Request
        throw new InvalidInputException("dueAfter має бути раніше або дорівнювати dueBefore");
    }
}

Тут InvalidInputException — це ваш прикладний виняток, який уже вміє перетворюватися на ProblemDetail з 400. Важливе не конкретне імʼя класу, а сенс: ми сигналізуємо клієнту «ввід некоректний», а не «нічого не знайшлося».

Якщо ви ловите себе на думці «та це ж просто фільтр, навіщо 400», згадайте, що HTTP‑контракт — це теж частина UX. 400 дає змогу клієнту швидко зрозуміти: запит треба виправляти, а не чекати, поки зʼявляться дані.

5. Предикати matchesText і matchesDueDate

Щоб код фільтрації не перетворювався на одну гігантську кашу, зручно тримати кожен фільтр як маленьке правило: «чи збігається задача з цією умовою». Ми вже робили так для базових фільтрів (status, priority, archived, assigneeName, tag). Тепер додамо ще два предикати: matchesText і matchesDueDate. Вони мають бути максимально простими, читабельними і, головне, відповідати контракту, який ми щойно закріпили словами.

Почнемо з текстового пошуку. Важлива дрібниця: якщо ви робите порівняння без урахування регістру через toLowerCase, краще використовувати Locale.ROOT, щоб не залежати від локалі системи (класичне «турецьке i» — це не жарт, це реальний біль). І ще: description може бути null, тому краще не влаштовувати собі NPE.

import java.util.Locale;

private boolean matchesText(Task task, String q) {
    // Контракт: якщо q не задано або містить лише пробіли, текстовий фільтр не застосовується
    if (q == null || q.isBlank()) return true;

    // Контракт: нормалізуємо запит (trim + без урахування регістру)
    String needle = q.trim().toLowerCase(Locale.ROOT);

    // Шукаємо лише за заздалегідь узгодженими полями: title і description
    String title = task.getTitle().toLowerCase(Locale.ROOT);

    // description може бути null — за контрактом це просто "порожній текст", а не привід падати
    String description = nullToEmpty(task.getDescription()).toLowerCase(Locale.ROOT);

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

nullToEmpty — маленький допоміжний метод (так, він банальний; зате ви не ловите винятки «на рівному місці»):

private String nullToEmpty(String value) {
    // Допоміжний метод для безпечної роботи з рядками у фільтрах
    return value == null ? "" : value;
}

Тепер діапазон дат. Ми домовилися, що фільтр застосовується до dueDate, межі включні, а задачі без dueDate не підходять, якщо хоча б одну межу задано. Це перетворюється на компактну логіку:

import java.time.LocalDate;

private boolean matchesDueDate(Task task, LocalDate dueAfter, LocalDate dueBefore) {
    // Контракт: якщо діапазон не задано, фільтр за dueDate не застосовується
    if (dueAfter == null && dueBefore == null) return true;

    LocalDate dueDate = task.getDueDate();

    // Контракт: якщо клієнт просить фільтрацію за строком, задачі без dueDate не підходять
    if (dueDate == null) return false;

    // Контракт: dueAfter включно → dueDate >= dueAfter
    if (dueAfter != null && dueDate.isBefore(dueAfter)) return false;

    // Контракт: dueBefore включно → dueDate <= dueBefore
    return dueBefore == null || !dueDate.isAfter(dueBefore);
}

І тепер найприємніше: композиція читається майже як людське речення. Усередині вашої загальної перевірки ви просто додаєте дві нові умови — «і текст підходить, і дата підходить». Наприклад, у стилі, схожому на те, що у нас уже було в першій лекції:

private boolean matchesAllFilters(Task task, BasicFilters basic, String q,
                                 LocalDate dueAfter, LocalDate dueBefore) {
    // Обʼєднуємо предикати через «і» — кожен шматок відповідає за свій параметр і свій контракт
    return matchesBasicFilters(task, basic)
            && matchesText(task, q)
            && matchesDueDate(task, dueAfter, dueBefore);
}

Тут BasicFilters — умовний обʼєкт або набір параметрів із минулої лекції (або просто набір аргументів). Важливо саме те, що q і діапазон дат стають ще двома фільтрами, а не окремою «підсистемою пошуку».

6. Приклади запитів і відповідей

Коли ви проєктуєте API, дуже корисно періодично перемикатися в режим клієнта. Не у філософському сенсі «я відчуваю біль клієнта», а в дуже практичному: «я бачу URL і розумію, що він робить». Якщо запит виглядає як заклинання з книги темної магії — значить, ми десь недоговорилися про семантику.

Приклад простого пошуку за текстом (зверніть увагу на пробіли — їх потрібно кодувати в URL):

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

Приклад пошуку за діапазоном дат:

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

Приклад комбінованого сценарію (ми ще не розбираємо порядок «filtering + sorting + pagination» детально — це наступна лекція, але клієнт уже може так робити):

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

Якщо все коректно, відповідь залишається в тій самій формі PagedResponse<T>. Приклад (скорочений, щоб побачити структуру, а не потонути в JSON):

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

А тепер важливі негативні сценарії.

Якщо клієнт надіслав неправильний формат дати, наприклад dueAfter=2026-99-99, то Spring не зможе сконвертувати це в LocalDate, і ваш обробник помилок має повернути 400. Приклад відповіді з ProblemDetail (поля можуть відрізнятися в деталях, але сенс один):

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "Не вдалося перетворити значення 'dueAfter' на LocalDate",
  "instance": "/api/v1/tasks",
  "code": "INVALID_INPUT"
}

Якщо клієнт переплутав межі діапазону, і ви обрали стратегію «це 400», то буде схожа відповідь, тільки detail буде про ваш контракт, а не про конверсію:

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "dueAfter має бути раніше або дорівнювати dueBefore",
  "instance": "/api/v1/tasks",
  "code": "INVALID_INPUT"
}

Зверніть увагу, наскільки це корисніше, ніж мовчки віддати порожній список. Порожній список говорить «даних немає». Такий ProblemDetail говорить «виправ запит».

7. Типові помилки при q і діапазоні дат

У цій темі найчастіше ламаються не в коді, а в домовленостях. Тобто застосунок «якось працює», але потім раптово виявляється, що в різних людей різне розуміння того, що означає q і що означає «after/before». Хороша новина: майже всі типові помилки лікуються одними й тими самими ліками — явною семантикою і маленькими, читабельними перевірками в коді.

Помилка №1: вважати, що q — це «розумний пошук по всьому, що рухається».
Якщо ви не назвали поля пошуку, клієнт почне очікувати, що q шукає і за тегами, і за виконавцем, і за статусом, і ще невідомо за чим. У результаті API здається «нестабільним»: то знаходить, то ні. Лікується просто: зафіксуйте, що q шукає за title і description (і дотримуйтеся цього), а для решти полів використовуйте окремі фільтри.

Помилка №2: не нормалізувати q і отримувати «магічні» відмінності між q=report та q= report .
Без trim() і обробки isBlank() ви легко отримуєте ситуацію, коли один і той самий текст, набраний із пробілами, не дає результатів. Користувач відчуває себе так, ніби сперечається з компʼютером із 90-х. Нормалізація q — це не «зайва обробка», а частина контракту: порожній рядок вважається відсутністю фільтра.

Помилка №3: робити порівняння без урахування регістру через toLowerCase() без Locale.ROOT.
На вашій машині може бути одна локаль, на сервері — інша, і раптом порівняння рядків починає поводитися дивно. Це рідкісний, але дуже неприємний баг: він живе тихо, а потім спливає в найнедоречніший момент. Використовуйте toLowerCase(Locale.ROOT) — і спіть спокійніше.

Помилка №4: не визначитися, що робити із задачами без dueDate.
Якщо клієнт задає dueAfter або dueBefore, а ви включаєте в результат задачі з dueDate = null, частина людей буде здивована: «Я просив за строком». Якщо ви їх виключаєте — інша частина скаже: «Чому зникли мої безстрокові задачі?». Важливо не вгадати «єдино правильне» рішення, а обрати правило й закріпити його. У нашому контракті: якщо діапазон задано, задачі без dueDate не підходять.

Помилка №5: трактувати dueAfter > dueBefore як «ну просто порожній результат».
Так ви ховаєте помилку клієнта, і він може дуже довго не розуміти, чому його запит нічого не знаходить. Для параметрів діапазону логічніше вважати це некоректним введенням і повертати 400 Bad Request із зрозумілою причиною.

1
Задача
Spring REST & MVC, 23 рівень, 1 лекція
Недоступна
Пошук за `q` у полях `title` і `description`
Пошук за `q` у полях `title` і `description`
1
Задача
Spring REST & MVC, 23 рівень, 1 лекція
Недоступна
Діапазон `dueAfter` і `dueBefore` для поля `dueDate`
Діапазон `dueAfter` і `dueBefore` для поля `dueDate`
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ