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