diff --git a/README.md b/README.md index ccf08e1..50b4159 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # java-filmorate Учебный backend-проект на Java и Spring Boot: сервис для работы с фильмами, -оценками пользователей и возврата топ фильмов, рекомендованных к просмотру. +оценками пользователей и топом популярных фильмов. -Проект развивается поэтапно. На текущем этапе реализованы слоистая архитектура -(controller → service → storage), социальные функции (друзья, лайки) -и полноценный REST API. +Проект развивается поэтапно. На текущем этапе данные хранятся в реляционной базе H2, +доступ к ним — через `JdbcTemplate`, у фильмов появились жанры и возрастной рейтинг MPA. ## Автор **Ксения Пылькина** *(Ksenia Pylkina)* @@ -13,16 +12,20 @@ ## Ключевые возможности на данном этапе - CRUD-операции для `Film` и `User` (создание, обновление, получение по ID и списком). -- Хранение данных в оперативной памяти (`HashMap`). -- Социальные функции: добавление/удаление друзей, список общих друзей. +- Хранение данных в базе H2 (файловый режим — данные переживают перезапуск), + доступ через `JdbcTemplate` и `RowMapper`. +- Справочники жанров и рейтингов MPA в базе, эндпоинты `/genres` и `/mpa`. +- У фильма есть рейтинг и список жанров; жанры в ответе — без дублей, по порядку id. +- Односторонняя дружба: добавление/удаление друзей, список друзей, общие друзья. - Лайки фильмам: добавление/удаление, топ-N фильмов по количеству лайков. -- Слоистая архитектура: контроллер → сервис → хранилище. +- Слоистая архитектура: контроллер → сервис → хранилище, выбор реализации хранилища через `@Qualifier`. - Внедрение зависимостей через конструктор (`@Autowired`). - Строгая валидация входящих данных через `spring-boot-starter-validation`. -- Кастомная бизнес-валидация (дата релиза, пробелы в логине, автозамена имени на логин). +- Кастомная бизнес-валидация (дата релиза, пробелы в логине, автозамена имени на логин, + существование рейтинга и жанров). - Централизованная обработка ошибок (`@RestControllerAdvice`): 400, 404, 500. - Логирование HTTP-запросов и ответов через Logbook. -- Покрытие бизнес-логики unit-тестами. +- Unit-тесты бизнес-логики и интеграционные тесты хранилищ (`@JdbcTest`). ## Технологии @@ -30,9 +33,11 @@ - **Spring Boot 3.2.2** - `spring-boot-starter-web` (REST API) - `spring-boot-starter-validation` (валидация данных) + - `spring-boot-starter-jdbc` (доступ к базе через `JdbcTemplate`) +- **H2 Database** (встроенная реляционная база данных) - **Logbook 3.7.2** (логирование HTTP-запросов) - **Lombok** (генерация boilerplate-кода и логгеров) -- **JUnit 5** (unit-тестирование) +- **JUnit 5**, **Spring Boot Test**, **Mockito** (unit- и интеграционные тесты) - **Maven** (сборка проекта) - **Checkstyle** (контроль стиля кода) @@ -45,8 +50,15 @@ После запуска сервер будет доступен на порту **8080**. +База данных создаётся автоматически при старте: таблицы — из `schema.sql`, +справочники жанров и рейтингов — из `data.sql`. Данные хранятся в папке `db` +в корне проекта. Чтобы начать с чистой базы, остановите приложение и удалите эту папку. + +Содержимое базы можно посмотреть в консоли H2: `http://localhost:8080/h2-console` +(JDBC URL `jdbc:h2:file:./db/filmorate`, пользователь `sa`, пароль `password`). + Для проверки запросов можно использовать **Postman** -(коллекция `postman.json` доступна в материалах курса). +(коллекция `sprint.json` доступна в шаблоне курса). ## Основные HTTP-эндпоинты @@ -64,11 +76,128 @@ - `GET /users/{id}` — получить пользователя по ID. - `POST /users` — создать нового пользователя. - `PUT /users` — обновить данные пользователя. -- `PUT /users/{id}/friends/{friendId}` — добавить в друзья. +- `PUT /users/{id}/friends/{friendId}` — добавить в друзья (дружба односторонняя). - `DELETE /users/{id}/friends/{friendId}` — удалить из друзей. - `GET /users/{id}/friends` — список друзей пользователя. - `GET /users/{id}/friends/common/{otherId}` — общие друзья с другим пользователем. +### Жанры (`/genres`) +- `GET /genres` — получить список всех жанров. +- `GET /genres/{id}` — получить жанр по ID. + +### Рейтинги MPA (`/mpa`) +- `GET /mpa` — получить список всех рейтингов. +- `GET /mpa/{id}` — получить рейтинг по ID. + +## Схема базы данных + +```mermaid +erDiagram + genres ||--o{ film_genres : "genre_id" + films ||--o{ film_genres : "film_id" + mpa |o--o{ films : "mpa_id" + films ||--o{ film_likes : "film_id" + users ||--o{ film_likes : "user_id" + users ||--o{ friendship : "user_id" + users ||--o{ friendship : "friend_id" + + films { + BIGINT id PK + VARCHAR name + VARCHAR description "до 200 символов" + DATE release_date + INTEGER duration + INTEGER mpa_id FK "может быть NULL" + } + users { + BIGINT id PK + VARCHAR email + VARCHAR login + VARCHAR name + DATE birthday + } + mpa { + INTEGER id PK + VARCHAR name + } + genres { + INTEGER id PK + VARCHAR name + } + film_genres { + BIGINT film_id PK, FK + INTEGER genre_id PK, FK + } + film_likes { + BIGINT film_id PK, FK + BIGINT user_id PK, FK + } + friendship { + BIGINT user_id PK, FK + BIGINT friend_id PK, FK + } +``` + +| Таблица | Что хранит | +|---|---| +| `films` | фильмы; `mpa_id` — ссылка на рейтинг, может быть пустой | +| `users` | пользователи | +| `mpa` | справочник рейтингов MPA (5 записей) | +| `genres` | справочник жанров (6 записей) | +| `film_genres` | связь «фильм — жанр», многие ко многим | +| `film_likes` | лайки: какой пользователь лайкнул какой фильм | +| `friendship` | дружба: одна строка — `user_id` добавил в друзья `friend_id`; подтверждённая (взаимная) дружба — две встречные строки | + +У таблиц-связок составной первичный ключ, поэтому одна и та же пара не запишется дважды. +При удалении фильма или пользователя связанные с ним записи удаляются каскадно. + +
+Примеры запросов + +Топ-10 популярных фильмов: по числу лайков, при равенстве — по id: + +```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_likes fl ON f.id = fl.film_id +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 10; +``` + +Жанры фильма с id = 1 по порядку id: + +```sql +SELECT g.id, g.name +FROM film_genres fg +JOIN genres g ON fg.genre_id = g.id +WHERE fg.film_id = 1 +ORDER BY g.id; +``` + +Друзья пользователя с id = 1 — те, кого он добавил: + +```sql +SELECT u.id, u.email, u.login, u.name, u.birthday +FROM users u +JOIN friendship f ON u.id = f.friend_id +WHERE f.user_id = 1 +ORDER BY u.id; +``` + +Общие друзья пользователей с id = 1 и id = 2: + +```sql +SELECT u.id, u.email, u.login, u.name, u.birthday +FROM users u +JOIN friendship f1 ON u.id = f1.friend_id AND f1.user_id = 1 +JOIN friendship f2 ON u.id = f2.friend_id AND f2.user_id = 2 +ORDER BY u.id; +``` + +
+ ## Реализация по этапам
@@ -89,7 +218,7 @@
-
+
Этап 2 — Слоистая архитектура, социальные функции, полный REST На этом этапе логика вынесена из контроллеров, добавлены друзья и лайки, @@ -107,4 +236,29 @@ API доведён до соответствия REST. - Подключён 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-тесты сервисов обновлены. +
\ No newline at end of file diff --git a/pom.xml b/pom.xml index d6903f7..9bbdc4a 100644 --- a/pom.xml +++ b/pom.xml @@ -33,6 +33,17 @@ spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-jdbc + + + + com.h2database + h2 + runtime + + org.projectlombok lombok diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/GenreController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/GenreController.java new file mode 100644 index 0000000..034eaa2 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/GenreController.java @@ -0,0 +1,38 @@ +package ru.yandex.practicum.filmorate.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import ru.yandex.practicum.filmorate.model.Genre; +import ru.yandex.practicum.filmorate.service.GenreService; + +import java.util.List; + +@RestController +@RequestMapping("/genres") +@Slf4j +public class GenreController { + + private final GenreService genreService; + + @Autowired + public GenreController(GenreService genreService) { + this.genreService = genreService; + } + + @GetMapping + public List findAll() { + log.info("GET /genres"); + return genreService.findAll(); + } + + @GetMapping("/{id}") + public Genre findById(@PathVariable int id) { + log.info("GET /genres/{}", id); + return genreService.findById(id); + } +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/MpaController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/MpaController.java new file mode 100644 index 0000000..1ed36c4 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/MpaController.java @@ -0,0 +1,37 @@ +package ru.yandex.practicum.filmorate.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import ru.yandex.practicum.filmorate.model.Mpa; +import ru.yandex.practicum.filmorate.service.MpaService; + +import java.util.List; + +@RestController +@RequestMapping("/mpa") +@Slf4j +public class MpaController { + + private final MpaService mpaService; + + @Autowired + public MpaController(MpaService mpaService) { + this.mpaService = mpaService; + } + + @GetMapping + public List findAll() { + log.info("GET /mpa"); + return mpaService.findAll(); + } + + @GetMapping("/{id}") + public Mpa findById(@PathVariable int id) { + log.info("GET /mpa/{}", id); + return mpaService.findById(id); + } +} \ No newline at end of file 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 97f7679..08a9aca 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/model/Film.java +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Film.java @@ -1,5 +1,6 @@ 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,6 +9,7 @@ import java.time.LocalDate; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.Set; /** @@ -30,6 +32,10 @@ public class Film { @Positive(message = "Продолжительность фильма должна быть положительной") private Integer duration; + private Mpa mpa; + private Set genres = new LinkedHashSet<>(); + + @JsonIgnore private Set likes = new HashSet<>(); public void addLike(Long userId) { diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Genre.java b/src/main/java/ru/yandex/practicum/filmorate/model/Genre.java new file mode 100644 index 0000000..ce8a4cf --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Genre.java @@ -0,0 +1,11 @@ +package ru.yandex.practicum.filmorate.model; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(of = "id") +public class Genre { + private Integer id; + private String name; +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Mpa.java b/src/main/java/ru/yandex/practicum/filmorate/model/Mpa.java new file mode 100644 index 0000000..831f780 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Mpa.java @@ -0,0 +1,11 @@ +package ru.yandex.practicum.filmorate.model; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(of = "id") +public class Mpa { + private Integer id; + private String name; +} 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 11d905c..86cda7c 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/model/User.java +++ b/src/main/java/ru/yandex/practicum/filmorate/model/User.java @@ -1,5 +1,6 @@ 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; @@ -27,6 +28,7 @@ public class User { @PastOrPresent(message = "Дата рождения не может быть в будущем") private LocalDate birthday; + @JsonIgnore private Set friends = new HashSet<>(); public void addFriend(Long 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 c1ee020..160de6e 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java @@ -2,15 +2,19 @@ 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.Film; +import ru.yandex.practicum.filmorate.model.Genre; import ru.yandex.practicum.filmorate.storage.film.FilmStorage; import ru.yandex.practicum.filmorate.storage.user.UserStorage; import java.time.LocalDate; import java.util.Collection; +import java.util.HashSet; +import java.util.Set; @Service @Slf4j @@ -23,10 +27,19 @@ public class FilmService { // Нужен для проверки существования пользователя, который ставит лайк private final UserStorage userStorage; + // Нужны для проверки рейтинга и жанра из запроса + private final MpaService mpaService; + private final GenreService genreService; + @Autowired - public FilmService(FilmStorage filmStorage, UserStorage userStorage) { + public FilmService(@Qualifier("filmDbStorage") FilmStorage filmStorage, + @Qualifier("userDbStorage") UserStorage userStorage, + MpaService mpaService, + GenreService genreService) { this.filmStorage = filmStorage; this.userStorage = userStorage; + this.mpaService = mpaService; + this.genreService = genreService; } // Получение фильма по id с проверкой существования @@ -45,8 +58,7 @@ public Film addLike(long filmId, long userId) { Film film = getFilmOrThrow(filmId); getUserOrThrow(userId); // проверка существования пользователя — иначе 404 - film.addLike(userId); - filmStorage.update(film); + filmStorage.addLike(filmId, userId); log.debug("Пользователь id={} поставил лайк фильму id={}", userId, filmId); return film; } @@ -55,13 +67,12 @@ public Film removeLike(long filmId, long userId) { Film film = getFilmOrThrow(filmId); getUserOrThrow(userId); // проверка существования пользователя — иначе 404 - if (!film.removeLike(userId)) { + if (!filmStorage.removeLike(filmId, userId)) { throw new NotFoundException( "Пользователь id=" + userId + " не ставил лайк фильму id=" + filmId ); } - filmStorage.update(film); log.debug("Пользователь id={} убрал лайк с фильма id={}", userId, filmId); return film; } @@ -86,14 +97,36 @@ private void validateReleaseDate(Film film) { } } + // Рейтинг и жанры из запроса должны существовать в справочниках — иначе 404 + private void validateMpaAndGenres(Film film) { + if (film.getMpa() != null) { + mpaService.findById(film.getMpa().getId()); // бросит NotFoundException + } + if (film.getGenres() == null || film.getGenres().isEmpty()) { + return; + } + // Один запрос за всеми жанрами вместо запроса на каждый + Set knownIds = new HashSet<>(); + for (Genre genre : genreService.findAll()) { + knownIds.add(genre.getId()); + } + for (Genre genre : film.getGenres()) { + if (!knownIds.contains(genre.getId())) { + throw new NotFoundException("Жанр с id=" + genre.getId() + " не найден"); + } + } + } + public Film add(Film film) { validateReleaseDate(film); + validateMpaAndGenres(film); return filmStorage.add(film); } public Film update(Film film) { getFilmOrThrow(film.getId()); // проверка существования — иначе 404 validateReleaseDate(film); + validateMpaAndGenres(film); return filmStorage.update(film); } diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/GenreService.java b/src/main/java/ru/yandex/practicum/filmorate/service/GenreService.java new file mode 100644 index 0000000..fa9f5bd --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/GenreService.java @@ -0,0 +1,29 @@ +package ru.yandex.practicum.filmorate.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.model.Genre; +import ru.yandex.practicum.filmorate.storage.genre.GenreStorage; + +import java.util.List; + +@Service +public class GenreService { + + private final GenreStorage genreStorage; + + @Autowired + public GenreService(GenreStorage genreStorage) { + this.genreStorage = genreStorage; + } + + public List findAll() { + return genreStorage.findAll(); + } + + public Genre findById(int id) { + return genreStorage.findById(id) + .orElseThrow(() -> new NotFoundException("Жанр с id=" + id + " не найден")); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/MpaService.java b/src/main/java/ru/yandex/practicum/filmorate/service/MpaService.java new file mode 100644 index 0000000..c5a841f --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/MpaService.java @@ -0,0 +1,29 @@ +package ru.yandex.practicum.filmorate.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.model.Mpa; +import ru.yandex.practicum.filmorate.storage.mpa.MpaStorage; + +import java.util.List; + +@Service +public class MpaService { + + private final MpaStorage mpaStorage; + + @Autowired + public MpaService(MpaStorage mpaStorage) { + this.mpaStorage = mpaStorage; + } + + public List findAll() { + return mpaStorage.findAll(); + } + + public Mpa findById(int id) { + return mpaStorage.findById(id) + .orElseThrow(() -> new NotFoundException("Рейтинг с id=" + id + " не найден")); + } +} \ No newline at end of file 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 fb95354..640cf50 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java @@ -2,15 +2,14 @@ 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.User; import ru.yandex.practicum.filmorate.storage.user.UserStorage; -import java.util.ArrayList; import java.util.Collection; -import java.util.List; @Service @Slf4j @@ -19,7 +18,7 @@ public class UserService { private final UserStorage userStorage; @Autowired - public UserService(UserStorage userStorage) { + public UserService(@Qualifier("userDbStorage") UserStorage userStorage) { this.userStorage = userStorage; } @@ -37,16 +36,15 @@ private User getUserOrThrow(long id) { .orElseThrow(() -> new NotFoundException("Пользователь с id=" + id + " не найден")); } + // Дружба односторонняя: обратная запись для friendId не создаётся public User addFriend(long userId, long friendId) { validateDifferentUsers(userId, friendId); User user = getUserOrThrow(userId); - User friend = getUserOrThrow(friendId); + getUserOrThrow(friendId); // проверка существования друга — иначе 404 - user.addFriend(friendId); - friend.addFriend(userId); - - log.debug("Пользователи id={} и id={} теперь друзья", userId, friendId); + userStorage.addFriend(userId, friendId); + log.debug("Пользователь id={} добавил в друзья id={}", userId, friendId); return user; } @@ -54,40 +52,24 @@ public User removeFriend(long userId, long friendId) { validateDifferentUsers(userId, friendId); User user = getUserOrThrow(userId); - User friend = getUserOrThrow(friendId); - - user.removeFriend(friendId); - friend.removeFriend(userId); + getUserOrThrow(friendId); // проверка существования друга — иначе 404 - log.debug("Пользователи id={} и id={} больше не друзья", userId, friendId); + userStorage.removeFriend(userId, friendId); + log.debug("Пользователь id={} удалил из друзей id={}", userId, friendId); return user; } public Collection getFriends(long userId) { - User user = getUserOrThrow(userId); - List friends = new ArrayList<>(); - - for (Long friendId : user.getFriends()) { - friends.add(getUserOrThrow(friendId)); - } - - return friends; + getUserOrThrow(userId); + return userStorage.findFriends(userId); } public Collection getCommonFriends(long userId, long otherId) { validateDifferentUsers(userId, otherId); - User user = getUserOrThrow(userId); - User other = getUserOrThrow(otherId); - List common = new ArrayList<>(); - - for (Long friendId : user.getFriends()) { - if (other.getFriends().contains(friendId)) { - common.add(getUserOrThrow(friendId)); - } - } - - return common; + getUserOrThrow(userId); + getUserOrThrow(otherId); + return userStorage.findCommonFriends(userId, otherId); } // Логин не может содержать пробелы 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 new file mode 100644 index 0000000..df032fd --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorage.java @@ -0,0 +1,182 @@ +package ru.yandex.practicum.filmorate.storage.film; + +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.Film; +import ru.yandex.practicum.filmorate.model.Genre; +import ru.yandex.practicum.filmorate.storage.mapper.FilmRowMapper; +import ru.yandex.practicum.filmorate.storage.mapper.GenreRowMapper; + +import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +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 { + + // Общее начало запросов на чтение: фильм + название его рейтинга + private static final String SELECT_FILMS = + "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 "; + 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 " + + "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 INSERT_QUERY = + "INSERT INTO films (name, description, release_date, duration, mpa_id) VALUES (?, ?, ?, ?, ?)"; + private static final String UPDATE_QUERY = + "UPDATE films SET name = ?, description = ?, release_date = ?, duration = ?, mpa_id = ? WHERE id = ?"; + private static final String DELETE_QUERY = "DELETE FROM films WHERE id = ?"; + private static final String DELETE_FILM_GENRES_QUERY = "DELETE FROM film_genres WHERE film_id = ?"; + private static final String INSERT_FILM_GENRE_QUERY = + "INSERT INTO film_genres (film_id, genre_id) VALUES (?, ?)"; + // Жанры одного фильма — по порядку id + private static final String FIND_GENRES_BY_FILM_QUERY = + "SELECT g.id, g.name FROM film_genres fg " + + "JOIN genres g ON fg.genre_id = g.id " + + "WHERE fg.film_id = ? " + + "ORDER BY g.id"; + // Жанры нескольких фильмов одним запросом; вместо %s подставляется «?, ?, ?» по числу фильмов + private static final String FIND_GENRES_BY_FILMS_QUERY = + "SELECT fg.film_id, g.id, g.name FROM film_genres fg " + + "JOIN genres g ON fg.genre_id = g.id " + + "WHERE fg.film_id IN (%s) " + + "ORDER BY fg.film_id, g.id"; + private static final String ADD_LIKE_QUERY = + "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 final JdbcTemplate jdbc; + private final FilmRowMapper mapper; + private final GenreRowMapper genreMapper; + + @Autowired + public FilmDbStorage(JdbcTemplate jdbc, FilmRowMapper mapper, GenreRowMapper genreMapper) { + this.jdbc = jdbc; + this.mapper = mapper; + this.genreMapper = genreMapper; + } + + @Override + public Film add(Film film) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbc.update(connection -> { + PreparedStatement ps = connection.prepareStatement(INSERT_QUERY, Statement.RETURN_GENERATED_KEYS); + ps.setString(1, film.getName()); + ps.setString(2, film.getDescription()); + ps.setDate(3, Date.valueOf(film.getReleaseDate())); + ps.setInt(4, film.getDuration()); + ps.setObject(5, getMpaId(film)); + return ps; + }, keyHolder); + + Long id = keyHolder.getKeyAs(Long.class); + saveGenres(id, film.getGenres()); + // Перечитываем из базы: так в ответе будут названия рейтинга и жанров, а не только их id + return findById(id).orElseThrow(); + } + + @Override + 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()); + return findById(film.getId()).orElseThrow(); + } + + @Override + public Film delete(long id) { + Film film = findById(id) + .orElseThrow(() -> new NotFoundException("Фильм с id=" + id + " не найден")); + jdbc.update(DELETE_QUERY, id); + return film; + } + + @Override + public Collection findAll() { + List films = jdbc.query(FIND_ALL_QUERY, mapper); + loadGenresForFilms(films); + return films; + } + + @Override + public Optional findById(long id) { + Optional film = jdbc.query(FIND_BY_ID_QUERY, mapper, id).stream().findFirst(); + film.ifPresent(this::loadGenres); + return film; + } + + @Override + public List findPopular(int count) { + List films = jdbc.query(FIND_POPULAR_QUERY, mapper, count); + loadGenresForFilms(films); + return films; + } + + @Override + public void addLike(long filmId, long userId) { + jdbc.update(ADD_LIKE_QUERY, filmId, userId); + } + + @Override + public boolean removeLike(long filmId, long userId) { + // update возвращает число удалённых строк: 0 — лайка не было + return jdbc.update(REMOVE_LIKE_QUERY, filmId, userId) > 0; + } + + // Рейтинга может не быть — тогда в колонку mpa_id уходит NULL + private Integer getMpaId(Film film) { + return film.getMpa() == null ? null : film.getMpa().getId(); + } + + // Заменяет жанры фильма: старые связи удаляются, новые вставляются одним пакетом + private void saveGenres(long filmId, Set genres) { + jdbc.update(DELETE_FILM_GENRES_QUERY, filmId); + if (genres == null || genres.isEmpty()) { + return; + } + List rows = new ArrayList<>(); + for (Genre genre : genres) { + rows.add(new Object[]{filmId, genre.getId()}); + } + jdbc.batchUpdate(INSERT_FILM_GENRE_QUERY, rows); + } + + // Жанры одного фильма + private void loadGenres(Film film) { + film.getGenres().addAll(jdbc.query(FIND_GENRES_BY_FILM_QUERY, genreMapper, film.getId())); + } + + // Жанры списка фильмов: один запрос только по этим фильмам, раскладываем через Map + private void loadGenresForFilms(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(), "?")); + jdbc.query(String.format(FIND_GENRES_BY_FILMS_QUERY, placeholders), rs -> { + Film film = filmsById.get(rs.getLong("film_id")); + film.getGenres().add(genreMapper.mapRow(rs, rs.getRow())); + }, filmsById.keySet().toArray()); + } +} \ 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 2d5eff2..501bf43 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 @@ -19,4 +19,8 @@ public interface FilmStorage { Optional findById(long id); List findPopular(int count); + + void addLike(long filmId, long userId); + + boolean removeLike(long filmId, long userId); } \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/film/InMemoryFilmStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/film/InMemoryFilmStorage.java index 9d9dc97..835d48f 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/storage/film/InMemoryFilmStorage.java +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/film/InMemoryFilmStorage.java @@ -63,4 +63,15 @@ public List findPopular(int count) { .limit(count) .toList(); } + + // Существование фильма и пользователя проверяет сервис до вызова этих методов + @Override + public void addLike(long filmId, long userId) { + films.get(filmId).addLike(userId); + } + + @Override + public boolean removeLike(long filmId, long userId) { + return films.get(filmId).removeLike(userId); + } } \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/genre/GenreDbStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/genre/GenreDbStorage.java new file mode 100644 index 0000000..99c5efe --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/genre/GenreDbStorage.java @@ -0,0 +1,35 @@ +package ru.yandex.practicum.filmorate.storage.genre; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import ru.yandex.practicum.filmorate.model.Genre; +import ru.yandex.practicum.filmorate.storage.mapper.GenreRowMapper; + +import java.util.List; +import java.util.Optional; + +@Repository +public class GenreDbStorage implements GenreStorage { + private static final String FIND_ALL_QUERY = "SELECT id, name FROM genres ORDER BY id"; + private static final String FIND_BY_ID_QUERY = "SELECT id, name FROM genres WHERE id = ?"; + + private final JdbcTemplate jdbc; + private final GenreRowMapper mapper; + + @Autowired + public GenreDbStorage(JdbcTemplate jdbc, GenreRowMapper mapper) { + this.jdbc = jdbc; + this.mapper = mapper; + } + + @Override + public List findAll() { + return jdbc.query(FIND_ALL_QUERY, mapper); + } + + @Override + public Optional findById(int id) { + return jdbc.query(FIND_BY_ID_QUERY, mapper, id).stream().findFirst(); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/genre/GenreStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/genre/GenreStorage.java new file mode 100644 index 0000000..4710be3 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/genre/GenreStorage.java @@ -0,0 +1,12 @@ +package ru.yandex.practicum.filmorate.storage.genre; + +import ru.yandex.practicum.filmorate.model.Genre; + +import java.util.List; +import java.util.Optional; + +public interface GenreStorage { + List findAll(); + + Optional findById(int id); +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/FilmRowMapper.java b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/FilmRowMapper.java new file mode 100644 index 0000000..6a091eb --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/FilmRowMapper.java @@ -0,0 +1,34 @@ +package ru.yandex.practicum.filmorate.storage.mapper; + +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; +import ru.yandex.practicum.filmorate.model.Film; +import ru.yandex.practicum.filmorate.model.Mpa; + +import java.sql.ResultSet; +import java.sql.SQLException; + +@Component +public class FilmRowMapper implements RowMapper { + + @Override + public Film mapRow(ResultSet rs, int rowNum) throws SQLException { + Film film = new Film(); + film.setId(rs.getLong("id")); + film.setName(rs.getString("name")); + film.setDescription(rs.getString("description")); + film.setReleaseDate(rs.getDate("release_date").toLocalDate()); + film.setDuration(rs.getInt("duration")); + + // У фильма может не быть рейтинга: тогда в mpa_id лежит NULL + Integer mpaId = rs.getObject("mpa_id", Integer.class); + if (mpaId != null) { + Mpa mpa = new Mpa(); + mpa.setId(mpaId); + mpa.setName(rs.getString("mpa_name")); + film.setMpa(mpa); + } + + return film; + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/GenreRowMapper.java b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/GenreRowMapper.java new file mode 100644 index 0000000..84d8543 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/GenreRowMapper.java @@ -0,0 +1,20 @@ +package ru.yandex.practicum.filmorate.storage.mapper; + +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; +import ru.yandex.practicum.filmorate.model.Genre; + +import java.sql.ResultSet; +import java.sql.SQLException; + +@Component +public class GenreRowMapper implements RowMapper { + + @Override + public Genre mapRow(ResultSet rs, int rowNum) throws SQLException { + Genre genre = new Genre(); + genre.setId(rs.getInt("id")); + genre.setName(rs.getString("name")); + return genre; + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/MpaRowMapper.java b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/MpaRowMapper.java new file mode 100644 index 0000000..7203370 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/MpaRowMapper.java @@ -0,0 +1,20 @@ +package ru.yandex.practicum.filmorate.storage.mapper; + +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; +import ru.yandex.practicum.filmorate.model.Mpa; + +import java.sql.ResultSet; +import java.sql.SQLException; + +@Component +public class MpaRowMapper implements RowMapper { + + @Override + public Mpa mapRow(ResultSet rs, int rowNum) throws SQLException { + Mpa mpa = new Mpa(); + mpa.setId(rs.getInt("id")); + mpa.setName(rs.getString("name")); + return mpa; + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/UserRowMapper.java b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/UserRowMapper.java new file mode 100644 index 0000000..7f51ca8 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/UserRowMapper.java @@ -0,0 +1,23 @@ +package ru.yandex.practicum.filmorate.storage.mapper; + +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; +import ru.yandex.practicum.filmorate.model.User; + +import java.sql.ResultSet; +import java.sql.SQLException; + +@Component +public class UserRowMapper implements RowMapper { + + @Override + public User mapRow(ResultSet rs, int rowNum) throws SQLException { + User user = new User(); + user.setId(rs.getLong("id")); + user.setEmail(rs.getString("email")); + user.setLogin(rs.getString("login")); + user.setName(rs.getString("name")); + user.setBirthday(rs.getDate("birthday").toLocalDate()); + return user; + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/mpa/MpaDbStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/mpa/MpaDbStorage.java new file mode 100644 index 0000000..439ceac --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/mpa/MpaDbStorage.java @@ -0,0 +1,35 @@ +package ru.yandex.practicum.filmorate.storage.mpa; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import ru.yandex.practicum.filmorate.model.Mpa; +import ru.yandex.practicum.filmorate.storage.mapper.MpaRowMapper; + +import java.util.List; +import java.util.Optional; + +@Repository +public class MpaDbStorage implements MpaStorage { + private static final String FIND_ALL_QUERY = "SELECT id, name FROM mpa ORDER BY id"; + private static final String FIND_BY_ID_QUERY = "SELECT id, name FROM mpa WHERE id = ?"; + + private final JdbcTemplate jdbc; + private final MpaRowMapper mapper; + + @Autowired + public MpaDbStorage(JdbcTemplate jdbc, MpaRowMapper mapper) { + this.jdbc = jdbc; + this.mapper = mapper; + } + + @Override + public List findAll() { + return jdbc.query(FIND_ALL_QUERY, mapper); + } + + @Override + public Optional findById(int id) { + return jdbc.query(FIND_BY_ID_QUERY, mapper, id).stream().findFirst(); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/mpa/MpaStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/mpa/MpaStorage.java new file mode 100644 index 0000000..6540ba6 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/mpa/MpaStorage.java @@ -0,0 +1,12 @@ +package ru.yandex.practicum.filmorate.storage.mpa; + +import ru.yandex.practicum.filmorate.model.Mpa; + +import java.util.List; +import java.util.Optional; + +public interface MpaStorage { + List findAll(); + + Optional findById(int id); +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/user/InMemoryUserStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/user/InMemoryUserStorage.java index e9106a5..abae035 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/storage/user/InMemoryUserStorage.java +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/user/InMemoryUserStorage.java @@ -5,8 +5,10 @@ import ru.yandex.practicum.filmorate.exception.NotFoundException; import ru.yandex.practicum.filmorate.model.User; +import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -52,4 +54,42 @@ public Collection findAll() { public Optional findById(long id) { return Optional.ofNullable(users.get(id)); } + + // Существование пользователей проверяет сервис до вызова этих методов + @Override + public void addFriend(long userId, long friendId) { + users.get(userId).addFriend(friendId); + } + + @Override + public void removeFriend(long userId, long friendId) { + users.get(userId).removeFriend(friendId); + } + + @Override + public Collection findFriends(long userId) { + User user = users.get(userId); + List friends = new ArrayList<>(); + + for (Long friendId : user.getFriends()) { + friends.add(users.get(friendId)); + } + + return friends; + } + + @Override + public Collection findCommonFriends(long userId, long otherId) { + User user = users.get(userId); + User other = users.get(otherId); + List common = new ArrayList<>(); + + for (Long friendId : user.getFriends()) { + if (other.getFriends().contains(friendId)) { + common.add(users.get(friendId)); + } + } + + return common; + } } \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorage.java new file mode 100644 index 0000000..076ec76 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorage.java @@ -0,0 +1,117 @@ +package ru.yandex.practicum.filmorate.storage.user; + +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.User; +import ru.yandex.practicum.filmorate.storage.mapper.UserRowMapper; + +import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.Collection; +import java.util.Optional; + +@Repository +public class UserDbStorage implements UserStorage { + + private static final String INSERT_QUERY = + "INSERT INTO users (email, login, name, birthday) VALUES (?, ?, ?, ?)"; + private static final String UPDATE_QUERY = + "UPDATE users SET email = ?, login = ?, name = ?, birthday = ? WHERE id = ?"; + private static final String DELETE_QUERY = + "DELETE FROM users WHERE id = ?"; + private static final String FIND_ALL_QUERY = + "SELECT id, email, login, name, birthday FROM users ORDER BY id"; + private static final String FIND_BY_ID_QUERY = + "SELECT id, email, login, name, birthday FROM users WHERE id = ?"; + private static final String ADD_FRIEND_QUERY = + "MERGE INTO friendship (user_id, friend_id) KEY (user_id, friend_id) VALUES (?, ?)"; + private static final String REMOVE_FRIEND_QUERY = + "DELETE FROM friendship WHERE user_id = ? AND friend_id = ?"; + private static final String FIND_FRIENDS_QUERY = + "SELECT u.id, u.email, u.login, u.name, u.birthday FROM users u " + + "JOIN friendship f ON u.id = f.friend_id " + + "WHERE f.user_id = ? ORDER BY u.id"; + private static final String FIND_COMMON_FRIENDS_QUERY = + "SELECT u.id, u.email, u.login, u.name, u.birthday FROM users u " + + "JOIN friendship f1 ON u.id = f1.friend_id AND f1.user_id = ? " + + "JOIN friendship f2 ON u.id = f2.friend_id AND f2.user_id = ? " + + "ORDER BY u.id"; + + private final JdbcTemplate jdbc; + private final UserRowMapper mapper; + + @Autowired + public UserDbStorage(JdbcTemplate jdbc, UserRowMapper mapper) { + this.jdbc = jdbc; + this.mapper = mapper; + } + + @Override + public User add(User user) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbc.update(connection -> { + PreparedStatement ps = connection.prepareStatement(INSERT_QUERY, Statement.RETURN_GENERATED_KEYS); + ps.setString(1, user.getEmail()); + ps.setString(2, user.getLogin()); + ps.setString(3, user.getName()); + ps.setDate(4, Date.valueOf(user.getBirthday())); + return ps; + }, keyHolder); + user.setId(keyHolder.getKeyAs(Long.class)); + return user; + } + + @Override + public User update(User user) { + jdbc.update(UPDATE_QUERY, + user.getEmail(), + user.getLogin(), + user.getName(), + Date.valueOf(user.getBirthday()), + user.getId()); + return user; + } + + @Override + public User delete(long id) { + User user = findById(id) + .orElseThrow(() -> new NotFoundException("Пользователь с id=" + id + " не найден")); + jdbc.update(DELETE_QUERY, id); + return user; + } + + @Override + public Collection findAll() { + return jdbc.query(FIND_ALL_QUERY, mapper); + } + + @Override + public Optional findById(long id) { + return jdbc.query(FIND_BY_ID_QUERY, mapper, id).stream().findFirst(); + } + + @Override + public void addFriend(long userId, long friendId) { + jdbc.update(ADD_FRIEND_QUERY, userId, friendId); + } + + @Override + public void removeFriend(long userId, long friendId) { + jdbc.update(REMOVE_FRIEND_QUERY, userId, friendId); + } + + @Override + public Collection findFriends(long userId) { + return jdbc.query(FIND_FRIENDS_QUERY, mapper, userId); + } + + @Override + public Collection findCommonFriends(long userId, long otherId) { + return jdbc.query(FIND_COMMON_FRIENDS_QUERY, mapper, userId, otherId); + } +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserStorage.java index 5c2c298..f44bea4 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserStorage.java +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserStorage.java @@ -16,4 +16,12 @@ public interface UserStorage { Collection findAll(); Optional findById(long id); + + void addFriend(long userId, long friendId); + + void removeFriend(long userId, long friendId); + + Collection findFriends(long userId); + + Collection findCommonFriends(long userId, long otherId); } \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 21af5c7..20b9eb8 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,12 @@ logging.level.org.zalando.logbook=TRACE +logbook.exclude=/h2-console/** + +spring.sql.init.mode=always +spring.datasource.url=jdbc:h2:file:./db/filmorate +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password=password + +spring.h2.console.enabled=true + +spring.sql.init.encoding=UTF-8 diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..5081d9e --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,14 @@ +MERGE INTO genres (id, name) KEY (id) +VALUES (1, 'Комедия'), + (2, 'Драма'), + (3, 'Мультфильм'), + (4, 'Триллер'), + (5, 'Документальный'), + (6, 'Боевик'); + +MERGE INTO mpa (id, name) KEY (id) +VALUES (1, 'G'), + (2, 'PG'), + (3, 'PG-13'), + (4, 'R'), + (5, 'NC-17'); \ No newline at end of file diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 0000000..3d660c3 --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,55 @@ +-- Схема БД Filmorate. Spring выполняет этот файл при каждом старте (spring.sql.init.mode=always), +-- поэтому таблицы создаются через IF NOT EXISTS. + +-- Возрастные рейтинги MPA. id не генерируется: значения фиксированы (1-5) и приходят из data.sql. +CREATE TABLE IF NOT EXISTS mpa ( + id INTEGER PRIMARY KEY, + name VARCHAR(50) NOT NULL +); + +-- Жанры. id не генерируется: значения фиксированы (1-6) и приходят из data.sql. +CREATE TABLE IF NOT EXISTS genres ( + id INTEGER PRIMARY KEY, + name VARCHAR(50) NOT NULL +); + +CREATE TABLE IF NOT EXISTS users ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + email VARCHAR(255) NOT NULL, + login VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + birthday DATE NOT NULL +); + +CREATE TABLE IF NOT EXISTS films ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description VARCHAR(200), + release_date DATE NOT NULL, + duration INTEGER NOT NULL, + -- без NOT NULL: API допускает фильм без рейтинга + mpa_id INTEGER REFERENCES mpa(id) +); + +-- Связь «фильм — жанр», многие ко многим. +-- Составной первичный ключ не даёт записать один и тот же жанр фильму дважды. +CREATE TABLE IF NOT EXISTS film_genres ( + film_id BIGINT NOT NULL REFERENCES films(id) ON DELETE CASCADE, + genre_id INTEGER NOT NULL REFERENCES genres(id), + PRIMARY KEY (film_id, genre_id) +); + +-- Лайки: строка означает, что пользователь отметил фильм. Повторный лайк не пройдёт по первичному ключу. +CREATE TABLE IF NOT EXISTS film_likes ( + film_id BIGINT NOT NULL REFERENCES films(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (film_id, user_id) +); + +-- Дружба односторонняя: строка означает, что user_id добавил friend_id. +-- Взаимная дружба — две строки. +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 diff --git a/src/test/java/ru/yandex/practicum/filmorate/FilmorateApplicationTests.java b/src/test/java/ru/yandex/practicum/filmorate/FilmorateApplicationTests.java index 660412e..7e025d0 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/FilmorateApplicationTests.java +++ b/src/test/java/ru/yandex/practicum/filmorate/FilmorateApplicationTests.java @@ -1,9 +1,11 @@ package ru.yandex.practicum.filmorate; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest +@AutoConfigureTestDatabase class FilmorateApplicationTests { @Test diff --git a/src/test/java/ru/yandex/practicum/filmorate/controller/FilmControllerTest.java b/src/test/java/ru/yandex/practicum/filmorate/controller/FilmControllerTest.java index 2164488..74c9cd0 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/controller/FilmControllerTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/controller/FilmControllerTest.java @@ -5,12 +5,15 @@ import ru.yandex.practicum.filmorate.exception.ValidationException; import ru.yandex.practicum.filmorate.model.Film; import ru.yandex.practicum.filmorate.service.FilmService; +import ru.yandex.practicum.filmorate.service.GenreService; +import ru.yandex.practicum.filmorate.service.MpaService; import ru.yandex.practicum.filmorate.storage.film.InMemoryFilmStorage; import ru.yandex.practicum.filmorate.storage.user.InMemoryUserStorage; import java.time.LocalDate; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; class FilmControllerTest { private FilmController filmController; @@ -20,7 +23,8 @@ void setUp() { // Перед каждым тестом создаётся новая цепочка зависимостей InMemoryFilmStorage filmStorage = new InMemoryFilmStorage(); InMemoryUserStorage userStorage = new InMemoryUserStorage(); - FilmService filmService = new FilmService(filmStorage, userStorage); + FilmService filmService = new FilmService(filmStorage, userStorage, + mock(MpaService.class), mock(GenreService.class)); filmController = new FilmController(filmService); } @@ -48,9 +52,7 @@ void shouldThrowExceptionWhenReleaseDateIsTooOld() { film.setDuration(100); // Проверка, что при попытке создать такой фильм выбросится ValidationException - ValidationException exception = assertThrows(ValidationException.class, () -> { - filmController.create(film); - }); + ValidationException exception = assertThrows(ValidationException.class, () -> filmController.create(film)); assertEquals("Дата релиза фильма не может быть раньше 28 декабря 1895 года", exception.getMessage()); } diff --git a/src/test/java/ru/yandex/practicum/filmorate/service/FilmServiceTest.java b/src/test/java/ru/yandex/practicum/filmorate/service/FilmServiceTest.java index d953f85..97c90de 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/service/FilmServiceTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/service/FilmServiceTest.java @@ -13,6 +13,7 @@ import java.util.Collection; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; class FilmServiceTest { @@ -23,7 +24,8 @@ class FilmServiceTest { void setUp() { InMemoryUserStorage userStorage = new InMemoryUserStorage(); userService = new UserService(userStorage); - filmService = new FilmService(new InMemoryFilmStorage(), userStorage); + filmService = new FilmService(new InMemoryFilmStorage(), userStorage, + mock(MpaService.class), mock(GenreService.class)); } // Вспомогательные методы - отмена дублирования кода создания объектов в каждом тесте diff --git a/src/test/java/ru/yandex/practicum/filmorate/service/UserServiceTest.java b/src/test/java/ru/yandex/practicum/filmorate/service/UserServiceTest.java index 210d61e..c0e2b44 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/service/UserServiceTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/service/UserServiceTest.java @@ -65,14 +65,15 @@ void shouldAddAndRemoveFriend() { userService.addFriend(user1.getId(), user2.getId()); - // Оба должны видеть друг друга - assertTrue(userService.findById(user1.getId()).getFriends().contains(user2.getId())); - assertTrue(userService.findById(user2.getId()).getFriends().contains(user1.getId())); + // Дружба односторонняя: user2 появился у user1, обратной записи нет + Collection friends1 = userService.getFriends(user1.getId()); + assertEquals(1, friends1.size()); + assertEquals(user2.getId(), friends1.iterator().next().getId()); + assertTrue(userService.getFriends(user2.getId()).isEmpty()); userService.removeFriend(user1.getId(), user2.getId()); - assertFalse(userService.findById(user1.getId()).getFriends().contains(user2.getId())); - assertFalse(userService.findById(user2.getId()).getFriends().contains(user1.getId())); + assertTrue(userService.getFriends(user1.getId()).isEmpty()); } @Test 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 new file mode 100644 index 0000000..002d03a --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java @@ -0,0 +1,167 @@ +package ru.yandex.practicum.filmorate.storage.film; + +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.Genre; +import ru.yandex.practicum.filmorate.model.Mpa; +import ru.yandex.practicum.filmorate.model.User; +import ru.yandex.practicum.filmorate.storage.mapper.FilmRowMapper; +import ru.yandex.practicum.filmorate.storage.mapper.GenreRowMapper; +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({FilmDbStorage.class, FilmRowMapper.class, GenreRowMapper.class, + UserDbStorage.class, UserRowMapper.class}) +class FilmDbStorageTest { + + private final FilmDbStorage filmStorage; + private final UserDbStorage userStorage; + + @Autowired + FilmDbStorageTest(FilmDbStorage filmStorage, UserDbStorage userStorage) { + this.filmStorage = filmStorage; + this.userStorage = userStorage; + } + + // Фильм без рейтинга и жанров: тесты добавляют их сами, где нужно + private Film newFilm(String name) { + Film film = new Film(); + film.setName(name); + film.setDescription("Описание"); + film.setReleaseDate(LocalDate.of(2000, 1, 1)); + film.setDuration(120); + return film; + } + + private Film createFilm(String name) { + return filmStorage.add(newFilm(name)); + } + + 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 static Mpa mpa(int id) { + Mpa mpa = new Mpa(); + mpa.setId(id); + return mpa; + } + + private static Genre genre(int id) { + Genre genre = new Genre(); + genre.setId(id); + return genre; + } + + private static List genreNames(Film film) { + return film.getGenres().stream().map(Genre::getName).toList(); + } + + @Test + void shouldAddFilmWithMpaAndGenres() { + Film film = newFilm("С жанрами"); + film.setMpa(mpa(1)); + // Дубль жанра 3 схлопнется в Set, а в базе жанры встанут по порядку id + film.getGenres().addAll(List.of(genre(3), genre(1), genre(3))); + + Film saved = filmStorage.add(film); + + Film found = filmStorage.findById(saved.getId()).orElseThrow(); + assertEquals("G", found.getMpa().getName()); + assertEquals(List.of("Комедия", "Мультфильм"), genreNames(found)); + } + + @Test + void shouldAddFilmWithoutMpaAndGenres() { + Film film = createFilm("Без рейтинга"); + + Film found = filmStorage.findById(film.getId()).orElseThrow(); + assertNull(found.getMpa()); + assertTrue(found.getGenres().isEmpty()); + } + + @Test + void shouldReturnEmptyWhenFilmNotFound() { + assertTrue(filmStorage.findById(9999).isEmpty()); + } + + @Test + void shouldFindAllFilms() { + createFilm("Первый"); + createFilm("Второй"); + + assertEquals(2, filmStorage.findAll().size()); + } + + @Test + void shouldUpdateFilmAndReplaceGenres() { + Film film = createFilm("Черновик"); + film.getGenres().add(genre(1)); + filmStorage.update(film); + + film.setName("Чистовик"); + film.getGenres().clear(); + film.getGenres().add(genre(4)); + filmStorage.update(film); + + Film found = filmStorage.findById(film.getId()).orElseThrow(); + assertEquals("Чистовик", found.getName()); + assertEquals(List.of("Триллер"), genreNames(found)); + } + + @Test + void shouldDeleteFilm() { + Film film = createFilm("Лишний"); + + filmStorage.delete(film.getId()); + + assertTrue(filmStorage.findById(film.getId()).isEmpty()); + } + + @Test + void shouldAddAndRemoveLike() { + Film film = createFilm("Любимый"); + User user = createUser("anna"); + + filmStorage.addLike(film.getId(), user.getId()); + + assertTrue(filmStorage.removeLike(film.getId(), user.getId())); // лайк был + assertFalse(filmStorage.removeLike(film.getId(), user.getId())); // удалять уже нечего + } + + @Test + void shouldFindPopularByLikes() { + Film first = createFilm("Первый"); + Film second = createFilm("Второй"); + Film third = createFilm("Третий"); + User anna = createUser("anna"); + User boris = createUser("boris"); + // У второго два лайка, у первого один, у третьего ни одного + filmStorage.addLike(second.getId(), anna.getId()); + filmStorage.addLike(second.getId(), boris.getId()); + filmStorage.addLike(first.getId(), anna.getId()); + + List popular = filmStorage.findPopular(10); + + assertEquals(List.of(second.getId(), first.getId(), third.getId()), + popular.stream().map(Film::getId).toList()); + assertEquals(2, filmStorage.findPopular(2).size()); + } +} \ No newline at end of file diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/genre/GenreDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/genre/GenreDbStorageTest.java new file mode 100644 index 0000000..872ec75 --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/genre/GenreDbStorageTest.java @@ -0,0 +1,50 @@ +package ru.yandex.practicum.filmorate.storage.genre; + +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.Genre; +import ru.yandex.practicum.filmorate.storage.mapper.GenreRowMapper; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +// Поднимается только база и JdbcTemplate, хранилище и маппер подключаем сами +@JdbcTest +@AutoConfigureTestDatabase +@Import({GenreDbStorage.class, GenreRowMapper.class}) +class GenreDbStorageTest { + + private final GenreDbStorage genreStorage; + + @Autowired + GenreDbStorageTest(GenreDbStorage genreStorage) { + this.genreStorage = genreStorage; + } + + @Test + void shouldFindAllGenresInIdOrder() { + List genres = genreStorage.findAll(); + + assertEquals(6, genres.size()); + assertEquals("Комедия", genres.get(0).getName()); + assertEquals("Боевик", genres.get(5).getName()); + } + + @Test + void shouldFindGenreById() { + Optional genre = genreStorage.findById(2); + + assertTrue(genre.isPresent()); + assertEquals("Драма", genre.get().getName()); + } + + @Test + void shouldReturnEmptyWhenGenreNotFound() { + assertTrue(genreStorage.findById(9999).isEmpty()); + } +} \ No newline at end of file diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/mpa/MpaDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/mpa/MpaDbStorageTest.java new file mode 100644 index 0000000..fa457d5 --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/mpa/MpaDbStorageTest.java @@ -0,0 +1,49 @@ +package ru.yandex.practicum.filmorate.storage.mpa; + +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.Mpa; +import ru.yandex.practicum.filmorate.storage.mapper.MpaRowMapper; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +// Поднимается только база и JdbcTemplate, хранилище и маппер подключаем сами +@JdbcTest +@AutoConfigureTestDatabase +@Import({MpaDbStorage.class, MpaRowMapper.class}) +class MpaDbStorageTest { + private final MpaDbStorage mpaStorage; + + @Autowired + MpaDbStorageTest(MpaDbStorage mpaStorage) { + this.mpaStorage = mpaStorage; + } + + @Test + void shouldFindAllRatingsInIdOrder() { + List ratings = mpaStorage.findAll(); + + assertEquals(5, ratings.size()); + assertEquals("G", ratings.get(0).getName()); + assertEquals("NC-17", ratings.get(4).getName()); + } + + @Test + void shouldFindMpaById() { + Optional mpa = mpaStorage.findById(3); + + assertTrue(mpa.isPresent()); + assertEquals("PG-13", mpa.get().getName()); + } + + @Test + void shouldReturnEmptyWhenMpaNotFound() { + assertTrue(mpaStorage.findById(9999).isEmpty()); + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..288802c --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorageTest.java @@ -0,0 +1,115 @@ +package ru.yandex.practicum.filmorate.storage.user; + +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.User; +import ru.yandex.practicum.filmorate.storage.mapper.UserRowMapper; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +// Поднимается только база и JdbcTemplate, хранилище и маппер подключаем сами +@JdbcTest +@AutoConfigureTestDatabase +@Import({UserDbStorage.class, UserRowMapper.class}) +class UserDbStorageTest { + + private final UserDbStorage userStorage; + + @Autowired + UserDbStorageTest(UserDbStorage userStorage) { + this.userStorage = userStorage; + } + + // Пользователей в тестовой базе нет: создаём сами, id берём из того, что вернул add + 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); + } + + @Test + void shouldAddUserAndFindById() { + User user = createUser("anna"); + + assertNotNull(user.getId()); + // @Data сравнивает объекты по всем полям — одна строка проверяет всё + assertEquals(user, userStorage.findById(user.getId()).orElseThrow()); + } + + @Test + void shouldReturnEmptyWhenUserNotFound() { + assertTrue(userStorage.findById(9999).isEmpty()); + } + + @Test + void shouldFindAllUsers() { + createUser("anna"); + createUser("boris"); + + assertEquals(2, userStorage.findAll().size()); + } + + @Test + void shouldUpdateUser() { + User user = createUser("anna"); + user.setName("Анна Каренина"); + user.setEmail("karenina@mail.ru"); + + userStorage.update(user); + + User updated = userStorage.findById(user.getId()).orElseThrow(); + assertEquals("Анна Каренина", updated.getName()); + assertEquals("karenina@mail.ru", updated.getEmail()); + } + + @Test + void shouldDeleteUser() { + User user = createUser("anna"); + + userStorage.delete(user.getId()); + + assertTrue(userStorage.findById(user.getId()).isEmpty()); + } + + @Test + void shouldAddFriendOneWay() { + User anna = createUser("anna"); + User boris = createUser("boris"); + + userStorage.addFriend(anna.getId(), boris.getId()); + + assertEquals(List.of(boris), userStorage.findFriends(anna.getId())); + assertTrue(userStorage.findFriends(boris.getId()).isEmpty()); + } + + @Test + void shouldRemoveFriend() { + User anna = createUser("anna"); + User boris = createUser("boris"); + userStorage.addFriend(anna.getId(), boris.getId()); + + userStorage.removeFriend(anna.getId(), boris.getId()); + + assertTrue(userStorage.findFriends(anna.getId()).isEmpty()); + } + + @Test + void shouldFindCommonFriends() { + User anna = createUser("anna"); + User boris = createUser("boris"); + User common = createUser("common"); + userStorage.addFriend(anna.getId(), common.getId()); + userStorage.addFriend(boris.getId(), common.getId()); + + assertEquals(List.of(common), userStorage.findCommonFriends(anna.getId(), boris.getId())); + } +} \ No newline at end of file