diff --git a/.gitignore b/.gitignore index 048fda7..40d7b42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,14 @@ main -src/main/resources/application.properties node_modules .DS_Store .env target + +### Environment variables ### +src/main/resources/application.properties + +### IntelliJ IDEA ### .idea + +### VS Code ### +.vscode/ \ No newline at end of file diff --git a/README.adoc b/README.adoc index 2436728..99ce9a0 100644 --- a/README.adoc +++ b/README.adoc @@ -4,7 +4,7 @@ This repository accompanies the link:https://graphacademy.neo4j.com/courses/app-java/[Building Neo4j Applications with Java course^] on link:https://graphacademy.neo4j.com/[Neo4j GraphAcademy^]. -For a complete walkthrough of this repository, link:https://graphacademy.neo4j.com/courses/app-java/[enroll now^]. +For a complete walkthrough of this repository, link:https://graphacademy.neo4j.com/courses/app-java/[enroll now^]. == Setup @@ -16,7 +16,6 @@ sdk install java 17-open sdk use java 17-open sdk install maven mvn verify -mvn compile exec:java ---- .Connection details to your neo4j database are in `src/main/resources/application.properties` @@ -24,7 +23,7 @@ mvn compile exec:java ---- APP_PORT=3000 -NEO4J_URI=bolt://hostname-or-ip:7687 +NEO4J_URI=bolt://:7687 NEO4J_USERNAME=neo4j NEO4J_PASSWORD= @@ -32,6 +31,12 @@ JWT_SECRET=secret SALT_ROUNDS=10 ---- +.Run the application +[source,shell] +---- +mvn compile exec:java +---- + == A Note on comments You may spot a number of comments in this repository that look a little like this: diff --git a/diff/01-connect-to-neo4j.diff b/diff/01-connect-to-neo4j.diff deleted file mode 100644 index 4798d54..0000000 --- a/diff/01-connect-to-neo4j.diff +++ /dev/null @@ -1,17 +0,0 @@ -diff --git a/src/main/java/neoflix/AppUtils.java b/src/main/java/neoflix/AppUtils.java -index e0e6440..e26eb0b 100644 ---- a/src/main/java/neoflix/AppUtils.java -+++ b/src/main/java/neoflix/AppUtils.java -@@ -45,8 +45,10 @@ public class AppUtils { - - // tag::initDriver[] - static Driver initDriver() { -- // TODO: Create and assign an instance of the driver here -- return null; -+ AuthToken auth = AuthTokens.basic(getNeo4jUsername(), getNeo4jPassword()); -+ Driver driver = GraphDatabase.driver(getNeo4jUri(), auth); -+ driver.verifyConnectivity(); -+ return driver; - } - // end::initDriver[] - diff --git a/diff/02-movie-lists.diff b/diff/02-movie-lists.diff deleted file mode 100644 index 4aa2bd2..0000000 --- a/diff/02-movie-lists.diff +++ /dev/null @@ -1,47 +0,0 @@ -diff --git a/src/main/java/neoflix/services/MovieService.java b/src/main/java/neoflix/services/MovieService.java -index 10064b7..3c6b43b 100644 ---- a/src/main/java/neoflix/services/MovieService.java -+++ b/src/main/java/neoflix/services/MovieService.java -@@ -44,12 +44,36 @@ public class MovieService { - */ - // tag::all[] - public List> all(Params params, String userId) { -- // TODO: Open an Session -- // TODO: Execute a query in a new Read Transaction -- // TODO: Get a list of Movies from the Result -- // TODO: Close the session -- -- return AppUtils.process(popular, params); -+ // Open a new session -+ try (var session = this.driver.session()) { -+ // tag::allcypher[] -+ // Execute a query in a new Read Transaction -+ var movies = session.executeRead(tx -> { -+ // Retrieve a list of movies with the -+ // favorite flag appened to the movie's properties -+ Params.Sort sort = params.sort(Params.Sort.title); -+ String query = String.format(""" -+ MATCH (m:Movie) -+ WHERE m.`%s` IS NOT NULL -+ RETURN m { -+ .* -+ } AS movie -+ ORDER BY m.`%s` %s -+ SKIP $skip -+ LIMIT $limit -+ """, sort, sort, params.order()); -+ var res= tx.run(query, Values.parameters( "skip", params.skip(), "limit", params.limit())); -+ // tag::allmovies[] -+ // Get a list of Movies from the Result -+ return res.list(row -> row.get("movie").asMap()); -+ // end::allmovies[] -+ }); -+ // end::allcypher[] -+ -+ // tag::return[] -+ return movies; -+ // end::return[] -+ } - } - // end::all[] - diff --git a/diff/03-registering-a-user.diff b/diff/03-registering-a-user.diff deleted file mode 100644 index 46a3067..0000000 --- a/diff/03-registering-a-user.diff +++ /dev/null @@ -1,72 +0,0 @@ -diff --git a/src/main/java/neoflix/services/AuthService.java b/src/main/java/neoflix/services/AuthService.java -index a5e479a..d8a5487 100644 ---- a/src/main/java/neoflix/services/AuthService.java -+++ b/src/main/java/neoflix/services/AuthService.java -@@ -4,6 +4,7 @@ import neoflix.AppUtils; - import neoflix.AuthUtils; - import neoflix.ValidationException; - import org.neo4j.driver.Driver; -+import org.neo4j.driver.Values; - - import java.util.List; - import java.util.Map; -@@ -46,23 +47,33 @@ public class AuthService { - // tag::register[] - public Map register(String email, String plainPassword, String name) { - var encrypted = AuthUtils.encryptPassword(plainPassword); -- // tag::constraintError[] -- // TODO: Handle Unique constraints in the database -- var foundUser = users.stream().filter(u -> u.get("email").equals(email)).findAny(); -- if (foundUser.isPresent()) { -- throw new RuntimeException("An account already exists with the email address"); -- } -- // end::constraintError[] -- -- // TODO: Save user in database -- var user = Map.of("email",email, "name",name, -- "userId", String.valueOf(email.hashCode()), "password", encrypted); -- users.add(user); -+ // Open a new Session -+ try (var session = this.driver.session()) { -+ // tag::create[] -+ var user = session.executeWrite(tx -> { -+ String statement = """ -+ CREATE (u:User { -+ userId: randomUuid(), -+ email: $email, -+ password: $encrypted, -+ name: $name -+ }) -+ RETURN u { .userId, .name, .email } as u"""; -+ var res = tx.run(statement, Values.parameters("email", email, "encrypted", encrypted, "name", name)); -+ // end::create[] -+ // tag::extract[] -+ // Extract safe properties from the user node (`u`) in the first row -+ return res.single().get("u").asMap(); -+ // end::extract[] - -- String sub = (String) user.get("userId"); -- String token = AuthUtils.sign(sub,userToClaims(user), jwtSecret); -+ }); -+ String sub = (String)user.get("userId"); -+ String token = AuthUtils.sign(sub,userToClaims(user), jwtSecret); - -- return userWithToken(user, token); -+ // tag::return-register[] -+ return userWithToken(user, token); -+ // end::return-register[] -+ } - } - // end::register[] - -@@ -93,8 +104,8 @@ public class AuthService { - if (foundUser.isEmpty()) - throw new ValidationException("Incorrect email", Map.of("email","Incorrect email")); - var user = foundUser.get(); -- if (!plainPassword.equals(user.get("password")) && -- !AuthUtils.verifyPassword(plainPassword,(String)user.get("password"))) { // -+ if (!plainPassword.equals(user.get("password")) && -+ !AuthUtils.verifyPassword(plainPassword,(String)user.get("password"))) { // - throw new ValidationException("Incorrect password", Map.of("password","Incorrect password")); - } - // tag::return[] diff --git a/diff/04-handle-constraint-errors.diff b/diff/04-handle-constraint-errors.diff deleted file mode 100644 index 2c86c4f..0000000 --- a/diff/04-handle-constraint-errors.diff +++ /dev/null @@ -1,28 +0,0 @@ -diff --git a/src/main/java/neoflix/services/AuthService.java b/src/main/java/neoflix/services/AuthService.java -index d8a5487..8c83d07 100644 ---- a/src/main/java/neoflix/services/AuthService.java -+++ b/src/main/java/neoflix/services/AuthService.java -@@ -5,6 +5,7 @@ import neoflix.AuthUtils; - import neoflix.ValidationException; - import org.neo4j.driver.Driver; - import org.neo4j.driver.Values; -+import org.neo4j.driver.exceptions.ClientException; - - import java.util.List; - import java.util.Map; -@@ -73,7 +74,15 @@ public class AuthService { - // tag::return-register[] - return userWithToken(user, token); - // end::return-register[] -+ // tag::catch[] -+ } catch (ClientException e) { -+ // Handle unique constraints in the database -+ if (e.code().equals("Neo.ClientError.Schema.ConstraintValidationFailed")) { -+ throw new ValidationException("An account already exists with the email address", Map.of("email","Email address already taken")); -+ } -+ throw e; - } -+ // end::catch[] - } - // end::register[] - diff --git a/diff/05-authentication.diff b/diff/05-authentication.diff deleted file mode 100644 index d6062d7..0000000 --- a/diff/05-authentication.diff +++ /dev/null @@ -1,60 +0,0 @@ -diff --git a/src/main/java/neoflix/services/AuthService.java b/src/main/java/neoflix/services/AuthService.java -index 8c83d07..b71042d 100644 ---- a/src/main/java/neoflix/services/AuthService.java -+++ b/src/main/java/neoflix/services/AuthService.java -@@ -6,6 +6,7 @@ import neoflix.ValidationException; - import org.neo4j.driver.Driver; - import org.neo4j.driver.Values; - import org.neo4j.driver.exceptions.ClientException; -+import org.neo4j.driver.exceptions.NoSuchRecordException; - - import java.util.List; - import java.util.Map; -@@ -108,20 +109,35 @@ public class AuthService { - */ - // tag::authenticate[] - public Map authenticate(String email, String plainPassword) { -- // TODO: Authenticate the user from the database -- var foundUser = users.stream().filter(u -> u.get("email").equals(email)).findAny(); -- if (foundUser.isEmpty()) -+ // Open a new Session -+ try (var session = this.driver.session()) { -+ // tag::query[] -+ // Find the User node within a Read Transaction -+ var user = session.executeRead(tx -> { -+ String statement = "MATCH (u:User {email: $email}) RETURN u"; -+ var res = tx.run(statement, Values.parameters("email", email)); -+ return res.single().get("u").asMap(); -+ -+ }); -+ // end::query[] -+ -+ // tag::password[] -+ // Check password -+ if (!AuthUtils.verifyPassword(plainPassword, (String)user.get("password"))) { -+ throw new ValidationException("Incorrect password", Map.of("password","Incorrect password")); -+ } -+ // end::password[] -+ -+ // tag::auth-return[] -+ String sub = (String)user.get("userId"); -+ String token = AuthUtils.sign(sub, userToClaims(user), jwtSecret); -+ return userWithToken(user, token); -+ // end::auth-return[] -+ // tag::auth-catch[] -+ } catch(NoSuchRecordException e) { - throw new ValidationException("Incorrect email", Map.of("email","Incorrect email")); -- var user = foundUser.get(); -- if (!plainPassword.equals(user.get("password")) && -- !AuthUtils.verifyPassword(plainPassword,(String)user.get("password"))) { // -- throw new ValidationException("Incorrect password", Map.of("password","Incorrect password")); - } -- // tag::return[] -- String sub = (String) user.get("userId"); -- String token = AuthUtils.sign(sub, userToClaims(user), jwtSecret); -- return userWithToken(user, token); -- // end::return[] -+ // end::auth-catch[] - } - // end::authenticate[] - diff --git a/diff/06-rating-movies.diff b/diff/06-rating-movies.diff deleted file mode 100644 index a264d08..0000000 --- a/diff/06-rating-movies.diff +++ /dev/null @@ -1,61 +0,0 @@ -diff --git a/src/main/java/neoflix/services/RatingService.java b/src/main/java/neoflix/services/RatingService.java -index 45e06e4..1b04ee1 100644 ---- a/src/main/java/neoflix/services/RatingService.java -+++ b/src/main/java/neoflix/services/RatingService.java -@@ -2,7 +2,11 @@ package neoflix.services; - - import neoflix.AppUtils; - import neoflix.Params; -+import neoflix.ValidationException; -+ - import org.neo4j.driver.Driver; -+import org.neo4j.driver.Values; -+import org.neo4j.driver.exceptions.NoSuchRecordException; - - import java.util.HashMap; - import java.util.List; -@@ -60,13 +64,37 @@ public class RatingService { - */ - // tag::add[] - public Map add(String userId, String movieId, int rating) { -- // TODO: Convert the native integer into a Neo4j Integer -- // TODO: Save the rating in the database -- // TODO: Return movie details and a rating -+ // tag::write[] -+ // Save the rating in the database -+ -+ // Open a new session -+ try (var session = this.driver.session()) { -+ -+ // Run the cypher query -+ var movie = session.executeWrite(tx -> { -+ String query = """ -+ MATCH (u:User {userId: $userId}) -+ MATCH (m:Movie {tmdbId: $movieId}) -+ -+ MERGE (u)-[r:RATED]->(m) -+ SET r.rating = $rating, r.timestamp = timestamp() -+ -+ RETURN m { .*, rating: r.rating } AS movie -+ """; -+ var res = tx.run(query, Values.parameters("userId", userId, "movieId", movieId, "rating", rating)); -+ return res.single().get("movie").asMap(); -+ }); -+ // end::write[] - -- var copy = new HashMap<>(pulpfiction); -- copy.put("rating",rating); -- return copy; -+ // tag::addreturn[] -+ // Return movie details with rating -+ return movie; -+ // end::addreturn[] -+ // tag::throw[] -+ } catch(NoSuchRecordException e) { -+ throw new ValidationException("Movie or user not found to add rating", Map.of("movie", movieId, "user", userId)); -+ } -+ // end::throw[] - } - // end::add[] - } -\ No newline at end of file diff --git a/diff/07-favorites-list.diff b/diff/07-favorites-list.diff deleted file mode 100644 index f84e95f..0000000 --- a/diff/07-favorites-list.diff +++ /dev/null @@ -1,157 +0,0 @@ -diff --git a/src/main/java/neoflix/services/FavoriteService.java b/src/main/java/neoflix/services/FavoriteService.java -index 326c108..2947809 100644 ---- a/src/main/java/neoflix/services/FavoriteService.java -+++ b/src/main/java/neoflix/services/FavoriteService.java -@@ -2,7 +2,11 @@ package neoflix.services; - - import neoflix.AppUtils; - import neoflix.Params; -+import neoflix.ValidationException; -+ - import org.neo4j.driver.Driver; -+import org.neo4j.driver.Values; -+import org.neo4j.driver.exceptions.NoSuchRecordException; - - import java.util.ArrayList; - import java.util.HashMap; -@@ -43,11 +47,25 @@ public class FavoriteService { - */ - // tag::all[] - public List> all(String userId, Params params) { -- // TODO: Open a new session -- // TODO: Retrieve a list of movies favorited by the user -- // TODO: Close session -- -- return AppUtils.process(userFavorites.getOrDefault(userId, List.of()),params); -+ // Open a new session -+ try (var session = this.driver.session()) { -+ // Retrieve a list of movies favorited by the user -+ var favorites = session.executeRead(tx -> { -+ String query = String.format(""" -+ MATCH (u:User {userId: $userId})-[r:HAS_FAVORITE]->(m:Movie) -+ RETURN m { -+ .*, -+ favorite: true -+ } AS movie -+ ORDER BY m.`%s` %s -+ SKIP $skip -+ LIMIT $limit -+ """, params.sort(Params.Sort.title), params.order()); -+ var res = tx.run(query, Values.parameters("userId", userId, "skip", params.skip(), "limit", params.limit())); -+ return res.list(row -> row.get("movie").asMap()); -+ }); -+ return favorites; -+ } - } - // end::all[] - -@@ -63,25 +81,39 @@ public class FavoriteService { - */ - // tag::add[] - public Map add(String userId, String movieId) { -- // TODO: Open a new Session -- // TODO: Create HAS_FAVORITE relationship within a Write Transaction -- // TODO: Close the session -- // TODO: Return movie details and `favorite` property -- -- var foundMovie = popular.stream().filter(m -> movieId.equals(m.get("tmdbId"))).findAny(); -- -- if (users.stream().anyMatch(u -> u.get("userId").equals(userId)) || foundMovie.isEmpty()) { -- throw new RuntimeException("Couldn't create a favorite relationship for User %s and Movie %s".formatted(userId, movieId)); -+ // Open a new Session -+ try (var session = this.driver.session()) { -+ // tag::create[] -+ // Create HAS_FAVORITE relationship within a Write Transaction -+ var favorite = session.executeWrite(tx -> { -+ String statement = """ -+ MATCH (u:User {userId: $userId}) -+ MATCH (m:Movie {tmdbId: $movieId}) -+ -+ MERGE (u)-[r:HAS_FAVORITE]->(m) -+ ON CREATE SET r.createdAt = datetime() -+ -+ RETURN m { -+ .*, -+ favorite: true -+ } AS movie -+ """; -+ var res = tx.run(statement, Values.parameters("userId", userId, "movieId", movieId)); -+ // return res.single().get("movie").asMap(); -+ return res.single().get("movie").asMap(); -+ }); -+ // end::create[] -+ -+ // tag::return[] -+ // Return movie details and `favorite` property -+ return favorite; -+ // end::return[] -+ // tag::throw[] -+ // Throw an error if the user or movie could not be found -+ } catch (NoSuchRecordException e) { -+ throw new ValidationException(String.format("Couldn't create a favorite relationship for User %s and Movie %s", userId, movieId), Map.of("movie",movieId, "user",userId)); - } -- -- var movie = foundMovie.get(); -- var favorites = userFavorites.computeIfAbsent(userId, (k) -> new ArrayList<>()); -- if (!favorites.contains(movie)) { -- favorites.add(movie); -- } -- var copy = new HashMap<>(movie); -- copy.put("favorite", true); -- return copy; -+ // end::throw[] - } - // end::add[] - -@@ -97,23 +129,35 @@ public class FavoriteService { - */ - // tag::remove[] - public Map remove(String userId, String movieId) { -- // TODO: Open a new Session -- // TODO: Delete the HAS_FAVORITE relationship within a Write Transaction -- // TODO: Close the session -- // TODO: Return movie details and `favorite` property -- if (users.stream().anyMatch(u -> u.get("userId").equals(userId))) { -- throw new RuntimeException("Couldn't remove a favorite relationship for User %s and Movie %s".formatted(userId, movieId)); -+ // Open a new Session -+ try (var session = this.driver.session()) { -+ // tag::remove[] -+ // Removes HAS_FAVORITE relationship within a Write Transaction -+ var favorite = session.executeWrite(tx -> { -+ String statement = """ -+ MATCH (u:User {userId: $userId})-[r:HAS_FAVORITE]->(m:Movie {tmdbId: $movieId}) -+ DELETE r -+ -+ RETURN m { -+ .*, -+ favorite: false -+ } AS movie -+ """; -+ var res = tx.run(statement, Values.parameters("userId", userId, "movieId", movieId)); -+ return res.single().get("movie").asMap(); -+ }); -+ // end::remove[] -+ -+ // tag::return-remove[] -+ // Return movie details and `favorite` property -+ return favorite; -+ // end::return-remove[] -+ // tag::throw-remove[] -+ // Throw an error if the user or movie could not be found -+ } catch (NoSuchRecordException e) { -+ throw new ValidationException("Could not remove favorite movie for user", Map.of("movie",movieId, "user",userId)); - } -- -- var movie = popular.stream().filter(m -> movieId.equals(m.get("tmdbId"))).findAny().get(); -- var favorites = userFavorites.computeIfAbsent(userId, (k) -> new ArrayList<>()); -- if (favorites.contains(movie)) { -- favorites.remove(movie); -- } -- -- var copy = new HashMap<>(movie); -- copy.put("favorite", false); -- return copy; -+ // end::throw-remove[] - } - // end::remove[] - diff --git a/diff/08-favorite-flag.diff b/diff/08-favorite-flag.diff deleted file mode 100644 index d92ff0b..0000000 --- a/diff/08-favorite-flag.diff +++ /dev/null @@ -1,57 +0,0 @@ -diff --git a/src/main/java/neoflix/services/MovieService.java b/src/main/java/neoflix/services/MovieService.java -index 3c6b43b..fdf7591 100644 ---- a/src/main/java/neoflix/services/MovieService.java -+++ b/src/main/java/neoflix/services/MovieService.java -@@ -5,6 +5,7 @@ import neoflix.NeoflixApp; - import neoflix.Params; - import org.neo4j.driver.Driver; - import org.neo4j.driver.Transaction; -+import org.neo4j.driver.TransactionContext; - import org.neo4j.driver.Values; - - import java.util.HashMap; -@@ -49,6 +50,9 @@ public class MovieService { - // tag::allcypher[] - // Execute a query in a new Read Transaction - var movies = session.executeRead(tx -> { -+ // Get an array of IDs for the User's favorite movies -+ var favorites = getUserFavorites(tx, userId); -+ - // Retrieve a list of movies with the - // favorite flag appened to the movie's properties - Params.Sort sort = params.sort(Params.Sort.title); -@@ -56,13 +60,14 @@ public class MovieService { - MATCH (m:Movie) - WHERE m.`%s` IS NOT NULL - RETURN m { -- .* -+ .*, -+ favorite: m.tmdbId IN $favorites - } AS movie - ORDER BY m.`%s` %s - SKIP $skip - LIMIT $limit - """, sort, sort, params.order()); -- var res= tx.run(query, Values.parameters( "skip", params.skip(), "limit", params.limit())); -+ var res= tx.run(query, Values.parameters( "skip", params.skip(), "limit", params.limit(), "favorites",favorites)); - // tag::allmovies[] - // Get a list of Movies from the Result - return res.list(row -> row.get("movie").asMap()); -@@ -220,8 +225,15 @@ public class MovieService { - * @return List movieIds of favorite movies - */ - // tag::getUserFavorites[] -- private List getUserFavorites(Transaction tx, String userId) { -- return List.of(); -+ private List getUserFavorites(TransactionContext tx, String userId) { -+ // If userId is not defined, return an empty list -+ if (userId == null) return List.of(); -+ var favoriteResult = tx.run(""" -+ MATCH (u:User {userId: $userId})-[:HAS_FAVORITE]->(m) -+ RETURN m.tmdbId AS id -+ """, Values.parameters("userId",userId)); -+ // Extract the `id` value returned by the cypher query -+ return favoriteResult.list(row -> row.get("id").asString()); - } - // end::getUserFavorites[] - diff --git a/diff/09-genre-list.diff b/diff/09-genre-list.diff deleted file mode 100644 index 699d5ef..0000000 --- a/diff/09-genre-list.diff +++ /dev/null @@ -1,45 +0,0 @@ -diff --git a/src/main/java/neoflix/services/GenreService.java b/src/main/java/neoflix/services/GenreService.java -index fcac519..5a6fc96 100644 ---- a/src/main/java/neoflix/services/GenreService.java -+++ b/src/main/java/neoflix/services/GenreService.java -@@ -34,11 +34,36 @@ public class GenreService { - */ - // tag::all[] - public List> all() { -- // TODO: Open a new session -- // TODO: Get a list of Genres from the database -- // TODO: Close the session -+ // Open a new Session, close automatically at the end -+ try (var session = driver.session()) { -+ // Get a list of Genres from the database -+ var query = """ -+ MATCH (g:Genre) -+ WHERE g.name <> '(no genres listed)' -+ CALL { -+ WITH g -+ MATCH (g)<-[:IN_GENRE]-(m:Movie) -+ WHERE m.imdbRating IS NOT NULL -+ AND m.poster IS NOT NULL -+ RETURN m.poster AS poster -+ ORDER BY m.imdbRating DESC LIMIT 1 -+ } -+ RETURN g { -+ .name, -+ link: '/genres/'+ g.name, -+ poster: poster, -+ movies: size( (g)<-[:IN_GENRE]-() ) -+ } as genre -+ ORDER BY g.name ASC -+ """; -+ var genres = session.executeRead( -+ tx -> tx.run(query) -+ .list(row -> -+ row.get("genre").asMap())); - -- return genres; -+ // Return results -+ return genres; -+ } - } - // end::all[] - diff --git a/diff/10-genre-details.diff b/diff/10-genre-details.diff deleted file mode 100644 index 11accd6..0000000 --- a/diff/10-genre-details.diff +++ /dev/null @@ -1,54 +0,0 @@ -diff --git a/src/main/java/neoflix/services/GenreService.java b/src/main/java/neoflix/services/GenreService.java -index 5a6fc96..b9577c0 100644 ---- a/src/main/java/neoflix/services/GenreService.java -+++ b/src/main/java/neoflix/services/GenreService.java -@@ -2,6 +2,7 @@ package neoflix.services; - - import neoflix.AppUtils; - import org.neo4j.driver.Driver; -+import org.neo4j.driver.Values; - - import java.util.List; - import java.util.Map; -@@ -78,15 +79,33 @@ public class GenreService { - */ - // tag::find[] - public Map find(String name) { -- // TODO: Open a new session -- // TODO: Get Genre information from the database -- // TODO: Throw a 404 Error if the genre is not found -- // TODO: Close the session -+ // Open a new Session, close automatically at the end -+ try (var session = driver.session()) { -+ // Get a list of Genres from the database -+ var query = """ -+ MATCH (g:Genre {name: $name})<-[:IN_GENRE]-(m:Movie) -+ WHERE m.imdbRating IS NOT NULL -+ AND m.poster IS NOT NULL -+ AND g.name <> '(no genres listed)' -+ WITH g, m -+ ORDER BY m.imdbRating DESC -+ -+ WITH g, head(collect(m)) AS movie - -- return genres.stream() -- .filter(genre -> genre.get("name").equals(name)) -- .findFirst() -- .orElseThrow(() -> new RuntimeException("Genre "+name+" not found")); -+ RETURN g { -+ link: '/genres/'+ g.name, -+ .name, -+ movies: size((g)<-[:IN_GENRE]-()), -+ poster: movie.poster -+ } AS genre -+ """; -+ var genre = session.executeRead( -+ tx -> tx.run(query, Values.parameters("name", name)) -+ // Throw a NoSuchRecordException if the genre is not found -+ .single().get("genre").asMap()); -+ // Return results -+ return genre; -+ } - } - // end::find[] - } diff --git a/diff/11-movie-lists.diff b/diff/11-movie-lists.diff deleted file mode 100644 index fe59619..0000000 --- a/diff/11-movie-lists.diff +++ /dev/null @@ -1,141 +0,0 @@ -diff --git a/src/main/java/neoflix/services/MovieService.java b/src/main/java/neoflix/services/MovieService.java -index fdf7591..0938190 100644 ---- a/src/main/java/neoflix/services/MovieService.java -+++ b/src/main/java/neoflix/services/MovieService.java -@@ -51,7 +51,7 @@ public class MovieService { - // Execute a query in a new Read Transaction - var movies = session.executeRead(tx -> { - // Get an array of IDs for the User's favorite movies -- var favorites = getUserFavorites(tx, userId); -+ var favorites = getUserFavorites(tx, userId); - - // Retrieve a list of movies with the - // favorite flag appened to the movie's properties -@@ -156,10 +156,36 @@ public class MovieService { - */ - // tag::getByGenre[] - public List> byGenre(String name, Params params, String userId) { -- // TODO: Get Movies in a Genre -+ // Get Movies in a Genre - // MATCH (m:Movie)-[:IN_GENRE]->(:Genre {name: $name}) - -- return AppUtils.process(comedyMovies, params); -+ // Open a new session and close at the end -+ try (var session = driver.session()) { -+ // Execute a query in a new Read Transaction -+ return session.executeRead((tx) -> { -+ // Get an array of IDs for the User's favorite movies -+ var favorites = getUserFavorites(tx, userId); -+ -+ // Retrieve a list of movies with the -+ // favorite flag append to the movie's properties -+ var result = tx.run( -+ String.format(""" -+ MATCH (m:Movie)-[:IN_GENRE]->(:Genre {name: $name}) -+ WHERE m.`%s` IS NOT NULL -+ RETURN m { -+ .*, -+ favorite: m.tmdbId IN $favorites -+ } AS movie -+ ORDER BY m.`%s` %s -+ SKIP $skip -+ LIMIT $limit -+ """, params.sort(), params.sort(), params.order()), -+ Values.parameters("skip", params.skip(), "limit", params.limit(), -+ "favorites", favorites, "name", name)); -+ var movies = result.list(row -> row.get("movie").asMap()); -+ return movies; -+ }); -+ } - } - // end::getByGenre[] - -@@ -182,10 +208,37 @@ public class MovieService { - */ - // tag::getForActor[] - public List> getForActor(String actorId, Params params,String userId) { -- // TODO: Get Movies acted in by a Person -+ // Get Movies acted in by a Person - // MATCH (:Person {tmdbId: $id})-[:ACTED_IN]->(m:Movie) - -- return AppUtils.process(actedInTomHanks, params); -+ // Open a new session -+ try (var session = this.driver.session()) { -+ -+ // Execute a query in a new Read Transaction -+ var movies = session.executeRead(tx -> { -+ // Get an array of IDs for the User's favorite movies -+ var favorites = getUserFavorites(tx, userId); -+ var sort = params.sort(Params.Sort.title); -+ -+ // Retrieve a list of movies with the -+ // favorite flag appended to the movie's properties -+ String query = String.format(""" -+ MATCH (:Person {tmdbId: $id})-[:ACTED_IN]->(m:Movie) -+ WHERE m.`%s` IS NOT NULL -+ RETURN m { -+ .*, -+ favorite: m.tmdbId IN $favorites -+ } AS movie -+ ORDER BY m.`%s` %s -+ SKIP $skip -+ LIMIT $limit -+ """, sort, sort, params.order()); -+ var res = tx.run(query, Values.parameters("skip", params.skip(), "limit", params.limit(), "favorites", favorites, "id", actorId)); -+ // Get a list of Movies from the Result -+ return res.list(row -> row.get("movie").asMap()); -+ }); -+ return movies; -+ } - } - // end::getForActor[] - -@@ -208,10 +261,37 @@ public class MovieService { - */ - // tag::getForDirector[] - public List> getForDirector(String directorId, Params params,String userId) { -- // TODO: Get Movies directed by a Person -+ // Get Movies acted in by a Person - // MATCH (:Person {tmdbId: $id})-[:DIRECTED]->(m:Movie) - -- return AppUtils.process(directedByCoppola, params); -+ // Open a new session -+ try (var session = this.driver.session()) { -+ -+ // Execute a query in a new Read Transaction -+ var movies = session.executeRead(tx -> { -+ // Get an array of IDs for the User's favorite movies -+ var favorites = getUserFavorites(tx, userId); -+ var sort = params.sort(Params.Sort.title); -+ -+ // Retrieve a list of movies with the -+ // favorite flag appended to the movie's properties -+ String query = String.format(""" -+ MATCH (:Person {tmdbId: $id})-[:DIRECTED]->(m:Movie) -+ WHERE m.`%s` IS NOT NULL -+ RETURN m { -+ .*, -+ favorite: m.tmdbId IN $favorites -+ } AS movie -+ ORDER BY m.`%s` %s -+ SKIP $skip -+ LIMIT $limit -+ """, sort, sort, params.order()); -+ var res = tx.run(query, Values.parameters("skip", params.skip(), "limit", params.limit(), "favorites", favorites, "id", directorId)); -+ // Get a list of Movies from the Result -+ return res.list(row -> row.get("movie").asMap()); -+ }); -+ return movies; -+ } - } - // end::getForDirector[] - -@@ -236,6 +316,4 @@ public class MovieService { - return favoriteResult.list(row -> row.get("id").asString()); - } - // end::getUserFavorites[] -- -- record Movie() {} // todo --} -+} -\ No newline at end of file diff --git a/diff/12-movie-details.diff b/diff/12-movie-details.diff deleted file mode 100644 index d6a10cc..0000000 --- a/diff/12-movie-details.diff +++ /dev/null @@ -1,87 +0,0 @@ -diff --git a/src/main/java/neoflix/services/MovieService.java b/src/main/java/neoflix/services/MovieService.java -index 0938190..c6e7b7a 100644 ---- a/src/main/java/neoflix/services/MovieService.java -+++ b/src/main/java/neoflix/services/MovieService.java -@@ -98,10 +98,32 @@ public class MovieService { - */ - // tag::findById[] - public Map findById(String id, String userId) { -- // TODO: Find a movie by its ID -+ // Find a movie by its ID - // MATCH (m:Movie {tmdbId: $id}) - -- return popular.stream().filter(m -> id.equals(m.get("tmdbId"))).findAny().get(); -+ // Open a new database session -+ try (var session = this.driver.session()) { -+ // Find a movie by its ID -+ return session.executeRead(tx -> { -+ var favorites = getUserFavorites(tx, userId); -+ -+ String query = """ -+ MATCH (m:Movie {tmdbId: $id}) -+ RETURN m { -+ .*, -+ actors: [ (a)-[r:ACTED_IN]->(m) | a { .*, role: r.role } ], -+ directors: [ (d)-[:DIRECTED]->(m) | d { .* } ], -+ genres: [ (m)-[:IN_GENRE]->(g) | g { .name }], -+ ratingCount: size((m)<-[:RATED]-()), -+ favorite: m.tmdbId IN $favorites -+ } AS movie -+ LIMIT 1 -+ """; -+ var res = tx.run(query, Values.parameters("id", id, "favorites", favorites)); -+ return res.single().get("movie").asMap(); -+ }); -+ -+ } - } - // end::findById[] - -@@ -125,14 +147,39 @@ public class MovieService { - */ - // tag::getSimilarMovies[] - public List> getSimilarMovies(String id, Params params, String userId) { -- // TODO: Get similar movies based on genres or ratings -- -- return AppUtils.process(popular, params).stream() -- .map(item -> { -- Map copy = new HashMap<>(item); -- copy.put("score", ((int)(Math.random() * 10000)) / 100.0); -- return copy; -- }).toList(); -+ // Get similar movies based on genres or ratings -+ // MATCH (:Movie {tmdbId: $id})-[:IN_GENRE|ACTED_IN|DIRECTED]->()<-[:IN_GENRE|ACTED_IN|DIRECTED]-(m) -+ -+ // Open an Session -+ try (var session = this.driver.session()) { -+ -+ // Get similar movies based on genres or ratings -+ var movies = session.executeRead(tx -> { -+ var favorites = getUserFavorites(tx, userId); -+ String query = """ -+ MATCH (:Movie {tmdbId: $id})-[:IN_GENRE|ACTED_IN|DIRECTED]->()<-[:IN_GENRE|ACTED_IN|DIRECTED]-(m) -+ WHERE m.imdbRating IS NOT NULL -+ -+ WITH m, count(*) AS inCommon -+ WITH m, inCommon, m.imdbRating * inCommon AS score -+ ORDER BY score DESC -+ -+ SKIP $skip -+ LIMIT $limit -+ -+ RETURN m { -+ .*, -+ score: score, -+ favorite: m.tmdbId IN $favorites -+ } AS movie -+ """; -+ var res = tx.run(query, Values.parameters("id", id, "skip", params.skip(), "limit", params.limit(), "favorites", favorites)); -+ // Get a list of Movies from the Result -+ return res.list(row -> row.get("movie").asMap()); -+ }); -+ -+ return movies; -+ } - } - // end::getSimilarMovies[] - diff --git a/diff/13-listing-ratings.diff b/diff/13-listing-ratings.diff deleted file mode 100644 index f6b4d49..0000000 --- a/diff/13-listing-ratings.diff +++ /dev/null @@ -1,119 +0,0 @@ -diff --git a/src/main/java/neoflix/services/MovieService.java b/src/main/java/neoflix/services/MovieService.java -index c6e7b7a..0938190 100644 ---- a/src/main/java/neoflix/services/MovieService.java -+++ b/src/main/java/neoflix/services/MovieService.java -@@ -98,32 +98,10 @@ public class MovieService { - */ - // tag::findById[] - public Map findById(String id, String userId) { -- // Find a movie by its ID -+ // TODO: Find a movie by its ID - // MATCH (m:Movie {tmdbId: $id}) - -- // Open a new database session -- try (var session = this.driver.session()) { -- // Find a movie by its ID -- return session.executeRead(tx -> { -- var favorites = getUserFavorites(tx, userId); -- -- String query = """ -- MATCH (m:Movie {tmdbId: $id}) -- RETURN m { -- .*, -- actors: [ (a)-[r:ACTED_IN]->(m) | a { .*, role: r.role } ], -- directors: [ (d)-[:DIRECTED]->(m) | d { .* } ], -- genres: [ (m)-[:IN_GENRE]->(g) | g { .name }], -- ratingCount: size((m)<-[:RATED]-()), -- favorite: m.tmdbId IN $favorites -- } AS movie -- LIMIT 1 -- """; -- var res = tx.run(query, Values.parameters("id", id, "favorites", favorites)); -- return res.single().get("movie").asMap(); -- }); -- -- } -+ return popular.stream().filter(m -> id.equals(m.get("tmdbId"))).findAny().get(); - } - // end::findById[] - -@@ -147,39 +125,14 @@ public class MovieService { - */ - // tag::getSimilarMovies[] - public List> getSimilarMovies(String id, Params params, String userId) { -- // Get similar movies based on genres or ratings -- // MATCH (:Movie {tmdbId: $id})-[:IN_GENRE|ACTED_IN|DIRECTED]->()<-[:IN_GENRE|ACTED_IN|DIRECTED]-(m) -- -- // Open an Session -- try (var session = this.driver.session()) { -- -- // Get similar movies based on genres or ratings -- var movies = session.executeRead(tx -> { -- var favorites = getUserFavorites(tx, userId); -- String query = """ -- MATCH (:Movie {tmdbId: $id})-[:IN_GENRE|ACTED_IN|DIRECTED]->()<-[:IN_GENRE|ACTED_IN|DIRECTED]-(m) -- WHERE m.imdbRating IS NOT NULL -- -- WITH m, count(*) AS inCommon -- WITH m, inCommon, m.imdbRating * inCommon AS score -- ORDER BY score DESC -- -- SKIP $skip -- LIMIT $limit -- -- RETURN m { -- .*, -- score: score, -- favorite: m.tmdbId IN $favorites -- } AS movie -- """; -- var res = tx.run(query, Values.parameters("id", id, "skip", params.skip(), "limit", params.limit(), "favorites", favorites)); -- // Get a list of Movies from the Result -- return res.list(row -> row.get("movie").asMap()); -- }); -- -- return movies; -- } -+ // TODO: Get similar movies based on genres or ratings -+ -+ return AppUtils.process(popular, params).stream() -+ .map(item -> { -+ Map copy = new HashMap<>(item); -+ copy.put("score", ((int)(Math.random() * 10000)) / 100.0); -+ return copy; -+ }).toList(); - } - // end::getSimilarMovies[] - -diff --git a/src/main/java/neoflix/services/RatingService.java b/src/main/java/neoflix/services/RatingService.java -index 1b04ee1..a2a1e09 100644 ---- a/src/main/java/neoflix/services/RatingService.java -+++ b/src/main/java/neoflix/services/RatingService.java -@@ -44,9 +44,25 @@ public class RatingService { - */ - // tag::forMovie[] - public List> forMovie(String id, Params params) { -- // TODO: Get ratings for a Movie -+ // Open a new database session -+ try (var session = this.driver.session()) { - -- return AppUtils.process(ratings,params); -+ // Get ratings for a Movie -+ return session.executeRead(tx -> { -+ String query = String.format(""" -+ MATCH (u:User)-[r:RATED]->(m:Movie {tmdbId: $id}) -+ RETURN r { -+ .rating, -+ .timestamp, -+ user: u { .id, .name } -+ } AS review -+ ORDER BY r.`%s` %s -+ SKIP $skip -+ LIMIT $limit""", params.sort(Params.Sort.timestamp), params.order()); -+ var res = tx.run(query, Values.parameters("id", id, "limit", params.limit(), "skip", params.skip())); -+ return res.list(row -> row.get("review").asMap()); -+ }); -+ } - } - // end::forMovie[] - diff --git a/diff/14-person-list.diff b/diff/14-person-list.diff deleted file mode 100644 index a313312..0000000 --- a/diff/14-person-list.diff +++ /dev/null @@ -1,40 +0,0 @@ -diff --git a/src/main/java/neoflix/services/PeopleService.java b/src/main/java/neoflix/services/PeopleService.java -index 6fcee1f..a3970eb 100644 ---- a/src/main/java/neoflix/services/PeopleService.java -+++ b/src/main/java/neoflix/services/PeopleService.java -@@ -38,14 +38,28 @@ public class PeopleService { - */ - // tag::all[] - public List> all(Params params) { -- // TODO: Get a list of people from the database -- if (params.query() != null) { -- return AppUtils.process(people.stream() -- .filter(p -> ((String)p.get("name")) -- .contains(params.query())) -- .toList(), params); -+ // Open a new database session -+ try (var session = driver.session()) { -+ // Get a list of people from the database -+ var res = session.executeRead(tx -> { -+ String statement = String.format(""" -+ MATCH (p:Person) -+ WHERE $q IS null OR p.name CONTAINS $q -+ RETURN p { .* } AS person -+ ORDER BY p.`%s` %s -+ SKIP $skip -+ LIMIT $limit -+ """, params.sort(Params.Sort.name), params.order()); -+ return tx.run(statement -+ , Values.parameters("q", params.query(), "skip", params.skip(), "limit", params.limit())) -+ .list(row -> row.get("person").asMap()); -+ }); -+ -+ return res; -+ } catch(Exception e) { -+ e.printStackTrace(); - } -- return AppUtils.process(people, params); -+ return List.of(); - } - // end::all[] - diff --git a/diff/15-person-profile.diff b/diff/15-person-profile.diff deleted file mode 100644 index 333ae7e..0000000 --- a/diff/15-person-profile.diff +++ /dev/null @@ -1,60 +0,0 @@ -diff --git a/src/main/java/neoflix/services/PeopleService.java b/src/main/java/neoflix/services/PeopleService.java -index a3970eb..f3ce092 100644 ---- a/src/main/java/neoflix/services/PeopleService.java -+++ b/src/main/java/neoflix/services/PeopleService.java -@@ -73,9 +73,24 @@ public class PeopleService { - */ - // tag::findById[] - public Map findById(String id) { -- // TODO: Find a user by their ID -+ // Open a new database session -+ try (var session = driver.session()) { - -- return people.stream().filter(p -> id.equals(p.get("tmdbId"))).findAny().get(); -+ // Get a person from the database -+ var person = session.executeRead(tx -> { -+ String query = """ -+ MATCH (p:Person {tmdbId: $id}) -+ RETURN p { -+ .*, -+ actedCount: size((p)-[:ACTED_IN]->()), -+ directedCount: size((p)-[:DIRECTED]->()) -+ } AS person -+ """; -+ var res = tx.run(query, Values.parameters("id", id)); -+ return res.single().get("person").asMap(); -+ }); -+ return person; -+ } - } - // end::findById[] - -@@ -89,9 +104,26 @@ public class PeopleService { - */ - // tag::getSimilarPeople[] - public List> getSimilarPeople(String id, Params params) { -- // TODO: Get a list of similar people to the person by their id -+ // Open a new database session -+ try (var session = driver.session()) { -+ -+ // Get a list of similar people to the person by their id -+ var res = session.executeRead(tx -> tx.run(""" -+ MATCH (:Person {tmdbId: $id})-[:ACTED_IN|DIRECTED]->(m)<-[r:ACTED_IN|DIRECTED]-(p) -+ RETURN p { -+ .*, -+ actedCount: size((p)-[:ACTED_IN]->()), -+ directedCount: size((p)-[:DIRECTED]->()), -+ inCommon: collect(m {.tmdbId, .title, type: type(r)}) -+ } AS person -+ ORDER BY size(person.inCommon) DESC -+ SKIP $skip -+ LIMIT $limit -+ """,Values.parameters("id",id, "skip", params.skip(), "limit", params.limit())) -+ .list(row -> row.get("person").asMap())); - -- return AppUtils.process(people, params); -+ return res; -+ } - } - // end::getSimilarPeople[] - diff --git a/pom.xml b/pom.xml index 8c91aec..46fe307 100644 --- a/pom.xml +++ b/pom.xml @@ -17,46 +17,46 @@ io.javalin javalin - 4.6.4 + 6.6.0 org.slf4j slf4j-simple - 1.7.31 + 2.0.17 - + org.neo4j.driver neo4j-java-driver - 5.1.0 + 5.28.5 - + com.google.code.gson gson - 2.8.9 + 2.13.1 com.auth0 java-jwt - 3.19.2 + 4.5.0 at.favre.lib bcrypt - 0.9.0 + 0.10.2 io.projectreactor reactor-core - 3.4.21 + 3.7.6 true org.junit.jupiter junit-jupiter - 5.8.2 + 5.12.2 test @@ -72,7 +72,7 @@ org.codehaus.mojo exec-maven-plugin - 3.0.0 + 3.5.0 neoflix.NeoflixApp @@ -80,12 +80,12 @@ org.apache.maven.plugins maven-surefire-plugin - 3.0.0-M5 + 3.5.3 org.apache.maven.plugins maven-compiler-plugin - 3.10.1 + 3.14.0 diff --git a/src/main/java/example/AsyncApi.java b/src/main/java/example/AsyncApi.java index da548db..f105d5e 100644 --- a/src/main/java/example/AsyncApi.java +++ b/src/main/java/example/AsyncApi.java @@ -2,16 +2,19 @@ // tag::import[] // Import all relevant classes from neo4j-java-driver dependency import neoflix.AppUtils; + +import org.neo4j.driver.AuthTokens; +import org.neo4j.driver.Driver; +import org.neo4j.driver.GraphDatabase; + +import org.neo4j.driver.async.AsyncSession; + import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import org.neo4j.driver.*; -import org.neo4j.driver.reactive.RxSession; +import org.neo4j.driver.reactivestreams.ReactiveResult; +import org.neo4j.driver.reactivestreams.ReactiveSession; // end::import[] -import java.util.*; -import java.util.concurrent.TimeUnit; -import java.util.logging.Level; - public class AsyncApi { static { @@ -27,7 +30,7 @@ static void syncExample() { // tag::sync[] try (var session = driver.session()) { - var res = session.readTransaction(tx -> tx.run( + var res = session.executeRead(tx -> tx.run( "MATCH (p:Person) RETURN p.name AS name LIMIT 10").list()); res.stream() .map(row -> row.get("name")) @@ -42,8 +45,8 @@ static void syncExample() { static void asyncExample() { // tag::async[] - var session = driver.asyncSession(); - session.readTransactionAsync(tx -> tx.runAsync( + var session = driver.session(AsyncSession.class); + session.executeReadAsync(tx -> tx.runAsync( "MATCH (p:Person) RETURN p.name AS name LIMIT 10") .thenApplyAsync(res -> res.listAsync(row -> row.get("name"))) @@ -58,17 +61,18 @@ static void asyncExample() { static void reactiveExample() { // tag::reactive[] - Flux.usingWhen(Mono.fromSupplier(driver::rxSession), - session -> session.readTransaction(tx -> { - var rxResult = tx.run( - "MATCH (p:Person) RETURN p.name AS name LIMIT 10"); - return Flux - .from(rxResult.records()) - .map(r -> r.get("name").asString()) + Flux names = Flux.usingWhen( + Mono.just(driver.session(ReactiveSession.class)), + session -> session.executeRead(tx -> + Mono.fromDirect(tx.run("MATCH (p:Person) RETURN p.name AS name LIMIT 10")) + .flatMapMany(ReactiveResult::records) + .map(record -> record.get("name").asString()) .doOnNext(System.out::println) - .then(Mono.from(rxResult.consume())); - } - ), RxSession::close); + ), + session -> Mono.from(session.close()) + ); + // Optionally block to consume all results (for demonstration) + names.then().block(); // end::reactive[] } } \ No newline at end of file diff --git a/src/main/java/example/CatchErrors.java b/src/main/java/example/CatchErrors.java index f8355f2..5664110 100644 --- a/src/main/java/example/CatchErrors.java +++ b/src/main/java/example/CatchErrors.java @@ -8,8 +8,6 @@ import neoflix.ValidationException; import java.util.*; -import java.util.concurrent.TimeUnit; -import java.util.logging.Level; public class CatchErrors { @@ -24,7 +22,7 @@ public static void main () { String email = "uniqueconstraint@example.com"; // tag::constraint-error[] try (var session = driver.session()) { - session.writeTransaction(tx -> { + session.executeWrite(tx -> { var res = tx.run("CREATE (u:User {email: $email}) RETURN u", Values.parameters("email", email)); return res.single().get('u').asMap(); diff --git a/src/main/java/example/Index.java b/src/main/java/example/Index.java index c27618c..c9a0f09 100644 --- a/src/main/java/example/Index.java +++ b/src/main/java/example/Index.java @@ -5,8 +5,6 @@ // end::import[] import java.util.*; -import java.util.concurrent.TimeUnit; -import java.util.logging.Level; public class Index { /** @@ -56,19 +54,15 @@ public class Index { // end::driver[] // tag::configuration[] - Config config = Config.builder() - .withConnectionTimeout(30, TimeUnit.SECONDS) - .withMaxConnectionLifetime(30, TimeUnit.MINUTES) - .withMaxConnectionPoolSize(10) - .withConnectionAcquisitionTimeout(20, TimeUnit.SECONDS) + SessionConfig config = SessionConfig.builder() + .withDatabase("neo4j") + .withDefaultAccessMode(AccessMode.READ) .withFetchSize(1000) - .withDriverMetrics() - .withLogging(Logging.console(Level.INFO)) .build(); // end::configuration[] /** - * It is considered best practise to inject an instance of the driver. + * It is considered best practice to inject an instance of the driver. * This way the object can be mocked within unit tests */ public static class MyService { @@ -103,36 +97,29 @@ public static void main () { System.out.println("Connection verified!"); - // tag::driver.session[] - // Open a new session - var session = driver.session(); - // end::driver.session[] - - // tag::session.run[] + // tag::driver.executableQuery[] var query = "MATCH () RETURN count(*) AS count"; - var params = Values.parameters(); // Run a query in an auto-commit transaction - var res = session.run(query, params).single().get("count").asLong(); - // end::session.run[] - - System.out.println(res); - - // tag::session.close[] - // Close the session - session.close(); - // end::session.close[] + driver.executableQuery(query) + .execute() + .records() + .forEach(r -> { + // Print the result + System.out.println(r.get("count").asLong()); + }); + // end::driver.executableQuery[] new MyService(driver).method(); driver.close(); } -private static void showReadTransaction (Driver driver){ +private static void showReadTransaction(Driver driver){ try (var session = driver.session()) { // tag::session.readTransaction[] // Run a query within a Read Transaction - var res = session.readTransaction(tx -> { + var res = session.executeRead(tx -> { return tx.run(""" MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WHERE m.title = $title // <1> @@ -147,11 +134,11 @@ private static void showReadTransaction (Driver driver){ } } -private static void showWriteTransaction (Driver driver){ +private static void showWriteTransaction(Driver driver){ try (var session = driver.session()) { // tag::session.writeTransaction[] - session.writeTransaction(tx -> { + session.executeWrite(tx -> { return tx.run( "CREATE (p:Person {name: $name})", Values.parameters("name", "Michael")).consume(); @@ -211,7 +198,7 @@ private static Map createPerson(String name) { // end::sessionWithArgs[] // Create a node within a write transaction - var res = session.writeTransaction(tx -> + var res = session.executeWrite(tx -> tx.run("CREATE (p:Person {name: $name}) RETURN p", Values.parameters("name", name)) .single()); diff --git a/src/main/java/example/Results.java b/src/main/java/example/Results.java index 8835e38..8ae7b43 100644 --- a/src/main/java/example/Results.java +++ b/src/main/java/example/Results.java @@ -7,7 +7,6 @@ import org.neo4j.driver.summary.SummaryCounters; import org.neo4j.driver.types.*; -import java.util.Iterator; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; @@ -31,7 +30,7 @@ public static void main(String[] args) { // tag::run[] // Execute a query within a read transaction - Result res = session.readTransaction(tx -> tx.run(""" + Result res = session.executeRead(tx -> tx.run(""" MATCH path = (person:Person)-[actedIn:ACTED_IN]->(movie:Movie) RETURN path, person, actedIn, movie, size ( (person)-[:ACTED]->() ) as movieCount, @@ -110,11 +109,11 @@ public static void main(String[] args) { // tag::rel[] var actedIn = row.get("actedIn").asRelationship(); - var relId = actedIn.id(); // (1) + var relId = actedIn.elementId(); // (1) String type = actedIn.type(); // (2) var relProperties = actedIn.asMap(); // (3) - var startId = actedIn.startNodeId(); // (4) - var endId = actedIn.endNodeId(); // (5) + var startId = actedIn.startNodeElementId(); // (4) + var endId = actedIn.endNodeElementId(); // (5) // end::rel[] // Working with Paths diff --git a/src/main/java/neoflix/AppUtils.java b/src/main/java/neoflix/AppUtils.java index e0e6440..4c5439d 100644 --- a/src/main/java/neoflix/AppUtils.java +++ b/src/main/java/neoflix/AppUtils.java @@ -1,6 +1,5 @@ package neoflix; -import com.google.gson.Gson; import org.neo4j.driver.AuthToken; import org.neo4j.driver.AuthTokens; import org.neo4j.driver.Driver; @@ -12,9 +11,7 @@ import java.io.InputStreamReader; import java.util.List; import java.util.Map; -import java.util.function.Function; - -import javax.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequest; public class AppUtils { public static void loadProperties() { @@ -45,8 +42,10 @@ static void handleAuthAndSetUser(HttpServletRequest request, String jwtSecret) { // tag::initDriver[] static Driver initDriver() { - // TODO: Create and assign an instance of the driver here - return null; + AuthToken auth = AuthTokens.basic(getNeo4jUsername(), getNeo4jPassword()); + Driver driver = GraphDatabase.driver(getNeo4jUri(), auth); + driver.verifyConnectivity(); + return driver; } // end::initDriver[] @@ -68,23 +67,37 @@ static String getNeo4jPassword() { return System.getProperty("NEO4J_PASSWORD"); } - public static List> loadFixtureList(final String name) { - var fixture = new InputStreamReader(AppUtils.class.getResourceAsStream("/fixtures/" + name + ".json")); - return GsonUtils.gson().fromJson(fixture,List.class); + public static List> loadFixtureList(final String name) { + try (var fixture = new InputStreamReader(AppUtils.class.getResourceAsStream("/fixtures/" + name + ".json"))) { + var type = new com.google.gson.reflect.TypeToken>>(){}.getType(); + return GsonUtils.gson().fromJson(fixture, type); + } catch (IOException e) { + throw new RuntimeException("Error loading fixture: " + name, e); + } } + public static List> process(List> result, Params params) { return params == null ? result : result.stream() - .sorted((m1, m2) -> - (params.order() == Params.Order.ASC ? 1 : -1) * - ((Comparable)m1.getOrDefault(params.sort().name(),"")).compareTo( - m2.getOrDefault(params.sort().name(),"") - )) + .sorted((m1, m2) -> { + Object v1 = m1.getOrDefault(params.sort().name(), ""); + Object v2 = m2.getOrDefault(params.sort().name(), ""); + if (v1 instanceof Comparable c1 && v2 != null && c1.getClass().isInstance(v2)) { + @SuppressWarnings("unchecked") + int cmp = ((Comparable) c1).compareTo(v2); + return (params.order() == Params.Order.ASC ? 1 : -1) * cmp; + } + return 0; + }) .skip(params.skip()).limit(params.limit()) .toList(); } - public static Map loadFixtureSingle(final String name) { - var fixture = new InputStreamReader(AppUtils.class.getResourceAsStream("/fixtures/" + name + ".json")); - return GsonUtils.gson().fromJson(fixture,Map.class); + public static Map loadFixtureSingle(final String name) { + try (var fixture = new InputStreamReader(AppUtils.class.getResourceAsStream("/fixtures/" + name + ".json"))) { + var type = new com.google.gson.reflect.TypeToken>(){}.getType(); + return GsonUtils.gson().fromJson(fixture, type); + } catch (IOException e) { + throw new RuntimeException("Error loading fixture: " + name, e); + } } } diff --git a/src/main/java/neoflix/GsonUtils.java b/src/main/java/neoflix/GsonUtils.java index 167c982..2cb4d6b 100644 --- a/src/main/java/neoflix/GsonUtils.java +++ b/src/main/java/neoflix/GsonUtils.java @@ -10,7 +10,7 @@ public class GsonUtils { public static Gson gson() { try { - Class type = Class.forName("java.util.Collections$EmptyList"); + Class type = Class.forName("java.util.Collections$EmptyList"); GsonBuilder gsonBuilder = new GsonBuilder() .registerTypeAdapter(LocalDate.class, new LocalDateSerializer()) .setNumberToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) @@ -30,10 +30,9 @@ public JsonElement serialize(LocalDate localDate, Type srcType, JsonSerializatio return new JsonPrimitive(formatter.format(localDate)); } } - static class EmptyListSerializer implements JsonSerializer { - + static class EmptyListSerializer implements JsonSerializer> { @Override - public JsonElement serialize(List list, Type srcType, JsonSerializationContext context) { + public JsonElement serialize(List list, Type srcType, JsonSerializationContext context) { return new JsonArray(0); } } diff --git a/src/main/java/neoflix/NeoflixApp.java b/src/main/java/neoflix/NeoflixApp.java index 7379673..d1e2419 100644 --- a/src/main/java/neoflix/NeoflixApp.java +++ b/src/main/java/neoflix/NeoflixApp.java @@ -21,30 +21,30 @@ public static void main(String[] args) { var port = AppUtils.getServerPort(); var gson = GsonUtils.gson(); + // Initialize the Javalin server with API endpoints var server = Javalin .create(config -> { - config.addStaticFiles("/", Location.CLASSPATH); - config.addStaticFiles(staticFiles -> { - staticFiles.hostedPath = "/"; + config.staticFiles.add(staticFiles -> { staticFiles.directory = "/public"; staticFiles.location = Location.CLASSPATH; }); - }) - .before(ctx -> AppUtils.handleAuthAndSetUser(ctx.req, jwtSecret)) - .routes(() -> { - path("/api", () -> { - path("/movies", new MovieRoutes(driver, gson)); - path("/genres", new GenreRoutes(driver, gson)); - path("/auth", new AuthRoutes(driver, gson, jwtSecret)); - path("/account", new AccountRoutes(driver, gson)); - path("/people", new PeopleRoutes(driver, gson)); + config.router.apiBuilder(() -> { + path("api", () -> { + path("movies", () -> new MovieRoutes(driver, gson).addEndpoints()); + path("genres", () -> new GenreRoutes(driver, gson).addEndpoints()); + path("auth", () -> new AuthRoutes(driver, gson, jwtSecret).addEndpoints()); + path("account", () -> new AccountRoutes(driver, gson).addEndpoints()); + path("people", () -> new PeopleRoutes(driver, gson).addEndpoints()); + }); }); }) + .before(ctx -> AppUtils.handleAuthAndSetUser(ctx.req(), jwtSecret)) .exception(ValidationException.class, (exception, ctx) -> { var body = Map.of("message", exception.getMessage(), "details", exception.getDetails()); ctx.status(422).contentType("application/json").result(gson.toJson(body)); - }) - .start(port); + }); + + server.start(port); System.out.printf("Server listening on http://localhost:%d/%n", port); } } \ No newline at end of file diff --git a/src/main/java/neoflix/Params.java b/src/main/java/neoflix/Params.java index b67bc3f..9188760 100644 --- a/src/main/java/neoflix/Params.java +++ b/src/main/java/neoflix/Params.java @@ -21,8 +21,8 @@ static Order of(String value) { } } - public enum Sort { /* Movie */ - title, released, imdbRating, score, + public enum Sort { + /* Movie */ title, released, imdbRating, score, /* Person */ name, born, movieCount, /* */ rating, timestamp; diff --git a/src/main/java/neoflix/services/AuthService.java b/src/main/java/neoflix/services/AuthService.java index a5e479a..8db10aa 100644 --- a/src/main/java/neoflix/services/AuthService.java +++ b/src/main/java/neoflix/services/AuthService.java @@ -4,11 +4,12 @@ import neoflix.AuthUtils; import neoflix.ValidationException; import org.neo4j.driver.Driver; +import org.neo4j.driver.Values; +import org.neo4j.driver.exceptions.ClientException; +import org.neo4j.driver.exceptions.NoSuchRecordException; import java.util.List; import java.util.Map; -import java.util.Optional; -import java.util.UUID; public class AuthService { @@ -46,23 +47,41 @@ public AuthService(Driver driver, String jwtSecret) { // tag::register[] public Map register(String email, String plainPassword, String name) { var encrypted = AuthUtils.encryptPassword(plainPassword); - // tag::constraintError[] - // TODO: Handle Unique constraints in the database - var foundUser = users.stream().filter(u -> u.get("email").equals(email)).findAny(); - if (foundUser.isPresent()) { - throw new RuntimeException("An account already exists with the email address"); - } - // end::constraintError[] - - // TODO: Save user in database - var user = Map.of("email",email, "name",name, - "userId", String.valueOf(email.hashCode()), "password", encrypted); - users.add(user); + // Open a new Session + try (var session = this.driver.session()) { + // tag::create[] + var user = session.executeWrite(tx -> { + String statement = """ + CREATE (u:User { + userId: randomUuid(), + email: $email, + password: $encrypted, + name: $name + }) + RETURN u { .userId, .name, .email } as u"""; + var res = tx.run(statement, Values.parameters("email", email, "encrypted", encrypted, "name", name)); + // end::create[] + // tag::extract[] + // Extract safe properties from the user node (`u`) in the first row + return res.single().get("u").asMap(); + // end::extract[] - String sub = (String) user.get("userId"); - String token = AuthUtils.sign(sub,userToClaims(user), jwtSecret); + }); + String sub = (String)user.get("userId"); + String token = AuthUtils.sign(sub,userToClaims(user), jwtSecret); - return userWithToken(user, token); + // tag::return-register[] + return userWithToken(user, token); + // end::return-register[] + // tag::catch[] + } catch (ClientException e) { + // Handle unique constraints in the database + if (e.code().equals("Neo.ClientError.Schema.ConstraintValidationFailed")) { + throw new ValidationException("An account already exists with the email address", Map.of("email","Email address already taken")); + } + throw e; + } + // end::catch[] } // end::register[] @@ -88,20 +107,35 @@ public Map register(String email, String plainPassword, String na */ // tag::authenticate[] public Map authenticate(String email, String plainPassword) { - // TODO: Authenticate the user from the database - var foundUser = users.stream().filter(u -> u.get("email").equals(email)).findAny(); - if (foundUser.isEmpty()) + // Open a new Session + try (var session = this.driver.session()) { + // tag::query[] + // Find the User node within a Read Transaction + var user = session.executeRead(tx -> { + String statement = "MATCH (u:User {email: $email}) RETURN u"; + var res = tx.run(statement, Values.parameters("email", email)); + return res.single().get("u").asMap(); + + }); + // end::query[] + + // tag::password[] + // Check password + if (!AuthUtils.verifyPassword(plainPassword, (String)user.get("password"))) { + throw new ValidationException("Incorrect password", Map.of("password","Incorrect password")); + } + // end::password[] + + // tag::auth-return[] + String sub = (String)user.get("userId"); + String token = AuthUtils.sign(sub, userToClaims(user), jwtSecret); + return userWithToken(user, token); + // end::auth-return[] + // tag::auth-catch[] + } catch(NoSuchRecordException e) { throw new ValidationException("Incorrect email", Map.of("email","Incorrect email")); - var user = foundUser.get(); - if (!plainPassword.equals(user.get("password")) && - !AuthUtils.verifyPassword(plainPassword,(String)user.get("password"))) { // - throw new ValidationException("Incorrect password", Map.of("password","Incorrect password")); } - // tag::return[] - String sub = (String) user.get("userId"); - String token = AuthUtils.sign(sub, userToClaims(user), jwtSecret); - return userWithToken(user, token); - // end::return[] + // end::auth-catch[] } // end::authenticate[] diff --git a/src/main/java/neoflix/services/FavoriteService.java b/src/main/java/neoflix/services/FavoriteService.java index 326c108..667119f 100644 --- a/src/main/java/neoflix/services/FavoriteService.java +++ b/src/main/java/neoflix/services/FavoriteService.java @@ -2,9 +2,12 @@ import neoflix.AppUtils; import neoflix.Params; +import neoflix.ValidationException; + import org.neo4j.driver.Driver; +import org.neo4j.driver.Values; +import org.neo4j.driver.exceptions.NoSuchRecordException; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -43,11 +46,25 @@ public FavoriteService(Driver driver) { */ // tag::all[] public List> all(String userId, Params params) { - // TODO: Open a new session - // TODO: Retrieve a list of movies favorited by the user - // TODO: Close session - - return AppUtils.process(userFavorites.getOrDefault(userId, List.of()),params); + // Open a new session + try (var session = this.driver.session()) { + // Retrieve a list of movies favorited by the user + var favorites = session.executeRead(tx -> { + String query = String.format(""" + MATCH (u:User {userId: $userId})-[r:HAS_FAVORITE]->(m:Movie) + RETURN m { + .*, + favorite: true + } AS movie + ORDER BY m.`%s` %s + SKIP $skip + LIMIT $limit + """, params.sort(Params.Sort.title), params.order()); + var res = tx.run(query, Values.parameters("userId", userId, "skip", params.skip(), "limit", params.limit())); + return res.list(row -> row.get("movie").asMap()); + }); + return favorites; + } } // end::all[] @@ -63,25 +80,39 @@ public List> all(String userId, Params params) { */ // tag::add[] public Map add(String userId, String movieId) { - // TODO: Open a new Session - // TODO: Create HAS_FAVORITE relationship within a Write Transaction - // TODO: Close the session - // TODO: Return movie details and `favorite` property - - var foundMovie = popular.stream().filter(m -> movieId.equals(m.get("tmdbId"))).findAny(); - - if (users.stream().anyMatch(u -> u.get("userId").equals(userId)) || foundMovie.isEmpty()) { - throw new RuntimeException("Couldn't create a favorite relationship for User %s and Movie %s".formatted(userId, movieId)); + // Open a new Session + try (var session = this.driver.session()) { + // tag::create[] + // Create HAS_FAVORITE relationship within a Write Transaction + var favorite = session.executeWrite(tx -> { + String statement = """ + MATCH (u:User {userId: $userId}) + MATCH (m:Movie {tmdbId: $movieId}) + + MERGE (u)-[r:HAS_FAVORITE]->(m) + ON CREATE SET r.createdAt = datetime() + + RETURN m { + .*, + favorite: true + } AS movie + """; + var res = tx.run(statement, Values.parameters("userId", userId, "movieId", movieId)); + // return res.single().get("movie").asMap(); + return res.single().get("movie").asMap(); + }); + // end::create[] + + // tag::return[] + // Return movie details and `favorite` property + return favorite; + // end::return[] + // tag::throw[] + // Throw an error if the user or movie could not be found + } catch (NoSuchRecordException e) { + throw new ValidationException(String.format("Couldn't create a favorite relationship for User %s and Movie %s", userId, movieId), Map.of("movie",movieId, "user",userId)); } - - var movie = foundMovie.get(); - var favorites = userFavorites.computeIfAbsent(userId, (k) -> new ArrayList<>()); - if (!favorites.contains(movie)) { - favorites.add(movie); - } - var copy = new HashMap<>(movie); - copy.put("favorite", true); - return copy; + // end::throw[] } // end::add[] @@ -97,23 +128,35 @@ public Map add(String userId, String movieId) { */ // tag::remove[] public Map remove(String userId, String movieId) { - // TODO: Open a new Session - // TODO: Delete the HAS_FAVORITE relationship within a Write Transaction - // TODO: Close the session - // TODO: Return movie details and `favorite` property - if (users.stream().anyMatch(u -> u.get("userId").equals(userId))) { - throw new RuntimeException("Couldn't remove a favorite relationship for User %s and Movie %s".formatted(userId, movieId)); + // Open a new Session + try (var session = this.driver.session()) { + // tag::remove[] + // Removes HAS_FAVORITE relationship within a Write Transaction + var favorite = session.executeWrite(tx -> { + String statement = """ + MATCH (u:User {userId: $userId})-[r:HAS_FAVORITE]->(m:Movie {tmdbId: $movieId}) + DELETE r + + RETURN m { + .*, + favorite: false + } AS movie + """; + var res = tx.run(statement, Values.parameters("userId", userId, "movieId", movieId)); + return res.single().get("movie").asMap(); + }); + // end::remove[] + + // tag::return-remove[] + // Return movie details and `favorite` property + return favorite; + // end::return-remove[] + // tag::throw-remove[] + // Throw an error if the user or movie could not be found + } catch (NoSuchRecordException e) { + throw new ValidationException("Could not remove favorite movie for user", Map.of("movie",movieId, "user",userId)); } - - var movie = popular.stream().filter(m -> movieId.equals(m.get("tmdbId"))).findAny().get(); - var favorites = userFavorites.computeIfAbsent(userId, (k) -> new ArrayList<>()); - if (favorites.contains(movie)) { - favorites.remove(movie); - } - - var copy = new HashMap<>(movie); - copy.put("favorite", false); - return copy; + // end::throw-remove[] } // end::remove[] diff --git a/src/main/java/neoflix/services/GenreService.java b/src/main/java/neoflix/services/GenreService.java index fcac519..e622d83 100644 --- a/src/main/java/neoflix/services/GenreService.java +++ b/src/main/java/neoflix/services/GenreService.java @@ -2,6 +2,7 @@ import neoflix.AppUtils; import org.neo4j.driver.Driver; +import org.neo4j.driver.Values; import java.util.List; import java.util.Map; @@ -34,11 +35,36 @@ public GenreService(Driver driver) { */ // tag::all[] public List> all() { - // TODO: Open a new session - // TODO: Get a list of Genres from the database - // TODO: Close the session + // Open a new Session, close automatically at the end + try (var session = driver.session()) { + // Get a list of Genres from the database + var query = """ + MATCH (g:Genre) + WHERE g.name <> '(no genres listed)' + CALL { + WITH g + MATCH (g)<-[:IN_GENRE]-(m:Movie) + WHERE m.imdbRating IS NOT NULL + AND m.poster IS NOT NULL + RETURN m.poster AS poster + ORDER BY m.imdbRating DESC LIMIT 1 + } + RETURN g { + .name, + link: '/genres/'+ g.name, + poster: poster, + movies: count { (g)<-[:IN_GENRE]-() } + } as genre + ORDER BY g.name ASC + """; + var genres = session.executeRead( + tx -> tx.run(query) + .list(row -> + row.get("genre").asMap())); - return genres; + // Return results + return genres; + } } // end::all[] @@ -53,15 +79,33 @@ public List> all() { */ // tag::find[] public Map find(String name) { - // TODO: Open a new session - // TODO: Get Genre information from the database - // TODO: Throw a 404 Error if the genre is not found - // TODO: Close the session + // Open a new Session, close automatically at the end + try (var session = driver.session()) { + // Get a list of Genres from the database + var query = """ + MATCH (g:Genre {name: $name})<-[:IN_GENRE]-(m:Movie) + WHERE m.imdbRating IS NOT NULL + AND m.poster IS NOT NULL + AND g.name <> '(no genres listed)' + WITH g, m + ORDER BY m.imdbRating DESC - return genres.stream() - .filter(genre -> genre.get("name").equals(name)) - .findFirst() - .orElseThrow(() -> new RuntimeException("Genre "+name+" not found")); + WITH g, head(collect(m)) AS movie + + RETURN g { + link: '/genres/'+ g.name, + .name, + movies: count { (g)<-[:IN_GENRE]-() }, + poster: movie.poster + } AS genre + """; + var genre = session.executeRead( + tx -> tx.run(query, Values.parameters("name", name)) + // Throw a NoSuchRecordException if the genre is not found + .single().get("genre").asMap()); + // Return results + return genre; + } } // end::find[] } diff --git a/src/main/java/neoflix/services/MovieService.java b/src/main/java/neoflix/services/MovieService.java index e320cc3..c819735 100644 --- a/src/main/java/neoflix/services/MovieService.java +++ b/src/main/java/neoflix/services/MovieService.java @@ -1,13 +1,11 @@ package neoflix.services; import neoflix.AppUtils; -import neoflix.NeoflixApp; import neoflix.Params; import org.neo4j.driver.Driver; import org.neo4j.driver.TransactionContext; import org.neo4j.driver.Values; -import java.util.HashMap; import java.util.List; import java.util.Map; @@ -44,12 +42,40 @@ public MovieService(Driver driver) { */ // tag::all[] public List> all(Params params, String userId) { - // TODO: Open an Session - // TODO: Execute a query in a new Read Transaction - // TODO: Get a list of Movies from the Result - // TODO: Close the session + // Open a new session + try (var session = this.driver.session()) { + // tag::allcypher[] + // Execute a query in a new Read Transaction + var movies = session.executeRead(tx -> { + // Get an array of IDs for the User's favorite movies + var favorites = getUserFavorites(tx, userId); - return AppUtils.process(popular, params); + // Retrieve a list of movies with the + // favorite flag appened to the movie's properties + Params.Sort sort = params.sort(Params.Sort.title); + String query = String.format(""" + MATCH (m:Movie) + WHERE m.`%s` IS NOT NULL + RETURN m { + .*, + favorite: m.tmdbId IN $favorites + } AS movie + ORDER BY m.`%s` %s + SKIP $skip + LIMIT $limit + """, sort, sort, params.order()); + var res= tx.run(query, Values.parameters( "skip", params.skip(), "limit", params.limit(), "favorites",favorites)); + // tag::allmovies[] + // Get a list of Movies from the Result + return res.list(row -> row.get("movie").asMap()); + // end::allmovies[] + }); + // end::allcypher[] + + // tag::return[] + return movies; + // end::return[] + } } // end::all[] @@ -69,10 +95,32 @@ public List> all(Params params, String userId) { */ // tag::findById[] public Map findById(String id, String userId) { - // TODO: Find a movie by its ID + // Find a movie by its ID // MATCH (m:Movie {tmdbId: $id}) - return popular.stream().filter(m -> id.equals(m.get("tmdbId"))).findAny().get(); + // Open a new database session + try (var session = this.driver.session()) { + // Find a movie by its ID + return session.executeRead(tx -> { + var favorites = getUserFavorites(tx, userId); + + String query = """ + MATCH (m:Movie {tmdbId: $id}) + RETURN m { + .*, + actors: [ (a)-[r:ACTED_IN]->(m) | a { .*, role: r.role } ], + directors: [ (d)-[:DIRECTED]->(m) | d { .* } ], + genres: [ (m)-[:IN_GENRE]->(g) | g { .name }], + ratingCount: count {(m)<-[:RATED]-() }, + favorite: m.tmdbId IN $favorites + } AS movie + LIMIT 1 + """; + var res = tx.run(query, Values.parameters("id", id, "favorites", favorites)); + return res.single().get("movie").asMap(); + }); + + } } // end::findById[] @@ -96,14 +144,39 @@ public Map findById(String id, String userId) { */ // tag::getSimilarMovies[] public List> getSimilarMovies(String id, Params params, String userId) { - // TODO: Get similar movies based on genres or ratings - - return AppUtils.process(popular, params).stream() - .map(item -> { - Map copy = new HashMap<>(item); - copy.put("score", ((int)(Math.random() * 10000)) / 100.0); - return copy; - }).toList(); + // Get similar movies based on genres or ratings + // MATCH (:Movie {tmdbId: $id})-[:IN_GENRE|ACTED_IN|DIRECTED]->()<-[:IN_GENRE|ACTED_IN|DIRECTED]-(m) + + // Open an Session + try (var session = this.driver.session()) { + + // Get similar movies based on genres or ratings + var movies = session.executeRead(tx -> { + var favorites = getUserFavorites(tx, userId); + String query = """ + MATCH (:Movie {tmdbId: $id})-[:IN_GENRE|ACTED_IN|DIRECTED]->()<-[:IN_GENRE|ACTED_IN|DIRECTED]-(m) + WHERE m.imdbRating IS NOT NULL + + WITH m, count(*) AS inCommon + WITH m, inCommon, m.imdbRating * inCommon AS score + ORDER BY score DESC + + SKIP $skip + LIMIT $limit + + RETURN m { + .*, + score: score, + favorite: m.tmdbId IN $favorites + } AS movie + """; + var res = tx.run(query, Values.parameters("id", id, "skip", params.skip(), "limit", params.limit(), "favorites", favorites)); + // Get a list of Movies from the Result + return res.list(row -> row.get("movie").asMap()); + }); + + return movies; + } } // end::getSimilarMovies[] @@ -127,10 +200,36 @@ public List> getSimilarMovies(String id, Params params, Strin */ // tag::getByGenre[] public List> byGenre(String name, Params params, String userId) { - // TODO: Get Movies in a Genre + // Get Movies in a Genre // MATCH (m:Movie)-[:IN_GENRE]->(:Genre {name: $name}) - return AppUtils.process(comedyMovies, params); + // Open a new session and close at the end + try (var session = driver.session()) { + // Execute a query in a new Read Transaction + return session.executeRead((tx) -> { + // Get an array of IDs for the User's favorite movies + var favorites = getUserFavorites(tx, userId); + + // Retrieve a list of movies with the + // favorite flag append to the movie's properties + var result = tx.run( + String.format(""" + MATCH (m:Movie)-[:IN_GENRE]->(:Genre {name: $name}) + WHERE m.`%s` IS NOT NULL + RETURN m { + .*, + favorite: m.tmdbId IN $favorites + } AS movie + ORDER BY m.`%s` %s + SKIP $skip + LIMIT $limit + """, params.sort(), params.sort(), params.order()), + Values.parameters("skip", params.skip(), "limit", params.limit(), + "favorites", favorites, "name", name)); + var movies = result.list(row -> row.get("movie").asMap()); + return movies; + }); + } } // end::getByGenre[] @@ -153,10 +252,37 @@ public List> byGenre(String name, Params params, String userI */ // tag::getForActor[] public List> getForActor(String actorId, Params params,String userId) { - // TODO: Get Movies acted in by a Person + // Get Movies acted in by a Person // MATCH (:Person {tmdbId: $id})-[:ACTED_IN]->(m:Movie) - return AppUtils.process(actedInTomHanks, params); + // Open a new session + try (var session = this.driver.session()) { + + // Execute a query in a new Read Transaction + var movies = session.executeRead(tx -> { + // Get an array of IDs for the User's favorite movies + var favorites = getUserFavorites(tx, userId); + var sort = params.sort(Params.Sort.title); + + // Retrieve a list of movies with the + // favorite flag appended to the movie's properties + String query = String.format(""" + MATCH (:Person {tmdbId: $id})-[:ACTED_IN]->(m:Movie) + WHERE m.`%s` IS NOT NULL + RETURN m { + .*, + favorite: m.tmdbId IN $favorites + } AS movie + ORDER BY m.`%s` %s + SKIP $skip + LIMIT $limit + """, sort, sort, params.order()); + var res = tx.run(query, Values.parameters("skip", params.skip(), "limit", params.limit(), "favorites", favorites, "id", actorId)); + // Get a list of Movies from the Result + return res.list(row -> row.get("movie").asMap()); + }); + return movies; + } } // end::getForActor[] @@ -179,10 +305,37 @@ public List> getForActor(String actorId, Params params,String */ // tag::getForDirector[] public List> getForDirector(String directorId, Params params,String userId) { - // TODO: Get Movies directed by a Person + // Get Movies acted in by a Person // MATCH (:Person {tmdbId: $id})-[:DIRECTED]->(m:Movie) - return AppUtils.process(directedByCoppola, params); + // Open a new session + try (var session = this.driver.session()) { + + // Execute a query in a new Read Transaction + var movies = session.executeRead(tx -> { + // Get an array of IDs for the User's favorite movies + var favorites = getUserFavorites(tx, userId); + var sort = params.sort(Params.Sort.title); + + // Retrieve a list of movies with the + // favorite flag appended to the movie's properties + String query = String.format(""" + MATCH (:Person {tmdbId: $id})-[:DIRECTED]->(m:Movie) + WHERE m.`%s` IS NOT NULL + RETURN m { + .*, + favorite: m.tmdbId IN $favorites + } AS movie + ORDER BY m.`%s` %s + SKIP $skip + LIMIT $limit + """, sort, sort, params.order()); + var res = tx.run(query, Values.parameters("skip", params.skip(), "limit", params.limit(), "favorites", favorites, "id", directorId)); + // Get a list of Movies from the Result + return res.list(row -> row.get("movie").asMap()); + }); + return movies; + } } // end::getForDirector[] @@ -197,9 +350,14 @@ public List> getForDirector(String directorId, Params params, */ // tag::getUserFavorites[] private List getUserFavorites(TransactionContext tx, String userId) { - return List.of(); + // If userId is not defined, return an empty list + if (userId == null) return List.of(); + var favoriteResult = tx.run(""" + MATCH (u:User {userId: $userId})-[:HAS_FAVORITE]->(m) + RETURN m.tmdbId AS id + """, Values.parameters("userId",userId)); + // Extract the `id` value returned by the cypher query + return favoriteResult.list(row -> row.get("id").asString()); } // end::getUserFavorites[] - - record Movie() {} // todo -} +} \ No newline at end of file diff --git a/src/main/java/neoflix/services/PeopleService.java b/src/main/java/neoflix/services/PeopleService.java index 6fcee1f..22faf51 100644 --- a/src/main/java/neoflix/services/PeopleService.java +++ b/src/main/java/neoflix/services/PeopleService.java @@ -1,12 +1,10 @@ package neoflix.services; import neoflix.AppUtils; -import neoflix.AuthUtils; import neoflix.Params; import org.neo4j.driver.Driver; import org.neo4j.driver.Values; -import java.util.Comparator; import java.util.List; import java.util.Map; @@ -38,14 +36,28 @@ public PeopleService(Driver driver) { */ // tag::all[] public List> all(Params params) { - // TODO: Get a list of people from the database - if (params.query() != null) { - return AppUtils.process(people.stream() - .filter(p -> ((String)p.get("name")) - .contains(params.query())) - .toList(), params); + // Open a new database session + try (var session = driver.session()) { + // Get a list of people from the database + var res = session.executeRead(tx -> { + String statement = String.format(""" + MATCH (p:Person) + WHERE $q IS null OR p.name CONTAINS $q + RETURN p { .* } AS person + ORDER BY p.`%s` %s + SKIP $skip + LIMIT $limit + """, params.sort(Params.Sort.name), params.order()); + return tx.run(statement + , Values.parameters("q", params.query(), "skip", params.skip(), "limit", params.limit())) + .list(row -> row.get("person").asMap()); + }); + + return res; + } catch(Exception e) { + e.printStackTrace(); } - return AppUtils.process(people, params); + return List.of(); } // end::all[] @@ -59,9 +71,27 @@ public List> all(Params params) { */ // tag::findById[] public Map findById(String id) { - // TODO: Find a user by their ID + // Open a new database session + try (var session = driver.session()) { - return people.stream().filter(p -> id.equals(p.get("tmdbId"))).findAny().get(); + // Get a person from the database + var person = session.executeRead(tx -> { + String query = """ + MATCH (p:Person {tmdbId: $id}) + WITH p, + count { (p)-[:ACTED_IN]->() } AS actedCount, + count { (p)-[:DIRECTED]->() } AS directedCount + RETURN p { + .*, + actedCount: actedCount, + directedCount: directedCount + } AS person + """; + var res = tx.run(query, Values.parameters("id", id)); + return res.single().get("person").asMap(); + }); + return person; + } } // end::findById[] @@ -75,9 +105,30 @@ public Map findById(String id) { */ // tag::getSimilarPeople[] public List> getSimilarPeople(String id, Params params) { - // TODO: Get a list of similar people to the person by their id + // Open a new database session + try (var session = driver.session()) { - return AppUtils.process(people, params); + // Get a list of similar people to the person by their id + var res = session.executeRead(tx -> tx.run(""" + MATCH (:Person {tmdbId: $id})-[:ACTED_IN|DIRECTED]->(m)<-[r:ACTED_IN|DIRECTED]-(p) + WITH p, + count { (p)-[:ACTED_IN]->() } AS actedCount, + count { (p)-[:DIRECTED]->() } AS directedCount, + collect(m {.tmdbId, .title, type: type(r)}) AS inCommon + RETURN p { + .*, + actedCount: actedCount, + directedCount: directedCount, + inCommon: inCommon + } AS person + ORDER BY size(inCommon) DESC + SKIP $skip + LIMIT $limit + """,Values.parameters("id",id, "skip", params.skip(), "limit", params.limit())) + .list(row -> row.get("person").asMap())); + + return res; + } } // end::getSimilarPeople[] diff --git a/src/main/java/neoflix/services/RatingService.java b/src/main/java/neoflix/services/RatingService.java index 45e06e4..adf784c 100644 --- a/src/main/java/neoflix/services/RatingService.java +++ b/src/main/java/neoflix/services/RatingService.java @@ -2,9 +2,12 @@ import neoflix.AppUtils; import neoflix.Params; +import neoflix.ValidationException; + import org.neo4j.driver.Driver; +import org.neo4j.driver.Values; +import org.neo4j.driver.exceptions.NoSuchRecordException; -import java.util.HashMap; import java.util.List; import java.util.Map; @@ -40,9 +43,25 @@ public RatingService(Driver driver) { */ // tag::forMovie[] public List> forMovie(String id, Params params) { - // TODO: Get ratings for a Movie + // Open a new database session + try (var session = this.driver.session()) { - return AppUtils.process(ratings,params); + // Get ratings for a Movie + return session.executeRead(tx -> { + String query = String.format(""" + MATCH (u:User)-[r:RATED]->(m:Movie {tmdbId: $id}) + RETURN r { + .rating, + .timestamp, + user: u { .id, .name } + } AS review + ORDER BY r.`%s` %s + SKIP $skip + LIMIT $limit""", params.sort(Params.Sort.timestamp), params.order()); + var res = tx.run(query, Values.parameters("id", id, "limit", params.limit(), "skip", params.skip())); + return res.list(row -> row.get("review").asMap()); + }); + } } // end::forMovie[] @@ -60,13 +79,37 @@ public List> forMovie(String id, Params params) { */ // tag::add[] public Map add(String userId, String movieId, int rating) { - // TODO: Convert the native integer into a Neo4j Integer - // TODO: Save the rating in the database - // TODO: Return movie details and a rating + // tag::write[] + // Save the rating in the database + + // Open a new session + try (var session = this.driver.session()) { + + // Run the cypher query + var movie = session.executeWrite(tx -> { + String query = """ + MATCH (u:User {userId: $userId}) + MATCH (m:Movie {tmdbId: $movieId}) + + MERGE (u)-[r:RATED]->(m) + SET r.rating = $rating, r.timestamp = timestamp() + + RETURN m { .*, rating: r.rating } AS movie + """; + var res = tx.run(query, Values.parameters("userId", userId, "movieId", movieId, "rating", rating)); + return res.single().get("movie").asMap(); + }); + // end::write[] - var copy = new HashMap<>(pulpfiction); - copy.put("rating",rating); - return copy; + // tag::addreturn[] + // Return movie details with rating + return movie; + // end::addreturn[] + // tag::throw[] + } catch(NoSuchRecordException e) { + throw new ValidationException("Movie or user not found to add rating", Map.of("movie", movieId, "user", userId)); + } + // end::throw[] } // end::add[] } \ No newline at end of file diff --git a/src/test/java/neoflix/_13_ListingRatingsTest.java b/src/test/java/neoflix/_13_ListingRatingsTest.java index 59256a3..b76ea82 100644 --- a/src/test/java/neoflix/_13_ListingRatingsTest.java +++ b/src/test/java/neoflix/_13_ListingRatingsTest.java @@ -54,11 +54,16 @@ void getOrderedPaginatedMovieRatings() { assertNotEquals(first, last); System.out.println(""" - Here is the answer to the quiz question on the lesson: What is the name of the first person to rate the movie Pulp Fiction? Copy and paste the following answer into the text box: """); - System.out.println(((Map)first.get(0).get("user")).get("name")); + Object userObj = first.get(0).get("user"); + if (userObj instanceof Map userMap) { + Object name = userMap.get("name"); + System.out.println(name); + } else { + System.out.println("User is not a map."); + } } }