У попередній статті ми реалізували Specification Pattern для простих WHERE-умов: фільтрація за жанром, роком випуску, тривалістю. Ці специфікації генерують SQL-фрагменти типу g.name = ? або ab.duration BETWEEN ? AND ?, що легко комбінуються через AND/OR.
Але реальні бізнес-вимоги часто виходять за межі простих умов:
Запит 1: Популярні автори
COUNT(*) та підзапит.Запит 2: Аудіокниги популярних авторів
IN (SELECT ...).Запит 3: Жанри з великою кількістю книг
GROUP BY з HAVING COUNT(*) > 20.Чи можна реалізувати такі запити через Specification Pattern? Так, можна. Але виникає питання: чи варто? У цій статті ми розглянемо:
Бізнес-правило: «Аудіокнига належить популярному автору, якщо у цього автора більше 10 опублікованих книг».
SQL-запит:
SELECT ab.id, ab.title, ...
FROM audiobooks ab
WHERE ab.author_id IN (
SELECT author_id
FROM audiobooks
GROUP BY author_id
HAVING COUNT(*) > 10
)
Реалізуємо це як специфікацію:
package com.example.audiobook.specification.audiobook;
import com.example.audiobook.domain.Audiobook;
import com.example.audiobook.specification.Specification;
import java.util.List;
/**
* Специфікація: аудіокнига належить популярному автору (більше N публікацій).
* <p>
* SQL: {@code ab.author_id IN (SELECT author_id FROM audiobooks GROUP BY author_id HAVING COUNT(*) > ?)}.
* <p>
* Використовує підзапит з агрегатною функцією COUNT(*).
*/
public class PopularAuthorSpecification implements Specification<Audiobook> {
private final int minBooksCount;
/**
* @param minBooksCount мінімальна кількість опублікованих книг автора
*/
public PopularAuthorSpecification(int minBooksCount) {
this.minBooksCount = minBooksCount;
}
@Override
public boolean isSatisfiedBy(Audiobook candidate) {
// In-memory перевірка вимагає завантаження всіх книг автора
// Для спрощення припускаємо, що Author має метод getPublishedBooksCount()
// У реальності це може бути окремий запит до БД
return candidate.getAuthor() != null
&& candidate.getAuthor().getPublishedBooksCount() > minBooksCount;
}
@Override
public String toSql() {
return """
ab.author_id IN (
SELECT author_id
FROM audiobooks
GROUP BY author_id
HAVING COUNT(*) > ?
)
""";
}
@Override
public List<Object> getParameters() {
return List.of(minBooksCount);
}
}
Використання:
// Знайти аудіокниги популярних авторів (більше 10 публікацій)
Specification<Audiobook> popularAuthors = new PopularAuthorSpecification(10);
List<Audiobook> books = audiobookRepository.findAll(popularAuthors);
// Композиція: популярні автори + жанр "Фантастика" + останні 5 років
Specification<Audiobook> query = popularAuthors
.and(new GenreSpecification("Фантастика"))
.and(new YearRangeSpecification(2019, null));
List<Audiobook> result = audiobookRepository.findAll(query);
Переваги підходу:
and()/or()findAll(Specification) обробляє всі випадкиНедоліки:
isSatisfiedBy: in-memory перевірка вимагає додаткових запитів до БД або попереднього завантаження данихАльтернатива IN (SELECT ...) — використання EXISTS, що часто ефективніше:
package com.example.audiobook.specification.audiobook;
import com.example.audiobook.domain.Audiobook;
import com.example.audiobook.specification.Specification;
import java.util.List;
/**
* Специфікація: аудіокнига належить популярному автору (версія з EXISTS).
* <p>
* SQL: {@code EXISTS (SELECT 1 FROM audiobooks ab2 WHERE ab2.author_id = ab.author_id
* GROUP BY ab2.author_id HAVING COUNT(*) > ?)}.
* <p>
* EXISTS зазвичай ефективніший за IN для великих таблиць.
*/
public class PopularAuthorExistsSpecification implements Specification<Audiobook> {
private final int minBooksCount;
public PopularAuthorExistsSpecification(int minBooksCount) {
this.minBooksCount = minBooksCount;
}
@Override
public boolean isSatisfiedBy(Audiobook candidate) {
return candidate.getAuthor() != null
&& candidate.getAuthor().getPublishedBooksCount() > minBooksCount;
}
@Override
public String toSql() {
return """
EXISTS (
SELECT 1
FROM audiobooks ab2
WHERE ab2.author_id = ab.author_id
GROUP BY ab2.author_id
HAVING COUNT(*) > ?
)
""";
}
@Override
public List<Object> getParameters() {
return List.of(minBooksCount);
}
}
Порівняння IN vs EXISTS:
| Характеристика | IN (SELECT ...) | EXISTS (SELECT ...) |
|---|---|---|
| Продуктивність | ❌ Завантажує всі ID у пам'ять | ✅ Зупиняється на першому збігу |
| Читабельність | ✅ Зрозуміліший для простих випадків | ❌ Складніший синтаксис |
| Підтримка NULL | ❌ Проблеми з NULL у підзапиті | ✅ Коректно обробляє NULL |
| Оптимізація БД | ❌ Може бути неефективним | ✅ Краще оптимізується |
EXISTS. Для отримання конкретних значень — IN.Альтернатива складним специфікаціям — конкретні методи у репозиторії. Розглянемо той самий сценарій:
package com.example.audiobook.repository;
import com.example.audiobook.domain.Audiobook;
import java.util.List;
import java.util.UUID;
/**
* Специфічний інтерфейс репозиторію для сутності Audiobook.
*/
public interface AudiobookRepository extends Repository<Audiobook, UUID> {
// Базові специфічні методи
List<Audiobook> findByAuthorId(UUID authorId);
List<Audiobook> findByGenreName(String genreName);
// Складний запит як окремий метод
/**
* Знаходить аудіокниги авторів, у яких більше заданої кількості публікацій.
*
* @param minBooksCount мінімальна кількість книг автора
* @return список аудіокниг популярних авторів
*/
List<Audiobook> findByPopularAuthors(int minBooksCount);
// Універсальний метод для специфікацій
List<Audiobook> findAll(Specification<Audiobook> spec);
}
Реалізація у JdbcAudiobookRepository:
@Override
public List<Audiobook> findByPopularAuthors(int minBooksCount) {
String sql = SQL_SELECT_BASE + """
WHERE ab.author_id IN (
SELECT author_id
FROM audiobooks
GROUP BY author_id
HAVING COUNT(*) > ?
)
ORDER BY ab.release_year DESC
""";
List<Audiobook> books = new ArrayList<>();
try (Connection conn = connectionManager.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setInt(1, minBooksCount);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
books.add(mapRow(rs));
}
}
} catch (SQLException e) {
throw new DatabaseException(
"Помилка findByPopularAuthors для minBooksCount=" + minBooksCount, e);
}
return books;
}
Використання:
// Простий виклик
List<Audiobook> books = audiobookRepository.findByPopularAuthors(10);
// Але композиція неможлива — потрібен окремий метод для кожної комбінації
// findByPopularAuthorsAndGenre(int minBooks, String genre)
// findByPopularAuthorsAndYearRange(int minBooks, int minYear, int maxYear)
// ...
Переваги підходу:
findByPopularAuthors(10) зрозуміліший за findAll(new PopularAuthorSpecification(10))ORDER BY, LIMITisSatisfiedBy: не потрібна — метод працює лише з БДНедоліки:
Оптимальний підхід — комбінація обох стратегій. Ось критерії вибору:
Коли:
isSatisfiedBy)Приклади:
// Прості умови, що легко комбінуються
new GenreSpecification("Фантастика")
new YearRangeSpecification(2020, 2023)
new DurationRangeSpecification(3600, 7200)
new TitleContainsSpecification("Кобзар")
// Композиція
Specification<Audiobook> query = genre
.and(yearRange)
.and(duration);
Коли:
Приклади:
// Складні запити як окремі методи
List<Audiobook> findByPopularAuthors(int minBooks)
List<Audiobook> findRecentByGenre(String genre, int days)
Map<String, Long> countByGenre() // повертає Map, не List<Audiobook>
List<AuthorStatistics> getAuthorStatistics() // повертає DTO
Коли:
Specification як опціональний параметрПриклад:
// Метод з опціональною специфікацією
List<Audiobook> findByPopularAuthors(
int minBooksCount,
Specification<Audiobook> additionalFilter
) {
String baseSql = SQL_SELECT_BASE + """
WHERE ab.author_id IN (
SELECT author_id FROM audiobooks
GROUP BY author_id HAVING COUNT(*) > ?
)
""";
// Додаємо специфікацію, якщо передана
if (additionalFilter != null) {
baseSql += " AND (" + additionalFilter.toSql() + ")";
}
// ... виконання запиту
}
// Використання
repo.findByPopularAuthors(10, null); // без фільтра
repo.findByPopularAuthors(10, new GenreSpecification("Фантастика")); // з фільтром
Розглянемо запит, що не підходить для Specification Pattern:
Бізнес-вимога: Отримати статистику по жанрах — кількість книг, середню тривалість, найновіший рік випуску.
SQL:
SELECT g.name AS genre_name,
COUNT(*) AS books_count,
AVG(ab.duration) AS avg_duration,
MAX(ab.release_year) AS latest_year
FROM audiobooks ab
JOIN genres g ON ab.genre_id = g.id
GROUP BY g.name
HAVING COUNT(*) > 5
ORDER BY books_count DESC
Чому не підходить Specification?
List<Audiobook>, а агреговані даніCOUNT(*), AVG(), MAX() не є частиною WHEREПравильне рішення — DTO та спеціалізований метод:
package com.example.audiobook.dto;
/**
* DTO для статистики жанру.
*/
public record GenreStatistics(
String genreName,
long booksCount,
double avgDuration,
int latestYear
) {}
package com.example.audiobook.repository;
import com.example.audiobook.dto.GenreStatistics;
import java.util.List;
public interface AudiobookRepository extends Repository<Audiobook, UUID> {
// ... інші методи
/**
* Повертає статистику по жанрах (кількість книг, середня тривалість, останній рік).
* Включає лише жанри з більше ніж 5 книгами.
*
* @return список статистики, відсортований за кількістю книг (спадання)
*/
List<GenreStatistics> getGenreStatistics();
}
@Override
public List<GenreStatistics> getGenreStatistics() {
String sql = """
SELECT g.name AS genre_name,
COUNT(*) AS books_count,
AVG(ab.duration) AS avg_duration,
MAX(ab.release_year) AS latest_year
FROM audiobooks ab
JOIN genres g ON ab.genre_id = g.id
GROUP BY g.name
HAVING COUNT(*) > 5
ORDER BY books_count DESC
""";
List<GenreStatistics> stats = new ArrayList<>();
try (Connection conn = connectionManager.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
stats.add(new GenreStatistics(
rs.getString("genre_name"),
rs.getLong("books_count"),
rs.getDouble("avg_duration"),
rs.getInt("latest_year")
));
}
} catch (SQLException e) {
throw new DatabaseException("Помилка getGenreStatistics", e);
}
return stats;
}
Використання:
List<GenreStatistics> stats = audiobookRepository.getGenreStatistics();
for (GenreStatistics stat : stats) {
System.out.printf("Жанр: %s, Книг: %d, Середня тривалість: %.0f сек, Останній рік: %d%n",
stat.genreName(), stat.booksCount(), stat.avgDuration(), stat.latestYear());
}
| Критерій | Specification | Конкретний метод | Гібридний |
|---|---|---|---|
| Композиція | ✅ Повна | ❌ Відсутня | ⚠️ Обмежена |
| Читабельність | ⚠️ Середня | ✅ Висока | ✅ Висока |
| Продуктивність | ⚠️ Залежить від SQL | ✅ Оптимізована | ✅ Оптимізована |
| Підтримуваність | ✅ Легко змінювати | ⚠️ Дублювання SQL | ✅ Баланс |
| Тестування | ✅ In-memory тести | ❌ Потребує БД | ❌ Потребує БД |
| Складні запити | ⚠️ Можливо, але складно | ✅ Природно | ✅ Природно |
| Агрегації у SELECT | ❌ Неможливо | ✅ Так | ✅ Так |
| Повторне використання | ✅ Висока | ❌ Низька | ⚠️ Середня |
На основі досвіду реальних проєктів, ось рекомендована стратегія:
1. Прості фільтри → Specification
Використовуйте для:
Приклад:
new GenreSpecification("Фантастика")
new YearRangeSpecification(2020, 2023)
new TitleContainsSpecification("Кобзар")
2. Складні запити → Конкретний метод
Використовуйте для:
Приклад:
findByPopularAuthors(int minBooks)
getGenreStatistics()
findRecentTrending(int days)
3. Динамічні фільтри → Гібридний
Використовуйте для:
Приклад:
findByPopularAuthors(
int minBooks,
Specification<Audiobook> filter
)
Specification Pattern є потужним інструментом для композиції бізнес-правил, але не є універсальним рішенням для всіх типів запитів. Ключові висновки:
1. Специфікації ідеальні для:
2. Конкретні методи кращі для:
3. Гібридний підхід дає:
Золоте правило: Якщо запит змінює структуру ResultSet (GROUP BY, агрегації у SELECT) або повертає не сутності — використовуйте конкретний метод. Якщо запит фільтрує існуючі сутності — використовуйте Specification.
Реалізуйте AudiobookWithFilesSpecification — аудіокнига має хоча б один файл у таблиці audiobook_files.
Використовуйте EXISTS (SELECT 1 FROM audiobook_files WHERE audiobook_id = ab.id).
Реалізуйте метод findRecentByGenre(String genreName, int daysAgo), що знаходить аудіокниги заданого жанру, додані за останні N днів.
Підказка: використовуйте SQL-функцію CURRENT_DATE - INTERVAL '? days' або DATE_SUB(CURRENT_DATE, INTERVAL ? DAY).
Реалізуйте метод:
List<Audiobook> findByAuthor(
UUID authorId,
Specification<Audiobook> additionalFilter
)
Базовий запит фільтрує за author_id, а additionalFilter додає додаткові умови (жанр, рік, тривалість).
Реалізуйте метод getAuthorStatistics(), що повертає List<AuthorStatistics>:
record AuthorStatistics(
String authorName,
long booksCount,
int totalDuration,
int firstYear,
int lastYear
) {}
SQL має містити GROUP BY author_id з агрегатними функціями.
Реалізуйте AudiobookWithoutProgressSpecification(UUID userId) — аудіокниги, які користувач не почав слухати (немає запису у listening_progresses).
Використовуйте NOT EXISTS (SELECT 1 FROM listening_progresses WHERE audiobook_id = ab.id AND user_id = ?).
Specification Pattern: Композиція бізнес-правил для складних запитів
Від жорстко закодованих методів пошуку до гнучких композицій: реалізація Specification Pattern за Еріком Евансом, інтеграція з Repository через SqlSpecification, логічні оператори AND/OR/NOT та побудова динамічних WHERE-умов.
Асинхронність у JDBC: Від блокуючих викликів до CompletableFuture
Від синхронних блокуючих операцій до асинхронної обробки: реалізація AsyncRepository через ExecutorService, інтеграція з CompletableFuture, паралельне виконання запитів та координація асинхронних операцій у транзакціях.