Testea el mapeo de una API que expone la base de grafos de Películas que viene con Neo4J.
El ejemplo Movies que viene con Neo4j propone
- un nodo película (Movies)
- un nodo para cada actor (Person)
- y la relación entre ellos, marcada por el o los roles que cumplió cada actor en una película (ACTED_IN)
Instalar previamente Neo4j o bien levantar una imagen de Docker
docker run -p7474:7474 -p7687:7687 -e NEO4J_AUTH=neo4j/s3cr3t neo4j
- Abrir el Navegador de Neo4J Desktop o bien ingresar manualmente a la URL: http://localhost:7474
- Ejecutar el script que carga el grafo de películas (viene como ejemplo)
En el archivo application.yml
encontrarás la configuración hacia la base de grafos, que utiliza el protocolo liviano bolt:
spring:
data:
neo4j:
uri: bolt://localhost:7687
username: neo4j
password: #####
logging:
level:
org.springframework.data: DEBUG
org.neo4j: DEBUG
Algunas consideraciones:
- la contraseña por defecto cuando instalás localmente Neo4J es neo4j pero a veces te obliga a cambiarla, acordate de sincronizar con esta configuración (de hecho en el ejemplo de Docker estamos usando s3cr3t)
- el puerto por defecto para el protocolo bolt es 7687
- respecto al logging, le pusimos una configuración bastante exhaustiva: vas a ver conexiones y queries a la base. Se puede desactivar subiendo el nivel a INFO, WARN o directamente borrando la línea
Para conocer las películas en donde un valor de búsqueda esté contenido en el título (sin distinguir mayúsculas o minúsculas), y limitando la búsqueda a los primeros 10 nodos, ejecutaremos esta consulta
MATCH (pelicula:Movie) WHERE pelicula.title =~ '.*Good.*' RETURN pelicula LIMIT 10
La interfaz Neo4jRepository de Spring boot nos permite declarativamente establecer las consultas a la base, y reemplazaremos el valor concreto '.Good.' por el parámetro que recibe el contrato:
@Query("MATCH (pelicula:Movie) WHERE pelicula.title =~ $titulo RETURN pelicula LIMIT 10")
def List<Pelicula> peliculasPorTitulo(String titulo)
$titulo
es la nueva forma de asociar el valor del parámetro titulo
(hay que respetar los mismos nombres). Dado que queremos armar la expresión contiene, esto debemos hacerlo antes de llamar al repositorio, en este caso es el Service):
def buscarPorTitulo(String titulo) {
peliculasRepository.peliculasPorTitulo(titulo.contiene)
}
contiene
es en realidad un extension method definido en el archivo CipherUtils:
class CipherUtils {
static def contiene(String valor) {
'''(?i).*«valor».*'''.toString
}
}
En este caso solo queremos traer el nodo película, sin sus relaciones, por lo que el endpoint devuelve una lista de personajes vacía. Esto mejora la performance de la consulta aunque hay que exponer esta decisión a quien consuma nuestra API.
Cuando nos pasen un identificador de una película concreta, ahora sí queremos traer los datos de la película, más sus personajes y eso incluye los datos de cada uno de sus actores:
MATCH (pelicula:Movie)<-[actuo_en:ACTED_IN]-(persona:Person) WHERE ID(pelicula) = $id RETURN pelicula, collect(actuo_en), collect(persona) LIMIT 1
Es importante utilizar la instrucción collect
para que agrupe correctamente los personajes y los actores.
Es interesante ver que el controller delega la creación, actualización o eliminación al repositorio:
@PostMapping("/pelicula")
def createPelicula(@RequestBody Pelicula pelicula) {
peliculasRepository.save(pelicula)
}
@DeleteMapping("/pelicula/{id}")
def deletePelicula(@RequestBody Pelicula pelicula) {
peliculasRepository.delete(pelicula)
}
pero que esos métodos ni siquiera es necesario que los defina nuestra interfaz, porque ya están siendo inyectados por la interfaz Neo4jRepository (la declaratividad en su máxima expresión). El motor, en este caso Spring boot, persiste el nodo película y cualquier relación hasta el nivel de profundidad 5 que no entre en referencia circular. Anteriormente, existía un SessionManager donde podíamos tener un mayor control de la información que actualizábamos o recuperábamos: para algunos esto puede ser una desventaja, contra lo bueno que puede suponer delegar esa responsabilidad en un algoritmo optimizado.
Mostraremos a continuación cómo es el mapeo de las películas (las anotaciones a partir de las últimas versiones de Neo4J 4.2.x cambiaron ligeramente)
@Node("Movie")
@Accessors
class Pelicula {
static int MINIMO_VALOR_ANIO = 1900
@Id @GeneratedValue
Long id
@Property(name="title") // OJO, no es la property de xtend sino la de OGM
String titulo
@Property("tagline")
String frase
@Property("released")
Integer anio
@Relationship(type = "ACTED_IN", direction = Direction.INCOMING)
List<Personaje> personajes = new ArrayList<Personaje>
Para profundizar más recomendamos ver los otros objetos de dominio en este ejemplo y la página de mapeos de Neo4j - Spring boot
Por motivos didácticos hemos mantenido un ID Long que es el que genera Neo4J para sus nodos, aunque no resulta una buena estrategia, ya que cuando eliminamos nodos, Neo4j reutiliza esos identificadores para los nodos nuevos. Recomendamos investigar mecanismos alternativos para generar claves primarias, o bien tener como estrategia el borrado lógico y no físico.
Elegimos hacer tests de integración sobre el repositorio, podríamos a futuro incluir al controller, pero dado que no tiene demasiada lógica por el momento estamos bien manteniendo tests más simples. Los casos de prueba que vamos a desarrollar son:
- la búsqueda de películas, donde validaremos que se puede encontrar por "título contiene" sin distinguir mayúsculas o minúsculas y que además no trae los personajes
- la búsqueda puntual de una película que debe traer los personajes. La forma de buscar por id requiere que luego de enviar el mensaje
save
guardemos el nuevo estado de la película persistida, que tiene el identificador que el container de SDN (Spring Data Neo4J) le dio.
@Test
@DisplayName("la búsqueda por título funciona correctamente")
def void testPeliculasPorTitulo() {
val peliculas = peliculasRepository.peliculasPorTitulo('''(?i).*nueve.*''')
assertEquals(1, peliculas.size)
assertEquals(#[], peliculas.head.personajes)
}
@Test
@DisplayName("la búsqueda de una película trae los datos de la película y sus personajes")
def void testPeliculaConcreta() {
val pelicula = peliculasRepository.pelicula(nueveReinas.id)
assertEquals("Nueve reinas", pelicula.titulo)
assertEquals(2, pelicula.personajes.size)
val darin = pelicula.personajes.findFirst [ actor.nombreCompleto.equalsIgnoreCase("Ricardo Darín")]
assertEquals("Marcos", darin.roles.head)
}
Para profundizar más en el tema recomendamos leer esta página
Como de costumbre, pueden investigar los endpoints en el navegador mediante la siguiente URL:
http://localhost:8080/swagger-ui/index.html#