From a18913e2ad19de40d94adc90cdbebf8ae882c036 Mon Sep 17 00:00:00 2001 From: Ksenia Date: Sun, 11 Oct 2026 20:16:52 +0300 Subject: [PATCH] fix: final polish before review --- README.md | 248 ++++++++++-------- .../controller/DirectorController.java | 6 +- .../filmorate/controller/FilmController.java | 2 +- .../filmorate/exception/ErrorHandler.java | 69 ++++- .../practicum/filmorate/model/Director.java | 5 +- .../practicum/filmorate/model/Film.java | 15 -- .../practicum/filmorate/model/User.java | 14 - .../filmorate/service/DirectorService.java | 13 +- .../filmorate/service/FilmService.java | 31 ++- .../filmorate/service/ReviewService.java | 5 + .../filmorate/service/UserService.java | 9 +- .../storage/director/DirectorDbStorage.java | 43 ++- .../storage/director/DirectorStorage.java | 1 + .../filmorate/storage/film/FilmDbStorage.java | 154 +++++------ .../storage/mapper/DirectorRowMapper.java | 1 + src/main/resources/schema.sql | 11 +- .../filmorate/exception/ErrorHandlerTest.java | 126 +++++++++ 17 files changed, 483 insertions(+), 270 deletions(-) create mode 100644 src/test/java/ru/yandex/practicum/filmorate/exception/ErrorHandlerTest.java 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 params = new ArrayList<>(); + String pattern = "%" + query.toLowerCase() + "%"; if (by.contains("director")) { conditions.add("LOWER(d.name) LIKE ?"); - params.add("%" + query.toLowerCase() + "%"); + params.add(pattern); } - if (by.contains("title")) { conditions.add("LOWER(f.name) LIKE ?"); - params.add("%" + query.toLowerCase() + "%"); + params.add(pattern); } - if (conditions.isEmpty()) { - throw new ValidationException("Необходимо указать поиск: director или title"); + throw new IllegalArgumentException("Не указано, где искать: title или director"); } - sql.append("WHERE ") - .append(String.join(" OR ", conditions)) - .append(" "); - - sql.append("GROUP BY f.id, f.name, f.description, f.release_date, ") - .append("f.duration, f.mpa_id, m.name ") - .append("ORDER BY COUNT(DISTINCT fl.user_id) DESC, f.id"); - List films = jdbc.query(sql.toString(), mapper, params.toArray()); - - loadDirectors(films); + // DISTINCT: у фильма с двумя режиссёрами каждый лайк иначе посчитается дважды + String sql = FIND_FILMS_BY_SEARCH_QUERY + + "WHERE " + String.join(" OR ", conditions) + " " + + GROUP_BY_FILM + + "ORDER BY COUNT(DISTINCT fl.user_id) DESC, f.id"; + List films = jdbc.query(sql, mapper, params.toArray()); loadGenresForFilms(films); + loadDirectorsForFilms(films); return films; } - //Собирает запрос: чтение + WHERE по условиям + сортировка по числу лайков, без LIMIT + // Собирает запрос: чтение + WHERE по условиям + сортировка по числу лайков, без LIMIT private String buildPopularQuery(List conditions) { StringBuilder sql = new StringBuilder(FIND_POPULAR_BASE_QUERY); if (!conditions.isEmpty()) { @@ -384,12 +369,13 @@ private void loadGenresForFilms(List films) { }, filmsById.keySet().toArray()); } - // Директора одного фильма + // Режиссёры одного фильма private void loadDirectors(Film film) { film.getDirectors().addAll(jdbc.query(FIND_DIRECTORS_BY_FILM_QUERY, directorMapper, film.getId())); } - private void loadDirectors(List films) { + // Режиссёры списка фильмов: один запрос только по этим фильмам, раскладываем через Map + private void loadDirectorsForFilms(List films) { if (films.isEmpty()) { return; // фильмов нет — запрос не нужен } @@ -397,25 +383,15 @@ private void loadDirectors(List films) { for (Film film : films) { filmsById.put(film.getId(), film); } - String placeholders = String.join( - ", ", - Collections.nCopies(filmsById.size(), "?") - ); - - String query = String.format( - FIND_DIRECTORS_BY_FILMS_QUERY, - placeholders - ); - - jdbc.query(query, rs -> { + String placeholders = String.join(", ", Collections.nCopies(filmsById.size(), "?")); + jdbc.query(String.format(FIND_DIRECTORS_BY_FILMS_QUERY, placeholders), rs -> { Film film = filmsById.get(rs.getLong("film_id")); - film.getDirectors().add(directorMapper.mapRow(rs, rs.getRow()) - ); + film.getDirectors().add(directorMapper.mapRow(rs, rs.getRow())); }, filmsById.keySet().toArray()); } - // Заменяет директоров фильма: старые связи удаляются, новые вставляются одним пакетом - private void saveDirector(long filmId, Set directors) { + // Заменяет режиссёров фильма: старые связи удаляются, новые вставляются одним пакетом + private void saveDirectors(long filmId, Set directors) { jdbc.update(DELETE_DIRECTOR_FILM_QUERY, filmId); if (directors == null || directors.isEmpty()) { return; diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/DirectorRowMapper.java b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/DirectorRowMapper.java index c8e662f..b27b96e 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/DirectorRowMapper.java +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/DirectorRowMapper.java @@ -3,6 +3,7 @@ import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Component; import ru.yandex.practicum.filmorate.model.Director; + import java.sql.ResultSet; import java.sql.SQLException; diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 6869731..becfc96 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -70,16 +70,15 @@ CREATE TABLE IF NOT EXISTS review_likes ( PRIMARY KEY (review_id, user_id) ); - CREATE TABLE IF NOT EXISTS directors ( - id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - name VARCHAR(255) NOT NULL + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(255) NOT NULL ); ---Связь «фильм — режиссёр», многие ко многим. +-- Связь «фильм — режиссёр», многие ко многим. CREATE TABLE IF NOT EXISTS film_director ( - film_id BIGINT NOT NULL REFERENCES films(id) ON DELETE CASCADE, - director_id BIGINT NOT NULL REFERENCES directors(id) ON DELETE CASCADE, + film_id BIGINT NOT NULL REFERENCES films(id) ON DELETE CASCADE, + director_id BIGINT NOT NULL REFERENCES directors(id) ON DELETE CASCADE, PRIMARY KEY (film_id, director_id) ); diff --git a/src/test/java/ru/yandex/practicum/filmorate/exception/ErrorHandlerTest.java b/src/test/java/ru/yandex/practicum/filmorate/exception/ErrorHandlerTest.java new file mode 100644 index 0000000..1d70765 --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/exception/ErrorHandlerTest.java @@ -0,0 +1,126 @@ +package ru.yandex.practicum.filmorate.exception; + +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +// Некорректные и пограничные запросы: раньше часть из них падала с 500. +// Ошибки клиента должны давать 400/404/405/415, корректные запросы — 200 +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureTestDatabase +@Transactional +class ErrorHandlerTest { + + private static final String FILM_JSON = "{\"name\":\"Фильм\",\"description\":\"Описание\"," + + "\"releaseDate\":\"2000-01-01\",\"duration\":100,\"mpa\":{\"id\":1}%s}"; + + private final MockMvc mockMvc; + + @Autowired + ErrorHandlerTest(MockMvc mockMvc) { + this.mockMvc = mockMvc; + } + + private String film(String extraFields) { + return String.format(FILM_JSON, extraFields); + } + + @Test + void shouldReturn400WhenPathVariableHasWrongType() throws Exception { + mockMvc.perform(get("/films/abc")) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturn400WhenRequiredParameterIsMissing() throws Exception { + mockMvc.perform(get("/films/search").param("query", "фильм")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Не указан обязательный параметр 'by'")); + } + + @Test + void shouldReturn400WhenJsonIsBroken() throws Exception { + mockMvc.perform(post("/films").contentType(MediaType.APPLICATION_JSON).content("{не json")) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturn400WhenUpdatingFilmWithoutId() throws Exception { + mockMvc.perform(put("/films").contentType(MediaType.APPLICATION_JSON).content(film(""))) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturn400WhenUpdatingUserWithoutId() throws Exception { + String user = "{\"email\":\"anna@mail.ru\",\"login\":\"anna\",\"birthday\":\"1990-01-01\"}"; + mockMvc.perform(put("/users").contentType(MediaType.APPLICATION_JSON).content(user)) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturn400WhenMpaHasNoId() throws Exception { + String filmWithEmptyMpa = film("").replace("{\"id\":1}", "{}"); + mockMvc.perform(post("/films").contentType(MediaType.APPLICATION_JSON).content(filmWithEmptyMpa)) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturn400WhenSortByIsUnknown() throws Exception { + mockMvc.perform(get("/films/director/1").param("sortBy", "rating")) + .andExpect(status().isBadRequest()); + } + + // Значения by проверяются без учёта регистра — и поиск тоже должен их понимать + @Test + void shouldSearchWhenByIsInAnyCase() throws Exception { + mockMvc.perform(get("/films/search").param("query", "фильм").param("by", "Title,DIRECTOR")) + .andExpect(status().isOk()); + } + + @Test + void shouldReturn404ForUnknownAddress() throws Exception { + mockMvc.perform(get("/unknown")) + .andExpect(status().isNotFound()); + } + + @Test + void shouldReturn415WhenContentTypeIsNotJson() throws Exception { + mockMvc.perform(post("/films").contentType(MediaType.TEXT_PLAIN).content("фильм")) + .andExpect(status().isUnsupportedMediaType()); + } + + @Test + void shouldReturn405WhenMethodIsNotSupported() throws Exception { + mockMvc.perform(post("/films/1")) + .andExpect(status().isMethodNotAllowed()); + } + + // Режиссёр сравнивается по id: дубль в запросе не должен ронять запись связи + @Test + void shouldIgnoreDuplicateDirectorInFilm() throws Exception { + String director = mockMvc.perform(post("/directors") + .contentType(MediaType.APPLICATION_JSON).content("{\"name\":\"Режиссёр\"}")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + Integer directorId = JsonPath.read(director, "$.id"); + String directors = ",\"directors\":[{\"id\":" + directorId + "},{\"id\":" + directorId + + ",\"name\":\"Режиссёр\"}]"; + + mockMvc.perform(post("/films").contentType(MediaType.APPLICATION_JSON).content(film(directors))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.directors.length()").value(1)); + } +}