У попередніх статтях ми побудували архітектуру Repository + Data Mapper, що успішно відокремила доменну модель від SQL-логіки. Репозиторій JdbcAuthorRepository містить стандартні CRUD-операції (findById, findAll, save, update, deleteById) та кілька специфічних методів пошуку:
public interface AuthorRepository extends Repository<Author, UUID> {
List<Author> findByLastName(String lastNamePart);
Optional<Author> findByFullName(String lastName, String firstName);
}
Це працює для простих сценаріїв. Але що станеться, коли бізнес-вимоги ускладнюються? Розглянемо реальні запити, що виникають у системі управління аудіоплатформою:
Запит 1: Фільтрація авторів
Запит 2: Пошук аудіокниг
Запит 3: Динамічний фільтр
Наївний підхід — створити окремий метод для кожної комбінації:
List<Audiobook> findByGenre(String genre);
List<Audiobook> findByGenreAndYear(String genre, Integer year);
List<Audiobook> findByGenreAndYearRange(String genre, Integer minYear, Integer maxYear);
List<Audiobook> findByGenreAndMinDuration(String genre, Integer minDuration);
// ... ще десятки методів для всіх комбінацій
Це призводить до комбінаторного вибуху: кожна нова вимога множить кількість методів. Якщо у системі 5 критеріїв пошуку, кількість можливих комбінацій — 2⁵ = 32. Для 10 критеріїв — 1024 методи. Репозиторій перетворюється на нечитабельний монстр із сотнями рядків однотипного коду.
Друга проблема — дублювання SQL-логіки. Умова WHERE price <= ? повторюється у findByMaxPrice(), findByGenreAndMaxPrice(), findByYearAndMaxPrice() тощо. Зміна логіки (наприклад, додавання перевірки price > 0) вимагає правки у десятках місць.
Третя проблема — відсутність повторного використання бізнес-правил. Умова «аудіокнига доступна для покупки» може означати: ціна встановлена, мова підтримується платформою, жанр не заборонений у регіоні користувача. Це бізнес-правило, що має бути виражене один раз і використане скрізь — у пошуку, валідації, звітах.
Саме для вирішення цих проблем Ерік Еванс у книзі «Domain-Driven Design» (2003) описав патерн Specification — спосіб інкапсуляції бізнес-правил у композиційні об'єкти.
У книзі «Domain-Driven Design: Tackling Complexity in the Heart of Software» (2003) Ерік Еванс визначає Specification так:
«A Specification is a predicate that determines if an object satisfies certain criteria. It can be used to select objects from a collection, validate objects, or specify what kind of object should be created.»
«Специфікація — це предикат, що визначає, чи задовольняє об'єкт певним критеріям. Вона може використовуватися для вибору об'єктів з колекції, валідації об'єктів або специфікації того, який об'єкт має бути створений.»
Ключова ідея: бізнес-правило інкапсульоване в окремий об'єкт, що має метод isSatisfiedBy(T candidate). Цей об'єкт можна комбінувати з іншими через логічні оператори (AND, OR, NOT), створюючи складні умови з простих будівельних блоків.
Зверніть увагу на композиційну структуру: AndSpecification, OrSpecification та NotSpecification самі реалізують Specification<T> і містять посилання на інші специфікації. Це класичний Composite Pattern (GoF) — дерево об'єктів, де листки (прості специфікації) і вузли (логічні оператори) мають однаковий інтерфейс.
Розглянемо, як виглядає клієнтський код із Specification:
// Прості специфікації — будівельні блоки
Specification<Audiobook> poetry = new GenreSpec("Поезія");
Specification<Audiobook> prose = new GenreSpec("Проза");
Specification<Audiobook> after2020 = new YearAfterSpec(2020);
Specification<Audiobook> affordable = new PriceRangeSpec(
BigDecimal.valueOf(100),
BigDecimal.valueOf(500)
);
Specification<Audiobook> ukrainian = new LanguageSpec("українська");
// Композиція через логічні оператори
Specification<Audiobook> poetryOrProse = poetry.or(prose);
Specification<Audiobook> complexQuery = poetryOrProse
.and(after2020)
.and(affordable)
.and(ukrainian);
// Використання у репозиторії
List<Audiobook> results = audiobookRepository.findAll(complexQuery);
Порівняймо з наївним підходом:
// Жорстко закодований метод у репозиторії
List<Audiobook> findByGenresAndYearAndPriceAndLanguage(
List<String> genres,
Integer minYear,
BigDecimal minPrice,
BigDecimal maxPrice,
String language
) {
// SQL з багатьма умовами WHERE
String sql = """
SELECT ... FROM audiobooks ab
JOIN genres g ON ab.genre_id = g.id
WHERE (g.name = ? OR g.name = ?)
AND ab.year > ?
AND ab.price BETWEEN ? AND ?
AND ab.language = ?
""";
// ... JDBC-код
}
// Виклик
List<Audiobook> results = repo.findByGenresAndYearAndPriceAndLanguage(
List.of("Поезія", "Проза"), 2020,
BigDecimal.valueOf(100), BigDecimal.valueOf(500),
"українська"
);
// Гнучка композиція — жодних нових методів у репозиторії
Specification<Audiobook> spec = new GenreSpec("Поезія")
.or(new GenreSpec("Проза"))
.and(new YearAfterSpec(2020))
.and(new PriceRangeSpec(BigDecimal.valueOf(100), BigDecimal.valueOf(500)))
.and(new LanguageSpec("українська"));
List<Audiobook> results = repo.findAll(spec);
Ключова перевага: репозиторій має лише один метод findAll(Specification<T> spec) замість десятків спеціалізованих методів. Нові комбінації критеріїв не вимагають змін у репозиторії — лише створення нових специфікацій або композиції існуючих.
Specification Pattern може бути реалізований двома принципово різними способами, залежно від того, де виконується фільтрація:
In-Memory Specification
Фільтрація у Java-пам'яті:
isSatisfiedBy(T candidate) перевіряє об'єкт у пам'ятіstream().filter(spec::isSatisfiedBy)Переваги:
Недоліки:
SQL Specification
Фільтрація на рівні БД:
WHERE умову)Переваги:
Недоліки:
Для enterprise-систем із великими обсягами даних SQL Specification є єдиним прийнятним варіантом. Завантаження 100 000 записів у пам'ять для фільтрації 10 з них — неприпустима марнотратність.
У цій статті ми реалізуємо SQL Specification — підхід, що генерує WHERE-умови для JDBC-запитів.
Почнемо з базового інтерфейсу. На відміну від класичного визначення Еванса (що містить лише isSatisfiedBy), наш інтерфейс буде dual-mode: підтримувати і in-memory перевірку, і SQL-генерацію.
package com.example.audiobook.specification;
/**
* Базовий інтерфейс Specification Pattern за Еріком Евансом (DDD, 2003).
* <p>
* Specification інкапсулює бізнес-правило у вигляді предиката, що може бути:
* <ul>
* <li>Застосований до об'єкта у пам'яті ({@link #isSatisfiedBy});</li>
* <li>Перетворений на SQL WHERE-умову ({@link #toSql});</li>
* <li>Скомбінований з іншими специфікаціями ({@link #and}, {@link #or}, {@link #not}).</li>
* </ul>
* <p>
* <b>Приклад використання:</b>
* <pre>{@code
* Specification<Audiobook> affordable = new PriceRangeSpec(100, 500);
* Specification<Audiobook> ukrainian = new LanguageSpec("українська");
* Specification<Audiobook> query = affordable.and(ukrainian);
*
* // In-memory фільтрація
* boolean matches = query.isSatisfiedBy(someBook);
*
* // SQL-генерація
* String whereClause = query.toSql(); // "price BETWEEN ? AND ? AND language = ?"
* List<Object> params = query.getParameters(); // [100, 500, "українська"]
* }</pre>
*
* @param <T> тип доменної сутності (Author, Genre, Audiobook)
*/
public interface Specification<T> {
/**
* Перевіряє, чи задовольняє об'єкт умовам специфікації (in-memory).
* <p>
* Використовується для валідації об'єктів у пам'яті або фільтрації
* невеликих колекцій через {@code stream().filter(spec::isSatisfiedBy)}.
*
* @param candidate об'єкт для перевірки
* @return {@code true} якщо об'єкт задовольняє умовам
*/
boolean isSatisfiedBy(T candidate);
/**
* Генерує SQL WHERE-умову для цієї специфікації.
* <p>
* Повертає фрагмент SQL без ключового слова {@code WHERE}.
* Параметри представлені як {@code ?} (JDBC placeholders).
* <p>
* <b>Приклад:</b> {@code "price <= ? AND language = ?"}
*
* @return SQL-фрагмент для WHERE-умови
*/
String toSql();
/**
* Повертає список параметрів для {@link #toSql()} у порядку появи {@code ?}.
* <p>
* Ці значення передаються у {@link java.sql.PreparedStatement#setObject}.
*
* @return список параметрів (може бути порожнім)
*/
java.util.List<Object> getParameters();
/**
* Логічне AND: обидві специфікації мають бути задоволені.
*
* @param other друга специфікація
* @return нова специфікація {@code this AND other}
*/
default Specification<T> and(Specification<T> other) {
return new AndSpecification<>(this, other);
}
/**
* Логічне OR: хоча б одна специфікація має бути задоволена.
*
* @param other друга специфікація
* @return нова специфікація {@code this OR other}
*/
default Specification<T> or(Specification<T> other) {
return new OrSpecification<>(this, other);
}
/**
* Логічне NOT: інверсія умови.
*
* @return нова специфікація {@code NOT this}
*/
default Specification<T> not() {
return new NotSpecification<>(this);
}
}
Ключові архітектурні рішення:
isSatisfiedBy + toSql): dual-mode інтерфейс дозволяє використовувати ту саму специфікацію і для in-memory фільтрації, і для SQL-запитів. Це корисно для тестування: можна перевірити логіку специфікації без бази даних.getParameters): параметри відокремлені від SQL-рядка — це забезпечує безпеку від SQL-ін'єкцій. Репозиторій передає їх у PreparedStatement.setObject().and, or, not): композиційні оператори реалізовані як default-методи Java 8+. Це дозволяє писати spec1.and(spec2).or(spec3) без явного створення AndSpecification вручну.Тепер реалізуємо три логічні оператори. Вони є декораторами (Decorator Pattern, GoF) — обгортають інші специфікації і змінюють їх поведінку.
package com.example.audiobook.specification;
import java.util.ArrayList;
import java.util.List;
/**
* Логічне AND: обидві специфікації мають бути задоволені.
* <p>
* SQL-генерація: {@code (left) AND (right)}.
* Дужки обов'язкові для правильного пріоритету операторів.
*/
public class AndSpecification<T> implements Specification<T> {
private final Specification<T> left;
private final Specification<T> right;
public AndSpecification(Specification<T> left, Specification<T> right) {
this.left = left;
this.right = right;
}
@Override
public boolean isSatisfiedBy(T candidate) {
// Обидві умови мають бути true
return left.isSatisfiedBy(candidate) && right.isSatisfiedBy(candidate);
}
@Override
public String toSql() {
// Дужки критично важливі: (a OR b) AND c ≠ a OR (b AND c)
return "(" + left.toSql() + ") AND (" + right.toSql() + ")";
}
@Override
public List<Object> getParameters() {
// Об'єднуємо параметри обох специфікацій у порядку появи
List<Object> params = new ArrayList<>();
params.addAll(left.getParameters());
params.addAll(right.getParameters());
return params;
}
}
package com.example.audiobook.specification;
import java.util.ArrayList;
import java.util.List;
/**
* Логічне OR: хоча б одна специфікація має бути задоволена.
* <p>
* SQL-генерація: {@code (left) OR (right)}.
*/
public class OrSpecification<T> implements Specification<T> {
private final Specification<T> left;
private final Specification<T> right;
public OrSpecification(Specification<T> left, Specification<T> right) {
this.left = left;
this.right = right;
}
@Override
public boolean isSatisfiedBy(T candidate) {
// Достатньо однієї true
return left.isSatisfiedBy(candidate) || right.isSatisfiedBy(candidate);
}
@Override
public String toSql() {
return "(" + left.toSql() + ") OR (" + right.toSql() + ")";
}
@Override
public List<Object> getParameters() {
List<Object> params = new ArrayList<>();
params.addAll(left.getParameters());
params.addAll(right.getParameters());
return params;
}
}
package com.example.audiobook.specification;
import java.util.List;
/**
* Логічне NOT: інверсія умови.
* <p>
* SQL-генерація: {@code NOT (spec)}.
*/
public class NotSpecification<T> implements Specification<T> {
private final Specification<T> spec;
public NotSpecification(Specification<T> spec) {
this.spec = spec;
}
@Override
public boolean isSatisfiedBy(T candidate) {
// Інверсія результату
return !spec.isSatisfiedBy(candidate);
}
@Override
public String toSql() {
return "NOT (" + spec.toSql() + ")";
}
@Override
public List<Object> getParameters() {
// Параметри залишаються незмінними
return spec.getParameters();
}
}
Чому дужки у toSql()? Розглянемо вираз без дужок:
-- Без дужок (НЕПРАВИЛЬНО):
SELECT * FROM audiobooks WHERE price <= 500 AND language = 'uk' OR year > 2020
-- SQL інтерпретує як:
(price <= 500 AND language = 'uk') OR (year > 2020)
-- Але ми хотіли:
price <= 500 AND (language = 'uk' OR year > 2020)
Дужки у AndSpecification та OrSpecification гарантують правильний пріоритет операторів незалежно від порядку композиції.
Тепер найважливіша частина: як репозиторій використовує специфікації для побудови SQL-запитів? Додамо новий метод до інтерфейсу Repository<T, ID>:
package com.example.audiobook.repository;
import com.example.audiobook.specification.Specification;
import java.util.List;
import java.util.Optional;
/**
* Базовий контракт репозиторію з підтримкою Specification Pattern.
*/
public interface Repository<T, ID> {
Optional<T> findById(ID id);
List<T> findAll();
void save(T entity);
void update(T entity);
boolean deleteById(ID id);
long count();
boolean existsById(ID id);
/**
* Знаходить всі сутності, що задовольняють заданій специфікації.
* <p>
* Специфікація перетворюється на SQL WHERE-умову, що виконується на рівні БД.
* Це забезпечує ефективність для великих таблиць (використання індексів).
*
* @param spec специфікація для фільтрації
* @return список сутностей, що задовольняють умовам (може бути порожнім)
*/
List<T> findAll(Specification<T> spec);
}
Тепер реалізуємо цей метод у AbstractJdbcRepository. Ключова ідея: базовий SELECT-запит доповнюється WHERE-умовою зі специфікації.
package com.example.audiobook.repository.jdbc;
import com.example.audiobook.db.ConnectionManager;
import com.example.audiobook.db.DatabaseException;
import com.example.audiobook.repository.Repository;
import com.example.audiobook.specification.Specification;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Абстрактний JDBC-репозиторій з підтримкою Specification Pattern.
*/
public abstract class AbstractJdbcRepository<T, ID> implements Repository<T, ID> {
protected final ConnectionManager connectionManager;
protected AbstractJdbcRepository(ConnectionManager connectionManager) {
this.connectionManager = connectionManager;
}
// ... існуючі методи findById, findAll, save, update, deleteById ...
/**
* Знаходить сутності за специфікацією.
* <p>
* Алгоритм:
* <ol>
* <li>Отримати базовий SELECT-запит від підкласу ({@link #getSelectAllSql()});</li>
* <li>Додати WHERE-умову зі специфікації ({@code spec.toSql()});</li>
* <li>Встановити параметри ({@code spec.getParameters()});</li>
* <li>Виконати запит і змаппити результати.</li>
* </ol>
*/
@Override
public List<T> findAll(Specification<T> spec) {
// Базовий SELECT без WHERE (наприклад, "SELECT ... FROM audiobooks ab JOIN ...")
String baseSql = getSelectAllSql();
// Додаємо WHERE-умову зі специфікації
String sql = baseSql + " WHERE " + spec.toSql();
List<T> results = new ArrayList<>();
try (Connection conn = connectionManager.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
// Встановлюємо параметри зі специфікації
List<Object> params = spec.getParameters();
for (int i = 0; i < params.size(); i++) {
stmt.setObject(i + 1, params.get(i));
}
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
results.add(mapRow(rs));
}
}
} catch (SQLException e) {
throw new DatabaseException(
"Помилка findAll(Specification) для таблиці " + getTableName(), e);
}
return results;
}
// Абстрактні методи, що підкласи зобов'язані реалізувати
protected abstract T mapRow(ResultSet rs) throws SQLException;
protected abstract String getTableName();
protected abstract String getSelectAllSql();
// ... інші абстрактні методи
}
Ключові моменти реалізації:
baseSql + " WHERE " + spec.toSql()): базовий SELECT доповнюється WHERE-умовою. Якщо базовий запит вже містить WHERE (наприклад, для soft-delete: WHERE deleted_at IS NULL), потрібно використовувати AND замість WHERE.PreparedStatement у порядку появи. Це забезпечує безпеку від SQL-ін'єкцій.mapRow(rs)): маппінг залишається незмінним — специфікація не впливає на структуру ResultSet, лише на кількість рядків.Тепер реалізуємо прості (листкові) специфікації для доменної моделі Audiobook, використовуючи реальні поля зі схеми БД. Нагадаємо структуру таблиці audiobooks:
CREATE TABLE audiobooks (
id UUID PRIMARY KEY,
author_id UUID NOT NULL REFERENCES authors(id),
genre_id UUID NOT NULL REFERENCES genres(id),
title VARCHAR(255) NOT NULL,
duration INTEGER NOT NULL CHECK (duration > 0),
release_year INTEGER NOT NULL CHECK (release_year >= 1900),
description TEXT,
cover_image_path VARCHAR(2048)
);
Ключові поля для специфікацій: duration, release_year, genre_id, title.
package com.example.audiobook.specification.audiobook;
import com.example.audiobook.domain.Audiobook;
import com.example.audiobook.specification.Specification;
import java.util.ArrayList;
import java.util.List;
/**
* Специфікація: аудіокнига з тривалістю у заданому діапазоні (у секундах).
* <p>
* SQL: {@code duration BETWEEN ? AND ?} або {@code duration >= ?} / {@code duration <= ?}.
*/
public class DurationRangeSpecification implements Specification<Audiobook> {
private final Integer minDuration; // у секундах
private final Integer maxDuration; // у секундах
/**
* @param minDuration мінімальна тривалість (включно), може бути {@code null} — без нижньої межі
* @param maxDuration максимальна тривалість (включно), може бути {@code null} — без верхньої межі
*/
public DurationRangeSpecification(Integer minDuration, Integer maxDuration) {
this.minDuration = minDuration;
this.maxDuration = maxDuration;
}
@Override
public boolean isSatisfiedBy(Audiobook candidate) {
Integer duration = candidate.getDuration();
if (duration == null) return false;
boolean satisfiesMin = (minDuration == null) || duration >= minDuration;
boolean satisfiesMax = (maxDuration == null) || duration <= maxDuration;
return satisfiesMin && satisfiesMax;
}
@Override
public String toSql() {
if (minDuration != null && maxDuration != null) {
return "ab.duration BETWEEN ? AND ?";
} else if (minDuration != null) {
return "ab.duration >= ?";
} else if (maxDuration != null) {
return "ab.duration <= ?";
} else {
return "1=1"; // завжди true — немає обмежень
}
}
@Override
public List<Object> getParameters() {
List<Object> params = new ArrayList<>();
if (minDuration != null) params.add(minDuration);
if (maxDuration != null) params.add(maxDuration);
return params;
}
}
package com.example.audiobook.specification.audiobook;
import com.example.audiobook.domain.Audiobook;
import com.example.audiobook.specification.Specification;
import java.util.ArrayList;
import java.util.List;
/**
* Специфікація: аудіокнига опублікована у заданому діапазоні років.
* <p>
* SQL: {@code release_year BETWEEN ? AND ?}.
*/
public class YearRangeSpecification implements Specification<Audiobook> {
private final Integer minYear;
private final Integer maxYear;
/**
* @param minYear мінімальний рік (включно), може бути {@code null}
* @param maxYear максимальний рік (включно), може бути {@code null}
*/
public YearRangeSpecification(Integer minYear, Integer maxYear) {
this.minYear = minYear;
this.maxYear = maxYear;
}
@Override
public boolean isSatisfiedBy(Audiobook candidate) {
Integer year = candidate.getReleaseYear();
if (year == null) return false;
boolean satisfiesMin = (minYear == null) || year >= minYear;
boolean satisfiesMax = (maxYear == null) || year <= maxYear;
return satisfiesMin && satisfiesMax;
}
@Override
public String toSql() {
if (minYear != null && maxYear != null) {
return "ab.release_year BETWEEN ? AND ?";
} else if (minYear != null) {
return "ab.release_year >= ?";
} else if (maxYear != null) {
return "ab.release_year <= ?";
} else {
return "1=1";
}
}
@Override
public List<Object> getParameters() {
List<Object> params = new ArrayList<>();
if (minYear != null) params.add(minYear);
if (maxYear != null) params.add(maxYear);
return params;
}
}
package com.example.audiobook.specification.audiobook;
import com.example.audiobook.domain.Audiobook;
import com.example.audiobook.specification.Specification;
import java.util.List;
/**
* Специфікація: аудіокнига належить до заданого жанру.
* <p>
* SQL: {@code g.name = ?} (припускаємо, що у SELECT є JOIN до genres).
*/
public class GenreSpecification implements Specification<Audiobook> {
private final String genreName;
public GenreSpecification(String genreName) {
this.genreName = genreName;
}
@Override
public boolean isSatisfiedBy(Audiobook candidate) {
return candidate.getGenre() != null
&& genreName.equals(candidate.getGenre().getName());
}
@Override
public String toSql() {
// Припускаємо, що у SELECT є JOIN genres g
return "g.name = ?";
}
@Override
public List<Object> getParameters() {
return List.of(genreName);
}
}
package com.example.audiobook.specification.audiobook;
import com.example.audiobook.domain.Audiobook;
import com.example.audiobook.specification.Specification;
import java.util.List;
/**
* Специфікація: назва аудіокниги містить заданий підрядок (регістр-незалежний пошук).
* <p>
* SQL: {@code LOWER(ab.title) LIKE LOWER(?)}.
*/
public class TitleContainsSpecification implements Specification<Audiobook> {
private final String titlePart;
public TitleContainsSpecification(String titlePart) {
this.titlePart = titlePart;
}
@Override
public boolean isSatisfiedBy(Audiobook candidate) {
String title = candidate.getTitle();
return title != null && title.toLowerCase().contains(titlePart.toLowerCase());
}
@Override
public String toSql() {
return "LOWER(ab.title) LIKE LOWER(?)";
}
@Override
public List<Object> getParameters() {
return List.of("%" + titlePart + "%");
}
}
Тепер адаптуємо JdbcAudiobookRepository для роботи зі специфікаціями. Ключовий принцип: специфікація є додатковим методом пошуку, а не заміною існуючих специфічних методів.
package com.example.audiobook.repository.jdbc;
import com.example.audiobook.db.ConnectionManager;
import com.example.audiobook.db.DatabaseException;
import com.example.audiobook.domain.Author;
import com.example.audiobook.domain.Audiobook;
import com.example.audiobook.domain.Genre;
import com.example.audiobook.repository.AudiobookRepository;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* JDBC-реалізація {@link AudiobookRepository} з підтримкою Specification Pattern.
* <p>
* Клас зберігає всі специфічні методи пошуку (findByAuthorId, findByGenreName)
* і додає універсальний метод findAll(Specification) для складних запитів.
*/
public class JdbcAudiobookRepository
extends AbstractJdbcRepository<Audiobook, UUID>
implements AudiobookRepository {
/**
* Базовий SELECT з усіма JOIN для специфікацій.
* <p>
* Важливо: псевдоніми таблиць (ab, a, g) мають відповідати тим,
* що використовуються у специфікаціях (ab.duration, g.name тощо).
*/
private static final String SQL_SELECT_BASE = """
SELECT ab.id,
ab.title, ab.duration, ab.release_year,
ab.description, ab.cover_image_path,
a.id AS author_id,
a.first_name, a.last_name, a.bio, a.image_path,
g.id AS genre_id,
g.name AS genre_name,
g.description AS genre_description
FROM audiobooks ab
JOIN authors a ON ab.author_id = a.id
JOIN genres g ON ab.genre_id = g.id
""";
private static final String SQL_SELECT_BY_ID =
SQL_SELECT_BASE + "WHERE ab.id = ?";
private static final String SQL_INSERT = """
INSERT INTO audiobooks
(id, title, author_id, genre_id, duration, release_year, description, cover_image_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""";
private static final String SQL_UPDATE = """
UPDATE audiobooks
SET title = ?,
author_id = ?,
genre_id = ?,
duration = ?,
release_year = ?,
description = ?,
cover_image_path = ?
WHERE id = ?
""";
// Специфічні SQL для методів AudiobookRepository
private static final String SQL_FIND_BY_AUTHOR_ID =
SQL_SELECT_BASE + "WHERE ab.author_id = ? ORDER BY ab.release_year DESC";
private static final String SQL_FIND_BY_GENRE_NAME =
SQL_SELECT_BASE + "WHERE g.name = ? ORDER BY ab.release_year DESC";
public JdbcAudiobookRepository(ConnectionManager connectionManager) {
super(connectionManager);
}
// === Реалізація абстрактних методів AbstractJdbcRepository ===
@Override
protected String getTableName() {
return "audiobooks";
}
@Override
protected String getSelectByIdSql() {
return SQL_SELECT_BY_ID;
}
@Override
protected String getSelectAllSql() {
// Повертаємо базовий SELECT без WHERE — WHERE додається у findAll(Specification)
return SQL_SELECT_BASE;
}
@Override
protected String getInsertSql() {
return SQL_INSERT;
}
@Override
protected String getUpdateSql() {
return SQL_UPDATE;
}
/**
* Data Mapper: ResultSet → Audiobook з вкладеними Author та Genre.
*/
@Override
protected Audiobook mapRow(ResultSet rs) throws SQLException {
// Відновлюємо вкладені об'єкти з JOIN-рядка
Author author = new Author(
rs.getObject("author_id", UUID.class),
rs.getString("first_name"),
rs.getString("last_name"),
rs.getString("bio"),
rs.getString("image_path")
);
Genre genre = new Genre(
rs.getObject("genre_id", UUID.class),
rs.getString("genre_name"),
rs.getString("genre_description")
);
return new Audiobook(
rs.getObject("id", UUID.class),
rs.getString("title"),
author,
genre,
rs.getInt("duration"),
rs.getInt("release_year"),
rs.getString("description"),
rs.getString("cover_image_path")
);
}
@Override
protected void setInsertParams(PreparedStatement stmt, Audiobook audiobook) throws SQLException {
stmt.setObject(1, audiobook.getId());
stmt.setString(2, audiobook.getTitle());
stmt.setObject(3, audiobook.getAuthor().getId());
stmt.setObject(4, audiobook.getGenre().getId());
stmt.setInt(5, audiobook.getDuration());
stmt.setInt(6, audiobook.getReleaseYear());
stmt.setString(7, audiobook.getDescription());
stmt.setString(8, audiobook.getCoverImagePath());
}
@Override
protected void setUpdateParams(PreparedStatement stmt, Audiobook audiobook) throws SQLException {
stmt.setString(1, audiobook.getTitle());
stmt.setObject(2, audiobook.getAuthor().getId());
stmt.setObject(3, audiobook.getGenre().getId());
stmt.setInt(4, audiobook.getDuration());
stmt.setInt(5, audiobook.getReleaseYear());
stmt.setString(6, audiobook.getDescription());
stmt.setString(7, audiobook.getCoverImagePath());
stmt.setObject(8, audiobook.getId()); // WHERE id = ?
}
@Override
protected UUID getId(Audiobook audiobook) {
return audiobook.getId();
}
// === Специфічні методи AudiobookRepository — ЗБЕРІГАЄМО ЇХ ===
/**
* Знаходить всі аудіокниги заданого автора.
* Цей метод залишається у репозиторії — Specification не замінює його.
*/
@Override
public List<Audiobook> findByAuthorId(UUID authorId) {
List<Audiobook> books = new ArrayList<>();
try (Connection conn = connectionManager.getConnection();
PreparedStatement stmt = conn.prepareStatement(SQL_FIND_BY_AUTHOR_ID)) {
stmt.setObject(1, authorId);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
books.add(mapRow(rs));
}
}
} catch (SQLException e) {
throw new DatabaseException("Помилка findByAuthorId для: " + authorId, e);
}
return books;
}
/**
* Знаходить всі аудіокниги заданого жанру за назвою.
* Цей метод також залишається — він є зручним для простих запитів.
*/
@Override
public List<Audiobook> findByGenreName(String genreName) {
List<Audiobook> books = new ArrayList<>();
try (Connection conn = connectionManager.getConnection();
PreparedStatement stmt = conn.prepareStatement(SQL_FIND_BY_GENRE_NAME)) {
stmt.setString(1, genreName);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
books.add(mapRow(rs));
}
}
} catch (SQLException e) {
throw new DatabaseException("Помилка findByGenreName для: " + genreName, e);
}
return books;
}
// Метод findAll(Specification) успадкований від AbstractJdbcRepository
// і використовує getSelectAllSql() + spec.toSql()
}
Ключові архітектурні рішення:
SQL_SELECT_BASE): базовий SELECT містить усі поля зі схеми БД — duration, release_year, cover_image_path. Псевдоніми таблиць (ab, a, g) відповідають тим, що використовуються у специфікаціях.findByAuthorId): специфічний метод залишається у репозиторії. Specification Pattern не замінює його — він додає гнучкість для складних запитів, але прості запити залишаються простими.findByGenreName): аналогічно — зручний метод для частого сценарію. Якщо потрібен складний запит (жанр + рік + тривалість), використовується findAll(Specification).findAll(Specification<Audiobook> spec) успадкований від AbstractJdbcRepository і автоматично працює завдяки getSelectAllSql().Критична деталь: рядок 31 (SQL_SELECT_BASE) містить псевдонім g.name AS genre_name. Саме цей псевдонім використовується у GenreSpecification.toSql() ("g.name = ?"). Якби псевдоніми не збігалися — SQL-запит був би некоректним.
Тепер продемонструємо, як Specification Pattern вирішує проблему комбінаторного вибуху методів пошуку, використовуючи реальні поля зі схеми БД.
package com.example.audiobook;
import com.example.audiobook.db.ConnectionManager;
import com.example.audiobook.domain.Audiobook;
import com.example.audiobook.repository.AudiobookRepository;
import com.example.audiobook.repository.jdbc.JdbcAudiobookRepository;
import com.example.audiobook.specification.Specification;
import com.example.audiobook.specification.audiobook.*;
import java.util.List;
public class Main {
public static void main(String[] args) {
ConnectionManager cm = ConnectionManager.forH2("./data/audiobook_db");
AudiobookRepository repo = new JdbcAudiobookRepository(cm);
// === Сценарій 1: Простий запит ===
System.out.println("=== Аудіокниги жанру 'Поезія' ===");
Specification<Audiobook> poetry = new GenreSpecification("Поезія");
List<Audiobook> result1 = repo.findAll(poetry);
System.out.println("Знайдено: " + result1.size());
// === Сценарій 2: Композиція через AND ===
System.out.println("\n=== Поезія, опублікована після 2020 року ===");
Specification<Audiobook> after2020 = new YearRangeSpecification(2020, null);
Specification<Audiobook> query2 = poetry.and(after2020);
List<Audiobook> result2 = repo.findAll(query2);
System.out.println("Знайдено: " + result2.size());
// === Сценарій 3: Композиція через OR ===
System.out.println("\n=== Поезія АБО проза ===");
Specification<Audiobook> prose = new GenreSpecification("Проза");
Specification<Audiobook> poetryOrProse = poetry.or(prose);
List<Audiobook> result3 = repo.findAll(poetryOrProse);
System.out.println("Знайдено: " + result3.size());
// === Сценарій 4: Складна композиція ===
System.out.println("\n=== (Поезія АБО проза) І (2020-2023) І (тривалість 3600-7200 сек) ===");
Specification<Audiobook> yearRange = new YearRangeSpecification(2020, 2023);
Specification<Audiobook> durationRange = new DurationRangeSpecification(3600, 7200); // 1-2 години
Specification<Audiobook> complexQuery = poetryOrProse
.and(yearRange)
.and(durationRange);
List<Audiobook> result4 = repo.findAll(complexQuery);
System.out.println("Знайдено: " + result4.size());
// Виведемо згенерований SQL для діагностики
System.out.println("\nЗгенерований SQL WHERE:");
System.out.println(complexQuery.toSql());
System.out.println("\nПараметри:");
System.out.println(complexQuery.getParameters());
// === Сценарій 5: Динамічна побудова запиту ===
System.out.println("\n=== Динамічний фільтр (користувацький ввід) ===");
// Імітація користувацького вводу
String userGenre = "Фантастика"; // може бути null
Integer userMinYear = 2015; // може бути null
Integer userMaxDuration = 10800; // може бути null (3 години у секундах)
Specification<Audiobook> dynamicQuery = buildDynamicQuery(
userGenre, userMinYear, userMaxDuration
);
List<Audiobook> result5 = repo.findAll(dynamicQuery);
System.out.println("Знайдено: " + result5.size());
// === Сценарій 6: Пошук за назвою + жанр ===
System.out.println("\n=== Назва містить 'Кобзар' І жанр 'Поезія' ===");
Specification<Audiobook> titleContains = new TitleContainsSpecification("Кобзар");
Specification<Audiobook> query6 = titleContains.and(poetry);
List<Audiobook> result6 = repo.findAll(query6);
System.out.println("Знайдено: " + result6.size());
cm.close();
}
/**
* Будує специфікацію динамічно на основі користувацького вводу.
* Якщо параметр null — він не включається у запит.
*/
private static Specification<Audiobook> buildDynamicQuery(
String genre, Integer minYear, Integer maxDuration) {
// Початкова специфікація — завжди true (1=1)
Specification<Audiobook> spec = new AlwaysTrueSpecification<>();
if (genre != null) {
spec = spec.and(new GenreSpecification(genre));
}
if (minYear != null) {
spec = spec.and(new YearRangeSpecification(minYear, null));
}
if (maxDuration != null) {
spec = spec.and(new DurationRangeSpecification(null, maxDuration));
}
return spec;
}
}
Результат виконання:
Ключові спостереження:
poetry.and(after2020)): дві специфікації комбінуються через fluent API. Жодного нового методу у репозиторії не потрібно.poetry.or(prose)): логічне OR створює нову специфікацію, що може бути далі скомбінована через AND.AndSpecification та OrSpecification. Використовуються реальні поля: ab.release_year, ab.duration, g.name.buildDynamicQuery): динамічна побудова запиту на основі користувацького вводу. Якщо параметр null — він не включається у WHERE-умову.TitleContainsSpecification демонструє роботу з текстовими полями через LIKE.Для динамічних запитів корисна спеціальна специфікація, що завжди повертає true:
package com.example.audiobook.specification;
import java.util.Collections;
import java.util.List;
/**
* Специфікація, що завжди задоволена (нейтральний елемент для AND).
* <p>
* SQL: {@code 1=1} (завжди true).
* <p>
* Використовується як початкова точка для динамічної побудови запитів:
* <pre>{@code
* Specification<T> spec = new AlwaysTrueSpecification<>();
* if (condition1) spec = spec.and(new SomeSpec());
* if (condition2) spec = spec.and(new OtherSpec());
* }</pre>
*/
public class AlwaysTrueSpecification<T> implements Specification<T> {
@Override
public boolean isSatisfiedBy(T candidate) {
return true; // завжди задоволена
}
@Override
public String toSql() {
return "1=1"; // SQL-константа, що завжди true
}
@Override
public List<Object> getParameters() {
return Collections.emptyList(); // немає параметрів
}
}
Ця специфікація є нейтральним елементом для операції AND: AlwaysTrue AND X = X. Це дозволяє писати динамічні запити без спеціальної обробки першого елемента.
Підсумуємо, що змінилося у архітектурі після впровадження Specification Pattern:
// Репозиторій містить десятки спеціалізованих методів
public interface AudiobookRepository extends Repository<Audiobook, UUID> {
List<Audiobook> findByAuthorId(UUID authorId);
List<Audiobook> findByGenreName(String genreName);
// Для кожної комбінації критеріїв — окремий метод
List<Audiobook> findByGenreAndYear(String genre, Integer year);
List<Audiobook> findByGenreAndYearRange(String genre, Integer minYear, Integer maxYear);
List<Audiobook> findByGenreAndMinDuration(String genre, Integer minDuration);
List<Audiobook> findByYearAndDuration(Integer year, Integer minDuration);
List<Audiobook> findByGenreAndYearAndDuration(String genre, Integer year, Integer duration);
// ... ще десятки методів для всіх комбінацій
}
// Клієнтський код жорстко прив'язаний до методів
List<Audiobook> books = repo.findByGenreAndYearAndDuration(
"Поезія", 2020, 3600
);
// Нова комбінація критеріїв = новий метод у репозиторії
// Репозиторій зберігає специфічні методи + додає універсальний
public interface AudiobookRepository extends Repository<Audiobook, UUID> {
// Специфічні методи — ЗАЛИШАЮТЬСЯ для простих запитів
List<Audiobook> findByAuthorId(UUID authorId);
List<Audiobook> findByGenreName(String genreName);
// Універсальний метод для складних запитів — ДОДАЄТЬСЯ
List<Audiobook> findAll(Specification<Audiobook> spec);
}
// Клієнтський код будує запит через композицію
Specification<Audiobook> spec = new GenreSpecification("Поезія")
.and(new YearRangeSpecification(2020, null))
.and(new DurationRangeSpecification(3600, null));
List<Audiobook> books = repo.findAll(spec);
// Нова комбінація = нова композиція існуючих специфікацій
// Репозиторій НЕ змінюється
| Характеристика | Без Specification | З Specification |
|---|---|---|
| Кількість методів у репозиторії | O(2ⁿ) для n критеріїв | O(1) — один метод + специфічні |
| Повторне використання логіки | ❌ Дублювання SQL | ✅ Специфікації багаторазові |
| Динамічні запити | ❌ Складно | ✅ Природно |
| Тестування бізнес-правил | ❌ Потребує БД | ✅ In-memory isSatisfiedBy |
| Читабельність клієнтського коду | ❌ Довгі імена методів | ✅ Fluent API |
| Прості запити | ✅ Зручні методи | ✅ Зберігаються + Specification |
Незважаючи на переваги, Specification Pattern має низку обмежень, що стають помітними у реальних проєктах:
Специфікації генерують фрагменти SQL, що залежать від псевдонімів стовпців у базовому SELECT-запиті. Розглянемо GenreSpecification:
@Override
public String toSql() {
return "genre_name = ?"; // Припускає, що є псевдонім genre_name
}
Якщо у JdbcAudiobookRepository змінити псевдонім з g.name AS genre_name на g.name AS genre, всі специфікації, що використовують genre_name, стануть некоректними. Це порушує інкапсуляцію: специфікація (доменний шар) знає про деталі SQL-запиту (інфраструктурний шар).
Якщо специфікація потребує JOIN до таблиці, що не включена у базовий SELECT, виникає конфлікт. Наприклад, специфікація «автор має більше 10 опублікованих книг» потребує підзапиту або додаткового JOIN:
SELECT ... FROM audiobooks ab
JOIN authors a ON ab.author_id = a.id
WHERE a.id IN (
SELECT author_id FROM audiobooks GROUP BY author_id HAVING COUNT(*) > 10
)
Специфікація не може «додати» JOIN до базового SELECT — вона генерує лише WHERE-умову. Це обмежує виразність патерну.
Специфікації генерують SQL як рядки. Помилка у назві стовпця виявляється лише під час виконання:
@Override
public String toSql() {
return "genreName = ?"; // Помилка: має бути genre_name
}
// Компілятор не виявить помилку — SQLException під час виконання
Сучасні ORM (Hibernate Criteria API, JPA Specification, jOOQ) вирішують це через type-safe query builders, але це вимагає відмови від чистого JDBC.
Specification Pattern добре працює для WHERE-умов (включно з підзапитами та агрегаціями у WHERE), але має обмеження для:
ROW_NUMBER() OVER (PARTITION BY genre ORDER BY release_year) — не є частиною WHERESELECT genre, COUNT(*) GROUP BY genre — змінює структуру ResultSetДля таких сценаріїв потрібні спеціалізовані методи у репозиторії або Query Object Pattern.
Кожна специфікація реалізує одну і ту ж логіку двічі — для Java і для SQL:
// Java-версія
@Override
public boolean isSatisfiedBy(Audiobook candidate) {
return candidate.getPrice().compareTo(maxPrice) <= 0;
}
// SQL-версія
@Override
public String toSql() {
return "price <= ?";
}
Якщо логіка змінюється (наприклад, додається перевірка price > 0), треба оновити обидва методи. Це джерело помилок.
isSatisfiedBy. In-memory фільтрація виконується через окремі предикати (Predicate<T> у Java 8+), що не дублюють SQL-логіку.Specification Pattern є лише одним із підходів до вирішення проблеми динамічних запитів. Розглянемо альтернативи:
Criteria API (JPA)
Hibernate/JPA Criteria API:
Недоліки:
QueryDSL / jOOQ
Type-safe SQL builders:
Недоліки:
Specification (Spring Data)
Spring Data JPA Specification:
Недоліки:
Для чистого JDBC-проєкту без ORM наш підхід (SQL Specification) є оптимальним компромісом між гнучкістю та складністю. Він не вимагає додаткових залежностей і дає повний контроль над SQL.
Specification Pattern вирішує фундаментальну проблему enterprise-розробки: як виразити складні бізнес-правила у вигляді композиційних об'єктів, що можуть бути повторно використані у різних контекстах — пошуку, валідації, звітах.
Ключові досягнення патерну:
1. Усунення комбінаторного вибуху методів. Замість 2ⁿ методів для n критеріїв пошуку — один метод findAll(Specification<T>) і композиція специфікацій через and(), or(), not().
2. Повторне використання бізнес-правил. Специфікація «аудіокнига доступна для покупки» виражена один раз і використовується скрізь — у пошуку, валідації, звітах. Зміна правила вимагає правки в одному місці.
3. Читабельність клієнтського коду. Fluent API (affordable.and(ukrainian).and(after2020)) читається як природна мова і виражає намір, а не механізм.
4. Тестованість без бази даних. Метод isSatisfiedBy() дозволяє тестувати логіку специфікацій через прості unit-тести з mock-об'єктами.
5. Гнучкість для динамічних запитів. Побудова запиту на основі користувацького вводу стає тривіальною — просто комбінуємо специфікації через and().
Але патерн не є срібною кулею. Він має обмеження:
isSatisfiedBy та toSqlДля складних запитів, що виходять за межі простих WHERE-умов, доводиться повертатися до спеціалізованих методів у репозиторії або використовувати більш потужні інструменти (Criteria API, QueryDSL, jOOQ).
У наступних статтях ми розглянемо патерни, що доповнюють Specification: Query Object (для складних запитів із агрегаціями), Criteria Builder (type-safe альтернатива) та інтеграцію специфікацій із Unit of Work для транзакційної узгодженості.
Реалізуйте три специфікації для сутності Author:
AuthorWithBioSpecification — автор має біографію (поле bio не null і не порожнє)AuthorLastNameStartsWithSpecification — прізвище автора починається з заданого підрядкаAuthorWithPublishedBooksSpecification — автор має хоча б одну опубліковану аудіокнигуПідказка для третьої специфікації: потрібен підзапит або EXISTS-умова.
Використовуючи існуючі специфікації, побудуйте запит:
«Знайти аудіокниги жанру 'Фантастика' або 'Детектив', опубліковані у 2018-2023 роках, з тривалістю від 5400 до 10800 секунд (1.5-3 години)».
Виведіть згенерований SQL та параметри.
Реалізуйте метод buildFilterFromRequest(FilterRequest request), що будує специфікацію на основі HTTP-запиту:
class FilterRequest {
String genre; // опціонально
Integer minYear; // опціонально
Integer maxYear; // опціонально
Integer minDuration; // опціонально (у секундах)
Integer maxDuration; // опціонально (у секундах)
String titlePart; // опціонально (пошук у назві)
}
Якщо параметр null або порожній — він не включається у запит.
Створіть запит: «Знайти аудіокниги, що НЕ є поезією, з тривалістю НЕ більше 7200 секунд (2 години), і опубліковані після 2015 року».
Використовуйте метод not() для інверсії умов.
Реалізуйте PopularAuthorSpecification — автор, у якого більше 5 опублікованих аудіокниг.
SQL має містити підзапит:
WHERE author_id IN (
SELECT author_id
FROM audiobooks
GROUP BY author_id
HAVING COUNT(*) > 5
)
Підказка: toSql() може повертати складні конструкції, не лише прості умови.
Створіть специфікацію RecentAudiobooksSpecification, що знаходить аудіокниги, опубліковані за останні N років від поточної дати.
Підказка: використовуйте SQL-функцію EXTRACT(YEAR FROM CURRENT_DATE) або YEAR(CURRENT_DATE) залежно від діалекту БД.
📖 Domain-Driven Design
📖 Patterns of Enterprise Application Architecture
Generic Repository через Java Reflection: анотації та динамічний SQL
Усунення дублювання між JdbcAuthorRepository, JdbcGenreRepository та JdbcAudiobookRepository через власні анотації @Table, @Column, @Id та Java Reflection API. Динамічна генерація INSERT/UPDATE/SELECT/DELETE без жодного SQL у коді сутностей. Порівняння з JPA і аналіз обмежень рефлексійного підходу.
Розширені можливості Specification Pattern: підзапити, агрегації та гібридний підхід
Від простих WHERE-умов до складних підзапитів: реалізація специфікацій з EXISTS, IN (SELECT), HAVING, агрегатними функціями. Порівняння підходів: чисті специфікації vs спеціалізовані методи репозиторію. Коли використовувати що.