From 10cab49bc3214df3a5860a99e3ac5ffbbfcee87e Mon Sep 17 00:00:00 2001 From: Kirill-Kazantcev Date: Wed, 7 Oct 2026 15:38:17 +0300 Subject: [PATCH 1/9] feat: add genre and year filters to popular films (#4) * Add genre and year filters to popular films endpoint * Cover popular films filtering by genre and year with tests * Trigger CI --- .../filmorate/controller/FilmController.java | 8 ++- .../filmorate/service/FilmService.java | 14 ++++- .../filmorate/storage/film/FilmDbStorage.java | 41 ++++++++++-- .../filmorate/storage/film/FilmStorage.java | 2 +- .../storage/film/FilmDbStorageTest.java | 63 ++++++++++++++++++- 5 files changed, 116 insertions(+), 12 deletions(-) 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 21c3dda..961959c 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java @@ -59,8 +59,10 @@ public Film removeLike(@PathVariable long id, @PathVariable long userId) { @GetMapping("/popular") public Collection getPopular( - @RequestParam(defaultValue = "10") int count) { - log.info("GET /films/popular?count={}", count); - return filmService.getPopular(count); + @RequestParam(defaultValue = "10") int count, + @RequestParam(required = false) Integer genreId, + @RequestParam(required = false) Integer year) { + log.info("GET /films/popular?count={}&genreId={}&year={}", count, genreId, year); + return filmService.getPopular(count, genreId, year); } } \ No newline at end of file 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 160de6e..beee424 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java @@ -77,14 +77,24 @@ public Film removeLike(long filmId, long userId) { return film; } - public Collection getPopular(int count) { + public Collection getPopular(int count, Integer genreId, Integer year) { // Валидация: count должен быть положительным числом if (count <= 0) { throw new ValidationException( "Количество фильмов должно быть положительным числом, получено: " + count ); } - return filmStorage.findPopular(count); + // Жанр, если указан, должен существовать — иначе 404 + if (genreId != null) { + genreService.findById(genreId); + } + // Валидация: год, если указан, должен быть положительным + if (year != null && year <= 0) { + throw new ValidationException( + "Год должен быть положительным числом, получено: " + year + ); + } + return filmStorage.findPopular(count, genreId, year); } // Валидация даты релиза — не может быть раньше дня рождения кино 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 df032fd..36e2435 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 @@ -33,11 +33,21 @@ public class FilmDbStorage implements FilmStorage { + "LEFT JOIN mpa m ON f.mpa_id = m.id "; 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 = ?"; - private static final String FIND_POPULAR_QUERY = SELECT_FILMS - + "LEFT JOIN film_likes fl ON f.id = fl.film_id " + + // Базовый запрос для популярных фильмов: WHERE-условия добавляются динамически + 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 " + "ORDER BY COUNT(fl.user_id) DESC, f.id " + "LIMIT ?"; + + // Условия фильтрации популярных фильмов: по жанру и по году выпуска + private static final String FIND_POPULAR_GENRE_FILTER = + "f.id IN (SELECT film_id FROM film_genres WHERE genre_id = ?)"; + private static final String FIND_POPULAR_YEAR_FILTER = + "YEAR(f.release_date) = ?"; private static final String INSERT_QUERY = "INSERT INTO films (name, description, release_date, duration, mpa_id) VALUES (?, ?, ?, ?, ?)"; private static final String UPDATE_QUERY = @@ -124,8 +134,22 @@ public Optional findById(long id) { } @Override - public List findPopular(int count) { - List films = jdbc.query(FIND_POPULAR_QUERY, mapper, count); + public List findPopular(int count, Integer genreId, Integer year) { + List conditions = new ArrayList<>(); + List params = new ArrayList<>(); + + if (genreId != null) { + // Подзапрос вместо JOIN: иначе фильм с несколькими жанрами задвоит строки + conditions.add(FIND_POPULAR_GENRE_FILTER); + params.add(genreId); + } + if (year != null) { + conditions.add(FIND_POPULAR_YEAR_FILTER); + params.add(year); + } + params.add(count); // LIMIT всегда последний + + List films = jdbc.query(buildPopularQuery(conditions), mapper, params.toArray()); loadGenresForFilms(films); return films; } @@ -141,6 +165,15 @@ public boolean removeLike(long filmId, long userId) { return jdbc.update(REMOVE_LIKE_QUERY, filmId, userId) > 0; } + // Собирает запрос популярных фильмов: базовое чтение + WHERE по условиям + группировка с лимитом + private String buildPopularQuery(List conditions) { + StringBuilder sql = new StringBuilder(FIND_POPULAR_BASE_QUERY); + if (!conditions.isEmpty()) { + sql.append("WHERE ").append(String.join(" AND ", conditions)).append(" "); + } + return sql.append(FIND_POPULAR_TAIL_QUERY).toString(); + } + // Рейтинга может не быть — тогда в колонку mpa_id уходит NULL private Integer getMpaId(Film film) { return film.getMpa() == null ? null : film.getMpa().getId(); diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java index 501bf43..d1d178c 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java @@ -18,7 +18,7 @@ public interface FilmStorage { Optional findById(long id); - List findPopular(int count); + List findPopular(int count, Integer genreId, Integer year); void addLike(long filmId, long userId); diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java index 002d03a..b9ad5e6 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java @@ -158,10 +158,69 @@ void shouldFindPopularByLikes() { filmStorage.addLike(second.getId(), boris.getId()); filmStorage.addLike(first.getId(), anna.getId()); - List popular = filmStorage.findPopular(10); + List popular = filmStorage.findPopular(10, null, null); assertEquals(List.of(second.getId(), first.getId(), third.getId()), popular.stream().map(Film::getId).toList()); - assertEquals(2, filmStorage.findPopular(2).size()); + assertEquals(2, filmStorage.findPopular(2, null, null).size()); + } + + @Test + void shouldFilterPopularByGenre() { + Film comedy = createFilm("Комедия"); + comedy.getGenres().add(genre(1)); + filmStorage.update(comedy); + Film drama = createFilm("Драма"); + drama.getGenres().add(genre(2)); + filmStorage.update(drama); + User anna = createUser("anna"); + filmStorage.addLike(comedy.getId(), anna.getId()); + filmStorage.addLike(drama.getId(), anna.getId()); + + List popular = filmStorage.findPopular(10, 1, null); + + assertEquals(List.of(comedy.getId()), popular.stream().map(Film::getId).toList()); + } + + @Test + void shouldFilterPopularByYear() { + Film old = createFilm("Старый"); + Film fresh = newFilm("Новый"); + fresh.setReleaseDate(LocalDate.of(2020, 1, 1)); + fresh = filmStorage.add(fresh); + User anna = createUser("anna"); + filmStorage.addLike(old.getId(), anna.getId()); + filmStorage.addLike(fresh.getId(), anna.getId()); + + List popular = filmStorage.findPopular(10, null, 2000); + + assertEquals(List.of(old.getId()), popular.stream().map(Film::getId).toList()); + } + + @Test + void shouldFilterPopularByGenreAndYear() { + Film comedy2000 = newFilm("Комедия 2000"); + comedy2000.getGenres().add(genre(1)); + comedy2000 = filmStorage.add(comedy2000); + Film comedy2020 = newFilm("Комедия 2020"); + comedy2020.setReleaseDate(LocalDate.of(2020, 1, 1)); + comedy2020.getGenres().add(genre(1)); + comedy2020 = filmStorage.add(comedy2020); + User anna = createUser("anna"); + filmStorage.addLike(comedy2000.getId(), anna.getId()); + filmStorage.addLike(comedy2020.getId(), anna.getId()); + + List popular = filmStorage.findPopular(10, 1, 2000); + + assertEquals(List.of(comedy2000.getId()), popular.stream().map(Film::getId).toList()); + } + + @Test + void shouldReturnAllPopularWhenFiltersAreNull() { + Film film = createFilm("Один"); + User anna = createUser("anna"); + filmStorage.addLike(film.getId(), anna.getId()); + + assertEquals(1, filmStorage.findPopular(10, null, null).size()); } } \ No newline at end of file From b7cadec9f9a53f293379dcd2344495dab11849fd Mon Sep 17 00:00:00 2001 From: Ksenia Date: Thu, 8 Oct 2026 16:12:36 +0300 Subject: [PATCH 2/9] feat: add common films endpoint (#5) --- .../filmorate/controller/FilmController.java | 6 ++++ .../filmorate/service/FilmService.java | 7 ++++ .../filmorate/storage/film/FilmDbStorage.java | 24 +++++++++++--- .../filmorate/storage/film/FilmStorage.java | 2 ++ .../storage/film/FilmDbStorageTest.java | 33 +++++++++++++++++++ 5 files changed, 67 insertions(+), 5 deletions(-) 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 961959c..db89073 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java @@ -65,4 +65,10 @@ public Collection getPopular( log.info("GET /films/popular?count={}&genreId={}&year={}", count, genreId, year); return filmService.getPopular(count, genreId, year); } + + @GetMapping("/common") + public Collection getCommon(@RequestParam long userId, @RequestParam long friendId) { + log.info("GET /films/common?userId={}&friendId={}", userId, friendId); + return filmService.getCommon(userId, friendId); + } } \ No newline at end of file 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 beee424..94089cb 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java @@ -97,6 +97,13 @@ public Collection getPopular(int count, Integer genreId, Integer year) { return filmStorage.findPopular(count, genreId, year); } + // Общие фильмы двух пользователей — те, что лайкнули оба, по убыванию популярности + public Collection getCommon(long userId, long friendId) { + getUserOrThrow(userId); // оба пользователя должны существовать — иначе 404 + getUserOrThrow(friendId); + return filmStorage.findCommon(userId, friendId); + } + // Валидация даты релиза — не может быть раньше дня рождения кино private void validateReleaseDate(Film film) { if (film.getReleaseDate().isBefore(CINEMA_BIRTHDAY)) { 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 36e2435..5280bef 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 @@ -37,17 +37,21 @@ public class FilmDbStorage implements FilmStorage { // Базовый запрос для популярных фильмов: WHERE-условия добавляются динамически 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 " - + "ORDER BY COUNT(fl.user_id) DESC, f.id " - + "LIMIT ?"; + + "ORDER BY COUNT(fl.user_id) DESC, f.id"; + // Лимит отдельно: в общих фильмах, поиске и фильмах режиссёра его нет + private static final String LIMIT_CLAUSE = " LIMIT ?"; // Условия фильтрации популярных фильмов: по жанру и по году выпуска private static final String FIND_POPULAR_GENRE_FILTER = "f.id IN (SELECT film_id FROM film_genres WHERE genre_id = ?)"; private static final String FIND_POPULAR_YEAR_FILTER = "YEAR(f.release_date) = ?"; + private static final String LIKED_BY_USER_FILTER = + "f.id IN (SELECT film_id FROM film_likes WHERE user_id = ?)"; + private static final String INSERT_QUERY = "INSERT INTO films (name, description, release_date, duration, mpa_id) VALUES (?, ?, ?, ?, ?)"; private static final String UPDATE_QUERY = @@ -149,7 +153,17 @@ public List findPopular(int count, Integer genreId, Integer year) { } params.add(count); // LIMIT всегда последний - List films = jdbc.query(buildPopularQuery(conditions), mapper, params.toArray()); + String sql = buildPopularQuery(conditions) + LIMIT_CLAUSE; + List films = jdbc.query(sql, mapper, params.toArray()); + loadGenresForFilms(films); + return films; + } + + @Override + public List findCommon(long userId, long friendId) { + // Одно и то же условие дважды, с разными id: фильм лайкнули оба + String sql = buildPopularQuery(List.of(LIKED_BY_USER_FILTER, LIKED_BY_USER_FILTER)); + List films = jdbc.query(sql, mapper, userId, friendId); loadGenresForFilms(films); return films; } @@ -165,7 +179,7 @@ public boolean removeLike(long filmId, long userId) { return jdbc.update(REMOVE_LIKE_QUERY, filmId, userId) > 0; } - // Собирает запрос популярных фильмов: базовое чтение + WHERE по условиям + группировка с лимитом + // Собирает запрос: чтение + WHERE по условиям + сортировка по числу лайков, без LIMIT private String buildPopularQuery(List conditions) { StringBuilder sql = new StringBuilder(FIND_POPULAR_BASE_QUERY); if (!conditions.isEmpty()) { diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java index d1d178c..3e5ecd3 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java @@ -20,6 +20,8 @@ public interface FilmStorage { List findPopular(int count, Integer genreId, Integer year); + List findCommon(long userId, long friendId); + void addLike(long filmId, long userId); boolean removeLike(long filmId, long userId); diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java index b9ad5e6..67cb17b 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java @@ -223,4 +223,37 @@ void shouldReturnAllPopularWhenFiltersAreNull() { assertEquals(1, filmStorage.findPopular(10, null, null).size()); } + + @Test + void shouldFindCommonFilmsSortedByPopularity() { + Film first = createFilm("Первый"); + Film second = createFilm("Второй"); + Film onlyAnna = createFilm("Только Анны"); + User anna = createUser("anna"); + User boris = createUser("boris"); + User vera = createUser("vera"); + // Оба лайкнули первый и второй; у второго есть ещё лайк Веры — он популярнее + filmStorage.addLike(first.getId(), anna.getId()); + filmStorage.addLike(first.getId(), boris.getId()); + filmStorage.addLike(second.getId(), anna.getId()); + filmStorage.addLike(second.getId(), boris.getId()); + filmStorage.addLike(second.getId(), vera.getId()); + // Этот лайкнула только Анна — в общие не попадает + filmStorage.addLike(onlyAnna.getId(), anna.getId()); + + List common = filmStorage.findCommon(anna.getId(), boris.getId()); + + assertEquals(List.of(second.getId(), first.getId()), + common.stream().map(Film::getId).toList()); + } + + @Test + void shouldReturnEmptyCommonFilmsWhenNoSharedLikes() { + Film film = createFilm("Один"); + User anna = createUser("anna"); + User boris = createUser("boris"); + filmStorage.addLike(film.getId(), anna.getId()); + + assertEquals(0, filmStorage.findCommon(anna.getId(), boris.getId()).size()); + } } \ No newline at end of file From 90c59c8f6d6209040a845ec5e0bdadf3b510c658 Mon Sep 17 00:00:00 2001 From: Daria Prindina Date: Thu, 8 Oct 2026 23:19:00 +0300 Subject: [PATCH 3/9] feat: add film reviews with useful rating (#7) --- README.md | 31 +++ .../controller/ReviewController.java | 79 ++++++++ .../practicum/filmorate/model/Review.java | 24 +++ .../filmorate/service/ReviewService.java | 118 +++++++++++ .../storage/mapper/ReviewRowMapper.java | 24 +++ .../storage/review/ReviewDbStorage.java | 96 +++++++++ .../storage/review/ReviewStorage.java | 23 +++ src/main/resources/schema.sql | 18 +- .../storage/review/ReviewDbStorageTest.java | 185 ++++++++++++++++++ 9 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 src/main/java/ru/yandex/practicum/filmorate/controller/ReviewController.java create mode 100644 src/main/java/ru/yandex/practicum/filmorate/model/Review.java create mode 100644 src/main/java/ru/yandex/practicum/filmorate/service/ReviewService.java create mode 100644 src/main/java/ru/yandex/practicum/filmorate/storage/mapper/ReviewRowMapper.java create mode 100644 src/main/java/ru/yandex/practicum/filmorate/storage/review/ReviewDbStorage.java create mode 100644 src/main/java/ru/yandex/practicum/filmorate/storage/review/ReviewStorage.java create mode 100644 src/test/java/ru/yandex/practicum/filmorate/storage/review/ReviewDbStorageTest.java diff --git a/README.md b/README.md index 50b4159..fbad3e1 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ - У фильма есть рейтинг и список жанров; жанры в ответе — без дублей, по порядку id. - Односторонняя дружба: добавление/удаление друзей, список друзей, общие друзья. - Лайки фильмам: добавление/удаление, топ-N фильмов по количеству лайков. +- Отзывы к фильмам: положительные и негативные, с рейтингом полезности по лайкам и дизлайкам. - Слоистая архитектура: контроллер → сервис → хранилище, выбор реализации хранилища через `@Qualifier`. - Внедрение зависимостей через конструктор (`@Autowired`). - Строгая валидация входящих данных через `spring-boot-starter-validation`. @@ -81,6 +82,18 @@ - `GET /users/{id}/friends` — список друзей пользователя. - `GET /users/{id}/friends/common/{otherId}` — общие друзья с другим пользователем. +### Отзывы (`/reviews`) +- `POST /reviews` — добавить отзыв. +- `PUT /reviews` — изменить текст и тип отзыва. +- `DELETE /reviews/{id}` — удалить отзыв. +- `GET /reviews/{id}` — получить отзыв по ID. +- `GET /reviews?filmId={filmId}&count={count}` — отзывы к фильму (без `filmId` — ко всем), + по убыванию полезности; по умолчанию 10. +- `PUT /reviews/{id}/like/{userId}` — отметить отзыв полезным. +- `PUT /reviews/{id}/dislike/{userId}` — отметить отзыв бесполезным. +- `DELETE /reviews/{id}/like/{userId}` — убрать лайк. +- `DELETE /reviews/{id}/dislike/{userId}` — убрать дизлайк. + ### Жанры (`/genres`) - `GET /genres` — получить список всех жанров. - `GET /genres/{id}` — получить жанр по ID. @@ -100,6 +113,10 @@ erDiagram users ||--o{ film_likes : "user_id" users ||--o{ friendship : "user_id" users ||--o{ friendship : "friend_id" + users ||--o{ reviews : "user_id" + films ||--o{ reviews : "film_id" + reviews ||--o{ review_likes : "review_id" + users ||--o{ review_likes : "user_id" films { BIGINT id PK @@ -136,6 +153,18 @@ erDiagram BIGINT user_id PK, FK BIGINT friend_id PK, FK } + reviews { + BIGINT id PK + VARCHAR content + BOOLEAN is_positive + BIGINT user_id FK + BIGINT film_id FK + } + review_likes { + BIGINT review_id PK, FK + BIGINT user_id PK, FK + BOOLEAN is_useful "TRUE — лайк, FALSE — дизлайк" + } ``` | Таблица | Что хранит | @@ -147,6 +176,8 @@ erDiagram | `film_genres` | связь «фильм — жанр», многие ко многим | | `film_likes` | лайки: какой пользователь лайкнул какой фильм | | `friendship` | дружба: одна строка — `user_id` добавил в друзья `friend_id`; подтверждённая (взаимная) дружба — две встречные строки | +| `reviews` | отзывы пользователей к фильмам; рейтинг полезности не хранится, а считается по `review_likes` | +| `review_likes` | оценки отзывов: одна строка — один пользователь, лайк или дизлайк | У таблиц-связок составной первичный ключ, поэтому одна и та же пара не запишется дважды. При удалении фильма или пользователя связанные с ним записи удаляются каскадно. diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/ReviewController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/ReviewController.java new file mode 100644 index 0000000..e84423a --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/ReviewController.java @@ -0,0 +1,79 @@ +package ru.yandex.practicum.filmorate.controller; + +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.filmorate.model.Review; +import ru.yandex.practicum.filmorate.service.ReviewService; + +import java.util.Collection; + +@RestController +@RequestMapping("/reviews") +@Slf4j +public class ReviewController { + + private final ReviewService reviewService; + + @Autowired + public ReviewController(ReviewService reviewService) { + this.reviewService = reviewService; + } + + @PostMapping + public Review create(@Valid @RequestBody Review review) { + log.info("POST /reviews : userId={}, filmId={}", review.getUserId(), review.getFilmId()); + return reviewService.add(review); + } + + @PutMapping + public Review update(@Valid @RequestBody Review review) { + log.info("PUT /reviews : id={}", review.getReviewId()); + return reviewService.update(review); + } + + @DeleteMapping("/{id}") + public void delete(@PathVariable long id) { + log.info("DELETE /reviews/{}", id); + reviewService.delete(id); + } + + @GetMapping("/{id}") + public Review findById(@PathVariable long id) { + log.info("GET /reviews/{}", id); + return reviewService.findById(id); + } + + @GetMapping + public Collection findMostUseful( + @RequestParam(required = false) Long filmId, + @RequestParam(defaultValue = "10") int count) { + log.info("GET /reviews?filmId={}&count={}", filmId, count); + return reviewService.findMostUseful(filmId, count); + } + + @PutMapping("/{id}/like/{userId}") + public void addLike(@PathVariable long id, @PathVariable long userId) { + log.info("PUT /reviews/{}/like/{}", id, userId); + reviewService.addLike(id, userId); + } + + @PutMapping("/{id}/dislike/{userId}") + public void addDislike(@PathVariable long id, @PathVariable long userId) { + log.info("PUT /reviews/{}/dislike/{}", id, userId); + reviewService.addDislike(id, userId); + } + + @DeleteMapping("/{id}/like/{userId}") + public void removeLike(@PathVariable long id, @PathVariable long userId) { + log.info("DELETE /reviews/{}/like/{}", id, userId); + reviewService.removeLike(id, userId); + } + + @DeleteMapping("/{id}/dislike/{userId}") + public void removeDislike(@PathVariable long id, @PathVariable long userId) { + log.info("DELETE /reviews/{}/dislike/{}", id, userId); + reviewService.removeDislike(id, userId); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Review.java b/src/main/java/ru/yandex/practicum/filmorate/model/Review.java new file mode 100644 index 0000000..e8a4c41 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Review.java @@ -0,0 +1,24 @@ +package ru.yandex.practicum.filmorate.model; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class Review { + private Long reviewId; + + @NotBlank(message = "Текст отзыва не может быть пустым") + private String content; + + @NotNull(message = "Тип отзыва должен быть указан") + private Boolean isPositive; + + @NotNull(message = "Автор отзыва должен быть указан") + private Long userId; + + @NotNull(message = "Фильм должен быть указан") + private Long filmId; + + private int useful; +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/ReviewService.java b/src/main/java/ru/yandex/practicum/filmorate/service/ReviewService.java new file mode 100644 index 0000000..bed84b1 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/ReviewService.java @@ -0,0 +1,118 @@ +package ru.yandex.practicum.filmorate.service; + +import lombok.extern.slf4j.Slf4j; +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.Review; +import ru.yandex.practicum.filmorate.storage.film.FilmStorage; +import ru.yandex.practicum.filmorate.storage.review.ReviewStorage; +import ru.yandex.practicum.filmorate.storage.user.UserStorage; + +import java.util.Collection; + +@Service +@Slf4j +public class ReviewService { + + private final ReviewStorage reviewStorage; + private final FilmStorage filmStorage; + private final UserStorage userStorage; + + @Autowired + public ReviewService(ReviewStorage reviewStorage, + @Qualifier("filmDbStorage") FilmStorage filmStorage, + @Qualifier("userDbStorage") UserStorage userStorage) { + this.reviewStorage = reviewStorage; + this.filmStorage = filmStorage; + this.userStorage = userStorage; + } + + private Review getReviewOrThrow(long id) { + return reviewStorage.findById(id) + .orElseThrow(() -> new NotFoundException("Отзыв с id=" + id + " не найден")); + } + + private void checkUserExists(long id) { + userStorage.findById(id) + .orElseThrow(() -> new NotFoundException("Пользователь с id=" + id + " не найден")); + } + + private void checkFilmExists(long id) { + filmStorage.findById(id) + .orElseThrow(() -> new NotFoundException("Фильм с id=" + id + " не найден")); + } + + public Review add(Review review) { + checkUserExists(review.getUserId()); + checkFilmExists(review.getFilmId()); + Review created = reviewStorage.add(review); + log.debug("Пользователь id={} оставил отзыв id={} к фильму id={}", + created.getUserId(), created.getReviewId(), created.getFilmId()); + return created; + } + + // Автор, фильм и рейтинг полезности не меняются — только текст и тип отзыва + public Review update(Review review) { + if (review.getReviewId() == null) { + throw new ValidationException("Не указан id отзыва"); + } + getReviewOrThrow(review.getReviewId()); + return reviewStorage.update(review); + } + + public void delete(long id) { + getReviewOrThrow(id); + reviewStorage.delete(id); + log.debug("Отзыв id={} удалён", id); + } + + public Review findById(long id) { + return getReviewOrThrow(id); + } + + public Collection findMostUseful(Long filmId, int count) { + if (count <= 0) { + throw new ValidationException( + "Количество отзывов должно быть положительным числом, получено: " + count + ); + } + if (filmId != null) { + checkFilmExists(filmId); + } + return reviewStorage.findMostUseful(filmId, count); + } + + public void addLike(long reviewId, long userId) { + addRating(reviewId, userId, true); + } + + public void addDislike(long reviewId, long userId) { + addRating(reviewId, userId, false); + } + + public void removeLike(long reviewId, long userId) { + removeRating(reviewId, userId, true); + } + + public void removeDislike(long reviewId, long userId) { + removeRating(reviewId, userId, false); + } + + // Повторная оценка заменяет предыдущую: лайк после дизлайка меняет рейтинг на +2 + private void addRating(long reviewId, long userId, boolean isUseful) { + getReviewOrThrow(reviewId); + checkUserExists(userId); + reviewStorage.addRating(reviewId, userId, isUseful); + log.debug("Пользователь id={} оценил отзыв id={}: полезный={}", userId, reviewId, isUseful); + } + + private void removeRating(long reviewId, long userId, boolean isUseful) { + getReviewOrThrow(reviewId); + checkUserExists(userId); + reviewStorage.removeRating(reviewId, userId, isUseful); + log.debug("Пользователь id={} убрал оценку отзыва id={}: полезный={}", userId, reviewId, isUseful); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/ReviewRowMapper.java b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/ReviewRowMapper.java new file mode 100644 index 0000000..0132197 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/ReviewRowMapper.java @@ -0,0 +1,24 @@ +package ru.yandex.practicum.filmorate.storage.mapper; + +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; +import ru.yandex.practicum.filmorate.model.Review; + +import java.sql.ResultSet; +import java.sql.SQLException; + +@Component +public class ReviewRowMapper implements RowMapper { + + @Override + public Review mapRow(ResultSet rs, int rowNum) throws SQLException { + Review review = new Review(); + review.setReviewId(rs.getLong("id")); + review.setContent(rs.getString("content")); + review.setIsPositive(rs.getBoolean("is_positive")); + review.setUserId(rs.getLong("user_id")); + review.setFilmId(rs.getLong("film_id")); + review.setUseful(rs.getInt("useful")); + return review; + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/review/ReviewDbStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/review/ReviewDbStorage.java new file mode 100644 index 0000000..a292be8 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/review/ReviewDbStorage.java @@ -0,0 +1,96 @@ +package ru.yandex.practicum.filmorate.storage.review; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import ru.yandex.practicum.filmorate.model.Review; +import ru.yandex.practicum.filmorate.storage.mapper.ReviewRowMapper; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.List; +import java.util.Optional; + +@Repository +public class ReviewDbStorage implements ReviewStorage { + + // Рейтинг полезности: лайк +1, дизлайк -1 + private static final String SELECT_REVIEWS = + "SELECT r.id, r.content, r.is_positive, r.user_id, r.film_id, " + + "(SELECT COALESCE(SUM(CASE WHEN rl.is_useful THEN 1 ELSE -1 END), 0) " + + "FROM review_likes rl WHERE rl.review_id = r.id) AS useful " + + "FROM reviews r "; + private static final String FIND_BY_ID_QUERY = SELECT_REVIEWS + "WHERE r.id = ?"; + private static final String FIND_ALL_QUERY = SELECT_REVIEWS + + "ORDER BY useful DESC, r.id LIMIT ?"; + private static final String FIND_BY_FILM_QUERY = SELECT_REVIEWS + + "WHERE r.film_id = ? ORDER BY useful DESC, r.id LIMIT ?"; + private static final String INSERT_QUERY = + "INSERT INTO reviews (content, is_positive, user_id, film_id) VALUES (?, ?, ?, ?)"; + private static final String UPDATE_QUERY = + "UPDATE reviews SET content = ?, is_positive = ? WHERE id = ?"; + private static final String DELETE_QUERY = "DELETE FROM reviews WHERE id = ?"; + private static final String ADD_RATING_QUERY = + "MERGE INTO review_likes (review_id, user_id, is_useful) KEY (review_id, user_id) VALUES (?, ?, ?)"; + private static final String REMOVE_RATING_QUERY = + "DELETE FROM review_likes WHERE review_id = ? AND user_id = ? AND is_useful = ?"; + + private final JdbcTemplate jdbc; + private final ReviewRowMapper mapper; + + @Autowired + public ReviewDbStorage(JdbcTemplate jdbc, ReviewRowMapper mapper) { + this.jdbc = jdbc; + this.mapper = mapper; + } + + @Override + public Review add(Review review) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbc.update(connection -> { + PreparedStatement ps = connection.prepareStatement(INSERT_QUERY, Statement.RETURN_GENERATED_KEYS); + ps.setString(1, review.getContent()); + ps.setBoolean(2, review.getIsPositive()); + ps.setLong(3, review.getUserId()); + ps.setLong(4, review.getFilmId()); + return ps; + }, keyHolder); + return findById(keyHolder.getKeyAs(Long.class)).orElseThrow(); + } + + @Override + public Review update(Review review) { + jdbc.update(UPDATE_QUERY, review.getContent(), review.getIsPositive(), review.getReviewId()); + return findById(review.getReviewId()).orElseThrow(); + } + + @Override + public void delete(long id) { + jdbc.update(DELETE_QUERY, id); + } + + @Override + public Optional findById(long id) { + return jdbc.query(FIND_BY_ID_QUERY, mapper, id).stream().findFirst(); + } + + @Override + public List findMostUseful(Long filmId, int count) { + if (filmId == null) { + return jdbc.query(FIND_ALL_QUERY, mapper, count); + } + return jdbc.query(FIND_BY_FILM_QUERY, mapper, filmId, count); + } + + @Override + public void addRating(long reviewId, long userId, boolean isUseful) { + jdbc.update(ADD_RATING_QUERY, reviewId, userId, isUseful); + } + + @Override + public void removeRating(long reviewId, long userId, boolean isUseful) { + jdbc.update(REMOVE_RATING_QUERY, reviewId, userId, isUseful); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/review/ReviewStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/review/ReviewStorage.java new file mode 100644 index 0000000..5eaa3b4 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/review/ReviewStorage.java @@ -0,0 +1,23 @@ +package ru.yandex.practicum.filmorate.storage.review; + +import ru.yandex.practicum.filmorate.model.Review; + +import java.util.List; +import java.util.Optional; + +public interface ReviewStorage { + + Review add(Review review); + + Review update(Review review); + + void delete(long id); + + Optional findById(long id); + + List findMostUseful(Long filmId, int count); + + void addRating(long reviewId, long userId, boolean isUseful); + + void removeRating(long reviewId, long userId, boolean isUseful); +} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 3d660c3..b2dcd2d 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -52,4 +52,20 @@ CREATE TABLE IF NOT EXISTS friendship ( user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, friend_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY (user_id, friend_id) -); \ No newline at end of file +); + +CREATE TABLE IF NOT EXISTS reviews ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + content VARCHAR NOT NULL, + is_positive BOOLEAN NOT NULL, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + film_id BIGINT NOT NULL REFERENCES films(id) ON DELETE CASCADE +); + +-- Оценка отзыва: is_useful = TRUE — лайк, FALSE — дизлайк. Один пользователь — одна оценка. +CREATE TABLE IF NOT EXISTS review_likes ( + review_id BIGINT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + is_useful BOOLEAN NOT NULL, + PRIMARY KEY (review_id, user_id) +); diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/review/ReviewDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/review/ReviewDbStorageTest.java new file mode 100644 index 0000000..95d380c --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/review/ReviewDbStorageTest.java @@ -0,0 +1,185 @@ +package ru.yandex.practicum.filmorate.storage.review; + +import org.junit.jupiter.api.BeforeEach; +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.jdbc.JdbcTest; +import org.springframework.context.annotation.Import; +import ru.yandex.practicum.filmorate.model.Film; +import ru.yandex.practicum.filmorate.model.Review; +import ru.yandex.practicum.filmorate.model.User; +import ru.yandex.practicum.filmorate.storage.film.FilmDbStorage; +import ru.yandex.practicum.filmorate.storage.mapper.FilmRowMapper; +import ru.yandex.practicum.filmorate.storage.mapper.GenreRowMapper; +import ru.yandex.practicum.filmorate.storage.mapper.ReviewRowMapper; +import ru.yandex.practicum.filmorate.storage.mapper.UserRowMapper; +import ru.yandex.practicum.filmorate.storage.user.UserDbStorage; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@JdbcTest +@AutoConfigureTestDatabase +@Import({ReviewDbStorage.class, ReviewRowMapper.class, FilmDbStorage.class, FilmRowMapper.class, + GenreRowMapper.class, UserDbStorage.class, UserRowMapper.class}) +class ReviewDbStorageTest { + + private final ReviewDbStorage reviewStorage; + private final FilmDbStorage filmStorage; + private final UserDbStorage userStorage; + + private User author; + private Film film; + + @Autowired + ReviewDbStorageTest(ReviewDbStorage reviewStorage, FilmDbStorage filmStorage, UserDbStorage userStorage) { + this.reviewStorage = reviewStorage; + this.filmStorage = filmStorage; + this.userStorage = userStorage; + } + + @BeforeEach + void setUp() { + author = createUser("author"); + film = createFilm("Фильм"); + } + + private User createUser(String login) { + User user = new User(); + user.setEmail(login + "@mail.ru"); + user.setLogin(login); + user.setName("Пользователь " + login); + user.setBirthday(LocalDate.of(1990, 1, 1)); + return userStorage.add(user); + } + + private Film createFilm(String name) { + Film newFilm = new Film(); + newFilm.setName(name); + newFilm.setDescription("Описание"); + newFilm.setReleaseDate(LocalDate.of(2000, 1, 1)); + newFilm.setDuration(120); + return filmStorage.add(newFilm); + } + + private Review createReview(long filmId, String content) { + Review review = new Review(); + review.setContent(content); + review.setIsPositive(true); + review.setUserId(author.getId()); + review.setFilmId(filmId); + return reviewStorage.add(review); + } + + @Test + void shouldAddReviewAndFindById() { + Review review = createReview(film.getId(), "Отличный фильм"); + + assertNotNull(review.getReviewId()); + assertEquals(0, review.getUseful()); + assertEquals(review, reviewStorage.findById(review.getReviewId()).orElseThrow()); + } + + @Test + void shouldReturnEmptyWhenReviewNotFound() { + assertTrue(reviewStorage.findById(9999).isEmpty()); + } + + @Test + void shouldUpdateOnlyContentAndType() { + Review review = createReview(film.getId(), "Отличный фильм"); + User other = createUser("other"); + Review changes = new Review(); + changes.setReviewId(review.getReviewId()); + changes.setContent("Так себе фильм"); + changes.setIsPositive(false); + changes.setUserId(other.getId()); + changes.setFilmId(createFilm("Другой").getId()); + + Review updated = reviewStorage.update(changes); + + assertEquals("Так себе фильм", updated.getContent()); + assertFalse(updated.getIsPositive()); + assertEquals(author.getId(), updated.getUserId()); + assertEquals(film.getId(), updated.getFilmId()); + } + + @Test + void shouldDeleteReview() { + Review review = createReview(film.getId(), "Отличный фильм"); + + reviewStorage.delete(review.getReviewId()); + + assertTrue(reviewStorage.findById(review.getReviewId()).isEmpty()); + } + + @Test + void shouldCountUsefulFromLikesAndDislikes() { + Review review = createReview(film.getId(), "Отличный фильм"); + long reviewId = review.getReviewId(); + reviewStorage.addRating(reviewId, createUser("first").getId(), true); + reviewStorage.addRating(reviewId, createUser("second").getId(), true); + reviewStorage.addRating(reviewId, createUser("third").getId(), false); + + assertEquals(1, reviewStorage.findById(reviewId).orElseThrow().getUseful()); + } + + @Test + void shouldReplaceLikeWithDislike() { + Review review = createReview(film.getId(), "Отличный фильм"); + long reviewId = review.getReviewId(); + long userId = createUser("rater").getId(); + reviewStorage.addRating(reviewId, userId, true); + + reviewStorage.addRating(reviewId, userId, false); + + assertEquals(-1, reviewStorage.findById(reviewId).orElseThrow().getUseful()); + } + + @Test + void shouldRemoveOnlyMatchingRating() { + Review review = createReview(film.getId(), "Отличный фильм"); + long reviewId = review.getReviewId(); + long userId = createUser("rater").getId(); + reviewStorage.addRating(reviewId, userId, true); + + reviewStorage.removeRating(reviewId, userId, false); + assertEquals(1, reviewStorage.findById(reviewId).orElseThrow().getUseful()); + + reviewStorage.removeRating(reviewId, userId, true); + assertEquals(0, reviewStorage.findById(reviewId).orElseThrow().getUseful()); + } + + @Test + void shouldSortByUsefulAndFilterByFilm() { + Review first = createReview(film.getId(), "Первый"); + Review second = createReview(film.getId(), "Второй"); + Review third = createReview(film.getId(), "Третий"); + Review otherFilmReview = createReview(createFilm("Другой").getId(), "Про другой фильм"); + reviewStorage.addRating(second.getReviewId(), createUser("rater").getId(), true); + reviewStorage.addRating(first.getReviewId(), createUser("hater").getId(), false); + + List byFilm = reviewStorage.findMostUseful(film.getId(), 10).stream() + .map(Review::getReviewId) + .toList(); + assertEquals(List.of(second.getReviewId(), third.getReviewId(), first.getReviewId()), byFilm); + + assertEquals(4, reviewStorage.findMostUseful(null, 10).size()); + assertEquals(List.of(second.getReviewId(), third.getReviewId()), + reviewStorage.findMostUseful(null, 2).stream().map(Review::getReviewId).toList()); + assertTrue(reviewStorage.findMostUseful(film.getId(), 10).stream() + .noneMatch(review -> review.getReviewId().equals(otherFilmReview.getReviewId()))); + } + + @Test + void shouldDeleteReviewsWithFilm() { + Review review = createReview(film.getId(), "Отличный фильм"); + + filmStorage.delete(film.getId()); + + assertTrue(reviewStorage.findById(review.getReviewId()).isEmpty()); + } +} From eaaccc5ae0e19e3a7b1a1ed522f5602e97721c1c Mon Sep 17 00:00:00 2001 From: Ksenia Date: Thu, 8 Oct 2026 23:47:21 +0300 Subject: [PATCH 4/9] feat: add delete endpoints for films and users (#16) * feat: add delete endpoints for films and users * test: add unknown film delete test --- README.md | 9 +++-- .../filmorate/controller/FilmController.java | 6 ++++ .../filmorate/controller/UserController.java | 6 ++++ .../filmorate/service/FilmService.java | 6 ++++ .../filmorate/service/UserService.java | 6 ++++ .../storage/film/FilmDbStorageTest.java | 36 +++++++++++++++++++ .../storage/user/UserDbStorageTest.java | 20 +++++++++++ 7 files changed, 87 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fbad3e1..fe5e661 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ## Ключевые возможности на данном этапе -- CRUD-операции для `Film` и `User` (создание, обновление, получение по ID и списком). +- CRUD-операции для `Film` и `User` (создание, обновление, удаление, получение по ID и списком). - Хранение данных в базе H2 (файловый режим — данные переживают перезапуск), доступ через `JdbcTemplate` и `RowMapper`. - Справочники жанров и рейтингов MPA в базе, эндпоинты `/genres` и `/mpa`. @@ -68,15 +68,20 @@ - `GET /films/{id}` — получить фильм по ID. - `POST /films` — добавить новый фильм. - `PUT /films` — обновить существующий фильм. +- `DELETE /films/{id}` — удалить фильм; связанные с ним записи удаляются каскадно. - `PUT /films/{id}/like/{userId}` — поставить лайк фильму. - `DELETE /films/{id}/like/{userId}` — убрать лайк. -- `GET /films/popular?count={count}` — топ-N фильмов по лайкам (по умолчанию 10). +- `GET /films/popular?count={count}&genreId={genreId}&year={year}` — топ-N фильмов по лайкам + (по умолчанию 10); жанр и год — необязательные фильтры. +- `GET /films/common?userId={userId}&friendId={friendId}` — общие фильмы двух пользователей, + по убыванию популярности. ### Пользователи (`/users`) - `GET /users` — получить список всех пользователей. - `GET /users/{id}` — получить пользователя по ID. - `POST /users` — создать нового пользователя. - `PUT /users` — обновить данные пользователя. +- `DELETE /users/{id}` — удалить пользователя; связанные с ним записи удаляются каскадно. - `PUT /users/{id}/friends/{friendId}` — добавить в друзья (дружба односторонняя). - `DELETE /users/{id}/friends/{friendId}` — удалить из друзей. - `GET /users/{id}/friends` — список друзей пользователя. 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 db89073..f3473fa 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java @@ -45,6 +45,12 @@ public Film update(@Valid @RequestBody Film film) { return filmService.update(film); } + @DeleteMapping("/{id}") + public void delete(@PathVariable long id) { + log.info("DELETE /films/{}", id); + filmService.delete(id); + } + @PutMapping("/{id}/like/{userId}") public Film addLike(@PathVariable long id, @PathVariable long userId) { log.info("PUT /films/{}/like/{}", id, userId); diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java index ba0d0ec..8c154dc 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java @@ -45,6 +45,12 @@ public User update(@Valid @RequestBody User user) { return userService.update(user); } + @DeleteMapping("/{id}") + public void delete(@PathVariable long id) { + log.info("DELETE /users/{}", id); + userService.delete(id); + } + @PutMapping("/{id}/friends/{friendId}") public User addFriend(@PathVariable long id, @PathVariable long friendId) { log.info("PUT /users/{}/friends/{}", id, friendId); 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 94089cb..1a21f90 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java @@ -147,6 +147,12 @@ public Film update(Film film) { return filmStorage.update(film); } + // Лайки, жанры и прочие связи фильма база удаляет сама — каскадом из schema.sql + public void delete(long id) { + filmStorage.delete(id); // нет фильма — хранилище бросит NotFoundException, это 404 + log.debug("Фильм id={} удалён", id); + } + public Collection findAll() { return filmStorage.findAll(); } 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 640cf50..cde4926 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java @@ -102,6 +102,12 @@ public User update(User user) { return userStorage.update(user); } + // Дружбу, лайки и прочие связи пользователя база удаляет сама — каскадом из schema.sql + public void delete(long id) { + userStorage.delete(id); // нет пользователя — хранилище бросит NotFoundException, это 404 + log.debug("Пользователь id={} удалён", id); + } + public Collection findAll() { return userStorage.findAll(); } diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java index 67cb17b..bffa5f2 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java @@ -5,6 +5,7 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; import org.springframework.context.annotation.Import; +import ru.yandex.practicum.filmorate.exception.NotFoundException; import ru.yandex.practicum.filmorate.model.Film; import ru.yandex.practicum.filmorate.model.Genre; import ru.yandex.practicum.filmorate.model.Mpa; @@ -135,6 +136,41 @@ void shouldDeleteFilm() { assertTrue(filmStorage.findById(film.getId()).isEmpty()); } + // Без ON DELETE CASCADE удаление упало бы на внешних ключах film_genres и film_likes + @Test + void shouldDeleteFilmWithGenresAndLikes() { + Film film = newFilm("С жанром и лайком"); + film.getGenres().add(genre(1)); + film = filmStorage.add(film); + User anna = createUser("anna"); + filmStorage.addLike(film.getId(), anna.getId()); + + filmStorage.delete(film.getId()); + + assertTrue(filmStorage.findById(film.getId()).isEmpty()); + assertTrue(filmStorage.findPopular(10, null, null).isEmpty()); + } + + @Test + void shouldNotCountLikesOfDeletedUser() { + Film first = createFilm("Без лайков"); + Film liked = createFilm("С лайком"); + User boris = createUser("boris"); + filmStorage.addLike(liked.getId(), boris.getId()); + assertEquals(liked.getId(), filmStorage.findPopular(10, null, null).get(0).getId()); + + userStorage.delete(boris.getId()); + + // Лайков не осталось ни у кого — фильмы идут по порядку id + List ids = filmStorage.findPopular(10, null, null).stream().map(Film::getId).toList(); + assertEquals(List.of(first.getId(), liked.getId()), ids); + } + + @Test + void shouldThrowWhenDeletingUnknownFilm() { + assertThrows(NotFoundException.class, () -> filmStorage.delete(9999)); + } + @Test void shouldAddAndRemoveLike() { Film film = createFilm("Любимый"); diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorageTest.java index 288802c..24adb41 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorageTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorageTest.java @@ -5,6 +5,7 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; import org.springframework.context.annotation.Import; +import ru.yandex.practicum.filmorate.exception.NotFoundException; import ru.yandex.practicum.filmorate.model.User; import ru.yandex.practicum.filmorate.storage.mapper.UserRowMapper; @@ -80,6 +81,25 @@ void shouldDeleteUser() { assertTrue(userStorage.findById(user.getId()).isEmpty()); } + // Без ON DELETE CASCADE удаление упало бы на внешнем ключе friendship + @Test + void shouldDeleteUserWithFriendships() { + User anna = createUser("anna"); + User boris = createUser("boris"); + userStorage.addFriend(anna.getId(), boris.getId()); + userStorage.addFriend(boris.getId(), anna.getId()); + + userStorage.delete(boris.getId()); + + assertTrue(userStorage.findById(boris.getId()).isEmpty()); + assertTrue(userStorage.findFriends(anna.getId()).isEmpty()); + } + + @Test + void shouldThrowWhenDeletingUnknownUser() { + assertThrows(NotFoundException.class, () -> userStorage.delete(9999)); + } + @Test void shouldAddFriendOneWay() { User anna = createUser("anna"); From dc7fab87b5e12478cbc02cf4791bd8ddc4287078 Mon Sep 17 00:00:00 2001 From: Roman32-gif Date: Fri, 9 Oct 2026 12:28:49 +0300 Subject: [PATCH 5/9] feat: add film directors (#6) * add film directors * trigger ci * Fixed mistakes * Fixed small mistakes * Fixed mistakes and added info in README * Added import --------- Co-authored-by: Roman --- README.md | 26 ++++- .../controller/DirectorController.java | 53 +++++++++ .../filmorate/controller/FilmController.java | 7 ++ .../practicum/filmorate/model/Director.java | 12 ++ .../practicum/filmorate/model/Film.java | 3 + .../filmorate/service/DirectorService.java | 39 +++++++ .../filmorate/service/FilmService.java | 30 ++++- .../storage/director/DirectorDbStorage.java | 78 +++++++++++++ .../storage/director/DirectorStorage.java | 18 +++ .../filmorate/storage/film/FilmDbStorage.java | 106 +++++++++++++++++- .../filmorate/storage/film/FilmStorage.java | 2 + .../storage/mapper/DirectorRowMapper.java | 19 ++++ src/main/resources/schema.sql | 13 +++ .../director/DirectorDbStorageTest.java | 75 +++++++++++++ .../storage/film/FilmDbStorageTest.java | 60 +++++++++- .../storage/review/ReviewDbStorageTest.java | 7 +- 16 files changed, 537 insertions(+), 11 deletions(-) create mode 100644 src/main/java/ru/yandex/practicum/filmorate/controller/DirectorController.java create mode 100644 src/main/java/ru/yandex/practicum/filmorate/model/Director.java create mode 100644 src/main/java/ru/yandex/practicum/filmorate/service/DirectorService.java create mode 100644 src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorDbStorage.java create mode 100644 src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorStorage.java create mode 100644 src/main/java/ru/yandex/practicum/filmorate/storage/mapper/DirectorRowMapper.java create mode 100644 src/test/java/ru/yandex/practicum/filmorate/storage/director/DirectorDbStorageTest.java diff --git a/README.md b/README.md index fe5e661..af6f295 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ - Хранение данных в базе H2 (файловый режим — данные переживают перезапуск), доступ через `JdbcTemplate` и `RowMapper`. - Справочники жанров и рейтингов MPA в базе, эндпоинты `/genres` и `/mpa`. -- У фильма есть рейтинг и список жанров; жанры в ответе — без дублей, по порядку id. +- У фильма есть рейтинг и список жанров; жанры в ответе — без дублей, по порядку id. +- У фильмов можно указывать режиссёров и получать список фильмов выбранного режиссёра с сортировкой по году выпуска или количеству лайков. - Односторонняя дружба: добавление/удаление друзей, список друзей, общие друзья. - Лайки фильмам: добавление/удаление, топ-N фильмов по количеству лайков. - Отзывы к фильмам: положительные и негативные, с рейтингом полезности по лайкам и дизлайкам. @@ -71,6 +72,10 @@ - `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}` — общие фильмы двух пользователей, @@ -107,6 +112,13 @@ - `GET /mpa` — получить список всех рейтингов. - `GET /mpa/{id}` — получить рейтинг по ID. +## Режиссёры (`/directors`) +- `GET /directors` — получить список всех режиссёров. +- `GET /directors/{id}` — получить режиссёра по ID. +- `POST /directors` — создать нового режиссёра. +- `PUT /directors` — обновить данные режиссёра. +- `DELETE /directors/{id}` — удалить режиссёра. + ## Схема базы данных ```mermaid @@ -118,6 +130,8 @@ erDiagram users ||--o{ film_likes : "user_id" users ||--o{ friendship : "user_id" users ||--o{ friendship : "friend_id" + films ||--o{film_director : "film_id" + directors ||--o{film_director : "director_id" users ||--o{ reviews : "user_id" films ||--o{ reviews : "film_id" reviews ||--o{ review_likes : "review_id" @@ -158,6 +172,14 @@ erDiagram BIGINT user_id PK, FK BIGINT friend_id PK, FK } + directors { + BIGINT id PK + VARCHAR name + } + film_director { + BIGINT film_id PK, FK + BIGINT director_id PK, FK + } reviews { BIGINT id PK VARCHAR content @@ -181,6 +203,8 @@ erDiagram | `film_genres` | связь «фильм — жанр», многие ко многим | | `film_likes` | лайки: какой пользователь лайкнул какой фильм | | `friendship` | дружба: одна строка — `user_id` добавил в друзья `friend_id`; подтверждённая (взаимная) дружба — две встречные строки | +| `directors` | режиссёры | +| `film_director` | связь "фильм — режиссёр", многие ко многим | | `reviews` | отзывы пользователей к фильмам; рейтинг полезности не хранится, а считается по `review_likes` | | `review_likes` | оценки отзывов: одна строка — один пользователь, лайк или дизлайк | diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/DirectorController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/DirectorController.java new file mode 100644 index 0000000..d9baa58 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/DirectorController.java @@ -0,0 +1,53 @@ +package ru.yandex.practicum.filmorate.controller; + +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +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 +@RestController +@RequestMapping("/directors") +public class DirectorController { + + private final DirectorService directorService; + + @Autowired + public DirectorController(DirectorService directorService) { + this.directorService = directorService; + } + + @GetMapping + public List getDirectors() { + log.info("GET /directors"); + return directorService.getAllDirectors(); + } + + @GetMapping("/{id}") + public Director getDirectorById(@PathVariable Long id) { + log.info("GET /directors/{}", id); + return directorService.getDirectorById(id); + } + + @PostMapping + public Director createDirector(@Valid @RequestBody Director director) { + log.info("POST /directors, body:{}", director); + return directorService.addDirector(director); + } + + @PutMapping + public Director updateDirector(@Valid @RequestBody Director director) { + log.info("PUT /directors, body:{}", director); + return directorService.updateDirector(director); + } + + @DeleteMapping("/{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 f3473fa..68b2969 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java @@ -77,4 +77,11 @@ public Collection getCommon(@RequestParam long userId, @RequestParam long log.info("GET /films/common?userId={}&friendId={}", userId, friendId); return filmService.getCommon(userId, friendId); } + + @GetMapping("/director/{directorId}") + public Collection getFilmsByDirector(@PathVariable long directorId, + @RequestParam (defaultValue = "likes") String sortBy) { + log.info("GET /films/director/{}?sortBy={}", directorId, sortBy); + return filmService.findByDirectorSorted(directorId, sortBy); + } } \ 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 new file mode 100644 index 0000000..5924c72 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Director.java @@ -0,0 +1,12 @@ +package ru.yandex.practicum.filmorate.model; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class Director { + private Long id; + + @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 08a9aca..5ba8b59 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/model/Film.java +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Film.java @@ -38,6 +38,8 @@ public class Film { @JsonIgnore private Set likes = new HashSet<>(); + private Set directors = new LinkedHashSet<>(); + public void addLike(Long userId) { likes.add(userId); } @@ -45,4 +47,5 @@ public void addLike(Long userId) { public boolean removeLike(Long userId) { return likes.remove(userId); } + } diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/DirectorService.java b/src/main/java/ru/yandex/practicum/filmorate/service/DirectorService.java new file mode 100644 index 0000000..df8c8a6 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/DirectorService.java @@ -0,0 +1,39 @@ +package ru.yandex.practicum.filmorate.service; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.model.Director; +import ru.yandex.practicum.filmorate.storage.director.DirectorStorage; + +@Service +public class DirectorService { + + private final DirectorStorage directorStorage; + + public DirectorService(@Qualifier("directorDbStorage") DirectorStorage directorStorage) { + this.directorStorage = directorStorage; + } + + public Director addDirector(Director director) { + return directorStorage.addDirector(director); + } + + public Director updateDirector(Director director) { + return directorStorage.updateDirector(director); + } + + public void deleteDirector(long id) { + directorStorage.removeDirector(id); + } + + public Director getDirectorById(long id) { + return directorStorage.findDirectorById(id) + .orElseThrow(() -> new NotFoundException("Режиссер с id " + id + " не найден")); + } + + public java.util.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 1a21f90..3628c9b 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java @@ -6,6 +6,7 @@ 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.model.Film; import ru.yandex.practicum.filmorate.model.Genre; import ru.yandex.practicum.filmorate.storage.film.FilmStorage; @@ -30,16 +31,19 @@ public class FilmService { // Нужны для проверки рейтинга и жанра из запроса private final MpaService mpaService; private final GenreService genreService; + private final DirectorService directorService; @Autowired public FilmService(@Qualifier("filmDbStorage") FilmStorage filmStorage, @Qualifier("userDbStorage") UserStorage userStorage, MpaService mpaService, - GenreService genreService) { + GenreService genreService, + DirectorService directorService) { this.filmStorage = filmStorage; this.userStorage = userStorage; this.mpaService = mpaService; this.genreService = genreService; + this.directorService = directorService; } // Получение фильма по id с проверкой существования @@ -134,9 +138,27 @@ private void validateMpaAndGenres(Film film) { } } + 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 (!knownIds.contains(director.getId())) { + throw new NotFoundException("Режиссёр с id=" + director.getId() + " не найден"); + } + } + } + public Film add(Film film) { validateReleaseDate(film); validateMpaAndGenres(film); + validateDirectors(film); return filmStorage.add(film); } @@ -144,6 +166,7 @@ public Film update(Film film) { getFilmOrThrow(film.getId()); // проверка существования — иначе 404 validateReleaseDate(film); validateMpaAndGenres(film); + validateDirectors(film); return filmStorage.update(film); } @@ -160,4 +183,9 @@ public Collection findAll() { public Film findById(long id) { return getFilmOrThrow(id); } + + public Collection findByDirectorSorted(long directorId, String sortBy) { + directorService.getDirectorById(directorId); // проверка существования режиссёра — иначе 404 + return filmStorage.findFilmByDirector(directorId, sortBy); + } } \ No newline at end of file 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 new file mode 100644 index 0000000..c4bb187 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorDbStorage.java @@ -0,0 +1,78 @@ +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; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.model.Director; +import ru.yandex.practicum.filmorate.storage.mapper.DirectorRowMapper; +import java.util.List; + +@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"; + + @Autowired + public DirectorDbStorage(JdbcTemplate jdbcTemplate, DirectorRowMapper directorRowMapper) { + this.jdbcTemplate = jdbcTemplate; + this.directorRowMapper = directorRowMapper; + } + + @Override + public Director addDirector(Director director) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + var ps = connection.prepareStatement(INSERT_QUERY, new String[]{"id"}); + ps.setString(1, director.getName()); + return ps; + }, keyHolder); + director.setId(keyHolder.getKey().longValue()); + return director; + } + + @Override + public Director updateDirector(Director director) { + int updatedRows = jdbcTemplate.update( + UPDATE_QUERY, + director.getName(), + director.getId() + ); + + if (updatedRows == 0) { + throw new NotFoundException( + "Режиссёр с id=" + director.getId() + " не найден" + ); + } + return director; + } + + @Override + public void removeDirector(long id) { + Director director = findDirectorById(id) + .orElseThrow(() -> new NotFoundException("Режиссёр с id " + id + " не найден")); + jdbcTemplate.update(DELETE_QUERY, id); + } + + @Override + public Optional findDirectorById(long id) { + Optional director = jdbcTemplate.query(SELECT_BY_ID_QUERY, directorRowMapper, id).stream().findFirst(); + return director; + } + + @Override + public List findAllDirectors() { + List directors = jdbcTemplate.query(SELECT_ALL_QUERY, directorRowMapper); + return directors; + } +} 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 new file mode 100644 index 0000000..cdcdc40 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorStorage.java @@ -0,0 +1,18 @@ +package ru.yandex.practicum.filmorate.storage.director; + +import ru.yandex.practicum.filmorate.model.Director; +import java.util.List; +import java.util.Optional; + +public interface DirectorStorage { + + Director addDirector(Director director); + + Director updateDirector(Director director); + + void removeDirector(long id); + + Optional findDirectorById(long id); + + List findAllDirectors(); +} 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 5280bef..787bd86 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 @@ -6,8 +6,11 @@ import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Repository; 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; +import ru.yandex.practicum.filmorate.storage.mapper.DirectorRowMapper; import ru.yandex.practicum.filmorate.storage.mapper.FilmRowMapper; import ru.yandex.practicum.filmorate.storage.mapper.GenreRowMapper; @@ -76,16 +79,43 @@ public class FilmDbStorage implements FilmStorage { "MERGE INTO film_likes (film_id, user_id) KEY (film_id, user_id) VALUES (?, ?)"; private static final String REMOVE_LIKE_QUERY = "DELETE FROM film_likes WHERE film_id = ? AND user_id = ?"; + private static final String INSERT_FILM_DIRECTOR_QUERY = + "INSERT INTO film_director (film_id, director_id) VALUES (?, ?)"; + private static final String DELETE_DIRECTOR_FILM_QUERY = + "DELETE FROM film_director WHERE film_id = ?"; + private static final String FIND_DIRECTORS_BY_FILM_QUERY = + "SELECT d.id, d.name FROM film_director fd " + + "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 final JdbcTemplate jdbc; private final FilmRowMapper mapper; private final GenreRowMapper genreMapper; + private final DirectorRowMapper directorMapper; @Autowired - public FilmDbStorage(JdbcTemplate jdbc, FilmRowMapper mapper, GenreRowMapper genreMapper) { + public FilmDbStorage(JdbcTemplate jdbc, FilmRowMapper mapper, GenreRowMapper genreMapper, DirectorRowMapper directorMapper) { this.jdbc = jdbc; this.mapper = mapper; this.genreMapper = genreMapper; + this.directorMapper = directorMapper; } @Override @@ -103,6 +133,7 @@ public Film add(Film film) { Long id = keyHolder.getKeyAs(Long.class); saveGenres(id, film.getGenres()); + saveDirector(id, film.getDirectors()); // Перечитываем из базы: так в ответе будут названия рейтинга и жанров, а не только их id return findById(id).orElseThrow(); } @@ -112,6 +143,7 @@ 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()); return findById(film.getId()).orElseThrow(); } @@ -127,6 +159,7 @@ public Film delete(long id) { public Collection findAll() { List films = jdbc.query(FIND_ALL_QUERY, mapper); loadGenresForFilms(films); + loadDirectors(films); return films; } @@ -134,6 +167,7 @@ public Collection findAll() { public Optional findById(long id) { Optional film = jdbc.query(FIND_BY_ID_QUERY, mapper, id).stream().findFirst(); film.ifPresent(this::loadGenres); + film.ifPresent(this::loadDirectors); return film; } @@ -156,6 +190,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); return films; } @@ -165,6 +200,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); return films; } @@ -179,7 +215,30 @@ public boolean removeLike(long filmId, long userId) { return jdbc.update(REMOVE_LIKE_QUERY, filmId, userId) > 0; } - // Собирает запрос: чтение + WHERE по условиям + сортировка по числу лайков, без LIMIT + @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); + loadGenresForFilms(films); + + return films; + } + + //Собирает запрос: чтение + WHERE по условиям + сортировка по числу лайков, без LIMIT private String buildPopularQuery(List conditions) { StringBuilder sql = new StringBuilder(FIND_POPULAR_BASE_QUERY); if (!conditions.isEmpty()) { @@ -226,4 +285,47 @@ private void loadGenresForFilms(List films) { film.getGenres().add(genreMapper.mapRow(rs, rs.getRow())); }, 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) { + if (films.isEmpty()) { + return; // фильмов нет — запрос не нужен + } + Map filmsById = new HashMap<>(); + 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 -> { + Film film = filmsById.get(rs.getLong("film_id")); + film.getDirectors().add(directorMapper.mapRow(rs, rs.getRow()) + ); + }, filmsById.keySet().toArray()); + } + + // Заменяет директоров фильма: старые связи удаляются, новые вставляются одним пакетом + private void saveDirector(long filmId, Set directors) { + jdbc.update(DELETE_DIRECTOR_FILM_QUERY, filmId); + if (directors == null || directors.isEmpty()) { + return; + } + List rows = new ArrayList<>(); + for (Director director : directors) { + rows.add(new Object[]{filmId, director.getId()}); + } + jdbc.batchUpdate(INSERT_FILM_DIRECTOR_QUERY, rows); + } } \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java index 3e5ecd3..62f4a02 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java @@ -25,4 +25,6 @@ public interface FilmStorage { void addLike(long filmId, long userId); boolean removeLike(long filmId, long userId); + + List findFilmByDirector(long directorId, String sortBy); } \ No newline at end of file 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 new file mode 100644 index 0000000..c8e662f --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/DirectorRowMapper.java @@ -0,0 +1,19 @@ +package ru.yandex.practicum.filmorate.storage.mapper; + +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; + +@Component +public class DirectorRowMapper implements RowMapper { + + @Override + public Director mapRow(ResultSet rs, int rowNum) throws SQLException { + Director director = new Director(); + director.setId(rs.getLong("id")); + director.setName(rs.getString("name")); + return director; + } +} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index b2dcd2d..554a36c 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -69,3 +69,16 @@ CREATE TABLE IF NOT EXISTS review_likes ( is_useful BOOLEAN NOT NULL, 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 +); + +--Связь «фильм — режиссёр», многие ко многим. +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, + PRIMARY KEY (film_id, director_id) +); \ No newline at end of file diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/director/DirectorDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/director/DirectorDbStorageTest.java new file mode 100644 index 0000000..d00716c --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/director/DirectorDbStorageTest.java @@ -0,0 +1,75 @@ +package ru.yandex.practicum.filmorate.storage.director; + +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.jdbc.JdbcTest; +import org.springframework.context.annotation.Import; +import ru.yandex.practicum.filmorate.model.Director; +import ru.yandex.practicum.filmorate.storage.mapper.DirectorRowMapper; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +@JdbcTest +@AutoConfigureTestDatabase +@Import({DirectorDbStorage.class, DirectorRowMapper.class}) +public class DirectorDbStorageTest { + + private final DirectorDbStorage directorDbStorage; + + @Autowired + public DirectorDbStorageTest(DirectorDbStorage directorDbStorage) { + this.directorDbStorage = directorDbStorage; + } + + private Director createDirector(String directorName) { + Director director = new Director(); + director.setName(directorName); + return director; + } + + @Test + public void addDirector() { + Director director = createDirector("name"); + directorDbStorage.addDirector(director); + assertEquals("name", director.getName()); + assertNotNull(director.getId()); + } + + @Test + public void getDirectorById() { + Director director = createDirector("director"); + Director createdDirector = directorDbStorage.addDirector(director); + Director retrievedDirector = directorDbStorage.findDirectorById(createdDirector.getId()).orElse(null); + assertEquals(createdDirector.getId(), retrievedDirector.getId()); + assertEquals(createdDirector.getName(), retrievedDirector.getName()); + } + + @Test + public void deleteDirectorById() { + Director director = createDirector("director"); + directorDbStorage.addDirector(director); + directorDbStorage.removeDirector(director.getId()); + assertTrue(directorDbStorage.findDirectorById(director.getId()).isEmpty()); + } + + @Test + public void updateDirectorById() { + Director director = createDirector("director"); + directorDbStorage.addDirector(director); + director.setName("updated name"); + directorDbStorage.updateDirector(director); + Director updatedDirector = directorDbStorage.findDirectorById(director.getId()).orElse(null); + assertEquals("updated name", updatedDirector.getName()); + } + + @Test + public void getAllDirectors() { + Director director = createDirector("director"); + Director director2 = createDirector("director2"); + directorDbStorage.addDirector(director); + directorDbStorage.addDirector(director2); + List allDirectors = directorDbStorage.findAllDirectors(); + assertEquals(2, allDirectors.size()); + } +} diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java index bffa5f2..8a786fd 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java @@ -7,9 +7,12 @@ import org.springframework.context.annotation.Import; import ru.yandex.practicum.filmorate.exception.NotFoundException; import ru.yandex.practicum.filmorate.model.Film; +import ru.yandex.practicum.filmorate.model.Director; import ru.yandex.practicum.filmorate.model.Genre; import ru.yandex.practicum.filmorate.model.Mpa; import ru.yandex.practicum.filmorate.model.User; +import ru.yandex.practicum.filmorate.storage.director.DirectorDbStorage; +import ru.yandex.practicum.filmorate.storage.mapper.DirectorRowMapper; import ru.yandex.practicum.filmorate.storage.mapper.FilmRowMapper; import ru.yandex.practicum.filmorate.storage.mapper.GenreRowMapper; import ru.yandex.practicum.filmorate.storage.mapper.UserRowMapper; @@ -24,16 +27,19 @@ @JdbcTest @AutoConfigureTestDatabase @Import({FilmDbStorage.class, FilmRowMapper.class, GenreRowMapper.class, - UserDbStorage.class, UserRowMapper.class}) + UserDbStorage.class, UserRowMapper.class, + DirectorDbStorage.class, DirectorRowMapper.class}) class FilmDbStorageTest { private final FilmDbStorage filmStorage; private final UserDbStorage userStorage; + private final DirectorDbStorage directorStorage; @Autowired - FilmDbStorageTest(FilmDbStorage filmStorage, UserDbStorage userStorage) { + FilmDbStorageTest(FilmDbStorage filmStorage, UserDbStorage userStorage, DirectorDbStorage directorStorage) { this.filmStorage = filmStorage; this.userStorage = userStorage; + this.directorStorage = directorStorage; } // Фильм без рейтинга и жанров: тесты добавляют их сами, где нужно @@ -75,6 +81,12 @@ private static List genreNames(Film film) { return film.getGenres().stream().map(Genre::getName).toList(); } + private Director createDirector(String directorName) { + Director director = new Director(); + director.setName(directorName); + return director; + } + @Test void shouldAddFilmWithMpaAndGenres() { Film film = newFilm("С жанрами"); @@ -292,4 +304,48 @@ void shouldReturnEmptyCommonFilmsWhenNoSharedLikes() { assertEquals(0, filmStorage.findCommon(anna.getId(), boris.getId()).size()); } + + @Test + void shouldReturnAllFilmsByDirectorSortedByLikes() { + Director director = createDirector("Christopher Nolan"); + directorStorage.addDirector(director); + Film film1 = newFilm("Фильм 1"); + film1.getDirectors().add(director); + film1 = filmStorage.add(film1); + Film film2 = newFilm("Фильм 2"); + film2.getDirectors().add(director); + film2 = filmStorage.add(film2); + User anna = createUser("anna"); + User boris = createUser("boris"); + filmStorage.addLike(film1.getId(), anna.getId()); + filmStorage.addLike(film2.getId(), anna.getId()); + filmStorage.addLike(film2.getId(), boris.getId()); + + List allFilms = filmStorage.findFilmByDirector(director.getId(), "likes"); + assertEquals(2, allFilms.size()); + assertEquals(film2.getId(), allFilms.get(0).getId()); + assertEquals(film1.getId(), allFilms.get(1).getId()); + } + + @Test + void shouldReturnAllFilmsByDirectorSortedByYear() { + Director director = createDirector("Steven Spielberg"); + directorStorage.addDirector(director); + Film film1 = newFilm("Фильм 1"); + film1.setReleaseDate(LocalDate.of(2000, 1, 1)); + film1.getDirectors().add(director); + film1 = filmStorage.add(film1); + Film film2 = newFilm("Фильм 2"); + film2.setReleaseDate(LocalDate.of(2010, 1, 1)); + film2.getDirectors().add(director); + film2 = filmStorage.add(film2); + Film film3 = newFilm("Фильм 3"); + film3.setReleaseDate(LocalDate.of(2005, 1, 1)); + film3.getDirectors().add(director); + film3 = filmStorage.add(film3); + List allFilms = filmStorage.findFilmByDirector(director.getId(), "year"); + assertEquals(film1.getId(), allFilms.get(0).getId()); + assertEquals(film3.getId(), allFilms.get(1).getId()); + assertEquals(film2.getId(), allFilms.get(2).getId()); + } } \ No newline at end of file diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/review/ReviewDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/review/ReviewDbStorageTest.java index 95d380c..28bace8 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/storage/review/ReviewDbStorageTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/review/ReviewDbStorageTest.java @@ -10,10 +10,7 @@ import ru.yandex.practicum.filmorate.model.Review; import ru.yandex.practicum.filmorate.model.User; import ru.yandex.practicum.filmorate.storage.film.FilmDbStorage; -import ru.yandex.practicum.filmorate.storage.mapper.FilmRowMapper; -import ru.yandex.practicum.filmorate.storage.mapper.GenreRowMapper; -import ru.yandex.practicum.filmorate.storage.mapper.ReviewRowMapper; -import ru.yandex.practicum.filmorate.storage.mapper.UserRowMapper; +import ru.yandex.practicum.filmorate.storage.mapper.*; import ru.yandex.practicum.filmorate.storage.user.UserDbStorage; import java.time.LocalDate; @@ -24,7 +21,7 @@ @JdbcTest @AutoConfigureTestDatabase @Import({ReviewDbStorage.class, ReviewRowMapper.class, FilmDbStorage.class, FilmRowMapper.class, - GenreRowMapper.class, UserDbStorage.class, UserRowMapper.class}) + GenreRowMapper.class, UserDbStorage.class, UserRowMapper.class, DirectorRowMapper.class}) class ReviewDbStorageTest { private final ReviewDbStorage reviewStorage; From 3d835637bc2778c2806ef4883cdcde311443632a Mon Sep 17 00:00:00 2001 From: Daria Prindina Date: Fri, 9 Oct 2026 21:15:31 +0300 Subject: [PATCH 6/9] feat: add user event feed (#18) --- README.md | 13 +++ .../filmorate/controller/UserController.java | 8 ++ .../practicum/filmorate/model/Event.java | 13 +++ .../practicum/filmorate/model/EventType.java | 7 ++ .../practicum/filmorate/model/Operation.java | 7 ++ .../filmorate/service/FeedService.java | 36 ++++++ .../filmorate/service/FilmService.java | 9 +- .../filmorate/service/ReviewService.java | 15 ++- .../filmorate/service/UserService.java | 15 ++- .../filmorate/storage/feed/FeedDbStorage.java | 53 +++++++++ .../filmorate/storage/feed/FeedStorage.java | 12 ++ .../storage/mapper/EventRowMapper.java | 26 +++++ src/main/resources/schema.sql | 12 +- .../filmorate/service/FeedEventsTest.java | 110 ++++++++++++++++++ .../storage/feed/FeedDbStorageTest.java | 80 +++++++++++++ 15 files changed, 410 insertions(+), 6 deletions(-) create mode 100644 src/main/java/ru/yandex/practicum/filmorate/model/Event.java create mode 100644 src/main/java/ru/yandex/practicum/filmorate/model/EventType.java create mode 100644 src/main/java/ru/yandex/practicum/filmorate/model/Operation.java create mode 100644 src/main/java/ru/yandex/practicum/filmorate/service/FeedService.java create mode 100644 src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedDbStorage.java create mode 100644 src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedStorage.java create mode 100644 src/main/java/ru/yandex/practicum/filmorate/storage/mapper/EventRowMapper.java create mode 100644 src/test/java/ru/yandex/practicum/filmorate/service/FeedEventsTest.java create mode 100644 src/test/java/ru/yandex/practicum/filmorate/storage/feed/FeedDbStorageTest.java diff --git a/README.md b/README.md index af6f295..4a4fdd4 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - Односторонняя дружба: добавление/удаление друзей, список друзей, общие друзья. - Лайки фильмам: добавление/удаление, топ-N фильмов по количеству лайков. - Отзывы к фильмам: положительные и негативные, с рейтингом полезности по лайкам и дизлайкам. +- Лента событий пользователя: лайки, друзья и отзывы. - Слоистая архитектура: контроллер → сервис → хранилище, выбор реализации хранилища через `@Qualifier`. - Внедрение зависимостей через конструктор (`@Autowired`). - Строгая валидация входящих данных через `spring-boot-starter-validation`. @@ -91,6 +92,8 @@ - `DELETE /users/{id}/friends/{friendId}` — удалить из друзей. - `GET /users/{id}/friends` — список друзей пользователя. - `GET /users/{id}/friends/common/{otherId}` — общие друзья с другим пользователем. +- `GET /users/{id}/feed` — лента событий пользователя по порядку: лайки (`LIKE`), друзья (`FRIEND`) + и отзывы (`REVIEW`) с операциями `ADD`, `REMOVE`, `UPDATE`. ### Отзывы (`/reviews`) - `POST /reviews` — добавить отзыв. @@ -136,6 +139,7 @@ erDiagram films ||--o{ reviews : "film_id" reviews ||--o{ review_likes : "review_id" users ||--o{ review_likes : "user_id" + users ||--o{ feed_events : "user_id" films { BIGINT id PK @@ -192,6 +196,14 @@ erDiagram BIGINT user_id PK, FK BOOLEAN is_useful "TRUE — лайк, FALSE — дизлайк" } + feed_events { + BIGINT event_id PK + BIGINT user_id FK + VARCHAR event_type "LIKE, REVIEW, FRIEND" + VARCHAR operation "ADD, REMOVE, UPDATE" + BIGINT entity_id "id фильма, друга или отзыва" + BIGINT created_at "время события, мс" + } ``` | Таблица | Что хранит | @@ -207,6 +219,7 @@ erDiagram | `film_director` | связь "фильм — режиссёр", многие ко многим | | `reviews` | отзывы пользователей к фильмам; рейтинг полезности не хранится, а считается по `review_likes` | | `review_likes` | оценки отзывов: одна строка — один пользователь, лайк или дизлайк | +| `feed_events` | лента: что пользователь лайкнул, кого добавил в друзья, какие отзывы написал | У таблиц-связок составной первичный ключ, поэтому одна и та же пара не запишется дважды. При удалении фильма или пользователя связанные с ним записи удаляются каскадно. diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java index 8c154dc..4dbfac8 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java @@ -4,10 +4,12 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.filmorate.model.Event; import ru.yandex.practicum.filmorate.model.User; import ru.yandex.practicum.filmorate.service.UserService; import java.util.Collection; +import java.util.List; @RestController @RequestMapping("/users") @@ -76,4 +78,10 @@ public Collection getCommonFriends( log.info("GET /users/{}/friends/common/{}", id, otherId); return userService.getCommonFriends(id, otherId); } + + @GetMapping("/{id}/feed") + public List getFeed(@PathVariable long id) { + log.info("GET /users/{}/feed", id); + return userService.getFeed(id); + } } \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Event.java b/src/main/java/ru/yandex/practicum/filmorate/model/Event.java new file mode 100644 index 0000000..86331a3 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Event.java @@ -0,0 +1,13 @@ +package ru.yandex.practicum.filmorate.model; + +import lombok.Data; + +@Data +public class Event { + private Long eventId; + private Long timestamp; + private Long userId; + private EventType eventType; + private Operation operation; + private Long entityId; +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/EventType.java b/src/main/java/ru/yandex/practicum/filmorate/model/EventType.java new file mode 100644 index 0000000..5de71e3 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/EventType.java @@ -0,0 +1,7 @@ +package ru.yandex.practicum.filmorate.model; + +public enum EventType { + LIKE, + REVIEW, + FRIEND +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Operation.java b/src/main/java/ru/yandex/practicum/filmorate/model/Operation.java new file mode 100644 index 0000000..797ccbd --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Operation.java @@ -0,0 +1,7 @@ +package ru.yandex.practicum.filmorate.model; + +public enum Operation { + ADD, + REMOVE, + UPDATE +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/FeedService.java b/src/main/java/ru/yandex/practicum/filmorate/service/FeedService.java new file mode 100644 index 0000000..392d84a --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/FeedService.java @@ -0,0 +1,36 @@ +package ru.yandex.practicum.filmorate.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import ru.yandex.practicum.filmorate.model.Event; +import ru.yandex.practicum.filmorate.model.EventType; +import ru.yandex.practicum.filmorate.model.Operation; +import ru.yandex.practicum.filmorate.storage.feed.FeedStorage; + +import java.time.Instant; +import java.util.List; + +@Service +public class FeedService { + + private final FeedStorage feedStorage; + + @Autowired + public FeedService(FeedStorage feedStorage) { + this.feedStorage = feedStorage; + } + + public void addEvent(long userId, EventType eventType, Operation operation, long entityId) { + Event event = new Event(); + event.setTimestamp(Instant.now().toEpochMilli()); + event.setUserId(userId); + event.setEventType(eventType); + event.setOperation(operation); + event.setEntityId(entityId); + feedStorage.add(event); + } + + public List findByUserId(long userId) { + return feedStorage.findByUserId(userId); + } +} 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 3628c9b..8de1336 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java @@ -7,8 +7,10 @@ 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.EventType; import ru.yandex.practicum.filmorate.model.Film; import ru.yandex.practicum.filmorate.model.Genre; +import ru.yandex.practicum.filmorate.model.Operation; import ru.yandex.practicum.filmorate.storage.film.FilmStorage; import ru.yandex.practicum.filmorate.storage.user.UserStorage; @@ -32,18 +34,21 @@ public class FilmService { private final MpaService mpaService; private final GenreService genreService; private final DirectorService directorService; + private final FeedService feedService; @Autowired public FilmService(@Qualifier("filmDbStorage") FilmStorage filmStorage, @Qualifier("userDbStorage") UserStorage userStorage, MpaService mpaService, GenreService genreService, - DirectorService directorService) { + DirectorService directorService, + FeedService feedService) { this.filmStorage = filmStorage; this.userStorage = userStorage; this.mpaService = mpaService; this.genreService = genreService; this.directorService = directorService; + this.feedService = feedService; } // Получение фильма по id с проверкой существования @@ -63,6 +68,7 @@ public Film addLike(long filmId, long userId) { getUserOrThrow(userId); // проверка существования пользователя — иначе 404 filmStorage.addLike(filmId, userId); + feedService.addEvent(userId, EventType.LIKE, Operation.ADD, filmId); log.debug("Пользователь id={} поставил лайк фильму id={}", userId, filmId); return film; } @@ -77,6 +83,7 @@ public Film removeLike(long filmId, long userId) { ); } + feedService.addEvent(userId, EventType.LIKE, Operation.REMOVE, filmId); log.debug("Пользователь id={} убрал лайк с фильма id={}", userId, filmId); return film; } 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 bed84b1..10364e8 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/ReviewService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/ReviewService.java @@ -6,6 +6,8 @@ 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.EventType; +import ru.yandex.practicum.filmorate.model.Operation; import ru.yandex.practicum.filmorate.model.Review; import ru.yandex.practicum.filmorate.storage.film.FilmStorage; import ru.yandex.practicum.filmorate.storage.review.ReviewStorage; @@ -20,14 +22,17 @@ public class ReviewService { private final ReviewStorage reviewStorage; private final FilmStorage filmStorage; private final UserStorage userStorage; + private final FeedService feedService; @Autowired public ReviewService(ReviewStorage reviewStorage, @Qualifier("filmDbStorage") FilmStorage filmStorage, - @Qualifier("userDbStorage") UserStorage userStorage) { + @Qualifier("userDbStorage") UserStorage userStorage, + FeedService feedService) { this.reviewStorage = reviewStorage; this.filmStorage = filmStorage; this.userStorage = userStorage; + this.feedService = feedService; } private Review getReviewOrThrow(long id) { @@ -49,6 +54,7 @@ public Review add(Review review) { checkUserExists(review.getUserId()); checkFilmExists(review.getFilmId()); Review created = reviewStorage.add(review); + feedService.addEvent(created.getUserId(), EventType.REVIEW, Operation.ADD, created.getReviewId()); log.debug("Пользователь id={} оставил отзыв id={} к фильму id={}", created.getUserId(), created.getReviewId(), created.getFilmId()); return created; @@ -60,12 +66,15 @@ public Review update(Review review) { throw new ValidationException("Не указан id отзыва"); } getReviewOrThrow(review.getReviewId()); - return reviewStorage.update(review); + Review updated = reviewStorage.update(review); + feedService.addEvent(updated.getUserId(), EventType.REVIEW, Operation.UPDATE, updated.getReviewId()); + return updated; } public void delete(long id) { - getReviewOrThrow(id); + Review review = getReviewOrThrow(id); reviewStorage.delete(id); + feedService.addEvent(review.getUserId(), EventType.REVIEW, Operation.REMOVE, id); log.debug("Отзыв id={} удалён", 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 cde4926..baa3a43 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java @@ -6,20 +6,26 @@ 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.Event; +import ru.yandex.practicum.filmorate.model.EventType; +import ru.yandex.practicum.filmorate.model.Operation; import ru.yandex.practicum.filmorate.model.User; import ru.yandex.practicum.filmorate.storage.user.UserStorage; import java.util.Collection; +import java.util.List; @Service @Slf4j public class UserService { private final UserStorage userStorage; + private final FeedService feedService; @Autowired - public UserService(@Qualifier("userDbStorage") UserStorage userStorage) { + public UserService(@Qualifier("userDbStorage") UserStorage userStorage, FeedService feedService) { this.userStorage = userStorage; + this.feedService = feedService; } private void validateDifferentUsers(long userId, long otherId) { @@ -44,6 +50,7 @@ public User addFriend(long userId, long friendId) { getUserOrThrow(friendId); // проверка существования друга — иначе 404 userStorage.addFriend(userId, friendId); + feedService.addEvent(userId, EventType.FRIEND, Operation.ADD, friendId); log.debug("Пользователь id={} добавил в друзья id={}", userId, friendId); return user; } @@ -55,6 +62,7 @@ public User removeFriend(long userId, long friendId) { getUserOrThrow(friendId); // проверка существования друга — иначе 404 userStorage.removeFriend(userId, friendId); + feedService.addEvent(userId, EventType.FRIEND, Operation.REMOVE, friendId); log.debug("Пользователь id={} удалил из друзей id={}", userId, friendId); return user; } @@ -72,6 +80,11 @@ public Collection getCommonFriends(long userId, long otherId) { return userStorage.findCommonFriends(userId, otherId); } + public List getFeed(long userId) { + getUserOrThrow(userId); + return feedService.findByUserId(userId); + } + // Логин не может содержать пробелы private void validateLogin(User user) { if (user.getLogin().contains(" ")) { diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedDbStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedDbStorage.java new file mode 100644 index 0000000..a87b0e0 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedDbStorage.java @@ -0,0 +1,53 @@ +package ru.yandex.practicum.filmorate.storage.feed; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import ru.yandex.practicum.filmorate.model.Event; +import ru.yandex.practicum.filmorate.storage.mapper.EventRowMapper; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.List; + +@Repository +public class FeedDbStorage implements FeedStorage { + + private static final String INSERT_QUERY = + "INSERT INTO feed_events (user_id, event_type, operation, entity_id, created_at) VALUES (?, ?, ?, ?, ?)"; + private static final String FIND_BY_USER_QUERY = + "SELECT event_id, user_id, event_type, operation, entity_id, created_at FROM feed_events " + + "WHERE user_id = ? ORDER BY event_id"; + + private final JdbcTemplate jdbc; + private final EventRowMapper mapper; + + @Autowired + public FeedDbStorage(JdbcTemplate jdbc, EventRowMapper mapper) { + this.jdbc = jdbc; + this.mapper = mapper; + } + + @Override + public Event add(Event event) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbc.update(connection -> { + PreparedStatement ps = connection.prepareStatement(INSERT_QUERY, Statement.RETURN_GENERATED_KEYS); + ps.setLong(1, event.getUserId()); + ps.setString(2, event.getEventType().name()); + ps.setString(3, event.getOperation().name()); + ps.setLong(4, event.getEntityId()); + ps.setLong(5, event.getTimestamp()); + return ps; + }, keyHolder); + event.setEventId(keyHolder.getKeyAs(Long.class)); + return event; + } + + @Override + public List findByUserId(long userId) { + return jdbc.query(FIND_BY_USER_QUERY, mapper, userId); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedStorage.java new file mode 100644 index 0000000..96471ca --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedStorage.java @@ -0,0 +1,12 @@ +package ru.yandex.practicum.filmorate.storage.feed; + +import ru.yandex.practicum.filmorate.model.Event; + +import java.util.List; + +public interface FeedStorage { + + Event add(Event event); + + List findByUserId(long userId); +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/EventRowMapper.java b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/EventRowMapper.java new file mode 100644 index 0000000..2725f64 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/EventRowMapper.java @@ -0,0 +1,26 @@ +package ru.yandex.practicum.filmorate.storage.mapper; + +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; +import ru.yandex.practicum.filmorate.model.Event; +import ru.yandex.practicum.filmorate.model.EventType; +import ru.yandex.practicum.filmorate.model.Operation; + +import java.sql.ResultSet; +import java.sql.SQLException; + +@Component +public class EventRowMapper implements RowMapper { + + @Override + public Event mapRow(ResultSet rs, int rowNum) throws SQLException { + Event event = new Event(); + event.setEventId(rs.getLong("event_id")); + event.setTimestamp(rs.getLong("created_at")); + event.setUserId(rs.getLong("user_id")); + event.setEventType(EventType.valueOf(rs.getString("event_type"))); + event.setOperation(Operation.valueOf(rs.getString("operation"))); + event.setEntityId(rs.getLong("entity_id")); + return event; + } +} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 554a36c..6869731 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -81,4 +81,14 @@ 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, PRIMARY KEY (film_id, director_id) -); \ No newline at end of file +); + +-- Лента: действия пользователя с лайками, друзьями и отзывами. entity_id — id фильма, друга или отзыва. +CREATE TABLE IF NOT EXISTS feed_events ( + event_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_type VARCHAR(10) NOT NULL, + operation VARCHAR(10) NOT NULL, + entity_id BIGINT NOT NULL, + created_at BIGINT NOT NULL +); diff --git a/src/test/java/ru/yandex/practicum/filmorate/service/FeedEventsTest.java b/src/test/java/ru/yandex/practicum/filmorate/service/FeedEventsTest.java new file mode 100644 index 0000000..3dff377 --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/service/FeedEventsTest.java @@ -0,0 +1,110 @@ +package ru.yandex.practicum.filmorate.service; + +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.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.model.Event; +import ru.yandex.practicum.filmorate.model.EventType; +import ru.yandex.practicum.filmorate.model.Film; +import ru.yandex.practicum.filmorate.model.Operation; +import ru.yandex.practicum.filmorate.model.Review; +import ru.yandex.practicum.filmorate.model.User; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@AutoConfigureTestDatabase +@Transactional +class FeedEventsTest { + + private final UserService userService; + private final FilmService filmService; + private final ReviewService reviewService; + + @Autowired + FeedEventsTest(UserService userService, FilmService filmService, ReviewService reviewService) { + this.userService = userService; + this.filmService = filmService; + this.reviewService = reviewService; + } + + private User createUser(String login) { + User user = new User(); + user.setEmail(login + "@mail.ru"); + user.setLogin(login); + user.setBirthday(LocalDate.of(1990, 1, 1)); + return userService.add(user); + } + + private Film createFilm() { + Film film = new Film(); + film.setName("Фильм"); + film.setReleaseDate(LocalDate.of(2000, 1, 1)); + film.setDuration(120); + return filmService.add(film); + } + + @Test + void shouldRecordUserActionsInFeed() { + long userId = createUser("anna").getId(); + long friendId = createUser("boris").getId(); + long filmId = createFilm().getId(); + + userService.addFriend(userId, friendId); + userService.removeFriend(userId, friendId); + Review review = new Review(); + review.setContent("Отличный фильм"); + review.setIsPositive(true); + review.setUserId(userId); + review.setFilmId(filmId); + long reviewId = reviewService.add(review).getReviewId(); + review.setReviewId(reviewId); + review.setContent("Так себе фильм"); + reviewService.update(review); + reviewService.delete(reviewId); + filmService.addLike(filmId, userId); + filmService.removeLike(filmId, userId); + + List feed = userService.getFeed(userId); + + assertEquals(List.of( + List.of(EventType.FRIEND, Operation.ADD, friendId), + List.of(EventType.FRIEND, Operation.REMOVE, friendId), + List.of(EventType.REVIEW, Operation.ADD, reviewId), + List.of(EventType.REVIEW, Operation.UPDATE, reviewId), + List.of(EventType.REVIEW, Operation.REMOVE, reviewId), + List.of(EventType.LIKE, Operation.ADD, filmId), + List.of(EventType.LIKE, Operation.REMOVE, filmId) + ), feed.stream().map(e -> List.of(e.getEventType(), e.getOperation(), e.getEntityId())).toList()); + assertTrue(feed.stream().allMatch(e -> e.getUserId() == userId && e.getTimestamp() > 0)); + assertTrue(userService.getFeed(friendId).isEmpty()); + } + + @Test + void shouldNotRecordReviewRatings() { + long authorId = createUser("anna").getId(); + long raterId = createUser("boris").getId(); + Review review = new Review(); + review.setContent("Отличный фильм"); + review.setIsPositive(true); + review.setUserId(authorId); + review.setFilmId(createFilm().getId()); + long reviewId = reviewService.add(review).getReviewId(); + + reviewService.addLike(reviewId, raterId); + reviewService.addDislike(reviewId, raterId); + + assertTrue(userService.getFeed(raterId).isEmpty()); + } + + @Test + void shouldThrowWhenFeedOfUnknownUser() { + assertThrows(NotFoundException.class, () -> userService.getFeed(9999)); + } +} diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/feed/FeedDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/feed/FeedDbStorageTest.java new file mode 100644 index 0000000..6da9dab --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/feed/FeedDbStorageTest.java @@ -0,0 +1,80 @@ +package ru.yandex.practicum.filmorate.storage.feed; + +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.jdbc.JdbcTest; +import org.springframework.context.annotation.Import; +import ru.yandex.practicum.filmorate.model.Event; +import ru.yandex.practicum.filmorate.model.EventType; +import ru.yandex.practicum.filmorate.model.Operation; +import ru.yandex.practicum.filmorate.model.User; +import ru.yandex.practicum.filmorate.storage.mapper.EventRowMapper; +import ru.yandex.practicum.filmorate.storage.mapper.UserRowMapper; +import ru.yandex.practicum.filmorate.storage.user.UserDbStorage; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@JdbcTest +@AutoConfigureTestDatabase +@Import({FeedDbStorage.class, EventRowMapper.class, UserDbStorage.class, UserRowMapper.class}) +class FeedDbStorageTest { + + private final FeedDbStorage feedStorage; + private final UserDbStorage userStorage; + + @Autowired + FeedDbStorageTest(FeedDbStorage feedStorage, UserDbStorage userStorage) { + this.feedStorage = feedStorage; + this.userStorage = userStorage; + } + + private User createUser(String login) { + User user = new User(); + user.setEmail(login + "@mail.ru"); + user.setLogin(login); + user.setName("Пользователь " + login); + user.setBirthday(LocalDate.of(1990, 1, 1)); + return userStorage.add(user); + } + + private Event addEvent(long userId, EventType eventType, Operation operation, long entityId) { + Event event = new Event(); + event.setTimestamp(1_700_000_000_000L); + event.setUserId(userId); + event.setEventType(eventType); + event.setOperation(operation); + event.setEntityId(entityId); + return feedStorage.add(event); + } + + @Test + void shouldReturnUserEventsInOrderOfAdding() { + User anna = createUser("anna"); + User boris = createUser("boris"); + Event first = addEvent(anna.getId(), EventType.FRIEND, Operation.ADD, boris.getId()); + addEvent(boris.getId(), EventType.LIKE, Operation.ADD, 1); + Event second = addEvent(anna.getId(), EventType.REVIEW, Operation.UPDATE, 5); + + assertNotNull(first.getEventId()); + assertEquals(List.of(first, second), feedStorage.findByUserId(anna.getId())); + } + + @Test + void shouldReturnEmptyFeedForUserWithoutEvents() { + assertTrue(feedStorage.findByUserId(createUser("anna").getId()).isEmpty()); + } + + @Test + void shouldDeleteEventsWithUser() { + User anna = createUser("anna"); + addEvent(anna.getId(), EventType.LIKE, Operation.ADD, 1); + + userStorage.delete(anna.getId()); + + assertTrue(feedStorage.findByUserId(anna.getId()).isEmpty()); + } +} From 7d5e051c82aedcfccf08fd22e609805c20765843 Mon Sep 17 00:00:00 2001 From: Kirill-Kazantcev Date: Sun, 11 Oct 2026 19:37:20 +0300 Subject: [PATCH 7/9] feat: add film recommendations based on similar users (#17) * feat: add film recommendations based on similar users * Trigger CI * Include all users with max overlap in recommendations * fix: missing brace after merge * fix: after review --- README.md | 54 +++++++++++- .../filmorate/controller/UserController.java | 7 ++ .../filmorate/service/UserService.java | 13 ++- .../filmorate/storage/film/FilmDbStorage.java | 58 ++++++++++++- .../filmorate/storage/film/FilmStorage.java | 2 + .../storage/film/FilmDbStorageTest.java | 83 ++++++++++++++++++- 6 files changed, 213 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4a4fdd4..fcac98f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ - Односторонняя дружба: добавление/удаление друзей, список друзей, общие друзья. - Лайки фильмам: добавление/удаление, топ-N фильмов по количеству лайков. - Отзывы к фильмам: положительные и негативные, с рейтингом полезности по лайкам и дизлайкам. +- Рекомендации фильмов: пользователю предлагаются фильмы, которые лайкнули + пользователи с максимальным пересечением по лайкам (при равенстве — все + такие пользователи). - Лента событий пользователя: лайки, друзья и отзывы. - Слоистая архитектура: контроллер → сервис → хранилище, выбор реализации хранилища через `@Qualifier`. - Внедрение зависимостей через конструктор (`@Autowired`). @@ -92,6 +95,7 @@ - `DELETE /users/{id}/friends/{friendId}` — удалить из друзей. - `GET /users/{id}/friends` — список друзей пользователя. - `GET /users/{id}/friends/common/{otherId}` — общие друзья с другим пользователем. +- `GET /users/{id}/recommendations` — рекомендации фильмов на основе похожих пользователей. - `GET /users/{id}/feed` — лента событий пользователя по порядку: лайки (`LIKE`), друзья (`FRIEND`) и отзывы (`REVIEW`) с операциями `ADD`, `REMOVE`, `UPDATE`. @@ -269,6 +273,36 @@ JOIN friendship f2 ON u.id = f2.friend_id AND f2.user_id = 2 ORDER BY u.id; ``` +Рекомендации для пользователя с id = 1: фильмы всех пользователей +с максимальным пересечением по лайкам, которых нет у нашего: + +```sql +-- Шаг 1: максимальное пересечение лайков с любым другим пользователем +SELECT MAX(common) AS max_common FROM ( + SELECT COUNT(*) AS common FROM film_likes fl1 + JOIN film_likes fl2 ON fl1.film_id = fl2.film_id + WHERE fl1.user_id = 1 AND fl2.user_id <> 1 + GROUP BY fl2.user_id +) t; + +-- Шаг 2: все пользователи, у которых пересечение равно максимуму +SELECT fl2.user_id AS other_id FROM film_likes fl1 +JOIN film_likes fl2 ON fl1.film_id = fl2.film_id +WHERE fl1.user_id = 1 AND fl2.user_id <> 1 +GROUP BY fl2.user_id +HAVING COUNT(*) = ; + +-- Шаг 3: их фильмы, которых нет у пользователя 1 +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_likes fl ON f.id = fl.film_id +LEFT JOIN mpa m ON f.mpa_id = m.id +WHERE fl.user_id IN () + AND f.id NOT IN (SELECT film_id FROM film_likes WHERE user_id = 1) +GROUP BY f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name +ORDER BY f.id; +``` + ## Реализация по этапам @@ -311,7 +345,7 @@ API доведён до соответствия REST. -
+
Этап 3 — База данных H2, DAO, жанры и рейтинги На этом этапе данные переехали из памяти в базу данных, @@ -334,4 +368,22 @@ API доведён до соответствия REST. - При создании и обновлении фильма проверяются рейтинг и жанры: неизвестный 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/UserController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java index 4dbfac8..7a4718e 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java @@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import ru.yandex.practicum.filmorate.model.Event; +import ru.yandex.practicum.filmorate.model.Film; import ru.yandex.practicum.filmorate.model.User; import ru.yandex.practicum.filmorate.service.UserService; @@ -79,6 +80,12 @@ public Collection getCommonFriends( return userService.getCommonFriends(id, otherId); } + @GetMapping("/{id}/recommendations") + public Collection getRecommendations(@PathVariable long id) { + log.info("GET /users/{}/recommendations", id); + return userService.getRecommendations(id); + } + @GetMapping("/{id}/feed") public List getFeed(@PathVariable long id) { log.info("GET /users/{}/feed", 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 baa3a43..bd736dc 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java @@ -8,8 +8,10 @@ import ru.yandex.practicum.filmorate.exception.ValidationException; import ru.yandex.practicum.filmorate.model.Event; import ru.yandex.practicum.filmorate.model.EventType; +import ru.yandex.practicum.filmorate.model.Film; import ru.yandex.practicum.filmorate.model.Operation; import ru.yandex.practicum.filmorate.model.User; +import ru.yandex.practicum.filmorate.storage.film.FilmStorage; import ru.yandex.practicum.filmorate.storage.user.UserStorage; import java.util.Collection; @@ -20,11 +22,15 @@ public class UserService { private final UserStorage userStorage; + private final FilmStorage filmStorage; private final FeedService feedService; @Autowired - public UserService(@Qualifier("userDbStorage") UserStorage userStorage, FeedService feedService) { + public UserService(@Qualifier("userDbStorage") UserStorage userStorage, + @Qualifier("filmDbStorage") FilmStorage filmStorage, + FeedService feedService) { this.userStorage = userStorage; + this.filmStorage = filmStorage; this.feedService = feedService; } @@ -128,4 +134,9 @@ public Collection findAll() { public User findById(long id) { return getUserOrThrow(id); } + + public Collection getRecommendations(long userId) { + getUserOrThrow(userId); + return filmStorage.findRecommendations(userId); + } } \ No newline at end of file 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 787bd86..860d997 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 @@ -44,7 +44,7 @@ public class FilmDbStorage implements FilmStorage { 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 " + "ORDER BY COUNT(fl.user_id) DESC, f.id"; - // Лимит отдельно: в общих фильмах, поиске и фильмах режиссёра его нет + // Лимит отдельно: в общих фильмах, рекомендациях и фильмах режиссёра его нет private static final String LIMIT_CLAUSE = " LIMIT ?"; // Условия фильтрации популярных фильмов: по жанру и по году выпуска @@ -105,6 +105,29 @@ public class FilmDbStorage implements FilmStorage { "GROUP BY f.id, f.name, f.description, f.release_date, " + "f.duration, f.mpa_id, m.name "; + // Рекомендации: максимум пересечений по лайкам среди всех пользователей + private static final String FIND_MAX_OVERLAP_QUERY = + "SELECT MAX(common) AS max_common FROM (" + + "SELECT COUNT(*) AS common FROM film_likes fl1 " + + "JOIN film_likes fl2 ON fl1.film_id = fl2.film_id " + + "WHERE fl1.user_id = ? AND fl2.user_id <> ? " + + "GROUP BY fl2.user_id" + + ") t"; + // Все пользователи, у которых пересечение равно максимуму + private static final String FIND_SIMILAR_USER_IDS_QUERY = + "SELECT fl2.user_id AS other_id FROM film_likes fl1 " + + "JOIN film_likes fl2 ON fl1.film_id = fl2.film_id " + + "WHERE fl1.user_id = ? AND fl2.user_id <> ? " + + "GROUP BY fl2.user_id " + + "HAVING COUNT(*) = ?"; + // Фильмы, которые лайкнули похожие пользователи, но не лайкнул наш. + private static final String FIND_RECOMMENDATIONS_QUERY = SELECT_FILMS + + "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 " + + "ORDER BY f.id"; + private final JdbcTemplate jdbc; private final FilmRowMapper mapper; private final GenreRowMapper genreMapper; @@ -204,6 +227,39 @@ public List findCommon(long userId, long friendId) { return films; } + @Override + public List findRecommendations(long userId) { + Integer maxOverlap = jdbc.queryForObject( + FIND_MAX_OVERLAP_QUERY, + Integer.class, + userId, userId + ); + if (maxOverlap == null) { + return List.of(); + } + + List similarUserIds = jdbc.query( + FIND_SIMILAR_USER_IDS_QUERY, + (rs, rowNum) -> rs.getLong("other_id"), + userId, userId, maxOverlap + ); + if (similarUserIds.isEmpty()) { + return List.of(); + } + + String placeholders = String.join(", ", Collections.nCopies(similarUserIds.size(), "?")); + String sql = String.format(FIND_RECOMMENDATIONS_QUERY, placeholders); + + List params = new ArrayList<>(similarUserIds); + params.add(userId); // исключаем фильмы, которые уже лайкнул наш пользователь + + List films = jdbc.query(sql, mapper, params.toArray()); + // Жанры и режиссёры подтягиваются тем же путём, что и у остальных списочных запросов + loadGenresForFilms(films); + loadDirectors(films); + return films; + } + @Override public void addLike(long filmId, long userId) { jdbc.update(ADD_LIKE_QUERY, filmId, userId); diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java index 62f4a02..7045473 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java @@ -22,6 +22,8 @@ public interface FilmStorage { List findCommon(long userId, long friendId); + List findRecommendations(long userId); + void addLike(long filmId, long userId); boolean removeLike(long filmId, long userId); diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java index 8a786fd..cfaf0f5 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java @@ -6,8 +6,8 @@ import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; import org.springframework.context.annotation.Import; import ru.yandex.practicum.filmorate.exception.NotFoundException; -import ru.yandex.practicum.filmorate.model.Film; import ru.yandex.practicum.filmorate.model.Director; +import ru.yandex.practicum.filmorate.model.Film; import ru.yandex.practicum.filmorate.model.Genre; import ru.yandex.practicum.filmorate.model.Mpa; import ru.yandex.practicum.filmorate.model.User; @@ -305,6 +305,87 @@ void shouldReturnEmptyCommonFilmsWhenNoSharedLikes() { assertEquals(0, filmStorage.findCommon(anna.getId(), boris.getId()).size()); } + // Рекомендации: фильм похожего пользователя, которого нет у нашего + @Test + void shouldReturnRecommendationsFromMostSimilarUser() { + User anna = createUser("anna"); + User boris = createUser("boris"); + Film first = createFilm("Первый"); + Film second = createFilm("Второй"); + filmStorage.addLike(first.getId(), anna.getId()); + filmStorage.addLike(first.getId(), boris.getId()); + filmStorage.addLike(second.getId(), boris.getId()); + + List recommendations = filmStorage.findRecommendations(anna.getId()); + + assertEquals(List.of(second.getId()), + recommendations.stream().map(Film::getId).toList()); + } + + // Жанры в рекомендациях подтягиваются — это то, чего не хватало в UserDbStorage + @Test + void shouldReturnRecommendationsWithGenres() { + User anna = createUser("anna"); + User boris = createUser("boris"); + Film first = createFilm("Первый"); + Film second = newFilm("Второй"); + second.getGenres().add(genre(1)); + second = filmStorage.add(second); + filmStorage.addLike(first.getId(), anna.getId()); + filmStorage.addLike(first.getId(), boris.getId()); + filmStorage.addLike(second.getId(), boris.getId()); + + List recommendations = filmStorage.findRecommendations(anna.getId()); + + assertEquals(1, recommendations.size()); + assertEquals(List.of("Комедия"), genreNames(recommendations.getFirst())); + } + + @Test + void shouldReturnEmptyRecommendationsWhenNoSimilarUsers() { + User anna = createUser("anna"); + createUser("boris"); + Film film = createFilm("Фильм"); + filmStorage.addLike(film.getId(), anna.getId()); + + assertTrue(filmStorage.findRecommendations(anna.getId()).isEmpty()); + } + + @Test + void shouldReturnEmptyWhenSimilarUserLikedNothingNew() { + User anna = createUser("anna"); + User boris = createUser("boris"); + Film film = createFilm("Фильм"); + filmStorage.addLike(film.getId(), anna.getId()); + filmStorage.addLike(film.getId(), boris.getId()); + + assertTrue(filmStorage.findRecommendations(anna.getId()).isEmpty()); + } + + // При равном пересечении учитываем всех «похожих», а не только одного + @Test + void shouldUseAllSimilarUsersWithMaxOverlap() { + User anna = createUser("anna"); + User boris = createUser("boris"); + User vera = createUser("vera"); + Film first = createFilm("Первый"); + Film second = createFilm("Второй"); + Film third = createFilm("Третий"); + // У Анны и Бориса пересечение по first, у Анны и Веры — тоже по first + filmStorage.addLike(first.getId(), anna.getId()); + filmStorage.addLike(first.getId(), boris.getId()); + filmStorage.addLike(first.getId(), vera.getId()); + // У Бориса есть свой фильм second, у Веры — third; оба должны попасть в рекомендации + filmStorage.addLike(second.getId(), boris.getId()); + filmStorage.addLike(third.getId(), vera.getId()); + + List recommendations = filmStorage.findRecommendations(anna.getId()); + + // Оба фильма от похожих пользователей, порядок по id + assertEquals(List.of(second.getId(), third.getId()), + recommendations.stream().map(Film::getId).toList()); + } + @Test void shouldReturnAllFilmsByDirectorSortedByLikes() { Director director = createDirector("Christopher Nolan"); From ef7c0e2e8b77dd17c7abbbf7145fc74f0f484e2e Mon Sep 17 00:00:00 2001 From: Roman32-gif Date: Sun, 11 Oct 2026 19:49:13 +0300 Subject: [PATCH 8/9] feat: add film search by title and director (#19) * add film directors * trigger ci * Fixed mistakes * Fixed small mistakes * Fixed mistakes and added info in README * Added import * Finished add-search --------- Co-authored-by: Roman --- .../filmorate/controller/FilmController.java | 8 ++++ .../filmorate/service/FilmService.java | 20 +++++++++ .../filmorate/storage/film/FilmDbStorage.java | 42 +++++++++++++++++++ .../filmorate/storage/film/FilmStorage.java | 2 + .../storage/film/FilmDbStorageTest.java | 41 ++++++++++++++++++ 5 files changed, 113 insertions(+) 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 68b2969..e59aca4 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java @@ -8,6 +8,7 @@ import ru.yandex.practicum.filmorate.service.FilmService; import java.util.Collection; +import java.util.List; @RestController @RequestMapping("/films") @@ -84,4 +85,11 @@ public Collection getFilmsByDirector(@PathVariable long directorId, log.info("GET /films/director/{}?sortBy={}", directorId, sortBy); return filmService.findByDirectorSorted(directorId, sortBy); } + + @GetMapping("/search") + public Collection searchFilms(@RequestParam String query, + @RequestParam List by) { + log.info("GET /films/search?query={}&by={}", query, by); + return filmService.searchFilms(query, by); + } } \ No newline at end of file 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 8de1336..b393165 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java @@ -17,6 +17,7 @@ import java.time.LocalDate; import java.util.Collection; import java.util.HashSet; +import java.util.List; import java.util.Set; @Service @@ -195,4 +196,23 @@ public Collection findByDirectorSorted(long directorId, String sortBy) { directorService.getDirectorById(directorId); // проверка существования режиссёра — иначе 404 return filmStorage.findFilmByDirector(directorId, sortBy); } + + private void validateSearchParameters(String query, List by) { + if (query == null || query.isBlank()) { + throw new ValidationException("Параметр 'query' не может быть пустым"); + } + if (by == null || by.isEmpty()) { + throw new ValidationException("Параметр 'by' не может быть пустым"); + } + for (String field : by) { + if (field == null || !field.equalsIgnoreCase("title") && !field.equalsIgnoreCase("director")) { + throw new ValidationException("Недопустимое значение в параметре 'by': " + field); + } + } + } + + public Collection searchFilms(String query, List by) { + validateSearchParameters(query, by); + return filmStorage.searchFilms(query, by); + } } \ No newline at end of file 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 860d997..507d365 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 @@ -104,6 +104,14 @@ public class FilmDbStorage implements FilmStorage { "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 "; // Рекомендации: максимум пересечений по лайкам среди всех пользователей private static final String FIND_MAX_OVERLAP_QUERY = @@ -294,6 +302,40 @@ public List findFilmByDirector(long directorId, String sortBy) { return films; } + @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<>(); + + if (by.contains("director")) { + conditions.add("LOWER(d.name) LIKE ?"); + params.add("%" + query.toLowerCase() + "%"); + } + + if (by.contains("title")) { + conditions.add("LOWER(f.name) LIKE ?"); + params.add("%" + query.toLowerCase() + "%"); + } + + if (conditions.isEmpty()) { + throw new ValidationException("Необходимо указать поиск: director или title"); + } + + 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); + loadGenresForFilms(films); + return films; + } + //Собирает запрос: чтение + WHERE по условиям + сортировка по числу лайков, без LIMIT private String buildPopularQuery(List conditions) { StringBuilder sql = new StringBuilder(FIND_POPULAR_BASE_QUERY); diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java index 7045473..359c492 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java @@ -29,4 +29,6 @@ public interface FilmStorage { boolean removeLike(long filmId, long userId); List findFilmByDirector(long directorId, String sortBy); + + List searchFilms(String query, List by); } \ No newline at end of file diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java index cfaf0f5..a0ec136 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java @@ -429,4 +429,45 @@ void shouldReturnAllFilmsByDirectorSortedByYear() { assertEquals(film3.getId(), allFilms.get(1).getId()); assertEquals(film2.getId(), allFilms.get(2).getId()); } + + @Test + void searchFilmByTitle() { + Film film = newFilm("Интерстеллар"); + film.setReleaseDate(LocalDate.of(2000, 1, 1)); + film = filmStorage.add(film); + List foundFilms = filmStorage.searchFilms("инт", List.of("title")); + assertEquals(1, foundFilms.size()); + assertEquals(film.getId(), foundFilms.get(0).getId()); + assertEquals("Интерстеллар", foundFilms.get(0).getName()); + } + + @Test + void searchFilmByDirector() { + Director director = createDirector("Кристофер Нолан"); + directorStorage.addDirector(director); + Film film = newFilm("Начало"); + film.getDirectors().add(director); + film = filmStorage.add(film); + List foundFilms = filmStorage.searchFilms("нол", List.of("director")); + assertEquals(1, foundFilms.size()); + assertEquals(film.getId(), foundFilms.get(0).getId()); + assertEquals("Начало", foundFilms.get(0).getName()); + } + + @Test + void searchFilmByTitleAndDirector() { + Director director = createDirector("Кристофер Нолан"); + directorStorage.addDirector(director); + Film film = newFilm("Начало"); + film.getDirectors().add(director); + film = filmStorage.add(film); + Long filmId = film.getId(); + Film film1 = newFilm("Нолан и его фильмы"); + film1 = filmStorage.add(film1); + Long film1Id = film1.getId(); + List foundFilms = filmStorage.searchFilms("нол", List.of("title", "director")); + assertEquals(2, foundFilms.size()); + assertTrue(foundFilms.stream().anyMatch(f -> f.getId().equals(filmId))); + assertTrue(foundFilms.stream().anyMatch(f -> f.getId().equals(film1Id))); + } } \ No newline at end of file From 18498aec9b995467bdbd6786b371207f5e4f1f67 Mon Sep 17 00:00:00 2001 From: Ksenia Date: Sun, 11 Oct 2026 20:59:26 +0300 Subject: [PATCH 9/9] fix: final polish before review (#20) --- 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)); + } +}