diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..183ee28 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +FROM maven:3.9.11-amazoncorretto-21-alpine AS builder + +# Set working directory +WORKDIR /app + +COPY pom.xml ./ +COPY lombok.config ./ + +# Set up Maven local repository for caching +ENV MAVEN_OPTS="-Dmaven.repo.local=/app/.m2/repository" + +# Download dependencies (caching layer) +RUN mvn dependency:go-offline -B + +# Copy source code +COPY src ./src + +# Build the application (skip tests for faster build) +RUN mvn clean package -DskipTests -Dcheckstyle.skip=true + +# Stage 2: Layers stage +FROM amazoncorretto:21.0.8-alpine AS layers +WORKDIR /app +COPY --from=builder /app/target/*.jar app.jar +# Extract layers from the JAR for better caching in runtime +RUN java -Djarmode=layertools -jar app.jar extract + +# Stage 3: Runtime stage +FROM amazoncorretto:21.0.8-alpine + +# Set non-root user for security +RUN addgroup -g 1001 appgroup && adduser -u 1001 -G appgroup -s /bin/sh -D appuser +USER appuser + +# Set working directory +WORKDIR /app + +# Copy extracted layers from builder stage +COPY --from=layers /app/dependencies/ ./ +COPY --from=layers /app/spring-boot-loader/ ./ +COPY --from=layers /app/snapshot-dependencies/ ./ +COPY --from=layers /app/application/ ./ + +# Expose the application port +EXPOSE 8080 + +# Entry point to run the application +ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"] diff --git a/README.md b/README.md index 36857da..50a7f64 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,46 @@ Repository for filmorate - social network for film rating. Written in java. +# Contributors +Search - [Alexander-skipper](https://github.com/Alexander-skipper)
+Feed - [n20va](https://github.com/n20va)
+Recommendations - [Basementdoor](https://github.com/basementdoor)
+Reviews, top popular films by year and genre - [Nikolay Aleksandrov](https://github.com/Alextrusk27)
+ # DB schema ![db schema](assets/readme/db_schema.svg) +# Tables description +This section provides a detailed description of each table in the database schema. + +* Events +This table logs user activities such as likes, reviews, and friendships, serving as an audit or history trail for operations. +* Friendships +This table stores user-to-user friendship relationships. +* Users +This table holds user profile information for personalization. +* Likes +This table records users' likes on films, for recommendation systems or popularity tracking. +* Film Reviews +This table links users and films to their reviews, acting as a junction for many-to-many relationships. +* Reviews +This table stores the content and metadata of user reviews. +* Review Likes +This table tracks likes on reviews, similar to upvoting for helpfulness. +* Films +This core table contains movie details for the platform's catalog. +* Film Directors +This junction table associates films with their directors. +* Directors +This table lists film directors. +* MPA Ratings +This table defines Motion Picture Association ratings (e.g., G, PG, R). +* Film Genres +This junction table links films to genres. +* Genres +This table categorizes film genres (e.g., Action, Drama). + ## Examples of database queries ### Get all films @@ -86,5 +122,5 @@ WHERE u.user_id IN (SELECT user_id2 SELECT user_id2 FROM friendships - WHERE user_id1 = 2 + WHERE user_id1 = 2) ``` diff --git a/assets/readme/db_schema.svg b/assets/readme/db_schema.svg index b08713a..cce2b77 100644 --- a/assets/readme/db_schema.svg +++ b/assets/readme/db_schema.svg @@ -1,4 +1,4 @@ -1*1*1*1*1*1*1*1*1**1*1*1111**1usersuser_idBIGINTemailVARCHAR(80)loginVARCHAR(20)nameVARCHAR(20)birthdayDATEeventstimestampTIMESTAMPuser_idBIGINTevent_typeENUM(LIKE,REVIEW,FRIEND)operationENUM(REMOVE,ADD,UPDATE)event_idBIGINTentity_idBIGINTgenresgenre_idBIGINTnameVARCHAR(64)mpa_ratingsmpa_idBIGINTnameVARCHAR(20)directorsdirector_idBIGINTnameVARCHAR(120)filmsfilm_idBIGINTnameVARCHAR(120)descriptionVARCHAR(200)release_dateDATEduration_in_minutesINTEGERmpa_idBIGINTfilm_directorsfilm_idBIGINTdirector_idBIGINTreviewsreview_idBIGINTcontentVARCHAR(500)is_positiveBOOLEANuseful_ratingBIGINTfilm_reviewsuser_idBIGINTfilm_idBIGINTreview_idBIGINTreview_likesreview_idBIGINTuser_idBIGINTis_positiveBOOLEANfilm_genresfilm_idBIGINTgenre_idBIGINTlikesuser_idBIGINTfilm_idBIGINTfriendshipsuser_id1BIGINTuser_id2BIGINT \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..086972f --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,13 @@ +services: + filmorate: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + volumes: + - maven-repo:/app/.m2/repository + - ./db:/app/db + +volumes: + maven-repo: {} diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/DirectorController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/DirectorController.java new file mode 100644 index 0000000..1cb9e1e --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/DirectorController.java @@ -0,0 +1,55 @@ +package ru.yandex.practicum.filmorate.controller; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.filmorate.dto.director.DirectorDto; +import ru.yandex.practicum.filmorate.dto.director.NewDirectorRequest; +import ru.yandex.practicum.filmorate.dto.director.UpdateDirectorRequest; +import ru.yandex.practicum.filmorate.service.director.DirectorService; + +import java.util.Collection; + +@RestController +@RequestMapping("/directors") +@Slf4j +@RequiredArgsConstructor +@Validated +public class DirectorController { + private final DirectorService directorService; + + @GetMapping + public Collection findAll() { + log.trace("Collection of all directors requested"); + return directorService.findAll(); + } + + @GetMapping("/{id}") + public DirectorDto findById(@PathVariable @Positive long id) { + log.trace("Find director by id requested, id: {}", id); + return directorService.findById(id); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public DirectorDto create(@RequestBody @Valid NewDirectorRequest request) { + log.trace("Create new director requested: {}", request); + return directorService.create(request); + } + + @PutMapping + public DirectorDto update(@RequestBody @Valid UpdateDirectorRequest request) { + log.trace("Update director requested: {}", request); + return directorService.update(request); + } + + @DeleteMapping("/{id}") + public void deleteById(@PathVariable @Positive long id) { + log.trace("Delete director by id requested, id: {}", id); + directorService.deleteById(id); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/FeedController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/FeedController.java new file mode 100644 index 0000000..b5fad4b --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/FeedController.java @@ -0,0 +1,23 @@ +package ru.yandex.practicum.filmorate.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.filmorate.dto.event.EventDto; +import ru.yandex.practicum.filmorate.service.feed.FeedService; + +import java.util.List; + +@RestController +@RequestMapping("/users") +@Slf4j +@RequiredArgsConstructor +public class FeedController { + private final FeedService feedService; + + @GetMapping("/{id}/feed") + public List getUserFeed(@PathVariable long id) { + log.info("GET /users/{}/feed", id); + return feedService.getUserFeed(id); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java index 3852fc6..d82a7e3 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java @@ -1,6 +1,7 @@ package ru.yandex.practicum.filmorate.controller; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -10,9 +11,10 @@ import ru.yandex.practicum.filmorate.dto.film.FilmDto; import ru.yandex.practicum.filmorate.dto.film.NewFilmRequest; import ru.yandex.practicum.filmorate.dto.film.UpdateFilmRequest; -import ru.yandex.practicum.filmorate.service.FilmService; +import ru.yandex.practicum.filmorate.service.film.FilmService; import java.util.Collection; +import java.util.List; @RestController @RequestMapping("/films") @@ -42,36 +44,65 @@ public void deleteById(@PathVariable @Positive long filmId) { @PostMapping @ResponseStatus(HttpStatus.CREATED) - @Validated public FilmDto create(@RequestBody @Valid NewFilmRequest request) { log.trace("Create new film requested {}", request); return filmService.create(request); } @PutMapping - @Validated public FilmDto update(@RequestBody @Valid UpdateFilmRequest request) { log.trace("Update film requested {}", request); return filmService.update(request); } @PutMapping("/{id}/like/{userId}") - public void addLike(@PathVariable @Positive long id, - @PathVariable @Positive long userId) { + public void addLike(@PathVariable long id, + @PathVariable long userId) { log.trace("Add like requested film id: {}, user id: {}", id, userId); filmService.addLike(id, userId); } @DeleteMapping("/{id}/like/{userId}") - public void deleteLike(@PathVariable @Positive long id, - @PathVariable @Positive long userId) { + public void deleteLike(@PathVariable long id, + @PathVariable long userId) { log.trace("Delete like requested film id: {}, user id: {}", id, userId); filmService.removeLike(id, userId); } + @GetMapping("/search") + public Collection searchFilms(@RequestParam @NotBlank String query, + @RequestParam(defaultValue = "title") String by) { + log.trace("Search films requested with query: {}, by: {}", query, by); + + List parsedInput = SearchFilmsBy.parseStrOrThrow(by, () -> + new IllegalArgumentException("Invalid search films requested: " + by)); + + return filmService.searchFilms(query, parsedInput); + } + + @GetMapping("/common") + public Collection findCommonFilms(@RequestParam(name = "userId") @Positive long userId, + @RequestParam(name = "friendId") @Positive long friendId) { + log.trace("Find common films requested for user {} and friend {}", userId, friendId); + return filmService.findCommonFilms(userId, friendId); + } + @GetMapping("/popular") - public Collection findPopular(@RequestParam(defaultValue = "10") @Positive int count) { - log.trace("Find popular film requested with count: {}", count); - return filmService.findFilmsWithTopLikes(count); + public Collection findPopular(@RequestParam(defaultValue = "10") @Positive int count, + @RequestParam(required = false) @Positive Integer genreId, + @RequestParam(required = false) @Positive Integer year) { + log.trace("Find popular film requested with count: {} by genre: {} for year: {}", count, genreId, year); + return filmService.findFilmsWithTopLikes(count, genreId, year); + } + + @GetMapping("/director/{directorId}") + public Collection findFilmsOfDirector(@PathVariable @Positive long directorId, + @RequestParam String sortBy) { + + FilmsSortBy sortFilmsBy = FilmsSortBy.fromString(sortBy).orElseThrow( + () -> new IllegalArgumentException("invalid sort by: %s".formatted(sortBy))); + + log.trace("Find films of director with id {} requested", directorId); + return filmService.findFilmsOfDirector(directorId, sortFilmsBy); } } diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/FilmsSortBy.java b/src/main/java/ru/yandex/practicum/filmorate/controller/FilmsSortBy.java new file mode 100644 index 0000000..dc2fda2 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/FilmsSortBy.java @@ -0,0 +1,16 @@ +package ru.yandex.practicum.filmorate.controller; + +import java.util.Optional; + +public enum FilmsSortBy { + YEAR, + LIKES; + + public static Optional fromString(String sortBy) { + return switch (sortBy.toLowerCase()) { + case "year" -> Optional.of(YEAR); + case "likes" -> Optional.of(LIKES); + default -> Optional.empty(); + }; + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/GenreController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/GenreController.java index 0151f21..15f0ad9 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/GenreController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/GenreController.java @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import ru.yandex.practicum.filmorate.dto.genre.GenreDto; -import ru.yandex.practicum.filmorate.service.GenreService; +import ru.yandex.practicum.filmorate.service.genre.GenreService; import java.util.Collection; diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/MPARatingController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/MPARatingController.java index 3eee3ce..236b665 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/MPARatingController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/MPARatingController.java @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import ru.yandex.practicum.filmorate.dto.mparating.MPARatingDto; -import ru.yandex.practicum.filmorate.service.MPARatingService; +import ru.yandex.practicum.filmorate.service.mparating.MPARatingService; import java.util.Collection; diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/ReviewController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/ReviewController.java new file mode 100644 index 0000000..a081a78 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/ReviewController.java @@ -0,0 +1,85 @@ +package ru.yandex.practicum.filmorate.controller; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.filmorate.dto.review.NewReviewRequest; +import ru.yandex.practicum.filmorate.dto.review.ReviewDto; +import ru.yandex.practicum.filmorate.dto.review.UpdateReviewRequest; +import ru.yandex.practicum.filmorate.model.Reaction; +import ru.yandex.practicum.filmorate.service.review.ReviewService; + +import java.util.Collection; + +@RestController +@RequestMapping("/reviews") +@Slf4j +@Validated +@RequiredArgsConstructor +public class ReviewController { + private final ReviewService reviewService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ReviewDto create(@Valid @RequestBody NewReviewRequest request) { + log.trace("Create review request: {}", request); + return reviewService.create(request); + } + + @PutMapping + public ReviewDto update(@Valid @RequestBody UpdateReviewRequest request) { + log.trace("Update review request: {}", request); + return reviewService.update(request); + } + + @DeleteMapping("/{id}") + public void delete(@PathVariable @Positive long id) { + log.trace("Delete review request by reviewId: {}", id); + reviewService.delete(id); + } + + @GetMapping("/{id}") + public ReviewDto findById(@PathVariable @Positive long id) { + log.trace("Find review by reviewId: {}", id); + return reviewService.findById(id); + } + + @GetMapping + public Collection findAllByFilm(@RequestParam(required = false) @Positive Long filmId, + @RequestParam(defaultValue = "10") @Positive long count) { + log.trace("Find all reviews by film Id: {}, count: {}", filmId, count); + return reviewService.findAllByFilm(filmId, count); + } + + @PutMapping("/{id}/like/{userId}") + public void setLike(@PathVariable @Positive long id, + @PathVariable @Positive long userId) { + log.trace("Set review like by reviewId: {}, from userId: {}", id, userId); + reviewService.setLike(id, userId); + } + + @PutMapping("/{id}/dislike/{userId}") + public void setDislike(@PathVariable @Positive long id, + @PathVariable @Positive long userId) { + log.trace("Set review dislike by reviewId: {}, from userId: {}", id, userId); + reviewService.setDislike(id, userId); + } + + @DeleteMapping("/{id}/like/{userId}") + public void removeLike(@PathVariable @Positive long id, + @PathVariable @Positive long userId) { + log.trace("Remove review like by reviewId: {}, from userId: {}", id, userId); + reviewService.removeReaction(id, userId, Reaction.LIKE); + } + + @DeleteMapping("/{id}/dislike/{userId}") + public void removeDislike(@PathVariable @Positive long id, + @PathVariable @Positive long userId) { + log.trace("Remove review dislike by reviewId: {}, from userId: {}", id, userId); + reviewService.removeReaction(id, userId, Reaction.DISLIKE); + } +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/SearchFilmsBy.java b/src/main/java/ru/yandex/practicum/filmorate/controller/SearchFilmsBy.java new file mode 100644 index 0000000..99c1434 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/SearchFilmsBy.java @@ -0,0 +1,43 @@ +package ru.yandex.practicum.filmorate.controller; + +import lombok.Getter; + +import java.awt.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Supplier; + +@Getter +public enum SearchFilmsBy { + TITLE("title"), + DIRECTOR("director"); + + private final String value; + + SearchFilmsBy(String value) { + this.value = value; + } + + public static List parseStrOrThrow(String value, + Supplier exceptionSupplier) throws T { + String[] splittedStr = value.trim().toLowerCase().split(","); + List parsedList = new ArrayList<>(); + + for (String str : splittedStr) { + parsedList.add(SearchFilmsBy.fromString(str) + .orElseThrow(exceptionSupplier)); + } + + return parsedList; + } + + public static Optional fromString(String value) { + return switch (value) { + case "title" -> Optional.of(TITLE); + case "director" -> Optional.of(DIRECTOR); + default -> Optional.empty(); + }; + } + +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java index 4e8063f..a4ca3c7 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java @@ -7,10 +7,12 @@ import org.springframework.http.HttpStatus; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.filmorate.dto.film.FilmDto; import ru.yandex.practicum.filmorate.dto.user.NewUserRequest; import ru.yandex.practicum.filmorate.dto.user.UpdateUserRequest; import ru.yandex.practicum.filmorate.dto.user.UserDto; -import ru.yandex.practicum.filmorate.service.UserService; +import ru.yandex.practicum.filmorate.service.recommendation.RecommendationService; +import ru.yandex.practicum.filmorate.service.user.UserService; import java.util.Collection; @@ -22,6 +24,7 @@ @Validated public class UserController { private final UserService userService; + private final RecommendationService recommendationService; @GetMapping public Collection findAll() { @@ -43,22 +46,20 @@ public void deleteById(@PathVariable @Positive long userId) { @PostMapping @ResponseStatus(HttpStatus.CREATED) - @Validated public UserDto create(@RequestBody @Valid NewUserRequest user) { log.trace("create new user requested {}", user); return userService.create(user); } @PutMapping - @Validated public UserDto update(@RequestBody @Valid UpdateUserRequest user) { log.trace("Update user requested {}", user); return userService.update(user); } @PutMapping("/{id}/friends/{friendId}") - public void addFriend(@PathVariable @Positive long id, - @PathVariable @Positive long friendId) { + public void addFriend(@PathVariable long id, + @PathVariable long friendId) { log.trace("Add friend requested for id {}, friend id: {}", id, friendId); userService.addFriend(id, friendId); } @@ -82,4 +83,10 @@ public Collection findCommonFriends(@PathVariable @Positive long id, log.trace("Find common friends requested for id {}, other id: {}", id, otherId); return userService.findAllCommonFriends(id, otherId); } + + @GetMapping("/{id}/recommendations") + public Collection findFilmRecommendations(@PathVariable @Positive long id) { + log.trace("Find film recommendations requested for id {}", id); + return recommendationService.findFilmRecommendations(id); + } } diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/handler/ExceptionApiHandler.java b/src/main/java/ru/yandex/practicum/filmorate/controller/handler/ExceptionApiHandler.java index 63381ab..553c3d6 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/handler/ExceptionApiHandler.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/handler/ExceptionApiHandler.java @@ -3,6 +3,7 @@ import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; @@ -27,6 +28,20 @@ public ErrorResponse handleException(Exception ex) { "An error occurred while processing request"); } + @ExceptionHandler(IllegalArgumentException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleIllegalArgumentException(IllegalArgumentException e) { + log.warn(e.getMessage()); + return new ErrorResponse("Bad request", e.getMessage()); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.warn(e.getMessage()); + return new ErrorResponse("bad request", "Request body is not readable"); + } + @ExceptionHandler(ConstraintViolationException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ValidationErrorResponse onConstraintValidationException( diff --git a/src/main/java/ru/yandex/practicum/filmorate/dto/director/DirectorDto.java b/src/main/java/ru/yandex/practicum/filmorate/dto/director/DirectorDto.java new file mode 100644 index 0000000..d62ab7c --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/dto/director/DirectorDto.java @@ -0,0 +1,7 @@ +package ru.yandex.practicum.filmorate.dto.director; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record DirectorDto(Long id, String name) { +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/dto/director/NewDirectorRequest.java b/src/main/java/ru/yandex/practicum/filmorate/dto/director/NewDirectorRequest.java new file mode 100644 index 0000000..8f118b8 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/dto/director/NewDirectorRequest.java @@ -0,0 +1,13 @@ +package ru.yandex.practicum.filmorate.dto.director; + +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.Data; +import lombok.experimental.FieldDefaults; + +@Data +@FieldDefaults(level = AccessLevel.PRIVATE) +public class NewDirectorRequest { + @NotBlank + String name; +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/dto/director/UpdateDirectorRequest.java b/src/main/java/ru/yandex/practicum/filmorate/dto/director/UpdateDirectorRequest.java new file mode 100644 index 0000000..f3602cb --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/dto/director/UpdateDirectorRequest.java @@ -0,0 +1,18 @@ +package ru.yandex.practicum.filmorate.dto.director; + +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Data; +import lombok.experimental.FieldDefaults; + +@Data +@FieldDefaults(level = AccessLevel.PRIVATE) +public class UpdateDirectorRequest { + @NotNull + Long id; + String name; + + public boolean hasName() { + return name != null && !name.isEmpty(); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/dto/error/ErrorResponse.java b/src/main/java/ru/yandex/practicum/filmorate/dto/error/ErrorResponse.java index 9d2d3b4..35c4cfa 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/dto/error/ErrorResponse.java +++ b/src/main/java/ru/yandex/practicum/filmorate/dto/error/ErrorResponse.java @@ -1,4 +1,4 @@ package ru.yandex.practicum.filmorate.dto.error; -public record ErrorResponse(String errorName, String description) { +public record ErrorResponse(String error, String description) { } diff --git a/src/main/java/ru/yandex/practicum/filmorate/dto/event/EventDto.java b/src/main/java/ru/yandex/practicum/filmorate/dto/event/EventDto.java new file mode 100644 index 0000000..306c8f6 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/dto/event/EventDto.java @@ -0,0 +1,13 @@ +package ru.yandex.practicum.filmorate.dto.event; + +import ru.yandex.practicum.filmorate.model.EventType; +import ru.yandex.practicum.filmorate.model.Operation; + +public record EventDto(Long timestamp, + Long userId, + EventType eventType, + Operation operation, + Long eventId, + Long entityId) { + +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/dto/film/FilmDto.java b/src/main/java/ru/yandex/practicum/filmorate/dto/film/FilmDto.java index f9ffcc8..58753ed 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/dto/film/FilmDto.java +++ b/src/main/java/ru/yandex/practicum/filmorate/dto/film/FilmDto.java @@ -1,26 +1,19 @@ package ru.yandex.practicum.filmorate.dto.film; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Data; -import lombok.Singular; -import lombok.experimental.FieldDefaults; +import ru.yandex.practicum.filmorate.dto.director.DirectorDto; import ru.yandex.practicum.filmorate.dto.genre.GenreDto; import ru.yandex.practicum.filmorate.dto.mparating.MPARatingDto; import java.time.LocalDate; import java.util.List; -@Data -@Builder -@FieldDefaults(level = AccessLevel.PRIVATE) -public class FilmDto { - Long id; - String name; - String description; - LocalDate releaseDate; - Integer duration; - MPARatingDto mpa; - @Singular - List genres; +public record FilmDto( + Long id, + String name, + String description, + LocalDate releaseDate, + Integer duration, + MPARatingDto mpa, + List genres, + List directors) { } diff --git a/src/main/java/ru/yandex/practicum/filmorate/dto/film/NewFilmRequest.java b/src/main/java/ru/yandex/practicum/filmorate/dto/film/NewFilmRequest.java index fa9f827..b256069 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/dto/film/NewFilmRequest.java +++ b/src/main/java/ru/yandex/practicum/filmorate/dto/film/NewFilmRequest.java @@ -9,6 +9,7 @@ import lombok.AccessLevel; import lombok.Data; import lombok.experimental.FieldDefaults; +import ru.yandex.practicum.filmorate.dto.director.DirectorDto; import ru.yandex.practicum.filmorate.dto.genre.GenreDto; import ru.yandex.practicum.filmorate.dto.mparating.MPARatingDto; import ru.yandex.practicum.filmorate.validation.AfterDate; @@ -34,4 +35,6 @@ public class NewFilmRequest { MPARatingDto mpa; @JsonSetter(nulls = Nulls.AS_EMPTY) List genres = new ArrayList<>(); + @JsonSetter(nulls = Nulls.AS_EMPTY) + List directors = new ArrayList<>(); } diff --git a/src/main/java/ru/yandex/practicum/filmorate/dto/film/UpdateFilmRequest.java b/src/main/java/ru/yandex/practicum/filmorate/dto/film/UpdateFilmRequest.java index 90a5902..2f7c0d7 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/dto/film/UpdateFilmRequest.java +++ b/src/main/java/ru/yandex/practicum/filmorate/dto/film/UpdateFilmRequest.java @@ -1,16 +1,20 @@ package ru.yandex.practicum.filmorate.dto.film; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import lombok.AccessLevel; import lombok.Data; import lombok.experimental.FieldDefaults; +import ru.yandex.practicum.filmorate.dto.director.DirectorDto; import ru.yandex.practicum.filmorate.dto.genre.GenreDto; import ru.yandex.practicum.filmorate.dto.mparating.MPARatingDto; import ru.yandex.practicum.filmorate.validation.AfterDate; import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; @Data @@ -27,6 +31,8 @@ public class UpdateFilmRequest { Integer duration; MPARatingDto mpa; List genres; + @JsonSetter(nulls = Nulls.AS_EMPTY) + List directors = new ArrayList<>(); public boolean hasName() { return name != null && !name.isEmpty(); @@ -45,10 +51,14 @@ public boolean hasDuration() { } public boolean hasGenres() { - return genres != null && !genres.isEmpty(); + return genres != null; } public boolean hasMpa() { return mpa != null && mpa.id() != null; } + + public boolean hasDirectors() { + return directors != null; + } } diff --git a/src/main/java/ru/yandex/practicum/filmorate/dto/review/NewReviewRequest.java b/src/main/java/ru/yandex/practicum/filmorate/dto/review/NewReviewRequest.java new file mode 100644 index 0000000..5115277 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/dto/review/NewReviewRequest.java @@ -0,0 +1,25 @@ +package ru.yandex.practicum.filmorate.dto.review; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Data; +import lombok.experimental.FieldDefaults; + +@Data +@FieldDefaults(level = AccessLevel.PRIVATE) +public class NewReviewRequest { + @NotBlank(message = "Text must not be null or empty") + @Size(message = "Text length must be less than 500", max = 500) + String content; + + @NotNull(message = "Rating must not be null") + Boolean isPositive; + + @NotNull(message = "User ID must be present") + Long userId; + + @NotNull(message = "Film ID must be present") + Long filmId; +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/dto/review/ReviewDto.java b/src/main/java/ru/yandex/practicum/filmorate/dto/review/ReviewDto.java new file mode 100644 index 0000000..62c89a2 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/dto/review/ReviewDto.java @@ -0,0 +1,10 @@ +package ru.yandex.practicum.filmorate.dto.review; + +public record ReviewDto( + Long reviewId, + String content, + Boolean isPositive, + Long userId, + Long filmId, + Long useful) { +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/dto/review/UpdateReviewRequest.java b/src/main/java/ru/yandex/practicum/filmorate/dto/review/UpdateReviewRequest.java new file mode 100644 index 0000000..e4e0ada --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/dto/review/UpdateReviewRequest.java @@ -0,0 +1,29 @@ +package ru.yandex.practicum.filmorate.dto.review; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Data; +import lombok.experimental.FieldDefaults; + +@Data +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +public class UpdateReviewRequest { + @NotNull + Long reviewId; + + @Size(message = "Text length must be less than 500", max = 500) + String content; + + Boolean isPositive; + + public boolean hasContent() { + return this.content != null && !this.content.isEmpty(); + } + + public boolean hasIsPositive() { + return this.isPositive != null; + } +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/dto/user/UserDto.java b/src/main/java/ru/yandex/practicum/filmorate/dto/user/UserDto.java index 531d172..edd64ff 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/dto/user/UserDto.java +++ b/src/main/java/ru/yandex/practicum/filmorate/dto/user/UserDto.java @@ -1,21 +1,11 @@ package ru.yandex.practicum.filmorate.dto.user; -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Data; -import lombok.experimental.FieldDefaults; - import java.time.LocalDate; -@Data -@Builder -@FieldDefaults(level = AccessLevel.PRIVATE) -public class UserDto { - @JsonProperty(access = JsonProperty.Access.READ_ONLY) - long id; - String email; - String login; - String name; - LocalDate birthday; +public record UserDto( + long id, + String email, + String login, + String name, + LocalDate birthday) { } diff --git a/src/main/java/ru/yandex/practicum/filmorate/mapper/DirectorMapper.java b/src/main/java/ru/yandex/practicum/filmorate/mapper/DirectorMapper.java new file mode 100644 index 0000000..df423f6 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/mapper/DirectorMapper.java @@ -0,0 +1,36 @@ +package ru.yandex.practicum.filmorate.mapper; + +import lombok.experimental.UtilityClass; +import ru.yandex.practicum.filmorate.dto.director.DirectorDto; +import ru.yandex.practicum.filmorate.dto.director.NewDirectorRequest; +import ru.yandex.practicum.filmorate.dto.director.UpdateDirectorRequest; +import ru.yandex.practicum.filmorate.model.Director; + +@UtilityClass +public class DirectorMapper { + + public DirectorDto toDirectorDto(Director director) { + return new DirectorDto(director.getId(), director.getName()); + } + + public Director toDirector(NewDirectorRequest directorDto) { + return Director.builder() + .name(directorDto.getName()) + .build(); + } + + public Director toDirector(DirectorDto directorDto) { + return Director.builder() + .name(directorDto.name()) + .id(directorDto.id()) + .build(); + } + + public Director updateDirectorFields(Director director, UpdateDirectorRequest request) { + if (request.hasName()) { + director.setName(request.getName()); + } + + return director; + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/mapper/EventMapper.java b/src/main/java/ru/yandex/practicum/filmorate/mapper/EventMapper.java new file mode 100644 index 0000000..d02e99e --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/mapper/EventMapper.java @@ -0,0 +1,19 @@ +package ru.yandex.practicum.filmorate.mapper; + +import lombok.experimental.UtilityClass; +import ru.yandex.practicum.filmorate.dto.event.EventDto; +import ru.yandex.practicum.filmorate.model.Event; + +@UtilityClass +public class EventMapper { + + public EventDto toEventDto(Event event) { + return new EventDto( + event.getTimestamp(), + event.getUserId(), + event.getEventType(), + event.getOperation(), + event.getEventId(), + event.getEntityId()); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/mapper/FilmMapper.java b/src/main/java/ru/yandex/practicum/filmorate/mapper/FilmMapper.java index 3be6adc..3a048bb 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/mapper/FilmMapper.java +++ b/src/main/java/ru/yandex/practicum/filmorate/mapper/FilmMapper.java @@ -4,27 +4,26 @@ import ru.yandex.practicum.filmorate.dto.film.FilmDto; import ru.yandex.practicum.filmorate.dto.film.NewFilmRequest; import ru.yandex.practicum.filmorate.dto.film.UpdateFilmRequest; -import ru.yandex.practicum.filmorate.dto.genre.GenreDto; import ru.yandex.practicum.filmorate.model.Film; -import ru.yandex.practicum.filmorate.model.Genre; - -import java.util.stream.Collectors; @UtilityClass public class FilmMapper { public FilmDto toFilmDto(Film film) { - return FilmDto.builder() - .id(film.getId()) - .description(film.getDescription()) - .name(film.getName()) - .releaseDate(film.getReleaseDate()) - .duration(film.getDuration()) - .mpa(MPARatingMapper.toMPARatingDto(film.getMpaRating())) - .genres(film.getGenres() + return new FilmDto( + film.getId(), + film.getName(), + film.getDescription(), + film.getReleaseDate(), + film.getDuration(), + MPARatingMapper.toMPARatingDto(film.getMpaRating()), + film.getGenres() .stream() - .map(genre -> new GenreDto(genre.getId(), genre.getName())) - .toList()) - .build(); + .map(GenreMapper::toGenreDto) + .toList(), + film.getDirectors() + .stream() + .map(DirectorMapper::toDirectorDto) + .toList()); } public Film toFilm(NewFilmRequest request) { @@ -36,11 +35,14 @@ public Film toFilm(NewFilmRequest request) { .mpaRating(MPARatingMapper.toMPARating(request.getMpa())) .genres(request.getGenres() .stream() - .map(genreDto -> Genre.builder() - .id(genreDto.id()) - .name(genreDto.name()) - .build()) - .collect(Collectors.toSet())) + .map(GenreMapper::toGenre) + .distinct() + .toList()) + .directors(request.getDirectors() + .stream() + .map(DirectorMapper::toDirector) + .distinct() + .toList()) .build(); } @@ -64,13 +66,21 @@ public Film updateFilmFields(Film film, UpdateFilmRequest request) { if (request.hasGenres()) { film.setGenres(request.getGenres().stream() .map(GenreMapper::toGenre) - .collect(Collectors.toSet())); + .distinct() + .toList()); } if (request.hasMpa()) { film.setMpaRating(MPARatingMapper.toMPARating(request.getMpa())); } + if (request.hasDirectors()) { + film.setDirectors(request.getDirectors().stream() + .map(DirectorMapper::toDirector) + .distinct() + .toList()); + } + return film; } } diff --git a/src/main/java/ru/yandex/practicum/filmorate/mapper/ReviewMapper.java b/src/main/java/ru/yandex/practicum/filmorate/mapper/ReviewMapper.java new file mode 100644 index 0000000..1911f92 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/mapper/ReviewMapper.java @@ -0,0 +1,39 @@ +package ru.yandex.practicum.filmorate.mapper; + +import lombok.experimental.UtilityClass; +import ru.yandex.practicum.filmorate.dto.review.NewReviewRequest; +import ru.yandex.practicum.filmorate.dto.review.ReviewDto; +import ru.yandex.practicum.filmorate.dto.review.UpdateReviewRequest; +import ru.yandex.practicum.filmorate.model.Review; + +@UtilityClass +public class ReviewMapper { + public ReviewDto toDto(Review review) { + return new ReviewDto( + review.getId(), + review.getContent(), + review.getIsPositive(), + review.getUserId(), + review.getFilmId(), + review.getUsefulRating()); + } + + public Review toReview(NewReviewRequest request) { + return Review.builder() + .content(request.getContent()) + .isPositive(request.getIsPositive()) + .userId(request.getUserId()) + .filmId(request.getFilmId()) + .build(); + } + + public Review updateReviewFields(Review review, UpdateReviewRequest request) { + if (request.hasContent()) { + review.setContent(request.getContent()); + } + if (request.hasIsPositive()) { + review.setIsPositive(request.getIsPositive()); + } + return review; + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/mapper/UserMapper.java b/src/main/java/ru/yandex/practicum/filmorate/mapper/UserMapper.java index 099ad58..cc1391a 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/mapper/UserMapper.java +++ b/src/main/java/ru/yandex/practicum/filmorate/mapper/UserMapper.java @@ -10,13 +10,12 @@ @UtilityClass public class UserMapper { public UserDto toUserDto(User user) { - return UserDto.builder() - .id(user.getId()) - .email(user.getEmail()) - .login(user.getLogin()) - .name(user.getName()) - .birthday(user.getBirthday()) - .build(); + return new UserDto( + user.getId(), + user.getEmail(), + user.getLogin(), + user.getName(), + user.getBirthday()); } public User mapToUser(NewUserRequest request) { diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Director.java b/src/main/java/ru/yandex/practicum/filmorate/model/Director.java new file mode 100644 index 0000000..d38e6bf --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Director.java @@ -0,0 +1,14 @@ +package ru.yandex.practicum.filmorate.model; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Data; +import lombok.experimental.FieldDefaults; + +@Data +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +public class Director { + Long id; + String name; +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Event.java b/src/main/java/ru/yandex/practicum/filmorate/model/Event.java new file mode 100644 index 0000000..29dad68 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Event.java @@ -0,0 +1,18 @@ +package ru.yandex.practicum.filmorate.model; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Data; +import lombok.experimental.FieldDefaults; + +@Data +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +public class Event { + Long eventId; + Long timestamp; + Long userId; + EventType eventType; + Operation operation; + Long entityId; +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/EventType.java b/src/main/java/ru/yandex/practicum/filmorate/model/EventType.java new file mode 100644 index 0000000..508d573 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/EventType.java @@ -0,0 +1,5 @@ +package ru.yandex.practicum.filmorate.model; + +public enum EventType { + LIKE, REVIEW, FRIEND +} 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 684c52c..82e0a3c 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/model/Film.java +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Film.java @@ -6,8 +6,8 @@ import lombok.experimental.FieldDefaults; import java.time.LocalDate; -import java.util.HashSet; -import java.util.Set; +import java.util.ArrayList; +import java.util.List; @Data @Builder @@ -20,11 +20,7 @@ public class Film { Integer duration; MPARating mpaRating; @Builder.Default - Set genres = new HashSet<>(); + List genres = new ArrayList<>(); @Builder.Default - Set usersWhoLiked = new HashSet<>(); - - public void addGenre(Genre genre) { - this.genres.add(genre); - } + List directors = new ArrayList<>(); } diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Operation.java b/src/main/java/ru/yandex/practicum/filmorate/model/Operation.java new file mode 100644 index 0000000..74ef03b --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Operation.java @@ -0,0 +1,5 @@ +package ru.yandex.practicum.filmorate.model; + +public enum Operation { + REMOVE, ADD, UPDATE +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Reaction.java b/src/main/java/ru/yandex/practicum/filmorate/model/Reaction.java new file mode 100644 index 0000000..a7b7603 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Reaction.java @@ -0,0 +1,5 @@ +package ru.yandex.practicum.filmorate.model; + +public enum Reaction { + LIKE, DISLIKE +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Review.java b/src/main/java/ru/yandex/practicum/filmorate/model/Review.java new file mode 100644 index 0000000..983b06d --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Review.java @@ -0,0 +1,18 @@ +package ru.yandex.practicum.filmorate.model; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Data; +import lombok.experimental.FieldDefaults; + +@Data +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +public class Review { + Long id; + String content; + Boolean isPositive; + Long userId; + Long filmId; + Long usefulRating; +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/repository/director/DirectorRepository.java b/src/main/java/ru/yandex/practicum/filmorate/repository/director/DirectorRepository.java new file mode 100644 index 0000000..b26d76d --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/repository/director/DirectorRepository.java @@ -0,0 +1,21 @@ +package ru.yandex.practicum.filmorate.repository.director; + +import ru.yandex.practicum.filmorate.model.Director; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface DirectorRepository { + Collection findAll(); + + Optional findById(long id); + + Director create(Director director); + + void update(Director director); + + void deleteById(long id); + + List getByIds(List directorIds); +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/repository/director/DirectorRowMapper.java b/src/main/java/ru/yandex/practicum/filmorate/repository/director/DirectorRowMapper.java new file mode 100644 index 0000000..b7f45b5 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/repository/director/DirectorRowMapper.java @@ -0,0 +1,19 @@ +package ru.yandex.practicum.filmorate.repository.director; + +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; +import ru.yandex.practicum.filmorate.model.Director; + +import java.sql.ResultSet; +import java.sql.SQLException; + +@Component +public class DirectorRowMapper implements RowMapper { + @Override + public Director mapRow(ResultSet rs, int rowNum) throws SQLException { + return Director.builder() + .id(rs.getLong("director_id")) + .name(rs.getString("name")) + .build(); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/repository/director/JdbcDirectorRepository.java b/src/main/java/ru/yandex/practicum/filmorate/repository/director/JdbcDirectorRepository.java new file mode 100644 index 0000000..122404f --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/repository/director/JdbcDirectorRepository.java @@ -0,0 +1,81 @@ +package ru.yandex.practicum.filmorate.repository.director; + +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.stereotype.Repository; +import ru.yandex.practicum.filmorate.model.Director; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class JdbcDirectorRepository implements DirectorRepository { + private final NamedParameterJdbcTemplate jdbc; + private final RowMapper rowMapper; + + @Override + public Collection findAll() { + String selectAllDirectorsSql = "SELECT * FROM directors ORDER BY director_id"; + return jdbc.query(selectAllDirectorsSql, rowMapper); + } + + @Override + public Optional findById(long id) { + String selectDirectorByIdSql = "SELECT * FROM directors WHERE director_id = :id"; + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("id", id); + + List directors = jdbc.query(selectDirectorByIdSql, params, rowMapper); + + return directors.isEmpty() ? Optional.empty() : Optional.of(directors.getFirst()); + } + + @Override + public Director create(Director director) { + String saveDirectorSql = """ + INSERT INTO directors (name) + VALUES (:name)"""; + GeneratedKeyHolder keyHolder = new GeneratedKeyHolder(); + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("name", director.getName()); + + jdbc.update(saveDirectorSql, params, keyHolder, new String[]{"director_id"}); + + director.setId(keyHolder.getKeyAs(Long.class)); + return director; + } + + @Override + public void update(Director director) { + String updateDirectorSql = """ + UPDATE directors + SET name = :name + WHERE director_id = :id"""; + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("name", director.getName()) + .addValue("id", director.getId()); + jdbc.update(updateDirectorSql, params); + } + + @Override + public void deleteById(long id) { + String deleteDirectorByIdSql = "DELETE FROM directors WHERE director_id = :id"; + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("id", id); + + jdbc.update(deleteDirectorByIdSql, params); + } + + @Override + public List getByIds(List directorIds) { + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("list", directorIds); + String selectAllDirectorsByIdsSql = "SELECT * FROM directors WHERE director_id IN (:list)"; + return jdbc.query(selectAllDirectorsByIdsSql, params, rowMapper); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/repository/feed/EventRowMapper.java b/src/main/java/ru/yandex/practicum/filmorate/repository/feed/EventRowMapper.java new file mode 100644 index 0000000..68994d6 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/repository/feed/EventRowMapper.java @@ -0,0 +1,25 @@ +package ru.yandex.practicum.filmorate.repository.feed; + +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; +import ru.yandex.practicum.filmorate.model.Event; +import ru.yandex.practicum.filmorate.model.EventType; +import ru.yandex.practicum.filmorate.model.Operation; + +import java.sql.ResultSet; +import java.sql.SQLException; + +@Component +public class EventRowMapper implements RowMapper { + @Override + public Event mapRow(ResultSet rs, int rowNum) throws SQLException { + return Event.builder() + .eventId(rs.getLong("event_id")) + .timestamp(rs.getLong("created_at")) + .userId(rs.getLong("user_id")) + .eventType(EventType.valueOf(rs.getString("event_type"))) + .operation(Operation.valueOf(rs.getString("operation"))) + .entityId(rs.getLong("entity_id")) + .build(); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/repository/feed/FeedRepository.java b/src/main/java/ru/yandex/practicum/filmorate/repository/feed/FeedRepository.java new file mode 100644 index 0000000..7645f79 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/repository/feed/FeedRepository.java @@ -0,0 +1,10 @@ +package ru.yandex.practicum.filmorate.repository.feed; + +import ru.yandex.practicum.filmorate.model.Event; +import java.util.List; + +public interface FeedRepository { + List findByUserId(long userId); + + Event save(Event event); +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/repository/feed/JdbcFeedRepository.java b/src/main/java/ru/yandex/practicum/filmorate/repository/feed/JdbcFeedRepository.java new file mode 100644 index 0000000..cffbeda --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/repository/feed/JdbcFeedRepository.java @@ -0,0 +1,43 @@ +package ru.yandex.practicum.filmorate.repository.feed; + +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.stereotype.Repository; +import ru.yandex.practicum.filmorate.model.Event; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class JdbcFeedRepository implements FeedRepository { + private final NamedParameterJdbcOperations jdbc; + private final EventRowMapper eventRowMapper; + + @Override + public List findByUserId(long userId) { + String sql = "SELECT * FROM feed_events WHERE user_id = :user_id ORDER BY created_at ASC"; + return jdbc.query(sql, + new MapSqlParameterSource("user_id", userId), + eventRowMapper); + } + + @Override + public Event save(Event event) { + GeneratedKeyHolder keyHolder = new GeneratedKeyHolder(); + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("timestamp", event.getTimestamp()) + .addValue("user_id", event.getUserId()) + .addValue("event_type", event.getEventType().toString()) + .addValue("operation", event.getOperation().toString()) + .addValue("entity_id", event.getEntityId()); + String sql = """ + INSERT INTO feed_events (created_at, user_id, event_type, operation, entity_id) + VALUES (:timestamp, :user_id, :event_type, :operation, :entity_id) + """; + jdbc.update(sql, params, keyHolder, new String[]{"event_id"}); + event.setEventId(keyHolder.getKeyAs(Long.class)); + return event; + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/repository/film/FilmRepository.java b/src/main/java/ru/yandex/practicum/filmorate/repository/film/FilmRepository.java index 3317862..32949af 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/repository/film/FilmRepository.java +++ b/src/main/java/ru/yandex/practicum/filmorate/repository/film/FilmRepository.java @@ -1,8 +1,11 @@ package ru.yandex.practicum.filmorate.repository.film; +import ru.yandex.practicum.filmorate.controller.FilmsSortBy; +import ru.yandex.practicum.filmorate.controller.SearchFilmsBy; import ru.yandex.practicum.filmorate.model.Film; import java.util.Collection; +import java.util.List; import java.util.Optional; public interface FilmRepository { @@ -16,5 +19,13 @@ public interface FilmRepository { void deleteById(long id); - Collection findTopPopularFilms(int count); + Collection findTopPopularFilms(int count, Integer genreId, Integer year); + + Collection findCommonFilms(long userId, long friendId); + + Collection findFilmsOfDirector(long directorId, FilmsSortBy sortFilmsBy); + + Collection findFilmRecommendations(long userId, List similarUserIds); + + Collection searchFilms(String query, List searchBy); } diff --git a/src/main/java/ru/yandex/practicum/filmorate/repository/film/FilmResultSetExtractor.java b/src/main/java/ru/yandex/practicum/filmorate/repository/film/FilmResultSetExtractor.java deleted file mode 100644 index 7b11ad9..0000000 --- a/src/main/java/ru/yandex/practicum/filmorate/repository/film/FilmResultSetExtractor.java +++ /dev/null @@ -1,66 +0,0 @@ -package ru.yandex.practicum.filmorate.repository.film; - -import org.springframework.jdbc.core.ResultSetExtractor; -import org.springframework.stereotype.Component; -import ru.yandex.practicum.filmorate.model.Film; -import ru.yandex.practicum.filmorate.model.Genre; -import ru.yandex.practicum.filmorate.model.MPARating; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; - -@Component -public class FilmResultSetExtractor implements ResultSetExtractor> { - - @Override - public List extractData(ResultSet rs) throws SQLException { - List films = new ArrayList<>(); - Film currentFilm = null; - Long previousFilmId = null; - - while (rs.next()) { - Long currentFilmId = rs.getLong("film_id"); - - if (!currentFilmId.equals(previousFilmId)) { - if (currentFilm != null) { - films.add(currentFilm); - } - - MPARating mpaRating = MPARating.builder() - .id(rs.getLong("mpa_id")) - .name(rs.getString("mpa_name")) - .build(); - - currentFilm = Film.builder() - .id(currentFilmId) - .name(rs.getString("name")) - .description(rs.getString("description")) - .releaseDate(rs.getObject("release_date", LocalDate.class)) - .duration(rs.getInt("duration_in_minutes")) - .mpaRating(mpaRating) - .build(); - - previousFilmId = currentFilmId; - } - - - - Genre genre = Genre.builder() - .id(rs.getLong("genre_id")) - .name(rs.getString("genre_name")) - .build(); - if (!rs.wasNull()) { - currentFilm.addGenre(genre); - } - } - - if (currentFilm != null) { - films.add(currentFilm); - } - - return films; - } -} diff --git a/src/main/java/ru/yandex/practicum/filmorate/repository/film/FilmRowMapper.java b/src/main/java/ru/yandex/practicum/filmorate/repository/film/FilmRowMapper.java index 5707b4a..c2bac65 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/repository/film/FilmRowMapper.java +++ b/src/main/java/ru/yandex/practicum/filmorate/repository/film/FilmRowMapper.java @@ -2,12 +2,17 @@ import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Component; +import ru.yandex.practicum.filmorate.model.Director; import ru.yandex.practicum.filmorate.model.Film; +import ru.yandex.practicum.filmorate.model.Genre; import ru.yandex.practicum.filmorate.model.MPARating; import java.sql.ResultSet; import java.sql.SQLException; import java.time.LocalDate; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; @Component public class FilmRowMapper implements RowMapper { @@ -17,6 +22,39 @@ public Film mapRow(ResultSet rs, int rowNum) throws SQLException { .id(rs.getLong("mpa_id")) .name(rs.getString("mpa_name")) .build(); + + String genresString = rs.getString("genres"); + List genres; + if (genresString != null && !genresString.isEmpty() && !genresString.startsWith(":")) { + genres = Arrays.stream(genresString.split(";")) + .map(genreIdNameStr -> { + String[] splitIdNameStr = genreIdNameStr.split(":"); + return Genre.builder() + .id(Long.valueOf(splitIdNameStr[0])) + .name(splitIdNameStr[1]) + .build(); + }) + .toList(); + } else { + genres = Collections.emptyList(); + } + + String directorsString = rs.getString("directors"); + List directors; + if (directorsString != null && !directorsString.isEmpty() && !directorsString.startsWith(":")) { + directors = Arrays.stream(directorsString.split(";")) + .map(directorIdNameStr -> { + String[] splitIdNameStr = directorIdNameStr.split(":"); + return Director.builder() + .id(Long.valueOf(splitIdNameStr[0])) + .name(splitIdNameStr[1]) + .build(); + }) + .toList(); + } else { + directors = Collections.emptyList(); + } + return Film.builder() .id(rs.getLong("film_id")) .name(rs.getString("name")) @@ -24,6 +62,8 @@ public Film mapRow(ResultSet rs, int rowNum) throws SQLException { .releaseDate(rs.getObject("release_date", LocalDate.class)) .duration(rs.getInt("duration_in_minutes")) .mpaRating(mpaRating) + .genres(genres) + .directors(directors) .build(); } } diff --git a/src/main/java/ru/yandex/practicum/filmorate/repository/film/JdbcFilmRepository.java b/src/main/java/ru/yandex/practicum/filmorate/repository/film/JdbcFilmRepository.java index 73b73d2..0f3b4d7 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/repository/film/JdbcFilmRepository.java +++ b/src/main/java/ru/yandex/practicum/filmorate/repository/film/JdbcFilmRepository.java @@ -1,94 +1,70 @@ package ru.yandex.practicum.filmorate.repository.film; import lombok.RequiredArgsConstructor; -import org.springframework.jdbc.core.ResultSetExtractor; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; import org.springframework.jdbc.core.namedparam.SqlParameterSource; import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.stereotype.Repository; +import ru.yandex.practicum.filmorate.controller.FilmsSortBy; +import ru.yandex.practicum.filmorate.controller.SearchFilmsBy; +import ru.yandex.practicum.filmorate.model.Director; import ru.yandex.practicum.filmorate.model.Film; import ru.yandex.practicum.filmorate.model.Genre; -import java.util.*; -import java.util.function.Function; -import java.util.stream.Collectors; - +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; @Repository @RequiredArgsConstructor public class JdbcFilmRepository implements FilmRepository { + private static final String BASE_SELECT_SQL = """ + SELECT f.film_id, + f.name, + f.description, + f.release_date, + f.duration_in_minutes, + f.mpa_id, + mr.name AS mpa_name, + (SELECT GROUP_CONCAT(DISTINCT CONCAT(g2.genre_id, ':', g2.name) ORDER BY g2.genre_id SEPARATOR ';') + FROM film_genres fg2 + JOIN genres g2 ON g2.genre_id = fg2.genre_id + WHERE fg2.film_id = f.film_id) AS genres, + (SELECT GROUP_CONCAT(DISTINCT CONCAT(d2.director_id, ':', d2.name) ORDER BY d2.director_id SEPARATOR ';') + FROM film_directors fd2 + JOIN directors d2 ON d2.director_id = fd2.director_id + WHERE fd2.film_id = f.film_id) AS directors + FROM films f + JOIN mpa_ratings mr ON f.mpa_id = mr.mpa_id + LEFT JOIN film_genres fg ON f.film_id = fg.film_id + LEFT JOIN genres g ON g.genre_id = fg.genre_id + LEFT JOIN film_directors fd on f.film_id = fd.film_id + LEFT JOIN directors d ON fd.director_id = d.director_id + """; private final NamedParameterJdbcOperations jdbc; - private final RowMapper genreRowMapper; private final RowMapper filmRowMapper; - private final ResultSetExtractor> filmResultSetExtractor; @Override public Optional findById(long id) { MapSqlParameterSource params = new MapSqlParameterSource() .addValue("id", id); - String selectFilmByIdSql = """ - SELECT f.film_id, - f.name, - f.description, - f.release_date, - f.duration_in_minutes, - f.mpa_id, - mr.name AS mpa_name, - g.genre_id, - g.name AS genre_name - FROM films f - JOIN mpa_ratings mr ON f.mpa_id = mr.mpa_id - LEFT JOIN film_genres fg ON f.film_id = fg.film_id - LEFT JOIN genres g ON g.genre_id = fg.genre_id - WHERE f.film_id = :id"""; + String selectFilmByIdSql = BASE_SELECT_SQL.concat(""" + WHERE f.film_id = :id + GROUP BY f.film_id"""); - List films = jdbc.query(selectFilmByIdSql, params, filmResultSetExtractor); + List films = jdbc.query(selectFilmByIdSql, params, filmRowMapper); return films.isEmpty() ? Optional.empty() : Optional.of(films.getFirst()); } @Override public Collection findAll() { - String filmsAndMpaSql = """ - SELECT f.film_id, - f.name, - f.description, - f.release_date, - f.duration_in_minutes, - f.mpa_id, - mr.name AS mpa_name - FROM films f - JOIN mpa_ratings mr ON f.mpa_id = mr.mpa_id"""; - Map films = jdbc.query(filmsAndMpaSql, filmRowMapper).stream() - .collect(Collectors.toMap( - Film::getId, - Function.identity() - )); - - String genresSql = "SELECT * FROM genres"; - Map genres = jdbc.query(genresSql, genreRowMapper).stream() - .collect(Collectors.toMap( - Genre::getId, - Function.identity() - )); - - String filmsGenresSql = """ - SELECT film_id, - genre_id - FROM film_genres"""; - List> filmsGenresList = jdbc.query(filmsGenresSql, - (rs, rowNum) -> Map.entry(rs.getLong("film_id"), - rs.getLong("genre_id")) - ); - - for (Map.Entry entry : filmsGenresList) { - films.get(entry.getKey()).addGenre(genres.get(entry.getValue())); - } - - return films.values(); + String findAllSql = BASE_SELECT_SQL.concat("GROUP BY f.film_id"); + return jdbc.query(findAllSql, filmRowMapper); } @Override @@ -104,6 +80,7 @@ INSERT INTO films(name, description, release_date, duration_in_minutes, mpa_id) film.setId(keyHolder.getKeyAs(Long.class)); saveGenres(film.getGenres(), film.getId()); + saveDirectors(film.getDirectors(), film.getId()); return film; } @@ -120,11 +97,16 @@ public void update(Film film) { String deleteFilmGenresSql = """ DELETE FROM film_genres WHERE film_id = :film_id"""; - MapSqlParameterSource deleteParams = new MapSqlParameterSource(); - deleteParams.addValue("film_id", film.getId()); - jdbc.update(deleteFilmGenresSql, deleteParams); - + MapSqlParameterSource filmIdParams = new MapSqlParameterSource() + .addValue("film_id", film.getId()); + jdbc.update(deleteFilmGenresSql, filmIdParams); saveGenres(film.getGenres(), film.getId()); + + String deleteFilmDirectorsSql = """ + DELETE FROM film_directors WHERE film_id = :film_id"""; + + jdbc.update(deleteFilmDirectorsSql, filmIdParams); + saveDirectors(film.getDirectors(), film.getId()); } @Override @@ -132,36 +114,117 @@ public void deleteById(long id) { MapSqlParameterSource params = new MapSqlParameterSource() .addValue("id", id); String deleteFilmByIdSql = "DELETE FROM films WHERE film_id = :id"; + jdbc.update(deleteFilmByIdSql, params); } @Override - public Collection findTopPopularFilms(int count) { - String selectTopFilmsSql = """ - SELECT f.film_id, - f.name, - f.description, - f.release_date, - f.duration_in_minutes, - f.mpa_id, - mr.name AS mpa_name, - g.genre_id, - g.name AS genre_name - FROM films f - JOIN mpa_ratings mr ON f.mpa_id = mr.mpa_id - LEFT JOIN film_genres fg ON f.film_id = fg.film_id - LEFT JOIN genres g ON g.genre_id = fg.genre_id + public Collection findTopPopularFilms(int count, Integer genreId, Integer year) { + String selectTopFilmsSql = BASE_SELECT_SQL.concat(""" + LEFT JOIN likes fl ON f.film_id = fl.film_id + WHERE (g.genre_id = :genreId OR :genreId IS NULL) + AND (YEAR(f.release_date) =:year OR :year IS NULL) + GROUP BY f.film_id + ORDER BY COUNT(DISTINCT fl.user_id) DESC, f.film_id + LIMIT :count"""); + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("count", count) + .addValue("genreId", genreId) + .addValue("year", year); + + return jdbc.query(selectTopFilmsSql, params, filmRowMapper); + } + + @Override + public Collection findCommonFilms(long userId, long friendId) { + String selectCommonFilmsSql = BASE_SELECT_SQL.concat(""" + LEFT JOIN likes fl ON f.film_id = fl.film_id + WHERE f.film_id in (SELECT l1.film_id + FROM likes l1 + WHERE l1.user_id = :user_id + INTERSECT + SELECT l2.film_id + FROM likes l2 + WHERE l2.user_id = :friend_id) + GROUP BY f.film_id, g.genre_id + ORDER BY COUNT(DISTINCT fl.user_id) DESC, f.film_id + """); + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("user_id", userId) + .addValue("friend_id", friendId); + + return jdbc.query(selectCommonFilmsSql, params, filmRowMapper); + } + + @Override + public Collection findFilmsOfDirector(long directorId, FilmsSortBy sortFilmsBy) { + String sortBySql = switch (sortFilmsBy) { + case YEAR -> "EXTRACT(YEAR from f.release_date)"; + case LIKES -> "COUNT(DISTINCT fl.user_id) DESC"; + }; + + String selectFilmsOfDirectorSortedSql = BASE_SELECT_SQL.concat(""" LEFT JOIN likes fl ON f.film_id = fl.film_id - GROUP BY f.film_id, f.name, g.genre_id - ORDER BY COUNT(fl.user_id) DESC, f.film_id - LIMIT :count"""; + WHERE d.director_id = :director_id + GROUP BY f.film_id, g.genre_id, f.release_date + ORDER BY %s""".formatted(sortBySql) + ); MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("count", count); + .addValue("director_id", directorId); + return jdbc.query(selectFilmsOfDirectorSortedSql, params, filmRowMapper); + } - return jdbc.query(selectTopFilmsSql, params, filmResultSetExtractor); + @Override + public Collection findFilmRecommendations(long userId, List similarUserIds) { + String sqlRecommendations = BASE_SELECT_SQL.concat(""" + LEFT JOIN likes l ON f.film_id = l.film_id + WHERE l.user_id IN (:similarUserIds) + AND f.film_id NOT IN ( + SELECT film_id FROM likes WHERE user_id = :userId + ) + GROUP BY f.film_id + ORDER BY COUNT(DISTINCT l.user_id) DESC + """); + + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("similarUserIds", similarUserIds) + .addValue("userId", userId); + + return jdbc.query(sqlRecommendations, params, filmRowMapper); } - private void saveGenres(Set genres, long filmId) { + @Override + public Collection searchFilms(String query, List searchBy) { + String searchParamName = "query"; + String searchFilmsSql = buildSearchSql(searchBy, searchParamName); + String searchPattern = "%%%s%%".formatted(query.toLowerCase()); + + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue(searchParamName, searchPattern); + + return jdbc.query(searchFilmsSql, params, filmRowMapper); + } + + private String buildSearchSql(List searchFilmsBy, String searchParamName) { + List conditions = new ArrayList<>(); + + for (SearchFilmsBy searchBy : searchFilmsBy) { + switch (searchBy) { + case TITLE -> conditions.add("LOWER(f.name) LIKE :%s".formatted(searchParamName)); + case DIRECTOR -> conditions.add("LOWER(d.name) LIKE :%s".formatted(searchParamName)); + } + } + + String whereClause = String.join(" OR ", conditions); + + return BASE_SELECT_SQL.concat(""" + WHERE (%s) + GROUP BY f.film_id + ORDER BY (SELECT COUNT(*) FROM likes l WHERE l.film_id = f.film_id) DESC, f.film_id""".formatted( + whereClause)); + } + + private void saveGenres(Collection genres, long filmId) { String insertGenresSql = """ INSERT INTO film_genres (film_id, genre_id) VALUES (:film_id, :genre_id)"""; @@ -176,6 +239,20 @@ INSERT INTO film_genres (film_id, genre_id) jdbc.batchUpdate(insertGenresSql, batchParams); } + private void saveDirectors(Collection directors, long filmId) { + String insertDirectorsSql = """ + INSERT INTO film_directors (film_id, director_id) + VALUES (:film_id, :director_id)"""; + SqlParameterSource[] batchParams = directors.stream() + .map(Director::getId) + .map(directorId -> new MapSqlParameterSource() + .addValue("film_id", filmId) + .addValue("director_id", directorId)) + .toArray(SqlParameterSource[]::new); + + jdbc.batchUpdate(insertDirectorsSql, batchParams); + } + private MapSqlParameterSource prepareParamMap(Film film) { return new MapSqlParameterSource() .addValue("name", film.getName()) diff --git a/src/main/java/ru/yandex/practicum/filmorate/repository/friendship/FriendshipsRepository.java b/src/main/java/ru/yandex/practicum/filmorate/repository/friendship/FriendshipsRepository.java index 8f58c07..0dfeaa1 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/repository/friendship/FriendshipsRepository.java +++ b/src/main/java/ru/yandex/practicum/filmorate/repository/friendship/FriendshipsRepository.java @@ -4,4 +4,6 @@ public interface FriendshipsRepository { void addFriendship(long userId, long friendId); void removeFriendship(long userId, long friendId); + + boolean isFriends(long userId, long friendId); } diff --git a/src/main/java/ru/yandex/practicum/filmorate/repository/friendship/JdbcFriendshipsRepository.java b/src/main/java/ru/yandex/practicum/filmorate/repository/friendship/JdbcFriendshipsRepository.java index 8fe0296..bf45d9c 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/repository/friendship/JdbcFriendshipsRepository.java +++ b/src/main/java/ru/yandex/practicum/filmorate/repository/friendship/JdbcFriendshipsRepository.java @@ -28,9 +28,23 @@ public void removeFriendship(long userId, long friendId) { jdbc.update(deleteFriendshipSql, params); } + @Override + public boolean isFriends(long userId, long friendId) { + String checkFriendshipSql = """ + SELECT EXISTS ( + SELECT 1 + FROM friendships + WHERE user_id1 = :user_id AND user_id2 = :friend_id)"""; + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("user_id", userId) + .addValue("friend_id", friendId); + + return Boolean.TRUE.equals(jdbc.queryForObject(checkFriendshipSql, params, Boolean.class)); + } + private MapSqlParameterSource getParameterMap(long userId, long friendId) { return new MapSqlParameterSource() - .addValue("user_id1", userId) - .addValue("user_id2", friendId); + .addValue("user_id1", userId) + .addValue("user_id2", friendId); } } diff --git a/src/main/java/ru/yandex/practicum/filmorate/repository/like/JdbcLikesRepository.java b/src/main/java/ru/yandex/practicum/filmorate/repository/like/JdbcLikesRepository.java index 85a9b6f..f309b94 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/repository/like/JdbcLikesRepository.java +++ b/src/main/java/ru/yandex/practicum/filmorate/repository/like/JdbcLikesRepository.java @@ -14,8 +14,10 @@ public class JdbcLikesRepository implements LikesRepository { public void addLike(long userId, long filmId) { MapSqlParameterSource params = getParameterMap(userId, filmId); String addLikeToFilmSql = """ - INSERT INTO likes (user_id, film_id) - VALUES (:user_id, :film_id)"""; + MERGE INTO likes (user_id, film_id) + KEY(user_id, film_id) + VALUES (:user_id, :film_id) + """; jdbc.update(addLikeToFilmSql, params); } diff --git a/src/main/java/ru/yandex/practicum/filmorate/repository/review/JdbcReviewRepository.java b/src/main/java/ru/yandex/practicum/filmorate/repository/review/JdbcReviewRepository.java new file mode 100644 index 0000000..4df544b --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/repository/review/JdbcReviewRepository.java @@ -0,0 +1,138 @@ +package ru.yandex.practicum.filmorate.repository.review; + +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.stereotype.Repository; +import ru.yandex.practicum.filmorate.model.Reaction; +import ru.yandex.practicum.filmorate.model.Review; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class JdbcReviewRepository implements ReviewRepository { + private static final String BASE_SELECT_REVIEW_SQL = """ + SELECT r.review_id, + r.content, + r.is_positive, + fr.film_id, + fr.user_id, + r.useful_rating + FROM reviews r + JOIN film_reviews fr ON r.review_id = fr.review_id + """; + private final NamedParameterJdbcOperations jdbc; + private final RowMapper mapper; + + + @Override + public Review save(Review review) { + GeneratedKeyHolder keyHolder = new GeneratedKeyHolder(); + MapSqlParameterSource params = getBaseParams(review); + jdbc.update(""" + INSERT INTO reviews(content, is_positive) + VALUES (:content, :is_Positive) + """, params, keyHolder, new String[]{"review_id"}); + params.addValue("film_id", review.getFilmId()); + params.addValue("user_id", review.getUserId()); + review.setId(keyHolder.getKeyAs(Long.class)); + params.addValue("review_id", review.getId()); + jdbc.update(""" + INSERT INTO film_reviews(film_id, user_id, review_id) + VALUES (:film_id, :user_id, :review_id) + """, params); + review.setUsefulRating(0L); + return review; + } + + @Override + public void update(Review review) { + MapSqlParameterSource params = getBaseParams(review) + .addValue("review_id", review.getId()); + jdbc.update(""" + UPDATE reviews + SET content = :content, is_positive = :is_Positive + WHERE review_id = :review_id + """, params); + } + + @Override + public void delete(long id) { + MapSqlParameterSource params = new MapSqlParameterSource("review_id", id); + jdbc.update(""" + DELETE FROM reviews + WHERE review_id = :review_id + """, params); + } + + @Override + public Optional findById(long id) { + MapSqlParameterSource params = new MapSqlParameterSource("review_id", id); + List list = jdbc.query(BASE_SELECT_REVIEW_SQL.concat(""" + WHERE r.review_id = :review_id + ORDER BY useful_rating DESC + """), params, mapper); + return list.isEmpty() ? Optional.empty() : Optional.of(list.getFirst()); + } + + @Override + public Collection findAllByFilm(Long filmId, long count) { + MapSqlParameterSource params = new MapSqlParameterSource("film_id", filmId) + .addValue("count", count); + return jdbc.query(BASE_SELECT_REVIEW_SQL.concat(""" + WHERE fr.film_id = COALESCE(:film_id, fr.film_id) + ORDER BY useful_rating DESC + LIMIT :count + """), params, mapper); + } + + @Override + public void setReaction(long id, long userId, Reaction reaction) { + MapSqlParameterSource params = new MapSqlParameterSource("review_id", id) + .addValue("user_id", userId); + switch (reaction) { + case LIKE -> params.addValue("is_positive", true); + case DISLIKE -> params.addValue("is_positive", false); + } + jdbc.update(""" + MERGE INTO review_likes (review_id, user_id, is_positive) + KEY (review_id, user_id) + VALUES (:review_id, :user_id, :is_positive) + """, params); + updateUsefulRating(params); + } + + @Override + public void removeReaction(long id, long userId) { + MapSqlParameterSource params = new MapSqlParameterSource("review_id", id) + .addValue("user_id", userId); + jdbc.update(""" + DELETE FROM review_likes + WHERE review_id = :review_id AND user_id = :user_id + """, params); + updateUsefulRating(params); + } + + private MapSqlParameterSource getBaseParams(Review review) { + return new MapSqlParameterSource() + .addValue("content", review.getContent()) + .addValue("is_Positive", review.getIsPositive()); + } + + private void updateUsefulRating(MapSqlParameterSource params) { + jdbc.update(""" + UPDATE reviews r + SET useful_rating = COALESCE(( + SELECT SUM(CASE WHEN is_positive THEN 1 ELSE -1 END) + FROM review_likes rl + WHERE rl.review_id = r.review_id), + 0) + WHERE r.review_id = :review_id; + """, params); + } +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/repository/review/ReviewRepository.java b/src/main/java/ru/yandex/practicum/filmorate/repository/review/ReviewRepository.java new file mode 100644 index 0000000..cf91540 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/repository/review/ReviewRepository.java @@ -0,0 +1,23 @@ +package ru.yandex.practicum.filmorate.repository.review; + +import ru.yandex.practicum.filmorate.model.Reaction; +import ru.yandex.practicum.filmorate.model.Review; + +import java.util.Collection; +import java.util.Optional; + +public interface ReviewRepository { + Review save(Review review); + + void update(Review review); + + void delete(long id); + + Optional findById(long id); + + Collection findAllByFilm(Long filmId, long count); + + void setReaction(long id, long userId, Reaction reaction); + + void removeReaction(long id, long userId); +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/repository/review/ReviewRowMapper.java b/src/main/java/ru/yandex/practicum/filmorate/repository/review/ReviewRowMapper.java new file mode 100644 index 0000000..870c76d --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/repository/review/ReviewRowMapper.java @@ -0,0 +1,23 @@ +package ru.yandex.practicum.filmorate.repository.review; + +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; +import ru.yandex.practicum.filmorate.model.Review; + +import java.sql.ResultSet; +import java.sql.SQLException; + +@Component +public class ReviewRowMapper implements RowMapper { + @Override + public Review mapRow(ResultSet rs, int rowNum) throws SQLException { + return Review.builder() + .id(rs.getLong("review_id")) + .content(rs.getString("content")) + .isPositive(rs.getBoolean("is_positive")) + .filmId(rs.getLong("film_id")) + .userId(rs.getLong("user_id")) + .usefulRating(rs.getLong("useful_rating")) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/repository/user/JdbcUserRepository.java b/src/main/java/ru/yandex/practicum/filmorate/repository/user/JdbcUserRepository.java index 6851f9b..79b02ee 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/repository/user/JdbcUserRepository.java +++ b/src/main/java/ru/yandex/practicum/filmorate/repository/user/JdbcUserRepository.java @@ -76,6 +76,7 @@ public void deleteById(long id) { MapSqlParameterSource params = new MapSqlParameterSource() .addValue("id", id); String deleteUserByIdSql = "DELETE FROM users WHERE user_id = :id"; + jdbc.update(deleteUserByIdSql, params); } @@ -113,4 +114,22 @@ WHERE u.user_id IN (SELECT user_id2 return jdbc.query(selectCommonFriendsSql, params, rowMapper); } + + public List findSimilarFilmTasteUsers(long userId) { + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("userId", userId); + + String sql = """ + SELECT l2.user_id AS other_user, + COUNT(*) AS common_like_count + FROM likes l1 + JOIN likes l2 ON l1.film_id = l2.film_id + WHERE l1.user_id = :userId + AND l2.user_id != :userId + GROUP BY l2.user_id + ORDER BY common_like_count DESC + LIMIT 5"""; + + return jdbc.query(sql, params, (rs, rowNum) -> rs.getLong("other_user")); + } } diff --git a/src/main/java/ru/yandex/practicum/filmorate/repository/user/UserRepository.java b/src/main/java/ru/yandex/practicum/filmorate/repository/user/UserRepository.java index e15ae2e..ed45ac7 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/repository/user/UserRepository.java +++ b/src/main/java/ru/yandex/practicum/filmorate/repository/user/UserRepository.java @@ -3,6 +3,7 @@ import ru.yandex.practicum.filmorate.model.User; import java.util.Collection; +import java.util.List; import java.util.Optional; public interface UserRepository { @@ -19,4 +20,6 @@ public interface UserRepository { Collection findAllFriends(long userId); Collection findAllCommonFriends(long userId1, long userId2); + + List findSimilarFilmTasteUsers(long userId); } diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java b/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java deleted file mode 100644 index f0a790f..0000000 --- a/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java +++ /dev/null @@ -1,25 +0,0 @@ -package ru.yandex.practicum.filmorate.service; - -import ru.yandex.practicum.filmorate.dto.film.FilmDto; -import ru.yandex.practicum.filmorate.dto.film.NewFilmRequest; -import ru.yandex.practicum.filmorate.dto.film.UpdateFilmRequest; - -import java.util.Collection; - -public interface FilmService { - Collection findAll(); - - FilmDto findById(long id); - - FilmDto create(NewFilmRequest request); - - FilmDto update(UpdateFilmRequest request); - - void deleteById(long id); - - void addLike(long filmId, long userId); - - void removeLike(long filmId, long userId); - - Collection findFilmsWithTopLikes(int count); -} diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/director/DirectorService.java b/src/main/java/ru/yandex/practicum/filmorate/service/director/DirectorService.java new file mode 100644 index 0000000..57be400 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/director/DirectorService.java @@ -0,0 +1,19 @@ +package ru.yandex.practicum.filmorate.service.director; + +import ru.yandex.practicum.filmorate.dto.director.DirectorDto; +import ru.yandex.practicum.filmorate.dto.director.NewDirectorRequest; +import ru.yandex.practicum.filmorate.dto.director.UpdateDirectorRequest; + +import java.util.Collection; + +public interface DirectorService { + Collection findAll(); + + DirectorDto findById(long id); + + DirectorDto create(NewDirectorRequest request); + + DirectorDto update(UpdateDirectorRequest request); + + void deleteById(long id); +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/director/DirectorServiceImpl.java b/src/main/java/ru/yandex/practicum/filmorate/service/director/DirectorServiceImpl.java new file mode 100644 index 0000000..c5df349 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/director/DirectorServiceImpl.java @@ -0,0 +1,61 @@ +package ru.yandex.practicum.filmorate.service.director; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import ru.yandex.practicum.filmorate.dto.director.DirectorDto; +import ru.yandex.practicum.filmorate.dto.director.NewDirectorRequest; +import ru.yandex.practicum.filmorate.dto.director.UpdateDirectorRequest; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.mapper.DirectorMapper; +import ru.yandex.practicum.filmorate.model.Director; +import ru.yandex.practicum.filmorate.repository.director.DirectorRepository; + +import java.util.Collection; + +@Service +@Slf4j +@RequiredArgsConstructor +public class DirectorServiceImpl implements DirectorService { + private final DirectorRepository directorRepository; + + @Override + public Collection findAll() { + return directorRepository.findAll() + .stream() + .map(DirectorMapper::toDirectorDto) + .toList(); + } + + @Override + public DirectorDto findById(long id) { + return directorRepository.findById(id) + .map(DirectorMapper::toDirectorDto) + .orElseThrow(NotFoundException.supplier("Director with id %d not found", id)); + } + + @Override + public DirectorDto create(NewDirectorRequest request) { + Director director = DirectorMapper.toDirector(request); + director = directorRepository.create(director); + log.info("Director with id {} created", director.getId()); + return DirectorMapper.toDirectorDto(director); + } + + @Override + public DirectorDto update(UpdateDirectorRequest request) { + Director director = directorRepository.findById(request.getId()).orElseThrow( + NotFoundException.supplier("Director with id %d not found", request.getId()) + ); + director = DirectorMapper.updateDirectorFields(director, request); + directorRepository.update(director); + log.info("Director with id {} updated", director.getId()); + return DirectorMapper.toDirectorDto(director); + } + + @Override + public void deleteById(long id) { + directorRepository.deleteById(id); + log.info("Director with id {} deleted", id); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/feed/FeedService.java b/src/main/java/ru/yandex/practicum/filmorate/service/feed/FeedService.java new file mode 100644 index 0000000..d9f9df3 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/feed/FeedService.java @@ -0,0 +1,13 @@ +package ru.yandex.practicum.filmorate.service.feed; + +import ru.yandex.practicum.filmorate.dto.event.EventDto; +import ru.yandex.practicum.filmorate.model.EventType; +import ru.yandex.practicum.filmorate.model.Operation; + +import java.util.List; + +public interface FeedService { + List getUserFeed(long userId); + + void addEvent(EventType eventType, Operation operation, long userId, long entityId); +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/feed/FeedServiceImpl.java b/src/main/java/ru/yandex/practicum/filmorate/service/feed/FeedServiceImpl.java new file mode 100644 index 0000000..f941602 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/feed/FeedServiceImpl.java @@ -0,0 +1,49 @@ +package ru.yandex.practicum.filmorate.service.feed; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import ru.yandex.practicum.filmorate.dto.event.EventDto; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.mapper.EventMapper; +import ru.yandex.practicum.filmorate.model.Event; +import ru.yandex.practicum.filmorate.model.EventType; +import ru.yandex.practicum.filmorate.model.Operation; +import ru.yandex.practicum.filmorate.repository.feed.FeedRepository; +import ru.yandex.practicum.filmorate.repository.user.UserRepository; + +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class FeedServiceImpl implements FeedService { + private final FeedRepository feedRepository; + private final UserRepository userRepository; + + @Override + public List getUserFeed(long userId) { + userRepository.findById(userId) + .orElseThrow(NotFoundException.supplier("User with id %d not found", userId)); + + List events = feedRepository.findByUserId(userId); + log.info("Retrieved {} events for user {}", events.size(), userId); + return events.stream() + .map(EventMapper::toEventDto) + .toList(); + } + + @Override + public void addEvent(EventType eventType, Operation operation, long userId, long entityId) { + Event event = Event.builder() + .timestamp(System.currentTimeMillis()) + .userId(userId) + .eventType(eventType) + .operation(operation) + .entityId(entityId) + .build(); + feedRepository.save(event); + log.debug("Event saved: type={}, operation={}, userId={}, entityId={}", + eventType, operation, userId, entityId); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/film/FilmService.java b/src/main/java/ru/yandex/practicum/filmorate/service/film/FilmService.java new file mode 100644 index 0000000..c175560 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/film/FilmService.java @@ -0,0 +1,34 @@ +package ru.yandex.practicum.filmorate.service.film; + +import ru.yandex.practicum.filmorate.controller.FilmsSortBy; +import ru.yandex.practicum.filmorate.controller.SearchFilmsBy; +import ru.yandex.practicum.filmorate.dto.film.FilmDto; +import ru.yandex.practicum.filmorate.dto.film.NewFilmRequest; +import ru.yandex.practicum.filmorate.dto.film.UpdateFilmRequest; + +import java.util.Collection; +import java.util.List; + +public interface FilmService { + Collection findAll(); + + FilmDto findById(long id); + + FilmDto create(NewFilmRequest request); + + FilmDto update(UpdateFilmRequest request); + + void deleteById(long id); + + void addLike(long filmId, long userId); + + void removeLike(long filmId, long userId); + + Collection findFilmsWithTopLikes(int count, Integer genreId, Integer year); + + Collection findCommonFilms(long userId, long friendId); + + Collection findFilmsOfDirector(long directorId, FilmsSortBy sortFilmsBy); + + Collection searchFilms(String query, List searchFilmsBy); +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/FilmServiceImpl.java b/src/main/java/ru/yandex/practicum/filmorate/service/film/FilmServiceImpl.java similarity index 63% rename from src/main/java/ru/yandex/practicum/filmorate/service/FilmServiceImpl.java rename to src/main/java/ru/yandex/practicum/filmorate/service/film/FilmServiceImpl.java index e03ab81..a1f17a0 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/FilmServiceImpl.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/film/FilmServiceImpl.java @@ -1,23 +1,27 @@ -package ru.yandex.practicum.filmorate.service; +package ru.yandex.practicum.filmorate.service.film; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import ru.yandex.practicum.filmorate.controller.FilmsSortBy; +import ru.yandex.practicum.filmorate.controller.SearchFilmsBy; +import ru.yandex.practicum.filmorate.dto.director.DirectorDto; import ru.yandex.practicum.filmorate.dto.film.FilmDto; import ru.yandex.practicum.filmorate.dto.film.NewFilmRequest; import ru.yandex.practicum.filmorate.dto.film.UpdateFilmRequest; import ru.yandex.practicum.filmorate.dto.genre.GenreDto; import ru.yandex.practicum.filmorate.exception.NotFoundException; import ru.yandex.practicum.filmorate.mapper.FilmMapper; -import ru.yandex.practicum.filmorate.model.Film; -import ru.yandex.practicum.filmorate.model.Genre; +import ru.yandex.practicum.filmorate.model.*; +import ru.yandex.practicum.filmorate.repository.director.DirectorRepository; import ru.yandex.practicum.filmorate.repository.film.FilmRepository; import ru.yandex.practicum.filmorate.repository.genre.GenreRepository; import ru.yandex.practicum.filmorate.repository.like.LikesRepository; import ru.yandex.practicum.filmorate.repository.mparating.MPARatingRepository; import ru.yandex.practicum.filmorate.repository.user.UserRepository; +import ru.yandex.practicum.filmorate.service.feed.FeedService; import java.util.Collection; import java.util.List; @@ -32,6 +36,8 @@ public class FilmServiceImpl implements FilmService { LikesRepository likesRepository; GenreRepository genreRepository; MPARatingRepository mpaRepository; + DirectorRepository directorRepository; + FeedService feedService; @Override public Collection findAll() { @@ -64,6 +70,17 @@ public FilmDto create(NewFilmRequest request) { throw new NotFoundException("No such genres found"); } + List directorIds = request.getDirectors().stream() + .distinct() + .map(DirectorDto::id) + .toList(); + + List directors = directorRepository.getByIds(directorIds); + + if (directorIds.size() != directors.size()) { + throw new NotFoundException("No such directors found"); + } + film = filmRepository.save(film); log.info("Film with id {} has been created", film.getId()); return FilmMapper.toFilmDto(film); @@ -90,6 +107,20 @@ public FilmDto update(UpdateFilmRequest request) { } } + if (request.hasDirectors()) { + + List directorIds = request.getDirectors().stream() + .distinct() + .map(DirectorDto::id) + .toList(); + + List directors = directorRepository.getByIds(directorIds); + + if (directorIds.size() != directors.size()) { + throw new NotFoundException("No such directors found"); + } + } + film = FilmMapper.updateFilmFields(film, request); log.info("Film with id {} has been updated", film.getId()); filmRepository.update(film); @@ -107,6 +138,7 @@ public void addLike(long filmId, long userId) { throwIfFilmNotFound(filmId); throwIfUserNotFound(userId); likesRepository.addLike(userId, filmId); + feedService.addEvent(EventType.LIKE, Operation.ADD, userId, filmId); log.info("Like to film with id {} has been added by user {}", filmId, userId); } @@ -115,17 +147,52 @@ public void removeLike(long filmId, long userId) { throwIfFilmNotFound(filmId); throwIfUserNotFound(userId); likesRepository.removeLike(userId, filmId); + feedService.addEvent(EventType.LIKE, Operation.REMOVE, userId, filmId); log.info("Like to film with id {} has been removed by user {}", filmId, userId); } @Override - public Collection findFilmsWithTopLikes(int count) { - return filmRepository.findTopPopularFilms(count) + public Collection findFilmsWithTopLikes(int count, Integer genreId, Integer year) { + return filmRepository.findTopPopularFilms(count, genreId, year) .stream() .map(FilmMapper::toFilmDto) .toList(); } + @Override + public Collection findCommonFilms(long userId, long friendId) { + throwIfUserNotFound(userId); + throwIfUserNotFound(friendId); + + return filmRepository.findCommonFilms(userId, friendId) + .stream() + .map(FilmMapper::toFilmDto) + .toList(); + } + + @Override + public Collection findFilmsOfDirector(long directorId, FilmsSortBy sortFilmsBy) { + throwIfDirectorNotFound(directorId); + + return filmRepository.findFilmsOfDirector(directorId, sortFilmsBy) + .stream() + .map(FilmMapper::toFilmDto) + .toList(); + } + + @Override + public Collection searchFilms(String query, List searchFilmsBy) { + return filmRepository.searchFilms(query, searchFilmsBy) + .stream() + .map(FilmMapper::toFilmDto) + .toList(); + } + + private void throwIfDirectorNotFound(long directorId) { + directorRepository.findById(directorId) + .orElseThrow(NotFoundException.supplier("Director with id %d not found", directorId)); + } + private void throwIfUserNotFound(long userId) { userRepository.findById(userId) .orElseThrow(NotFoundException.supplier("User with id %d not found", userId)); diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/GenreService.java b/src/main/java/ru/yandex/practicum/filmorate/service/genre/GenreService.java similarity index 78% rename from src/main/java/ru/yandex/practicum/filmorate/service/GenreService.java rename to src/main/java/ru/yandex/practicum/filmorate/service/genre/GenreService.java index 04be5dc..182990c 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/GenreService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/genre/GenreService.java @@ -1,4 +1,4 @@ -package ru.yandex.practicum.filmorate.service; +package ru.yandex.practicum.filmorate.service.genre; import ru.yandex.practicum.filmorate.dto.genre.GenreDto; diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/GenreServiceImpl.java b/src/main/java/ru/yandex/practicum/filmorate/service/genre/GenreServiceImpl.java similarity index 94% rename from src/main/java/ru/yandex/practicum/filmorate/service/GenreServiceImpl.java rename to src/main/java/ru/yandex/practicum/filmorate/service/genre/GenreServiceImpl.java index 23f435e..848c11e 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/GenreServiceImpl.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/genre/GenreServiceImpl.java @@ -1,4 +1,4 @@ -package ru.yandex.practicum.filmorate.service; +package ru.yandex.practicum.filmorate.service.genre; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/MPARatingService.java b/src/main/java/ru/yandex/practicum/filmorate/service/mparating/MPARatingService.java similarity index 79% rename from src/main/java/ru/yandex/practicum/filmorate/service/MPARatingService.java rename to src/main/java/ru/yandex/practicum/filmorate/service/mparating/MPARatingService.java index 675edc1..68a73d6 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/MPARatingService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/mparating/MPARatingService.java @@ -1,4 +1,4 @@ -package ru.yandex.practicum.filmorate.service; +package ru.yandex.practicum.filmorate.service.mparating; import ru.yandex.practicum.filmorate.dto.mparating.MPARatingDto; diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/MPARatingServiceImpl.java b/src/main/java/ru/yandex/practicum/filmorate/service/mparating/MPARatingServiceImpl.java similarity index 94% rename from src/main/java/ru/yandex/practicum/filmorate/service/MPARatingServiceImpl.java rename to src/main/java/ru/yandex/practicum/filmorate/service/mparating/MPARatingServiceImpl.java index 85d5140..d9d80d5 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/MPARatingServiceImpl.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/mparating/MPARatingServiceImpl.java @@ -1,4 +1,4 @@ -package ru.yandex.practicum.filmorate.service; +package ru.yandex.practicum.filmorate.service.mparating; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/recommendation/RecommendationService.java b/src/main/java/ru/yandex/practicum/filmorate/service/recommendation/RecommendationService.java new file mode 100644 index 0000000..2b048b6 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/recommendation/RecommendationService.java @@ -0,0 +1,9 @@ +package ru.yandex.practicum.filmorate.service.recommendation; + +import ru.yandex.practicum.filmorate.dto.film.FilmDto; + +import java.util.Collection; + +public interface RecommendationService { + Collection findFilmRecommendations(long userId); +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/recommendation/RecommendationServiceImpl.java b/src/main/java/ru/yandex/practicum/filmorate/service/recommendation/RecommendationServiceImpl.java new file mode 100644 index 0000000..26d9c8e --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/recommendation/RecommendationServiceImpl.java @@ -0,0 +1,39 @@ +package ru.yandex.practicum.filmorate.service.recommendation; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import ru.yandex.practicum.filmorate.dto.film.FilmDto; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.mapper.FilmMapper; +import ru.yandex.practicum.filmorate.repository.film.FilmRepository; +import ru.yandex.practicum.filmorate.repository.user.UserRepository; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class RecommendationServiceImpl implements RecommendationService { + private final UserRepository userRepository; + private final FilmRepository filmRepository; + + @Override + public Collection findFilmRecommendations(long userId) { + userRepository.findById(userId) + .orElseThrow(NotFoundException.supplier("User with id %d not found", userId)); + + List similarUsers = userRepository.findSimilarFilmTasteUsers(userId); + + if (similarUsers.isEmpty()) { + return Collections.emptyList(); + } + + return filmRepository.findFilmRecommendations(userId, similarUsers) + .stream() + .map(FilmMapper::toFilmDto) + .toList(); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/review/ReviewService.java b/src/main/java/ru/yandex/practicum/filmorate/service/review/ReviewService.java new file mode 100644 index 0000000..10a346a --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/review/ReviewService.java @@ -0,0 +1,27 @@ +package ru.yandex.practicum.filmorate.service.review; + +import ru.yandex.practicum.filmorate.dto.review.NewReviewRequest; +import ru.yandex.practicum.filmorate.dto.review.ReviewDto; +import ru.yandex.practicum.filmorate.dto.review.UpdateReviewRequest; +import ru.yandex.practicum.filmorate.model.Reaction; + +import java.util.Collection; + +public interface ReviewService { + + ReviewDto create(NewReviewRequest request); + + ReviewDto update(UpdateReviewRequest request); + + void delete(long id); + + ReviewDto findById(long id); + + Collection findAllByFilm(Long filmId, long count); + + void setLike(long id, long userId); + + void setDislike(long id, long userId); + + void removeReaction(long id, long userId, Reaction reaction); +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/review/ReviewServiceImpl.java b/src/main/java/ru/yandex/practicum/filmorate/service/review/ReviewServiceImpl.java new file mode 100644 index 0000000..701cce3 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/review/ReviewServiceImpl.java @@ -0,0 +1,115 @@ +package ru.yandex.practicum.filmorate.service.review; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import ru.yandex.practicum.filmorate.dto.review.*; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.mapper.ReviewMapper; +import ru.yandex.practicum.filmorate.model.EventType; +import ru.yandex.practicum.filmorate.model.Operation; +import ru.yandex.practicum.filmorate.model.Reaction; +import ru.yandex.practicum.filmorate.model.Review; +import ru.yandex.practicum.filmorate.repository.film.FilmRepository; +import ru.yandex.practicum.filmorate.repository.review.ReviewRepository; +import ru.yandex.practicum.filmorate.repository.user.UserRepository; +import ru.yandex.practicum.filmorate.service.feed.FeedService; + +import java.util.Collection; + +@Service +@Slf4j +@RequiredArgsConstructor +public class ReviewServiceImpl implements ReviewService { + private final ReviewRepository reviewRepository; + private final UserRepository userRepository; + private final FilmRepository filmRepository; + private final FeedService feedService; + + @Override + public ReviewDto create(NewReviewRequest request) { + throwIfUserNotFound(request.getUserId()); + throwIfFilmNotFound(request.getFilmId()); + Review review = ReviewMapper.toReview(request); + review = reviewRepository.save(review); + feedService.addEvent(EventType.REVIEW, Operation.ADD, request.getUserId(), review.getId()); + log.info("Review with reviewId {} has been created", review.getId()); + return ReviewMapper.toDto(review); + } + + @Override + public ReviewDto update(UpdateReviewRequest request) { + Review review = getReviewOrThrow(request.getReviewId()); + review = ReviewMapper.updateReviewFields(review, request); + feedService.addEvent(EventType.REVIEW, Operation.UPDATE, review.getUserId(), review.getId()); + log.info("Review with reviewId {} has been updated", review.getId()); + reviewRepository.update(review); + return ReviewMapper.toDto(review); + } + + @Override + public void delete(long id) { + Review review = getReviewOrThrow(id); + feedService.addEvent(EventType.REVIEW, Operation.REMOVE, review.getUserId(), id); + log.info("Review with reviewId {} has been deleted", id); + reviewRepository.delete(id); + } + + @Override + public ReviewDto findById(long id) { + return ReviewMapper.toDto(getReviewOrThrow(id)); + } + + @Override + public Collection findAllByFilm(Long filmId, long count) { + if (filmId != null) { + throwIfFilmNotFound(filmId); + } + + return reviewRepository.findAllByFilm(filmId, count).stream() + .map(ReviewMapper::toDto) + .toList(); + } + + @Override + public void setLike(long id, long userId) { + getReviewOrThrow(id); + throwIfUserNotFound(userId); + log.info("Like to review {} has been added by user {}", id, userId); + reviewRepository.setReaction(id, userId, Reaction.LIKE); + } + + @Override + public void setDislike(long id, long userId) { + getReviewOrThrow(id); + throwIfUserNotFound(userId); + log.info("Dislike to review {} has been added by user {}", id, userId); + reviewRepository.setReaction(id, userId, Reaction.DISLIKE); + } + + @Override + public void removeReaction(long id, long userId, Reaction reaction) { + getReviewOrThrow(id); + throwIfUserNotFound(userId); + switch (reaction) { + case LIKE -> log.info("Like to review {} has been removed by user {}", id, userId); + case DISLIKE -> log.info("Dislike to review {} has been removed by user {}", id, userId); + } + reviewRepository.removeReaction(id, userId); + } + + private Review getReviewOrThrow(long id) { + return reviewRepository.findById(id) + .orElseThrow(NotFoundException.supplier("Review with reviewId %d not found", id)); + } + + private void throwIfUserNotFound(long id) { + userRepository.findById(id) + .orElseThrow(NotFoundException.supplier("User with userId %d not found", id)); + } + + private void throwIfFilmNotFound(long id) { + filmRepository.findById(id) + .orElseThrow(NotFoundException.supplier("Film with filmId %d not found", 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/user/UserService.java similarity index 92% rename from src/main/java/ru/yandex/practicum/filmorate/service/UserService.java rename to src/main/java/ru/yandex/practicum/filmorate/service/user/UserService.java index 5163030..fc35bd8 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/user/UserService.java @@ -1,4 +1,4 @@ -package ru.yandex.practicum.filmorate.service; +package ru.yandex.practicum.filmorate.service.user; import ru.yandex.practicum.filmorate.dto.user.NewUserRequest; import ru.yandex.practicum.filmorate.dto.user.UpdateUserRequest; diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/UserServiceImpl.java b/src/main/java/ru/yandex/practicum/filmorate/service/user/UserServiceImpl.java similarity index 87% rename from src/main/java/ru/yandex/practicum/filmorate/service/UserServiceImpl.java rename to src/main/java/ru/yandex/practicum/filmorate/service/user/UserServiceImpl.java index f7baaaf..f199adf 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/UserServiceImpl.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/user/UserServiceImpl.java @@ -1,4 +1,4 @@ -package ru.yandex.practicum.filmorate.service; +package ru.yandex.practicum.filmorate.service.user; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -8,9 +8,12 @@ import ru.yandex.practicum.filmorate.dto.user.UserDto; import ru.yandex.practicum.filmorate.exception.NotFoundException; import ru.yandex.practicum.filmorate.mapper.UserMapper; +import ru.yandex.practicum.filmorate.model.EventType; +import ru.yandex.practicum.filmorate.model.Operation; import ru.yandex.practicum.filmorate.model.User; import ru.yandex.practicum.filmorate.repository.friendship.FriendshipsRepository; import ru.yandex.practicum.filmorate.repository.user.UserRepository; +import ru.yandex.practicum.filmorate.service.feed.FeedService; import java.util.Collection; @@ -20,6 +23,7 @@ public class UserServiceImpl implements UserService { private final UserRepository userRepository; private final FriendshipsRepository friendshipsRepository; + private final FeedService feedService; @Override public Collection findAll() { @@ -38,7 +42,7 @@ public UserDto findById(Long id) { @Override public UserDto create(NewUserRequest request) { - if (request.getName() == null) { + if (request.getName() == null || request.getName().isEmpty()) { request.setName(request.getLogin()); } User user = UserMapper.mapToUser(request); @@ -68,8 +72,8 @@ public void deleteById(long id) { public void addFriend(long userId, long friendId) { throwIfUserNotFound(userId); throwIfUserNotFound(friendId); - friendshipsRepository.addFriendship(userId, friendId); + feedService.addEvent(EventType.FRIEND, Operation.ADD, userId, friendId); log.info("User with id {} added user with id {} as friend", userId, friendId); } @@ -78,6 +82,7 @@ public void removeFriend(long userId, long friendId) { throwIfUserNotFound(userId); throwIfUserNotFound(friendId); friendshipsRepository.removeFriendship(userId, friendId); + feedService.addEvent(EventType.FRIEND, Operation.REMOVE, userId, friendId); log.info("User with id {} removed user with id {} from friends", userId, friendId); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a840ff7..fc2688c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,8 +1,8 @@ -logging.level.org.zalando.logbook=Trace +logging.level.org.zalando.logbook=INFO 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 \ No newline at end of file +spring.datasource.password=password diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 08c651a..0dc4b91 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -19,17 +19,32 @@ CREATE TABLE IF NOT EXISTS mpa_ratings name varchar(20) NOT NULL UNIQUE ); +CREATE TABLE IF NOT EXISTS directors +( + director_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name varchar(120) NOT NULL UNIQUE +); + CREATE TABLE IF NOT EXISTS films ( film_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name varchar(120) NOT NULL, description varchar(200) NOT NULL, release_date date NOT NULL CHECK (release_date >= '1895-12-28'), - duration_in_minutes BIGINT NOT NULL CHECK (duration_in_minutes > 0), - mpa_id BIGINT NOT NULL, + duration_in_minutes BIGINT NOT NULL CHECK (duration_in_minutes > 0), + mpa_id BIGINT NOT NULL, FOREIGN KEY (mpa_id) REFERENCES mpa_ratings (mpa_id) ); +CREATE TABLE IF NOT EXISTS film_directors +( + film_id BIGINT NOT NULL, + director_id BIGINT NOT NULL, + PRIMARY KEY (film_id, director_id), + FOREIGN KEY (film_id) REFERENCES films (film_id) ON DELETE CASCADE, + FOREIGN KEY (director_id) REFERENCES directors (director_id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS film_genres ( film_id BIGINT NOT NULL, @@ -50,11 +65,50 @@ CREATE TABLE IF NOT EXISTS likes CREATE TABLE IF NOT EXISTS friendships ( - user_id1 BIGINT NOT NULL, - user_id2 BIGINT NOT NULL, + user_id1 BIGINT NOT NULL, + user_id2 BIGINT NOT NULL, status ENUM ('pending', 'accepted') NOT NULL DEFAULT 'pending', PRIMARY KEY (user_id1, user_id2), FOREIGN KEY (user_id1) REFERENCES users (user_id) ON DELETE CASCADE, FOREIGN KEY (user_id2) REFERENCES users (user_id) ON DELETE CASCADE, CHECK (user_id1 <> user_id2) -); \ No newline at end of file +); + +CREATE TABLE IF NOT EXISTS reviews +( + review_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + content VARCHAR(500) NOT NULL, + is_positive BOOL NOT NULL, + useful_rating BIGINT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS film_reviews +( + review_id BIGINT NOT NULL UNIQUE, + film_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + PRIMARY KEY (film_id, user_id), + FOREIGN KEY (film_id) REFERENCES films (film_id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE, + FOREIGN KEY (review_id) REFERENCES reviews (review_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS review_likes +( + review_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + is_positive BOOL NOT NULL, + PRIMARY KEY (review_id, user_id), + FOREIGN KEY (review_id) REFERENCES reviews (review_id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS feed_events ( + event_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + created_at BIGINT NOT NULL, + user_id BIGINT NOT NULL, + event_type ENUM('LIKE', 'REVIEW', 'FRIEND') NOT NULL, + operation ENUM('REMOVE', 'ADD', 'UPDATE') NOT NULL, + entity_id BIGINT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE +); diff --git a/src/test/java/ru/yandex/practicum/filmorate/repository/JdbcFilmRepositoryTest.java b/src/test/java/ru/yandex/practicum/filmorate/repository/JdbcFilmRepositoryTest.java index 4f6b5dd..d4ade53 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/repository/JdbcFilmRepositoryTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/repository/JdbcFilmRepositoryTest.java @@ -7,10 +7,8 @@ import org.springframework.context.annotation.Import; import ru.yandex.practicum.filmorate.model.Film; import ru.yandex.practicum.filmorate.model.MPARating; -import ru.yandex.practicum.filmorate.repository.film.FilmResultSetExtractor; import ru.yandex.practicum.filmorate.repository.film.FilmRowMapper; import ru.yandex.practicum.filmorate.repository.film.JdbcFilmRepository; -import ru.yandex.practicum.filmorate.repository.genre.GenreRowMapper; import ru.yandex.practicum.filmorate.util.TestFilmUtils; import java.time.LocalDate; @@ -20,7 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; @JdbcTest -@Import({JdbcFilmRepository.class, FilmRowMapper.class, GenreRowMapper.class, FilmResultSetExtractor.class}) +@Import({JdbcFilmRepository.class, FilmRowMapper.class}) public class JdbcFilmRepositoryTest { @Autowired private JdbcFilmRepository filmRepository;