diff --git a/README.md b/README.md
index fcac98f..3a007d4 100644
--- a/README.md
+++ b/README.md
@@ -1,24 +1,129 @@
# java-filmorate
-Учебный backend-проект на Java и Spring Boot: сервис для работы с фильмами,
-оценками пользователей и топом популярных фильмов.
+Учебный backend-проект на Java и Spring Boot: сервис для работы с фильмами, лайками,
+отзывами и рекомендациями. Данные хранятся в реляционной базе H2, доступ к ним — через `JdbcTemplate`.
-Проект развивается поэтапно. На текущем этапе данные хранятся в реляционной базе H2,
-доступ к ним — через `JdbcTemplate`, у фильмов появились жанры и возрастной рейтинг MPA.
+## Команда
-## Автор
-**Ксения Пылькина** *(Ksenia Pylkina)*
+| Участник | GitHub | Зона ответственности |
+|---|---|---|
+| **Ксения Пылькина** *(тимлид)* | [ksupyl](https://github.com/ksupyl) | этапы 1–3; общие фильмы, удаление фильмов и пользователей |
+| **Кирилл Казанцев** | [Kirill-Kazantcev](https://github.com/Kirill-Kazantcev) | популярные фильмы по жанру и году, рекомендации |
+| **Роман Егоров** | [Roman32-gif](https://github.com/Roman32-gif) | режиссёры, поиск |
+| **Дарья Приндина** | [DariaPrindina](https://github.com/DariaPrindina) | отзывы, лента событий |
-## Ключевые возможности на данном этапе
+## Реализация по этапам
+
+Этапы 1–3 — индивидуальные спринты, этап 4 — групповой проект, собранный на их основе.
+
+
+Этап 1 — Каркас Spring Boot приложения и базовый REST API
+
+На этом этапе был заложен фундамент будущего сервиса. Проект переведён на Spring Boot,
+реализованы базовые модели данных и контроллеры.
+
+**Что сделано:**
+- Настроен `pom.xml`, подключены стартеры Spring Boot, Lombok и плагин Checkstyle.
+- Созданы модели `Film` и `User` с аннотациями Lombok (`@Data`).
+- Реализованы REST-контроллеры `FilmController` и `UserController`.
+- In-memory хранение данных с ручной генерацией ID.
+- Валидация через аннотации `jakarta.validation.constraints`.
+- Кастомная бизнес-валидация (дата релиза, пробелы в логине).
+- Логирование запросов и ошибок.
+- Unit-тесты для проверки логики валидации.
+
+
+
+
+Этап 2 — Слоистая архитектура, социальные функции, полный REST
+
+На этом этапе логика вынесена из контроллеров, добавлены друзья и лайки,
+API доведён до соответствия REST.
+
+**Что сделано:**
+- Созданы интерфейсы `FilmStorage` и `UserStorage` и их реализации
+ `InMemoryFilmStorage` и `InMemoryUserStorage` (`@Component`).
+- Созданы `FilmService` (`@Service`) и `UserService` (`@Service`)
+ с бизнес-логикой лайков и друзей.
+- Контроллеры переработаны: зависят от сервисов, внедрение через конструктор.
+- Сервисы зависят от интерфейсов хранилищ — легко заменить реализацию в будущем.
+- Добавлены все новые эндпоинты (друзья, лайки, топ фильмов, получение по ID).
+- Централизованная обработка ошибок через `@RestControllerAdvice`: 400, 404, 500.
+- Подключён Logbook для логирования HTTP-запросов и ответов.
+- Unit-тесты обновлены под новую архитектуру.
-- CRUD-операции для `Film` и `User` (создание, обновление, удаление, получение по ID и списком).
+
+
+
+Этап 3 — База данных H2, DAO, жанры и рейтинги
+
+На этом этапе данные переехали из памяти в базу данных,
+у фильмов появились жанры и возрастной рейтинг MPA.
+
+**Что сделано:**
+- Подключены H2 и `spring-boot-starter-jdbc`; схема базы — `schema.sql`, справочники — `data.sql`.
+- Спроектированы 7 таблиц: `films`, `users`, `mpa`, `genres`
+ и таблицы-связки `film_genres`, `film_likes`, `friendship`.
+- Созданы хранилища на `JdbcTemplate`: `FilmDbStorage`, `UserDbStorage`,
+ `GenreDbStorage`, `MpaDbStorage` и мапперы `RowMapper` для каждой сущности.
+- Хранилища жанров и рейтингов, как и остальные, скрыты за интерфейсами (`GenreStorage`, `MpaStorage`).
+- Сервисы переключены на хранилища в базе через `@Qualifier`;
+ in-memory реализации и их unit-тесты после перехода на базу удалены.
+- Добавлены модели `Genre` и `Mpa`, сервисы и контроллеры `/genres` и `/mpa`.
+- Жанры фильма сохраняются пакетно (`batchUpdate`), а для списка фильмов загружаются одним запросом
+ только по этим фильмам (`IN (...)`).
+- Дружба стала односторонней: одна запись в `friendship` — один пользователь добавил другого.
+- Популярные фильмы считаются в SQL: `LEFT JOIN` с лайками, `GROUP BY`, сортировка по числу лайков.
+- При создании и обновлении фильма проверяются рейтинг и жанры: неизвестный id — 404.
+- Интеграционные тесты всех хранилищ (`@JdbcTest`, база в памяти).
+
+
+
+
+Этап 4 — Групповой проект: восемь новых функций
+
+Команда из четырёх человек добавила восемь функций. Работали по GitHub Flow: задача на доске
+GitHub Projects, своя ветка на каждую функцию, pull request в `develop`, два апрува от команды
+и Squash and merge. Каждый pull request проверял CI с тестами Postman по своей функции.
+
+**Что сделано:**
+- **Популярные фильмы по жанру и году** *(Кирилл Казанцев)*: фильтры добавляются в `WHERE`
+ динамически; жанр проверяется подзапросом, чтобы фильм с несколькими жанрами не задваивался.
+- **Общие фильмы** *(Ксения Пылькина)*: тот же запрос, что у популярных, с условием
+ «фильм лайкнул пользователь» для каждого из двух id.
+- **Удаление фильмов и пользователей** *(Ксения Пылькина)*: связанные записи — лайки, жанры,
+ дружбу, отзывы, ленту — удаляет база через `ON DELETE CASCADE`.
+- **Режиссёры** *(Роман Егоров)*: таблицы `directors` и `film_director`, у фильма — список режиссёров,
+ фильмы режиссёра сортируются по году выпуска или по лайкам.
+- **Отзывы** *(Дарья Приндина)*: таблицы `reviews` и `review_likes`; рейтинг полезности
+ считается в SQL через `SUM(CASE WHEN is_useful THEN 1 ELSE -1 END)`.
+- **Лента событий** *(Дарья Приндина)*: таблица `feed_events`; событие пишут сервисы лайков,
+ друзей и отзывов в той же транзакции, что и само действие.
+- **Рекомендации** *(Кирилл Казанцев)*: находим всех пользователей с максимальным пересечением
+ по лайкам и возвращаем их фильмы, которых нет у нашего. Запрос фильмов живёт в `FilmDbStorage`,
+ поэтому жанры и режиссёры в ответе подтягиваются тем же путём, что и у остальных списочных запросов.
+- **Поиск** *(Роман Егоров)*: подстрока без учёта регистра в названии и/или имени режиссёра,
+ результаты по убыванию популярности.
+- Перед сдачей: ошибки клиента (нет обязательного параметра, неверный тип, битый JSON,
+ нет id при обновлении) отдают 400 вместо 500, неизвестный адрес — 404; записи в несколько
+ таблиц выполняются в транзакции; обработка ошибок покрыта тестами на `MockMvc`.
+- Все хранилища покрыты интеграционными тестами (`@JdbcTest`).
+
+
+
+## Ключевые возможности
+
+- CRUD-операции для фильмов, пользователей и режиссёров; при удалении фильма или пользователя
+ связанные записи удаляются каскадно.
- Хранение данных в базе H2 (файловый режим — данные переживают перезапуск),
доступ через `JdbcTemplate` и `RowMapper`.
- Справочники жанров и рейтингов MPA в базе, эндпоинты `/genres` и `/mpa`.
-- У фильма есть рейтинг и список жанров; жанры в ответе — без дублей, по порядку id.
-- У фильмов можно указывать режиссёров и получать список фильмов выбранного режиссёра с сортировкой по году выпуска или количеству лайков.
+- У фильма есть рейтинг, список жанров и список режиссёров; в ответе — без дублей, по порядку id.
- Односторонняя дружба: добавление/удаление друзей, список друзей, общие друзья.
-- Лайки фильмам: добавление/удаление, топ-N фильмов по количеству лайков.
+- Лайки фильмам и топ-N популярных фильмов с фильтрами по жанру и году выпуска.
+- Общие фильмы двух пользователей — те, что лайкнули оба.
+- Фильмы режиссёра с сортировкой по году выпуска или количеству лайков.
+- Поиск фильмов по подстроке в названии и/или имени режиссёра.
- Отзывы к фильмам: положительные и негативные, с рейтингом полезности по лайкам и дизлайкам.
- Рекомендации фильмов: пользователю предлагаются фильмы, которые лайкнули
пользователи с максимальным пересечением по лайкам (при равенстве — все
@@ -28,10 +133,13 @@
- Внедрение зависимостей через конструктор (`@Autowired`).
- Строгая валидация входящих данных через `spring-boot-starter-validation`.
- Кастомная бизнес-валидация (дата релиза, пробелы в логине, автозамена имени на логин,
- существование рейтинга и жанров).
-- Централизованная обработка ошибок (`@RestControllerAdvice`): 400, 404, 500.
+ существование рейтинга, жанров и режиссёров).
+- Записи сразу в несколько таблиц выполняются в одной транзакции (`@Transactional`).
+- Централизованная обработка ошибок (`@RestControllerAdvice`): ошибки клиента — 400, 404, 405 и 415,
+ непредвиденные — 500 с общим сообщением, подробности только в логе.
- Логирование HTTP-запросов и ответов через Logbook.
-- Unit-тесты бизнес-логики и интеграционные тесты хранилищ (`@JdbcTest`).
+- Интеграционные тесты хранилищ (`@JdbcTest`), тесты ленты событий и обработки ошибок
+ (`@SpringBootTest`, `MockMvc`).
## Технологии
@@ -43,7 +151,7 @@
- **H2 Database** (встроенная реляционная база данных)
- **Logbook 3.7.2** (логирование HTTP-запросов)
- **Lombok** (генерация boilerplate-кода и логгеров)
-- **JUnit 5**, **Spring Boot Test**, **Mockito** (unit- и интеграционные тесты)
+- **JUnit 5**, **Spring Boot Test** (`@JdbcTest`, `@SpringBootTest`, `MockMvc`)
- **Maven** (сборка проекта)
- **Checkstyle** (контроль стиля кода)
@@ -54,7 +162,7 @@
3. Дождаться загрузки зависимостей Maven.
4. Запустить метод `main` в классе `FilmorateApplication`.
-После запуска сервер будет доступен на порту **8080**.
+После запуска сервер будет доступен на порту **8080**. Тесты запускаются командой `mvn test`.
База данных создаётся автоматически при старте: таблицы — из `schema.sql`,
справочники жанров и рейтингов — из `data.sql`. Данные хранятся в папке `db`
@@ -76,14 +184,14 @@
- `DELETE /films/{id}` — удалить фильм; связанные с ним записи удаляются каскадно.
- `PUT /films/{id}/like/{userId}` — поставить лайк фильму.
- `DELETE /films/{id}/like/{userId}` — убрать лайк.
-- `GET /films/popular?count={count}` — топ-N фильмов по лайкам (по умолчанию 10).
-- `GET /films/director/{directorId}` — получить список фильмов режиссёра.
-- `GET /films/director/{directorId}?sortBy=likes` — получить список фильмов режиссёра, отсортированный по лайкам.
-- `GET /films/director/{directorId}?sortBy=year` — получить список фильмов режиссёра, отсортированный по году выпуска.
- `GET /films/popular?count={count}&genreId={genreId}&year={year}` — топ-N фильмов по лайкам
(по умолчанию 10); жанр и год — необязательные фильтры.
- `GET /films/common?userId={userId}&friendId={friendId}` — общие фильмы двух пользователей,
по убыванию популярности.
+- `GET /films/director/{directorId}?sortBy={year|likes}` — фильмы режиссёра по году выпуска
+ или по количеству лайков (по умолчанию `likes`).
+- `GET /films/search?query={query}&by={title,director}` — поиск по подстроке без учёта регистра
+ в названии и/или имени режиссёра, по убыванию популярности.
### Пользователи (`/users`)
- `GET /users` — получить список всех пользователей.
@@ -119,7 +227,7 @@
- `GET /mpa` — получить список всех рейтингов.
- `GET /mpa/{id}` — получить рейтинг по ID.
-## Режиссёры (`/directors`)
+### Режиссёры (`/directors`)
- `GET /directors` — получить список всех режиссёров.
- `GET /directors/{id}` — получить режиссёра по ID.
- `POST /directors` — создать нового режиссёра.
@@ -253,6 +361,21 @@ WHERE fg.film_id = 1
ORDER BY g.id;
```
+Поиск «нол» по имени режиссёра и названию, по убыванию популярности.
+`COUNT(DISTINCT ...)` — потому что у фильма с двумя режиссёрами каждый лайк иначе посчитался бы дважды:
+
+```sql
+SELECT f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name AS mpa_name
+FROM films f
+LEFT JOIN mpa m ON f.mpa_id = m.id
+LEFT JOIN film_director fd ON f.id = fd.film_id
+LEFT JOIN directors d ON fd.director_id = d.id
+LEFT JOIN film_likes fl ON f.id = fl.film_id
+WHERE LOWER(d.name) LIKE '%нол%' OR LOWER(f.name) LIKE '%нол%'
+GROUP BY f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name
+ORDER BY COUNT(DISTINCT fl.user_id) DESC, f.id;
+```
+
Друзья пользователя с id = 1 — те, кого он добавил:
```sql
@@ -304,86 +427,3 @@ ORDER BY f.id;
```
-
-## Реализация по этапам
-
-
-Этап 1 — Каркас Spring Boot приложения и базовый REST API
-
-На этом этапе был заложен фундамент будущего сервиса. Проект переведён на Spring Boot,
-реализованы базовые модели данных и контроллеры.
-
-**Что сделано:**
-- Настроен `pom.xml`, подключены стартеры Spring Boot, Lombok и плагин Checkstyle.
-- Созданы модели `Film` и `User` с аннотациями Lombok (`@Data`).
-- Реализованы REST-контроллеры `FilmController` и `UserController`.
-- In-memory хранение данных с ручной генерацией ID.
-- Валидация через аннотации `jakarta.validation.constraints`.
-- Кастомная бизнес-валидация (дата релиза, пробелы в логине).
-- Логирование запросов и ошибок.
-- Unit-тесты для проверки логики валидации.
-
-
-
-
-Этап 2 — Слоистая архитектура, социальные функции, полный REST
-
-На этом этапе логика вынесена из контроллеров, добавлены друзья и лайки,
-API доведён до соответствия REST.
-
-**Что сделано:**
-- Созданы интерфейсы `FilmStorage` и `UserStorage` и их реализации
- `InMemoryFilmStorage` и `InMemoryUserStorage` (`@Component`).
-- Созданы `FilmService` (`@Service`) и `UserService` (`@Service`)
- с бизнес-логикой лайков и друзей.
-- Контроллеры переработаны: зависят от сервисов, внедрение через конструктор.
-- Сервисы зависят от интерфейсов хранилищ — легко заменить реализацию в будущем.
-- Добавлены все новые эндпоинты (друзья, лайки, топ фильмов, получение по ID).
-- Централизованная обработка ошибок через `@RestControllerAdvice`: 400, 404, 500.
-- Подключён Logbook для логирования HTTP-запросов и ответов.
-- Unit-тесты обновлены под новую архитектуру.
-
-
-
-
-Этап 3 — База данных H2, DAO, жанры и рейтинги
-
-На этом этапе данные переехали из памяти в базу данных,
-у фильмов появились жанры и возрастной рейтинг MPA.
-
-**Что сделано:**
-- Подключены H2 и `spring-boot-starter-jdbc`; схема базы — `schema.sql`, справочники — `data.sql`.
-- Спроектированы 7 таблиц: `films`, `users`, `mpa`, `genres`
- и таблицы-связки `film_genres`, `film_likes`, `friendship`.
-- Созданы хранилища на `JdbcTemplate`: `FilmDbStorage`, `UserDbStorage`,
- `GenreDbStorage`, `MpaDbStorage` и мапперы `RowMapper` для каждой сущности.
-- Хранилища жанров и рейтингов, как и остальные, скрыты за интерфейсами (`GenreStorage`, `MpaStorage`).
-- Сервисы переключены на хранилища в базе через `@Qualifier`;
- in-memory реализации оставлены для unit-тестов.
-- Добавлены модели `Genre` и `Mpa`, сервисы и контроллеры `/genres` и `/mpa`.
-- Жанры фильма сохраняются пакетно (`batchUpdate`), а для списка фильмов загружаются одним запросом
- только по этим фильмам (`IN (...)`).
-- Дружба стала односторонней: одна запись в `friendship` — один пользователь добавил другого.
-- Популярные фильмы считаются в SQL: `LEFT JOIN` с лайками, `GROUP BY`, сортировка по числу лайков.
-- При создании и обновлении фильма проверяются рейтинг и жанры: неизвестный id — 404.
-- Интеграционные тесты всех хранилищ (`@JdbcTest`, база в памяти); unit-тесты сервисов обновлены.
-
-
-
-
-Этап 4 — Отзывы, режиссёры, рекомендации
-
-На этом этапе добавлены отзывы с рейтингом полезности, режиссёры фильмов
-и простая рекомендательная система.
-
-**Что сделано:**
-- Отзывы: таблицы `reviews` и `review_likes`; рейтинг полезности считается в SQL
- через `SUM(CASE WHEN is_useful THEN 1 ELSE -1 END)`.
-- Режиссёры: таблицы `directors` и `film_director`, у фильма — список режиссёров.
-- Рекомендации фильмов: находим всех пользователей с максимальным пересечением
- по лайкам и возвращаем их фильмы, которых нет у нашего.
- Запрос фильмов живёт в FilmDbStorage, поэтому жанры и режиссёры
- в ответе подтягиваются тем же путём, что и у остальных списочных запросов.
-- Все хранилища покрыты интеграционными тестами (`@JdbcTest`).
-
-
\ No newline at end of file
diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/DirectorController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/DirectorController.java
index d9baa58..cf4b906 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/controller/DirectorController.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/controller/DirectorController.java
@@ -6,6 +6,7 @@
import org.springframework.web.bind.annotation.*;
import ru.yandex.practicum.filmorate.model.Director;
import ru.yandex.practicum.filmorate.service.DirectorService;
+
import java.util.List;
@Slf4j
@@ -27,7 +28,7 @@ public List getDirectors() {
}
@GetMapping("/{id}")
- public Director getDirectorById(@PathVariable Long id) {
+ public Director getDirectorById(@PathVariable long id) {
log.info("GET /directors/{}", id);
return directorService.getDirectorById(id);
}
@@ -45,9 +46,8 @@ public Director updateDirector(@Valid @RequestBody Director director) {
}
@DeleteMapping("/{id}")
- public void deleteDirector(@PathVariable Long id) {
+ public void deleteDirector(@PathVariable long id) {
log.info("DELETE /directors/{}", id);
directorService.deleteDirector(id);
}
-
}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java
index e59aca4..3eebc6b 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java
@@ -81,7 +81,7 @@ public Collection getCommon(@RequestParam long userId, @RequestParam long
@GetMapping("/director/{directorId}")
public Collection getFilmsByDirector(@PathVariable long directorId,
- @RequestParam (defaultValue = "likes") String sortBy) {
+ @RequestParam(defaultValue = "likes") String sortBy) {
log.info("GET /films/director/{}?sortBy={}", directorId, sortBy);
return filmService.findByDirectorSorted(directorId, sortBy);
}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/exception/ErrorHandler.java b/src/main/java/ru/yandex/practicum/filmorate/exception/ErrorHandler.java
index a7128b6..8ef6b47 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/exception/ErrorHandler.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/exception/ErrorHandler.java
@@ -2,12 +2,19 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.HttpMediaTypeNotSupportedException;
+import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.util.Map;
+import java.util.stream.Collectors;
@RestControllerAdvice
@Slf4j
@@ -27,18 +34,70 @@ public Map handleNotFoundException(NotFoundException e) {
return Map.of("error", e.getMessage());
}
+ // Нарушены аннотации валидации (@NotBlank, @Positive и т.п.): в ответе — поле и что с ним не так
@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
- public Map handleMethodArgumentNotValidException(
- MethodArgumentNotValidException e) {
+ public Map handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
+ String message = e.getBindingResult().getFieldErrors().stream()
+ .map(error -> error.getField() + ": " + error.getDefaultMessage())
+ .collect(Collectors.joining("; "));
+ log.warn("400: {}", message);
+ return Map.of("error", message);
+ }
+
+ // Нет обязательного параметра, например GET /films/search без by
+ @ExceptionHandler
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public Map handleMissingParameter(MissingServletRequestParameterException e) {
log.warn("400: {}", e.getMessage());
- return Map.of("error", e.getMessage());
+ return Map.of("error", "Не указан обязательный параметр '" + e.getParameterName() + "'");
+ }
+
+ // Параметр не того типа, например GET /films/abc вместо числового id
+ @ExceptionHandler
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public Map handleTypeMismatch(MethodArgumentTypeMismatchException e) {
+ log.warn("400: {}", e.getMessage());
+ return Map.of("error", "Некорректное значение параметра '" + e.getName() + "': " + e.getValue());
}
+ // Тело запроса не читается как JSON нужной структуры
+ @ExceptionHandler
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public Map handleNotReadableBody(HttpMessageNotReadableException e) {
+ log.warn("400: {}", e.getMessage());
+ return Map.of("error", "Некорректное тело запроса");
+ }
+
+ // Запрос на адрес, которого нет в API
+ @ExceptionHandler
+ @ResponseStatus(HttpStatus.NOT_FOUND)
+ public Map handleNoResource(NoResourceFoundException e) {
+ log.warn("404: {}", e.getMessage());
+ return Map.of("error", "Адрес /" + e.getResourcePath() + " не найден");
+ }
+
+ // Адрес есть, но с другим HTTP-методом, например POST /films/1
+ @ExceptionHandler
+ @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
+ public Map handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
+ log.warn("405: {}", e.getMessage());
+ return Map.of("error", "Метод " + e.getMethod() + " для этого адреса не поддерживается");
+ }
+
+ // Тело пришло не в JSON, например с Content-Type: text/plain
+ @ExceptionHandler
+ @ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
+ public Map handleMediaTypeNotSupported(HttpMediaTypeNotSupportedException e) {
+ log.warn("415: {}", e.getMessage());
+ return Map.of("error", "Тело запроса должно быть в формате JSON");
+ }
+
+ // Непредвиденная ошибка: подробности только в лог, клиенту — общее сообщение без SQL и стектрейсов
@ExceptionHandler
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Map handleException(Exception e) {
log.error("500: {}", e.getMessage(), e);
- return Map.of("error", e.getMessage());
+ return Map.of("error", "Внутренняя ошибка сервера");
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Director.java b/src/main/java/ru/yandex/practicum/filmorate/model/Director.java
index 5924c72..3fe4162 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/model/Director.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/model/Director.java
@@ -2,11 +2,14 @@
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
+import lombok.EqualsAndHashCode;
+// Равенство по id, как у Genre и Mpa: дубль режиссёра в запросе схлопнется в один
@Data
+@EqualsAndHashCode(of = "id")
public class Director {
private Long id;
- @NotBlank(message = "Имя режиссера не может быть пустым")
+ @NotBlank(message = "Имя режиссёра не может быть пустым")
private String name;
}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Film.java b/src/main/java/ru/yandex/practicum/filmorate/model/Film.java
index 5ba8b59..2b3a240 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/model/Film.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/model/Film.java
@@ -1,6 +1,5 @@
package ru.yandex.practicum.filmorate.model;
-import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
@@ -8,7 +7,6 @@
import lombok.Data;
import java.time.LocalDate;
-import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;
@@ -34,18 +32,5 @@ public class Film {
private Mpa mpa;
private Set genres = new LinkedHashSet<>();
-
- @JsonIgnore
- private Set likes = new HashSet<>();
-
private Set directors = new LinkedHashSet<>();
-
- public void addLike(Long userId) {
- likes.add(userId);
- }
-
- public boolean removeLike(Long userId) {
- return likes.remove(userId);
- }
-
}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/User.java b/src/main/java/ru/yandex/practicum/filmorate/model/User.java
index 86cda7c..33003b4 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/model/User.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/model/User.java
@@ -1,6 +1,5 @@
package ru.yandex.practicum.filmorate.model;
-import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@@ -8,8 +7,6 @@
import lombok.Data;
import java.time.LocalDate;
-import java.util.HashSet;
-import java.util.Set;
@Data
public class User {
@@ -27,15 +24,4 @@ public class User {
@NotNull(message = "Дата рождения не может быть пустой")
@PastOrPresent(message = "Дата рождения не может быть в будущем")
private LocalDate birthday;
-
- @JsonIgnore
- private Set friends = new HashSet<>();
-
- public void addFriend(Long friendId) {
- friends.add(friendId);
- }
-
- public boolean removeFriend(Long friendId) {
- return friends.remove(friendId);
- }
}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/DirectorService.java b/src/main/java/ru/yandex/practicum/filmorate/service/DirectorService.java
index df8c8a6..4c7f827 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/service/DirectorService.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/service/DirectorService.java
@@ -1,16 +1,21 @@
package ru.yandex.practicum.filmorate.service;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import ru.yandex.practicum.filmorate.exception.NotFoundException;
+import ru.yandex.practicum.filmorate.exception.ValidationException;
import ru.yandex.practicum.filmorate.model.Director;
import ru.yandex.practicum.filmorate.storage.director.DirectorStorage;
+import java.util.List;
+
@Service
public class DirectorService {
private final DirectorStorage directorStorage;
+ @Autowired
public DirectorService(@Qualifier("directorDbStorage") DirectorStorage directorStorage) {
this.directorStorage = directorStorage;
}
@@ -20,6 +25,9 @@ public Director addDirector(Director director) {
}
public Director updateDirector(Director director) {
+ if (director.getId() == null) {
+ throw new ValidationException("Не указан id режиссёра");
+ }
return directorStorage.updateDirector(director);
}
@@ -29,11 +37,10 @@ public void deleteDirector(long id) {
public Director getDirectorById(long id) {
return directorStorage.findDirectorById(id)
- .orElseThrow(() -> new NotFoundException("Режиссер с id " + id + " не найден"));
+ .orElseThrow(() -> new NotFoundException("Режиссёр с id=" + id + " не найден"));
}
- public java.util.List getAllDirectors() {
+ public List getAllDirectors() {
return directorStorage.findAllDirectors();
}
-
}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java b/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java
index b393165..6fa0537 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java
@@ -4,6 +4,7 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
import ru.yandex.practicum.filmorate.exception.NotFoundException;
import ru.yandex.practicum.filmorate.exception.ValidationException;
import ru.yandex.practicum.filmorate.model.Director;
@@ -18,6 +19,7 @@
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
+import java.util.Locale;
import java.util.Set;
@Service
@@ -64,6 +66,8 @@ private void getUserOrThrow(long id) {
.orElseThrow(() -> new NotFoundException("Пользователь с id=" + id + " не найден"));
}
+ // Лайк и событие в ленте пишутся вместе: либо оба, либо ни одного
+ @Transactional
public Film addLike(long filmId, long userId) {
Film film = getFilmOrThrow(filmId);
getUserOrThrow(userId); // проверка существования пользователя — иначе 404
@@ -74,6 +78,7 @@ public Film addLike(long filmId, long userId) {
return film;
}
+ @Transactional
public Film removeLike(long filmId, long userId) {
Film film = getFilmOrThrow(filmId);
getUserOrThrow(userId); // проверка существования пользователя — иначе 404
@@ -129,6 +134,9 @@ private void validateReleaseDate(Film film) {
// Рейтинг и жанры из запроса должны существовать в справочниках — иначе 404
private void validateMpaAndGenres(Film film) {
if (film.getMpa() != null) {
+ if (film.getMpa().getId() == null) {
+ throw new ValidationException("Не указан id рейтинга MPA");
+ }
mpaService.findById(film.getMpa().getId()); // бросит NotFoundException
}
if (film.getGenres() == null || film.getGenres().isEmpty()) {
@@ -140,23 +148,28 @@ private void validateMpaAndGenres(Film film) {
knownIds.add(genre.getId());
}
for (Genre genre : film.getGenres()) {
+ if (genre.getId() == null) {
+ throw new ValidationException("Не указан id жанра");
+ }
if (!knownIds.contains(genre.getId())) {
throw new NotFoundException("Жанр с id=" + genre.getId() + " не найден");
}
}
}
+ // Режиссёры из запроса должны существовать — иначе 404
private void validateDirectors(Film film) {
-
if (film.getDirectors() == null || film.getDirectors().isEmpty()) {
return;
}
-
Set knownIds = new HashSet<>();
for (Director director : directorService.getAllDirectors()) {
knownIds.add(director.getId());
}
for (Director director : film.getDirectors()) {
+ if (director.getId() == null) {
+ throw new ValidationException("Не указан id режиссёра");
+ }
if (!knownIds.contains(director.getId())) {
throw new NotFoundException("Режиссёр с id=" + director.getId() + " не найден");
}
@@ -171,6 +184,9 @@ public Film add(Film film) {
}
public Film update(Film film) {
+ if (film.getId() == null) {
+ throw new ValidationException("Не указан id фильма");
+ }
getFilmOrThrow(film.getId()); // проверка существования — иначе 404
validateReleaseDate(film);
validateMpaAndGenres(film);
@@ -192,9 +208,14 @@ public Film findById(long id) {
return getFilmOrThrow(id);
}
+ // Сортировка без учёта регистра: year или likes, остальное — 400
public Collection findByDirectorSorted(long directorId, String sortBy) {
+ String sort = sortBy.toLowerCase(Locale.ROOT);
+ if (!sort.equals("year") && !sort.equals("likes")) {
+ throw new ValidationException("Сортировка возможна только по year или likes, получено: " + sortBy);
+ }
directorService.getDirectorById(directorId); // проверка существования режиссёра — иначе 404
- return filmStorage.findFilmByDirector(directorId, sortBy);
+ return filmStorage.findFilmByDirector(directorId, sort);
}
private void validateSearchParameters(String query, List by) {
@@ -213,6 +234,8 @@ private void validateSearchParameters(String query, List by) {
public Collection searchFilms(String query, List by) {
validateSearchParameters(query, by);
- return filmStorage.searchFilms(query, by);
+ // Проверка выше не учитывает регистр, а хранилище ждёт title и director строчными
+ List fields = by.stream().map(field -> field.toLowerCase(Locale.ROOT)).toList();
+ return filmStorage.searchFilms(query, fields);
}
}
\ No newline at end of file
diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/ReviewService.java b/src/main/java/ru/yandex/practicum/filmorate/service/ReviewService.java
index 10364e8..2df51b0 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/service/ReviewService.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/service/ReviewService.java
@@ -4,6 +4,7 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
import ru.yandex.practicum.filmorate.exception.NotFoundException;
import ru.yandex.practicum.filmorate.exception.ValidationException;
import ru.yandex.practicum.filmorate.model.EventType;
@@ -50,6 +51,8 @@ private void checkFilmExists(long id) {
.orElseThrow(() -> new NotFoundException("Фильм с id=" + id + " не найден"));
}
+ // Отзыв и событие в ленте пишутся вместе: либо оба, либо ни одного
+ @Transactional
public Review add(Review review) {
checkUserExists(review.getUserId());
checkFilmExists(review.getFilmId());
@@ -61,6 +64,7 @@ public Review add(Review review) {
}
// Автор, фильм и рейтинг полезности не меняются — только текст и тип отзыва
+ @Transactional
public Review update(Review review) {
if (review.getReviewId() == null) {
throw new ValidationException("Не указан id отзыва");
@@ -71,6 +75,7 @@ public Review update(Review review) {
return updated;
}
+ @Transactional
public void delete(long id) {
Review review = getReviewOrThrow(id);
reviewStorage.delete(id);
diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java b/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java
index bd736dc..8c13bd2 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java
@@ -4,6 +4,7 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
import ru.yandex.practicum.filmorate.exception.NotFoundException;
import ru.yandex.practicum.filmorate.exception.ValidationException;
import ru.yandex.practicum.filmorate.model.Event;
@@ -48,7 +49,9 @@ private User getUserOrThrow(long id) {
.orElseThrow(() -> new NotFoundException("Пользователь с id=" + id + " не найден"));
}
- // Дружба односторонняя: обратная запись для friendId не создаётся
+ // Дружба односторонняя: обратная запись для friendId не создаётся.
+ // Дружба и событие в ленте пишутся вместе: либо оба, либо ни одного
+ @Transactional
public User addFriend(long userId, long friendId) {
validateDifferentUsers(userId, friendId);
@@ -61,6 +64,7 @@ public User addFriend(long userId, long friendId) {
return user;
}
+ @Transactional
public User removeFriend(long userId, long friendId) {
validateDifferentUsers(userId, friendId);
@@ -114,6 +118,9 @@ public User add(User user) {
}
public User update(User user) {
+ if (user.getId() == null) {
+ throw new ValidationException("Не указан id пользователя");
+ }
getUserOrThrow(user.getId());
validateLogin(user);
diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorDbStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorDbStorage.java
index c4bb187..f510957 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorDbStorage.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorDbStorage.java
@@ -1,6 +1,5 @@
package ru.yandex.practicum.filmorate.storage.director;
-import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
@@ -9,19 +8,22 @@
import ru.yandex.practicum.filmorate.exception.NotFoundException;
import ru.yandex.practicum.filmorate.model.Director;
import ru.yandex.practicum.filmorate.storage.mapper.DirectorRowMapper;
+
+import java.sql.PreparedStatement;
import java.util.List;
+import java.util.Optional;
@Repository
public class DirectorDbStorage implements DirectorStorage {
- private final JdbcTemplate jdbcTemplate;
- private final DirectorRowMapper directorRowMapper;
-
private static final String INSERT_QUERY = "INSERT INTO directors (name) VALUES (?)";
private static final String UPDATE_QUERY = "UPDATE directors SET name = ? WHERE id = ?";
private static final String DELETE_QUERY = "DELETE FROM directors WHERE id = ?";
- private static final String SELECT_BY_ID_QUERY = "SELECT * FROM directors WHERE id = ?";
- private static final String SELECT_ALL_QUERY = "SELECT * FROM directors ORDER BY id";
+ private static final String SELECT_BY_ID_QUERY = "SELECT id, name FROM directors WHERE id = ?";
+ private static final String SELECT_ALL_QUERY = "SELECT id, name FROM directors ORDER BY id";
+
+ private final JdbcTemplate jdbcTemplate;
+ private final DirectorRowMapper directorRowMapper;
@Autowired
public DirectorDbStorage(JdbcTemplate jdbcTemplate, DirectorRowMapper directorRowMapper) {
@@ -33,46 +35,39 @@ public DirectorDbStorage(JdbcTemplate jdbcTemplate, DirectorRowMapper directorRo
public Director addDirector(Director director) {
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
- var ps = connection.prepareStatement(INSERT_QUERY, new String[]{"id"});
+ PreparedStatement ps = connection.prepareStatement(INSERT_QUERY, new String[]{"id"});
ps.setString(1, director.getName());
return ps;
}, keyHolder);
- director.setId(keyHolder.getKey().longValue());
+ director.setId(keyHolder.getKeyAs(Long.class));
return director;
}
@Override
public Director updateDirector(Director director) {
- int updatedRows = jdbcTemplate.update(
- UPDATE_QUERY,
- director.getName(),
- director.getId()
- );
-
+ // update возвращает число изменённых строк: 0 — режиссёра с таким id нет
+ int updatedRows = jdbcTemplate.update(UPDATE_QUERY, director.getName(), director.getId());
if (updatedRows == 0) {
- throw new NotFoundException(
- "Режиссёр с id=" + director.getId() + " не найден"
- );
+ throw new NotFoundException("Режиссёр с id=" + director.getId() + " не найден");
}
return director;
}
+ // Связи с фильмами база удаляет сама — каскадом из schema.sql
@Override
public void removeDirector(long id) {
- Director director = findDirectorById(id)
- .orElseThrow(() -> new NotFoundException("Режиссёр с id " + id + " не найден"));
- jdbcTemplate.update(DELETE_QUERY, id);
+ if (jdbcTemplate.update(DELETE_QUERY, id) == 0) {
+ throw new NotFoundException("Режиссёр с id=" + id + " не найден");
+ }
}
@Override
public Optional findDirectorById(long id) {
- Optional director = jdbcTemplate.query(SELECT_BY_ID_QUERY, directorRowMapper, id).stream().findFirst();
- return director;
+ return jdbcTemplate.query(SELECT_BY_ID_QUERY, directorRowMapper, id).stream().findFirst();
}
@Override
public List findAllDirectors() {
- List directors = jdbcTemplate.query(SELECT_ALL_QUERY, directorRowMapper);
- return directors;
+ return jdbcTemplate.query(SELECT_ALL_QUERY, directorRowMapper);
}
}
diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorStorage.java
index cdcdc40..78ddb80 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorStorage.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorStorage.java
@@ -1,6 +1,7 @@
package ru.yandex.practicum.filmorate.storage.director;
import ru.yandex.practicum.filmorate.model.Director;
+
import java.util.List;
import java.util.Optional;
diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorage.java
index 507d365..4381a98 100644
--- a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorage.java
+++ b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorage.java
@@ -5,8 +5,8 @@
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
import ru.yandex.practicum.filmorate.exception.NotFoundException;
-import ru.yandex.practicum.filmorate.exception.ValidationException;
import ru.yandex.practicum.filmorate.model.Director;
import ru.yandex.practicum.filmorate.model.Film;
import ru.yandex.practicum.filmorate.model.Genre;
@@ -17,14 +17,14 @@
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.Statement;
+import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
-import java.util.ArrayList;
import java.util.Set;
-import java.util.Map;
-import java.util.HashMap;
@Repository
public class FilmDbStorage implements FilmStorage {
@@ -34,6 +34,10 @@ public class FilmDbStorage implements FilmStorage {
"SELECT f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name AS mpa_name "
+ "FROM films f "
+ "LEFT JOIN mpa m ON f.mpa_id = m.id ";
+ // Группировка по всем колонкам SELECT_FILMS — для запросов с JOIN film_likes:
+ // чтобы посчитать лайки или убрать повторы строк одного фильма
+ private static final String GROUP_BY_FILM =
+ "GROUP BY f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name ";
private static final String FIND_ALL_QUERY = SELECT_FILMS + "ORDER BY f.id";
private static final String FIND_BY_ID_QUERY = SELECT_FILMS + "WHERE f.id = ?";
@@ -41,10 +45,9 @@ public class FilmDbStorage implements FilmStorage {
private static final String FIND_POPULAR_BASE_QUERY = SELECT_FILMS
+ "LEFT JOIN film_likes fl ON f.id = fl.film_id ";
// Хвост запроса популярных фильмов: группировка и сортировка по числу лайков
- private static final String FIND_POPULAR_TAIL_QUERY = " "
- + "GROUP BY f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name "
+ private static final String FIND_POPULAR_TAIL_QUERY = " " + GROUP_BY_FILM
+ "ORDER BY COUNT(fl.user_id) DESC, f.id";
- // Лимит отдельно: в общих фильмах, рекомендациях и фильмах режиссёра его нет
+ // Лимит отдельно: общие фильмы собираются тем же запросом, что и популярные, но без лимита
private static final String LIMIT_CLAUSE = " LIMIT ?";
// Условия фильтрации популярных фильмов: по жанру и по году выпуска
@@ -88,30 +91,23 @@ public class FilmDbStorage implements FilmStorage {
+ "JOIN directors d ON fd.director_id = d.id "
+ "WHERE fd.film_id = ? "
+ "ORDER BY d.id";
+ // Режиссёры нескольких фильмов одним запросом — так же, как жанры
private static final String FIND_DIRECTORS_BY_FILMS_QUERY =
- "SELECT fd.film_id, d.id, d.name " +
- "FROM film_director fd " +
- "JOIN directors d ON fd.director_id = d.id " +
- "WHERE fd.film_id IN (%s) " +
- "ORDER BY fd.film_id, d.id";
- private static final String FIND_DIRECTORS_BY_LIKES_AND_YEAR =
- "SELECT f.id, f.name, f.description, f.release_date, f.duration, " +
- "f.mpa_id, m.name AS mpa_name " +
- "FROM films f " +
- "JOIN film_director fd ON f.id = fd.film_id " +
- "LEFT JOIN mpa m ON f.mpa_id = m.id " +
- "LEFT JOIN film_likes fl ON f.id = fl.film_id " +
- "WHERE fd.director_id = ? " +
- "GROUP BY f.id, f.name, f.description, f.release_date, " +
- "f.duration, f.mpa_id, m.name ";
- private static final String FIND_FILMS_BY_SEARCH_QUERY =
- "SELECT f.id, f.name, f.description, f.release_date, f.duration, " +
- "f.mpa_id, m.name AS mpa_name " +
- "FROM films f " +
- "LEFT JOIN mpa m ON f.mpa_id = m.id " +
- "LEFT JOIN film_director fd ON f.id = fd.film_id " +
- "LEFT JOIN directors d ON fd.director_id = d.id " +
- "LEFT JOIN film_likes fl ON f.id = fl.film_id ";
+ "SELECT fd.film_id, d.id, d.name FROM film_director fd "
+ + "JOIN directors d ON fd.director_id = d.id "
+ + "WHERE fd.film_id IN (%s) "
+ + "ORDER BY fd.film_id, d.id";
+ // Фильмы режиссёра с числом лайков; сортировка добавляется в findFilmByDirector
+ private static final String FIND_FILMS_BY_DIRECTOR_QUERY = SELECT_FILMS
+ + "JOIN film_director fd ON f.id = fd.film_id "
+ + "LEFT JOIN film_likes fl ON f.id = fl.film_id "
+ + "WHERE fd.director_id = ? "
+ + GROUP_BY_FILM;
+ // Поиск: условия по названию и режиссёру добавляются в searchFilms
+ private static final String FIND_FILMS_BY_SEARCH_QUERY = SELECT_FILMS
+ + "LEFT JOIN film_director fd ON f.id = fd.film_id "
+ + "LEFT JOIN directors d ON fd.director_id = d.id "
+ + "LEFT JOIN film_likes fl ON f.id = fl.film_id ";
// Рекомендации: максимум пересечений по лайкам среди всех пользователей
private static final String FIND_MAX_OVERLAP_QUERY =
@@ -133,7 +129,7 @@ public class FilmDbStorage implements FilmStorage {
+ "JOIN film_likes fl ON f.id = fl.film_id "
+ "WHERE fl.user_id IN (%s) "
+ "AND f.id NOT IN (SELECT film_id FROM film_likes WHERE user_id = ?) "
- + "GROUP BY f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name "
+ + GROUP_BY_FILM
+ "ORDER BY f.id";
private final JdbcTemplate jdbc;
@@ -142,14 +138,17 @@ public class FilmDbStorage implements FilmStorage {
private final DirectorRowMapper directorMapper;
@Autowired
- public FilmDbStorage(JdbcTemplate jdbc, FilmRowMapper mapper, GenreRowMapper genreMapper, DirectorRowMapper directorMapper) {
+ public FilmDbStorage(JdbcTemplate jdbc, FilmRowMapper mapper, GenreRowMapper genreMapper,
+ DirectorRowMapper directorMapper) {
this.jdbc = jdbc;
this.mapper = mapper;
this.genreMapper = genreMapper;
this.directorMapper = directorMapper;
}
+ // Фильм, жанры и режиссёры пишутся в три таблицы: либо всё, либо ничего
@Override
+ @Transactional
public Film add(Film film) {
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbc.update(connection -> {
@@ -164,17 +163,18 @@ public Film add(Film film) {
Long id = keyHolder.getKeyAs(Long.class);
saveGenres(id, film.getGenres());
- saveDirector(id, film.getDirectors());
+ saveDirectors(id, film.getDirectors());
// Перечитываем из базы: так в ответе будут названия рейтинга и жанров, а не только их id
return findById(id).orElseThrow();
}
@Override
+ @Transactional
public Film update(Film film) {
jdbc.update(UPDATE_QUERY, film.getName(), film.getDescription(), Date.valueOf(film.getReleaseDate()),
film.getDuration(), getMpaId(film), film.getId());
saveGenres(film.getId(), film.getGenres());
- saveDirector(film.getId(), film.getDirectors());
+ saveDirectors(film.getId(), film.getDirectors());
return findById(film.getId()).orElseThrow();
}
@@ -190,7 +190,7 @@ public Film delete(long id) {
public Collection findAll() {
List films = jdbc.query(FIND_ALL_QUERY, mapper);
loadGenresForFilms(films);
- loadDirectors(films);
+ loadDirectorsForFilms(films);
return films;
}
@@ -221,7 +221,7 @@ public List findPopular(int count, Integer genreId, Integer year) {
String sql = buildPopularQuery(conditions) + LIMIT_CLAUSE;
List films = jdbc.query(sql, mapper, params.toArray());
loadGenresForFilms(films);
- loadDirectors(films);
+ loadDirectorsForFilms(films);
return films;
}
@@ -231,7 +231,7 @@ public List findCommon(long userId, long friendId) {
String sql = buildPopularQuery(List.of(LIKED_BY_USER_FILTER, LIKED_BY_USER_FILTER));
List films = jdbc.query(sql, mapper, userId, friendId);
loadGenresForFilms(films);
- loadDirectors(films);
+ loadDirectorsForFilms(films);
return films;
}
@@ -264,7 +264,7 @@ public List findRecommendations(long userId) {
List films = jdbc.query(sql, mapper, params.toArray());
// Жанры и режиссёры подтягиваются тем же путём, что и у остальных списочных запросов
loadGenresForFilms(films);
- loadDirectors(films);
+ loadDirectorsForFilms(films);
return films;
}
@@ -279,64 +279,49 @@ public boolean removeLike(long filmId, long userId) {
return jdbc.update(REMOVE_LIKE_QUERY, filmId, userId) > 0;
}
+ // sortBy уже проверен в сервисе: year или likes
@Override
public List findFilmByDirector(long directorId, String sortBy) {
- String orderBy;
-
- if ("year".equals(sortBy)) {
- orderBy = "ORDER BY f.release_date ASC, f.id";
- } else if ("likes".equals(sortBy)) {
- orderBy = "ORDER BY COUNT(fl.user_id) DESC, f.id";
- } else {
- throw new ValidationException(
- "Неизвестный тип сортировки: " + sortBy
- );
- }
-
- List films = jdbc.query(FIND_DIRECTORS_BY_LIKES_AND_YEAR + orderBy, mapper, directorId
- );
-
- loadDirectors(films);
+ String orderBy = "year".equals(sortBy)
+ ? "ORDER BY f.release_date, f.id"
+ : "ORDER BY COUNT(fl.user_id) DESC, f.id";
+ List films = jdbc.query(FIND_FILMS_BY_DIRECTOR_QUERY + orderBy, mapper, directorId);
loadGenresForFilms(films);
-
+ loadDirectorsForFilms(films);
return films;
}
+ // by уже проверен в сервисе: title и/или director, строчными
@Override
public List searchFilms(String query, List by) {
- StringBuilder sql = new StringBuilder(FIND_FILMS_BY_SEARCH_QUERY);
List conditions = new ArrayList<>();
List