Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3939f47
build: add H2 and JDBC starter, configure datasource and H2 console
ksupyl Sep 22, 2026
8375684
feat: add database schema for films, users, genres and mpa
ksupyl Sep 22, 2026
f6e1bb0
feat: add initial data for genres and mpa ratings
ksupyl Sep 23, 2026
a1b3a99
feat: add Genre and Mpa models, extend Film with mpa and genres
ksupyl Sep 23, 2026
7a96083
feat: add genre and mpa repositories, services and controllers
ksupyl Sep 23, 2026
ec5ecd4
feat: add UserDbStorage and switch user storage to database
ksupyl Sep 23, 2026
cf1afe9
feat: store friendship in database and make it one-directional
ksupyl Sep 24, 2026
2230ad3
feat: add FilmDbStorage and switch film storage to database
ksupyl Sep 24, 2026
3921abb
feat: store film genres in database
ksupyl Sep 24, 2026
2cc2aaa
feat: store film likes in database
ksupyl Sep 24, 2026
1e38261
feat: return 404 for unknown film mpa and genres
ksupyl Sep 24, 2026
272c640
test: add storage tests for mpa, genres and users
ksupyl Sep 24, 2026
475ab3a
test: add film storage tests
ksupyl Sep 24, 2026
cf6ad8b
docs: describe database stage in README
ksupyl Sep 24, 2026
4befde7
test: use in-memory database in context test
ksupyl Sep 24, 2026
da6e16a
docs: clarify friendship confirmation and project summary
ksupyl Sep 24, 2026
1441df3
perf: load genres only for requested films
ksupyl Sep 24, 2026
e17058f
refactor: add storage interfaces for genres and mpa
ksupyl Sep 24, 2026
f1371e7
docs: add ER diagram and query examples to README
ksupyl Sep 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 167 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,38 +1,43 @@
# java-filmorate

Учебный backend-проект на Java и Spring Boot: сервис для работы с фильмами,
оценками пользователей и возврата топ фильмов, рекомендованных к просмотру.
оценками пользователей и топом популярных фильмов.

Проект развивается поэтапно. На текущем этапе реализованы слоистая архитектура
(controller → service → storage), социальные функции (друзья, лайки)
и полноценный REST API.
Проект развивается поэтапно. На текущем этапе данные хранятся в реляционной базе H2,
доступ к ним — через `JdbcTemplate`, у фильмов появились жанры и возрастной рейтинг MPA.

## Автор
**Ксения Пылькина** *(Ksenia Pylkina)*

## Ключевые возможности на данном этапе

- 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`).

## Технологии

- **Java 21**
- **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** (контроль стиля кода)

Expand All @@ -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-эндпоинты

Expand All @@ -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`; подтверждённая (взаимная) дружба — две встречные строки |

У таблиц-связок составной первичный ключ, поэтому одна и та же пара не запишется дважды.
При удалении фильма или пользователя связанные с ним записи удаляются каскадно.

<details>
<summary><strong>Примеры запросов</strong></summary>

Топ-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;
```

</details>

## Реализация по этапам

<details>
Expand All @@ -89,7 +218,7 @@

</details>

<details open>
<details>
<summary><strong>Этап 2 — Слоистая архитектура, социальные функции, полный REST</strong></summary>

На этом этапе логика вынесена из контроллеров, добавлены друзья и лайки,
Expand All @@ -107,4 +236,29 @@ API доведён до соответствия REST.
- Подключён Logbook для логирования HTTP-запросов и ответов.
- Unit-тесты обновлены под новую архитектуру.

</details>

<details open>
<summary><strong>Этап 3 — База данных H2, DAO, жанры и рейтинги</strong></summary>

На этом этапе данные переехали из памяти в базу данных,
у фильмов появились жанры и возрастной рейтинг 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-тесты сервисов обновлены.

</details>
11 changes: 11 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Genre> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<Mpa> 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);
}
}
6 changes: 6 additions & 0 deletions src/main/java/ru/yandex/practicum/filmorate/model/Film.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -8,6 +9,7 @@

import java.time.LocalDate;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;

/**
Expand All @@ -30,6 +32,10 @@ public class Film {
@Positive(message = "Продолжительность фильма должна быть положительной")
private Integer duration;

private Mpa mpa;
private Set<Genre> genres = new LinkedHashSet<>();

@JsonIgnore
private Set<Long> likes = new HashSet<>();

public void addLike(Long userId) {
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/ru/yandex/practicum/filmorate/model/Genre.java
Original file line number Diff line number Diff line change
@@ -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;
}
11 changes: 11 additions & 0 deletions src/main/java/ru/yandex/practicum/filmorate/model/Mpa.java
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading